From 380b55db35c0593390a8ada071a8798bab32e7d2 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Mon, 14 Sep 2026 18:20:01 +0000 Subject: [PATCH 001/299] feat(txpool): relax validity transaction replacement fees (#5027) --- Cargo.lock | 208 +++++++++--------- Cargo.toml | 132 +++++------ crates/execution/txpool/src/pool.rs | 28 ++- crates/execution/txpool/src/transaction.rs | 87 +++++++- .../execution/txpool/src/two_d_nonce_pool.rs | 30 ++- 5 files changed, 307 insertions(+), 178 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25b28b07fd..c4ff95e06b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16106,7 +16106,7 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "reth-basic-payload-builder" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16133,7 +16133,7 @@ dependencies = [ [[package]] name = "reth-chain-state" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16164,7 +16164,7 @@ dependencies = [ [[package]] name = "reth-chainspec" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -16184,7 +16184,7 @@ dependencies = [ [[package]] name = "reth-cli" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-genesis", "clap", @@ -16197,7 +16197,7 @@ dependencies = [ [[package]] name = "reth-cli-commands" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -16286,7 +16286,7 @@ dependencies = [ [[package]] name = "reth-cli-runner" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "reth-tasks", "tokio", @@ -16296,7 +16296,7 @@ dependencies = [ [[package]] name = "reth-cli-util" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -16347,7 +16347,7 @@ dependencies = [ [[package]] name = "reth-config" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "eyre", "humantime-serde", @@ -16364,7 +16364,7 @@ dependencies = [ [[package]] name = "reth-consensus" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -16378,7 +16378,7 @@ dependencies = [ [[package]] name = "reth-consensus-common" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16391,7 +16391,7 @@ dependencies = [ [[package]] name = "reth-consensus-debug-client" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16416,7 +16416,7 @@ dependencies = [ [[package]] name = "reth-db" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "derive_more 2.1.1", @@ -16445,7 +16445,7 @@ dependencies = [ [[package]] name = "reth-db-api" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -16471,7 +16471,7 @@ dependencies = [ [[package]] name = "reth-db-common" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-genesis", @@ -16501,7 +16501,7 @@ dependencies = [ [[package]] name = "reth-db-models" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -16516,7 +16516,7 @@ dependencies = [ [[package]] name = "reth-discv4" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -16541,7 +16541,7 @@ dependencies = [ [[package]] name = "reth-discv5" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -16565,7 +16565,7 @@ dependencies = [ [[package]] name = "reth-dns-discovery" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "dashmap", @@ -16589,7 +16589,7 @@ dependencies = [ [[package]] name = "reth-downloaders" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16624,7 +16624,7 @@ dependencies = [ [[package]] name = "reth-e2e-test-utils" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16681,7 +16681,7 @@ dependencies = [ [[package]] name = "reth-ecies" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "aes", "alloy-primitives", @@ -16708,7 +16708,7 @@ dependencies = [ [[package]] name = "reth-engine-local" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -16731,7 +16731,7 @@ dependencies = [ [[package]] name = "reth-engine-primitives" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16758,7 +16758,7 @@ dependencies = [ [[package]] name = "reth-engine-tree" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -16814,7 +16814,7 @@ dependencies = [ [[package]] name = "reth-engine-util" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -16844,7 +16844,7 @@ dependencies = [ [[package]] name = "reth-era" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16862,7 +16862,7 @@ dependencies = [ [[package]] name = "reth-era-downloader" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "bytes", @@ -16878,7 +16878,7 @@ dependencies = [ [[package]] name = "reth-era-utils" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -16902,7 +16902,7 @@ dependencies = [ [[package]] name = "reth-errors" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "reth-consensus", "reth-execution-errors", @@ -16913,7 +16913,7 @@ dependencies = [ [[package]] name = "reth-eth-wire" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-chains", "alloy-primitives", @@ -16942,7 +16942,7 @@ dependencies = [ [[package]] name = "reth-eth-wire-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -16968,7 +16968,7 @@ dependencies = [ [[package]] name = "reth-ethereum-consensus" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16984,7 +16984,7 @@ dependencies = [ [[package]] name = "reth-ethereum-engine-primitives" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -17000,7 +17000,7 @@ dependencies = [ [[package]] name = "reth-ethereum-forks" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eip2124", "alloy-hardforks 0.4.7", @@ -17014,7 +17014,7 @@ dependencies = [ [[package]] name = "reth-ethereum-payload-builder" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17044,7 +17044,7 @@ dependencies = [ [[package]] name = "reth-ethereum-primitives" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17058,7 +17058,7 @@ dependencies = [ [[package]] name = "reth-etl" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "rayon", "reth-db-api", @@ -17068,7 +17068,7 @@ dependencies = [ [[package]] name = "reth-evm" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -17094,7 +17094,7 @@ dependencies = [ [[package]] name = "reth-evm-ethereum" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17114,7 +17114,7 @@ dependencies = [ [[package]] name = "reth-execution-cache" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "fixed-cache", @@ -17132,7 +17132,7 @@ dependencies = [ [[package]] name = "reth-execution-errors" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-evm", "alloy-primitives", @@ -17145,7 +17145,7 @@ dependencies = [ [[package]] name = "reth-execution-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17164,7 +17164,7 @@ dependencies = [ [[package]] name = "reth-exex" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17202,7 +17202,7 @@ dependencies = [ [[package]] name = "reth-exex-test-utils" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "eyre", @@ -17234,7 +17234,7 @@ dependencies = [ [[package]] name = "reth-exex-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -17248,7 +17248,7 @@ dependencies = [ [[package]] name = "reth-fs-util" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "serde", "serde_json", @@ -17258,7 +17258,7 @@ dependencies = [ [[package]] name = "reth-invalid-block-hooks" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17284,7 +17284,7 @@ dependencies = [ [[package]] name = "reth-ipc" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "bytes", "futures", @@ -17304,7 +17304,7 @@ dependencies = [ [[package]] name = "reth-libmdbx" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "bitflags 2.13.1", "byteorder", @@ -17321,7 +17321,7 @@ dependencies = [ [[package]] name = "reth-mdbx-sys" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "bindgen 0.72.1", "cc", @@ -17330,7 +17330,7 @@ dependencies = [ [[package]] name = "reth-metrics" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "futures", "libc", @@ -17344,7 +17344,7 @@ dependencies = [ [[package]] name = "reth-net-banlist" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "ipnet", @@ -17353,7 +17353,7 @@ dependencies = [ [[package]] name = "reth-net-nat" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "futures-util", "if-addrs 0.14.0", @@ -17367,7 +17367,7 @@ dependencies = [ [[package]] name = "reth-network" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17426,7 +17426,7 @@ dependencies = [ [[package]] name = "reth-network-api" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17451,7 +17451,7 @@ dependencies = [ [[package]] name = "reth-network-p2p" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -17475,7 +17475,7 @@ dependencies = [ [[package]] name = "reth-network-peers" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -17490,7 +17490,7 @@ dependencies = [ [[package]] name = "reth-network-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eip2124", "humantime-serde", @@ -17504,7 +17504,7 @@ dependencies = [ [[package]] name = "reth-nippy-jar" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "anyhow", "bincode 1.3.3", @@ -17521,7 +17521,7 @@ dependencies = [ [[package]] name = "reth-node-api" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-rpc-types-engine", "eyre", @@ -17545,7 +17545,7 @@ dependencies = [ [[package]] name = "reth-node-builder" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17613,7 +17613,7 @@ dependencies = [ [[package]] name = "reth-node-core" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17668,7 +17668,7 @@ dependencies = [ [[package]] name = "reth-node-ethereum" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17717,7 +17717,7 @@ dependencies = [ [[package]] name = "reth-node-ethstats" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17741,7 +17741,7 @@ dependencies = [ [[package]] name = "reth-node-events" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17765,7 +17765,7 @@ dependencies = [ [[package]] name = "reth-node-metrics" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "bytes", "eyre", @@ -17794,7 +17794,7 @@ dependencies = [ [[package]] name = "reth-node-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "reth-chainspec", "reth-db-api", @@ -17806,7 +17806,7 @@ dependencies = [ [[package]] name = "reth-payload-builder" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17830,7 +17830,7 @@ dependencies = [ [[package]] name = "reth-payload-builder-primitives" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "pin-project", "reth-payload-primitives", @@ -17842,7 +17842,7 @@ dependencies = [ [[package]] name = "reth-payload-primitives" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17865,7 +17865,7 @@ dependencies = [ [[package]] name = "reth-payload-util" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17875,7 +17875,7 @@ dependencies = [ [[package]] name = "reth-payload-validator" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-rpc-types-engine", @@ -17918,7 +17918,7 @@ dependencies = [ [[package]] name = "reth-provider" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -17968,7 +17968,7 @@ dependencies = [ [[package]] name = "reth-prune" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17995,7 +17995,7 @@ dependencies = [ [[package]] name = "reth-prune-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "arbitrary", @@ -18011,7 +18011,7 @@ dependencies = [ [[package]] name = "reth-revm" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -18026,7 +18026,7 @@ dependencies = [ [[package]] name = "reth-rpc" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-dyn-abi", @@ -18102,7 +18102,7 @@ dependencies = [ [[package]] name = "reth-rpc-api" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-genesis", @@ -18132,7 +18132,7 @@ dependencies = [ [[package]] name = "reth-rpc-builder" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-network 2.4.1", "alloy-provider", @@ -18175,7 +18175,7 @@ dependencies = [ [[package]] name = "reth-rpc-convert" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-evm", @@ -18195,7 +18195,7 @@ dependencies = [ [[package]] name = "reth-rpc-engine-api" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18226,7 +18226,7 @@ dependencies = [ [[package]] name = "reth-rpc-eth-api" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-dyn-abi", @@ -18273,7 +18273,7 @@ dependencies = [ [[package]] name = "reth-rpc-eth-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -18324,7 +18324,7 @@ dependencies = [ [[package]] name = "reth-rpc-layer" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-rpc-types-engine", "http 1.4.2", @@ -18338,7 +18338,7 @@ dependencies = [ [[package]] name = "reth-rpc-server-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18369,7 +18369,7 @@ dependencies = [ [[package]] name = "reth-stages" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -18420,7 +18420,7 @@ dependencies = [ [[package]] name = "reth-stages-api" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18448,7 +18448,7 @@ dependencies = [ [[package]] name = "reth-stages-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "arbitrary", @@ -18462,7 +18462,7 @@ dependencies = [ [[package]] name = "reth-static-file" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "parking_lot", @@ -18482,7 +18482,7 @@ dependencies = [ [[package]] name = "reth-static-file-types" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "clap", @@ -18497,7 +18497,7 @@ dependencies = [ [[package]] name = "reth-storage-api" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -18523,7 +18523,7 @@ dependencies = [ [[package]] name = "reth-storage-errors" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18540,7 +18540,7 @@ dependencies = [ [[package]] name = "reth-storage-overlay" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18567,7 +18567,7 @@ dependencies = [ [[package]] name = "reth-tasks" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "crossbeam-utils", "dashmap", @@ -18588,7 +18588,7 @@ dependencies = [ [[package]] name = "reth-testing-utils" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -18604,7 +18604,7 @@ dependencies = [ [[package]] name = "reth-tokio-util" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "tokio", "tokio-stream", @@ -18614,7 +18614,7 @@ dependencies = [ [[package]] name = "reth-tracing" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "clap", "eyre", @@ -18634,7 +18634,7 @@ dependencies = [ [[package]] name = "reth-tracing-otlp" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "base64 0.22.1", "clap", @@ -18653,7 +18653,7 @@ dependencies = [ [[package]] name = "reth-transaction-pool" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -18696,7 +18696,7 @@ dependencies = [ [[package]] name = "reth-trie" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -18722,7 +18722,7 @@ dependencies = [ [[package]] name = "reth-trie-common" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -18749,7 +18749,7 @@ dependencies = [ [[package]] name = "reth-trie-db" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "reth-db-api", @@ -18765,7 +18765,7 @@ dependencies = [ [[package]] name = "reth-trie-parallel" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-evm", "alloy-primitives", @@ -18789,7 +18789,7 @@ dependencies = [ [[package]] name = "reth-trie-sparse" version = "2.5.2" -source = "git+https://github.com/base/reth?tag=base-v2.5.2.3#76a826109009c936c2ea98766e0b6b50d2803d26" +source = "git+https://github.com/base/reth?tag=base-v2.5.2.6#5877708bbf9219c44758cd2ce28a365f738661f7" dependencies = [ "alloy-primitives", "alloy-trie", diff --git a/Cargo.toml b/Cargo.toml index 60700195d8..b1e6f61973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -342,72 +342,72 @@ alloy-network-primitives = { version = "2.3.0", default-features = false } reth-zstd-compressors = { version = "0.6.0", default-features = false } reth-primitives-traits = { version = "0.6.0", default-features = false } reth-codecs = { version = "0.6.0", default-features = false, features = ["alloy"] } -reth-db = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-cli = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-ipc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-evm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-rpc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-revm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-trie = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-exex = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-tasks = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-ecies = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-db-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-discv4 = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-discv5 = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-trie-db = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-rpc-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-network = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-net-nat = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-tracing = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-eth-wire = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-provider = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-cli-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-node-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-db-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-rpc-layer = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-node-core = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-consensus = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-chainspec = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-cli-runner = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-rpc-convert = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-network-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-network-p2p = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-storage-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-rpc-eth-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-chain-state = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-trie-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-payload-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-node-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-node-metrics = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-cli-commands = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-evm-ethereum = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-tracing-otlp = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-testing-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-rpc-eth-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-network-peers = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-node-ethereum = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-rpc-engine-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-e2e-test-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-eth-wire-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-payload-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-execution-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-execution-cache = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-exex-test-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-rpc-server-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-transaction-pool = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-execution-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-payload-validator = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-payload-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-ethereum-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-basic-payload-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-payload-builder-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } -reth-prune-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3", default-features = false } -reth-stages-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3", default-features = false } -reth-storage-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3", default-features = false } -reth-ethereum-forks = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3", default-features = false } -reth-consensus-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3", default-features = false } -reth-trie-parallel = { git = "https://github.com/base/reth", tag = "base-v2.5.2.3" } +reth-db = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-cli = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-ipc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-evm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-rpc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-revm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-trie = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-exex = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-tasks = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-ecies = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-db-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-discv4 = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-discv5 = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-trie-db = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-rpc-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-network = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-net-nat = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-tracing = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-eth-wire = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-provider = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-cli-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-node-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-db-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-rpc-layer = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-node-core = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-consensus = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-chainspec = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-cli-runner = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-rpc-convert = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-network-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-network-p2p = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-storage-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-rpc-eth-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-chain-state = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-trie-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-payload-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-node-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-node-metrics = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-cli-commands = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-evm-ethereum = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-tracing-otlp = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-testing-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-rpc-eth-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-network-peers = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-node-ethereum = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-rpc-engine-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-e2e-test-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-eth-wire-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-payload-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-execution-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-execution-cache = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-exex-test-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-rpc-server-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-transaction-pool = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-execution-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-payload-validator = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-payload-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-ethereum-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-basic-payload-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-payload-builder-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-prune-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } +reth-stages-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } +reth-storage-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } +reth-ethereum-forks = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } +reth-consensus-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } +reth-trie-parallel = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } # tokio tokio = "1.53.1" diff --git a/crates/execution/txpool/src/pool.rs b/crates/execution/txpool/src/pool.rs index 09b01bee0a..cdd87919c6 100644 --- a/crates/execution/txpool/src/pool.rs +++ b/crates/execution/txpool/src/pool.rs @@ -1820,7 +1820,10 @@ mod tests { }; use super::*; - use crate::{BaseL1BlockInfo, BaseOrdering, BasePooledTransaction, LimitClass, WatchSet}; + use crate::{ + BaseL1BlockInfo, BaseOrdering, BasePooledTransaction, LimitClass, ValidityOperator, + ValidityPredicate, WatchSet, + }; fn test_chain_id() -> u64 { ChainConfig::mainnet().chain_id @@ -2103,6 +2106,29 @@ mod tests { assert!(pool.guard.read().is_empty()); } + #[tokio::test] + async fn protocol_validity_transaction_replaces_with_only_a_higher_max_fee() { + let (pool, client) = build_integration_pool(); + let signer = signer(); + fund(&client, signer.address()); + let predicate = + ValidityPredicate::BlockNumber { op: ValidityOperator::LessThan, value: U256::from(2) }; + let original = self_paid_eoa_8130(&signer, U256::ZERO, 0, 0, 1_000) + .with_validity_predicates(vec![predicate.clone()]); + let original_hash = *original.hash(); + pool.add_transaction(TransactionOrigin::Local, original).await.unwrap(); + + let replacement = self_paid_eoa_8130(&signer, U256::ZERO, 0, 0, 1_001) + .with_validity_predicates(vec![predicate]); + let replacement_hash = *replacement.hash(); + pool.add_transaction(TransactionOrigin::Local, replacement) + .await + .expect("validity replacement only requires a higher max fee"); + + assert!(pool.get(&original_hash).is_none()); + assert!(pool.get(&replacement_hash).is_some()); + } + #[tokio::test] async fn protocol_and_sidecar_eip8130_routes_enforce_sender_limit() { let cap = u64::from(GuardLimits::default().signature_limit); diff --git a/crates/execution/txpool/src/transaction.rs b/crates/execution/txpool/src/transaction.rs index 38e0c87a4d..07bff6d171 100644 --- a/crates/execution/txpool/src/transaction.rs +++ b/crates/execution/txpool/src/transaction.rs @@ -4,7 +4,9 @@ use std::{ sync::{Arc, OnceLock}, }; -use alloy_consensus::{BlobTransactionValidationError, Typed2718, transaction::Recovered}; +use alloy_consensus::{ + BlobTransactionValidationError, Transaction, Typed2718, transaction::Recovered, +}; use alloy_eips::{ eip2718::{Encodable2718, WithEncoded}, eip2930::AccessList, @@ -17,6 +19,7 @@ use c_kzg::KzgSettings; use reth_primitives_traits::{InMemorySize, SignedTransaction}; use reth_transaction_pool::{ EthBlobTransactionSidecar, EthPoolTransaction, EthPooledTransaction, PoolTransaction, + PriceBumpConfig, }; use crate::estimated_da_size::DataAvailabilitySized; @@ -151,6 +154,18 @@ where type Consensus = BaseTransactionSigned; type Pooled = Pooled; + fn is_replacement_underpriced( + &self, + replacement: &Self, + price_bumps: &PriceBumpConfig, + ) -> bool { + if self.validity_predicates().is_empty() || replacement.validity_predicates().is_empty() { + return price_bumps.is_replacement_underpriced(self, replacement); + } + + replacement.max_fee_per_gas() <= self.max_fee_per_gas() + } + fn clone_into_consensus(&self) -> Recovered { self.inner.transaction().clone() } @@ -506,7 +521,7 @@ mod tests { use reth_primitives_traits::InMemorySize; use reth_provider::test_utils::MockEthProvider; use reth_transaction_pool::{ - PoolTransaction, TransactionOrigin, TransactionValidationOutcome, + PoolTransaction, PriceBumpConfig, TransactionOrigin, TransactionValidationOutcome, blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, }; @@ -520,6 +535,14 @@ mod tests { } fn eip8130_pooled(nonce_key: U256) -> BasePooledTransaction { + eip8130_pooled_with_fees(nonce_key, 0, 1) + } + + fn eip8130_pooled_with_fees( + nonce_key: U256, + max_priority_fee_per_gas: u128, + max_fee_per_gas: u128, + ) -> BasePooledTransaction { let signer = signer(); let tx = TxEip8130 { chain_id: ChainConfig::mainnet().chain_id, @@ -528,8 +551,8 @@ mod tests { nonce_sequence: 0, valid_after: 0, valid_before: if nonce_key == Eip8130Constants::NONCE_KEY_MAX { 5 } else { 0 }, - max_priority_fee_per_gas: 0, - max_fee_per_gas: 1, + max_priority_fee_per_gas, + max_fee_per_gas, gas_limit: 50_000, account_changes: Vec::new(), calls: Vec::new(), @@ -588,6 +611,62 @@ mod tests { assert!(!eip8130_pooled(Eip8130Constants::NONCE_KEY_MAX).requires_nonce_check()); } + #[test] + fn validity_replacement_only_requires_a_higher_max_fee() { + let predicate = ValidityPredicate::Balance { + address: Address::ZERO, + op: ValidityOperator::Equal, + value: U256::ZERO, + }; + let existing = eip8130_pooled_with_fees(U256::ZERO, 10, 100) + .with_validity_predicates(vec![predicate.clone()]); + let replacement = eip8130_pooled_with_fees(U256::ZERO, 0, 101) + .with_validity_predicates(vec![predicate.clone()]); + + assert!(!existing.is_replacement_underpriced(&replacement, &PriceBumpConfig::default())); + + let unchanged_max_fee = eip8130_pooled_with_fees(U256::ZERO, 100, 100) + .with_validity_predicates(vec![predicate]); + assert!( + existing.is_replacement_underpriced(&unchanged_max_fee, &PriceBumpConfig::default()) + ); + } + + #[test] + fn validity_replacement_of_non_validity_transaction_uses_configured_price_bumps() { + let existing = eip8130_pooled_with_fees(U256::ZERO, 10, 100); + let predicate = ValidityPredicate::Balance { + address: Address::ZERO, + op: ValidityOperator::Equal, + value: U256::ZERO, + }; + let underpriced = eip8130_pooled_with_fees(U256::ZERO, 0, 109) + .with_validity_predicates(vec![predicate.clone()]); + + assert!(existing.is_replacement_underpriced(&underpriced, &PriceBumpConfig::default())); + + let replacement = + eip8130_pooled_with_fees(U256::ZERO, 0, 110).with_validity_predicates(vec![predicate]); + assert!(!existing.is_replacement_underpriced(&replacement, &PriceBumpConfig::default())); + } + + #[test] + fn non_validity_replacement_uses_configured_price_bumps() { + let predicate = ValidityPredicate::Balance { + address: Address::ZERO, + op: ValidityOperator::Equal, + value: U256::ZERO, + }; + let existing = + eip8130_pooled_with_fees(U256::ZERO, 10, 100).with_validity_predicates(vec![predicate]); + let underpriced = eip8130_pooled_with_fees(U256::ZERO, 10, 110); + + assert!(existing.is_replacement_underpriced(&underpriced, &PriceBumpConfig::default())); + + let replacement = eip8130_pooled_with_fees(U256::ZERO, 11, 110); + assert!(!existing.is_replacement_underpriced(&replacement, &PriceBumpConfig::default())); + } + #[test] fn in_memory_size_includes_watch_keys() { let transaction = eip8130_pooled(U256::ZERO); diff --git a/crates/execution/txpool/src/two_d_nonce_pool.rs b/crates/execution/txpool/src/two_d_nonce_pool.rs index c2e3348c4e..1582c3e328 100644 --- a/crates/execution/txpool/src/two_d_nonce_pool.rs +++ b/crates/execution/txpool/src/two_d_nonce_pool.rs @@ -254,7 +254,7 @@ impl TwoDNoncePool { transaction.transaction_id = TransactionId::new(sender_id, transaction.nonce()); let transaction = Arc::new(transaction); let replaced = if let Some(existing) = self.nonce_free.get(&replay_id) { - if existing.is_underpriced(&transaction, &self.price_bump_config) { + if existing.is_replacement_underpriced(&transaction, &self.price_bump_config) { return Err(PoolError::new(hash, PoolErrorKind::ReplacementUnderpriced)); } Some(Arc::clone(existing)) @@ -310,7 +310,7 @@ impl TwoDNoncePool { let replaced: Option>> = if let Some(existing) = lane.transactions.get(&nonce) { - if existing.is_underpriced(&transaction, &self.price_bump_config) { + if existing.is_replacement_underpriced(&transaction, &self.price_bump_config) { return Err(PoolError::new(hash, PoolErrorKind::ReplacementUnderpriced)); } Some(Arc::clone(existing)) @@ -678,7 +678,7 @@ mod tests { use reth_transaction_pool::{PoolTransaction, PriceBumpConfig, Priority, TransactionOrigin}; use super::*; - use crate::{BaseOrdering, BasePooledTransaction}; + use crate::{BaseOrdering, BasePooledTransaction, ValidityOperator, ValidityPredicate}; #[derive(Clone, Debug, Default)] struct CountingOrdering { @@ -914,6 +914,30 @@ mod tests { assert_eq!(pool.pending_and_queued_txn_count(), (2, 0)); } + #[test] + fn validity_transaction_replaces_with_only_a_higher_max_fee() { + let mut pool = TwoDNoncePool::new(PriceBumpConfig::default()); + let signer = signer(); + let predicate = ValidityPredicate::Balance { + address: Address::ZERO, + op: ValidityOperator::Equal, + value: U256::ZERO, + }; + let original = signed_nonce_free_tx(&signer, 1, 10, 100) + .with_validity_predicates(vec![predicate.clone()]); + let original = valid_pool_transaction(original); + let replacement = + signed_nonce_free_tx(&signer, 1, 0, 101).with_validity_predicates(vec![predicate]); + + pool.insert_validated(original, 0).unwrap(); + assert!( + pool.insert_validated(valid_pool_transaction(replacement), 0) + .unwrap() + .replaced + .is_some() + ); + } + #[test] fn nonce_free_removal_and_iteration_are_independent() { let mut pool = TwoDNoncePool::new(PriceBumpConfig::default()); From fdca4477bec63d0097e670651c65aef9dd7efb64 Mon Sep 17 00:00:00 2001 From: Danyal Prout Date: Mon, 14 Sep 2026 23:26:26 +0000 Subject: [PATCH 002/299] chore(devnet): improve rebuild performance (#4977) * chore(devnet): improve rebuild performance * chore(devnet): avoid duplicate freshness source list * chore(devnet): remove target cache freshness workaround --- etc/docker/Dockerfile.rust-services | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/etc/docker/Dockerfile.rust-services b/etc/docker/Dockerfile.rust-services index 57b0047ddb..3eeea029c4 100644 --- a/etc/docker/Dockerfile.rust-services +++ b/etc/docker/Dockerfile.rust-services @@ -46,8 +46,16 @@ RUN set -eux; \ unzip /tmp/protoc.zip -d /usr/local; \ rm /tmp/protoc.zip -FROM rust-builder-base AS planner -COPY . . +FROM rust-builder-base AS workspace-source +# Keep these inputs aligned with workspace members; runtime scripts are copied later. +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY actions ./actions +COPY bin ./bin +COPY crates ./crates +COPY etc/tools ./etc/tools +COPY etc/systems ./etc/systems + +FROM workspace-source AS planner RUN cargo chef prepare --recipe-path recipe.json FROM rust-builder-base AS rust-deps @@ -62,7 +70,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ cargo chef cook --profile "$PROFILE" --recipe-path recipe.json ${CARGO_CHEF_ARGS} ${CARGO_FEATURES} FROM rust-deps AS source -COPY . . +COPY --from=workspace-source /app/ /app/ FROM source AS base-image-builder ARG PROFILE=release @@ -72,6 +80,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ --mount=type=cache,id=${SCCACHE_CACHE_ID},target=/sccache,sharing=locked \ set -eux; \ + if [ "$PROFILE" = "dev" ]; then export CARGO_PROFILE_DEV_DEBUG=0; fi; \ cargo build --profile "$PROFILE" ${CARGO_FEATURES} \ --package base --bin base \ --package base-reth-node --bin base-reth-node \ @@ -219,8 +228,6 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* RUN mkdir -p /var/log/supervisor COPY --from=base-image-builder /app/base ./base -# Compose uses this wrapper to source generated devnet upgrade-signal env before /app/base. -COPY etc/scripts/devnet/base-entrypoint.sh ./base-entrypoint.sh # Keep split binaries here for now, needed for bare metal work # Remove only when there is an alternative available # e.g. when we publish independent separate Docker images for each @@ -228,6 +235,8 @@ COPY etc/scripts/devnet/base-entrypoint.sh ./base-entrypoint.sh COPY --from=base-image-builder /app/base-reth-node ./base-reth-node COPY --from=base-image-builder /app/base-consensus ./base-consensus COPY --from=base-image-builder /app/snapshotter ./snapshotter +# Compose uses this wrapper to source generated devnet upgrade-signal env before /app/base. +COPY etc/scripts/devnet/base-entrypoint.sh ./base-entrypoint.sh COPY etc/scripts/node/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY etc/scripts/node/execution-entrypoint . COPY etc/scripts/node/consensus-entrypoint . From eb3aff6ba55e7e200abe4a2033d317f1bce4c92d Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Tue, 15 Sep 2026 15:48:37 +0000 Subject: [PATCH 003/299] feat(benchmark): add fresh devnet transfer defaults and stall diagnostics (#4998) * fix(benchmark): pin snapshot base fee elasticity * fix(ci): make elasticity override helper const * feat(benchmark): add fresh devnet defaults * fix(ci): initialize benchmark test config directly * feat(load-tests): expand block watcher stall diagnostics Co-authored-by: Codex * fix(bench): skip snapshot database cleanup by default * fix(load-tests): correct block-batched rolling GPS * fix(bench): disable txpool backups for in-process nodes --------- Co-authored-by: Codex --- .../infra/load-tests/src/metrics/collector.rs | 8 +- .../load-tests/src/metrics/rolling_window.rs | 53 ++++++-- crates/infra/load-tests/src/metrics/types.rs | 4 +- .../load-tests/src/runner/block_watcher.rs | 11 ++ crates/infra/load-tests/src/runner/status.rs | 14 +- docs/guides/SNAPSHOT_BENCHMARKS.md | 6 +- etc/systems/README.md | 15 +++ etc/systems/src/benchmark_cli.rs | 122 +++++++++++++++--- etc/systems/src/l2/in_process_builder.rs | 5 + etc/systems/src/l2/in_process_client.rs | 5 + etc/systems/src/l2/snapshot_stack.rs | 39 +++++- etc/systems/src/system_config.rs | 15 +++ 12 files changed, 252 insertions(+), 45 deletions(-) diff --git a/crates/infra/load-tests/src/metrics/collector.rs b/crates/infra/load-tests/src/metrics/collector.rs index 6ef05bf824..664d503288 100644 --- a/crates/infra/load-tests/src/metrics/collector.rs +++ b/crates/infra/load-tests/src/metrics/collector.rs @@ -416,22 +416,22 @@ impl MetricsCollector { } } - /// Returns the rolling 30s TPS. + /// Returns the rolling 10-second TPS. pub fn rolling_tps(&mut self) -> f64 { self.rolling.tps() } - /// Returns the rolling 30s GPS. + /// Returns the rolling 10-second GPS. pub fn rolling_gps(&mut self) -> f64 { self.rolling.gps() } - /// Returns the rolling 30s (p50, p99) latency percentiles. + /// Returns the rolling 10-second (p50, p99) latency percentiles. pub fn rolling_p50_p99(&mut self) -> (std::time::Duration, std::time::Duration) { self.rolling.p50_p99() } - /// Rolling 30s flashblocks (p50, p99). + /// Rolling 10-second flashblocks (p50, p99). pub fn rolling_flashblocks_p50_p99(&mut self) -> (std::time::Duration, std::time::Duration) { self.flashblocks_rolling.p50_p99() } diff --git a/crates/infra/load-tests/src/metrics/rolling_window.rs b/crates/infra/load-tests/src/metrics/rolling_window.rs index 4a11435a4f..7a94d08e4a 100644 --- a/crates/infra/load-tests/src/metrics/rolling_window.rs +++ b/crates/infra/load-tests/src/metrics/rolling_window.rs @@ -3,9 +3,9 @@ use std::{ time::{Duration, Instant}, }; -const WINDOW: Duration = Duration::from_secs(5); +const WINDOW: Duration = Duration::from_secs(10); -/// A rolling 5-second window for computing instantaneous TPS, GPS, and latency percentiles. +/// A rolling 10-second window for computing instantaneous TPS, GPS, and latency percentiles. #[derive(Debug)] pub struct RollingWindow { gas_events: VecDeque<(Instant, u64)>, @@ -40,10 +40,10 @@ impl RollingWindow { /// Returns the transactions-per-second rate over the rolling window. pub fn tps(&mut self) -> f64 { self.prune(); - let count = self.gas_events.len(); - if count == 0 { + let Some((oldest, _)) = self.gas_events.front() else { return 0.0; - } + }; + let count = self.gas_events.iter().filter(|(at, _)| at > oldest).count(); let window = self.elapsed_window_secs(); if window <= 0.0 { 0.0 } else { count as f64 / window } } @@ -51,10 +51,15 @@ impl RollingWindow { /// Returns the gas-per-second rate over the rolling window. pub fn gps(&mut self) -> f64 { self.prune(); - if self.gas_events.is_empty() { + let Some((oldest, _)) = self.gas_events.front() else { return 0.0; - } - let total_gas: u64 = self.gas_events.iter().map(|(_, g)| *g).sum(); + }; + // Confirmations arrive in one batch per observed block. The oldest batch marks the start + // of the measured interval, so its gas belongs to the preceding interval and must not be + // divided by time that starts at the same observation. Including it makes three 200 Mgas + // blocks observed two seconds apart report 600 / 4 = 150 Mgas/s instead of 400 / 4 = 100. + let total_gas: u64 = + self.gas_events.iter().filter(|(at, _)| at > oldest).map(|(_, gas)| *gas).sum(); let window = self.elapsed_window_secs(); if window <= 0.0 { 0.0 } else { total_gas as f64 / window } } @@ -83,7 +88,7 @@ impl RollingWindow { } } - /// Actual elapsed time covered by the oldest event in the window (clamped to 30s). + /// Actual elapsed time covered by the oldest event in the window (clamped to 10 seconds). fn elapsed_window_secs(&self) -> f64 { match self.gas_events.front() { Some((oldest, _)) => oldest.elapsed().as_secs_f64().min(WINDOW.as_secs_f64()), @@ -97,3 +102,33 @@ impl Default for RollingWindow { Self::new() } } + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::RollingWindow; + + #[test] + fn block_batched_gas_excludes_the_interval_start_batch() { + let now = Instant::now(); + let mut rolling = RollingWindow::new(); + rolling.push_gas(200_000_000, now - Duration::from_secs(8)); + rolling.push_gas(200_000_000, now - Duration::from_secs(6)); + rolling.push_gas(200_000_000, now - Duration::from_secs(4)); + rolling.push_gas(200_000_000, now - Duration::from_secs(2)); + rolling.push_gas(200_000_000, now); + + let gps = rolling.gps(); + assert!((99_000_000.0..=101_000_000.0).contains(&gps), "unexpected GPS: {gps}"); + } + + #[test] + fn one_confirmation_batch_does_not_imply_a_rate() { + let mut rolling = RollingWindow::new(); + rolling.push_gas(200_000_000, Instant::now()); + + assert_eq!(rolling.gps(), 0.0); + assert_eq!(rolling.tps(), 0.0); + } +} diff --git a/crates/infra/load-tests/src/metrics/types.rs b/crates/infra/load-tests/src/metrics/types.rs index 90d49862e4..c901eff16c 100644 --- a/crates/infra/load-tests/src/metrics/types.rs +++ b/crates/infra/load-tests/src/metrics/types.rs @@ -185,9 +185,9 @@ pub struct GasMetrics { pub struct ThroughputSample { /// Elapsed time since the test started, in seconds. pub elapsed_secs: f64, - /// Rolling 30s transactions-per-second at this point. + /// Rolling 10-second transactions-per-second at this point. pub tps: f64, - /// Rolling 30s gas-per-second at this point. + /// Rolling 10-second gas-per-second at this point. pub gps: f64, } diff --git a/crates/infra/load-tests/src/runner/block_watcher.rs b/crates/infra/load-tests/src/runner/block_watcher.rs index ffcf500eb0..0b395a7247 100644 --- a/crates/infra/load-tests/src/runner/block_watcher.rs +++ b/crates/infra/load-tests/src/runner/block_watcher.rs @@ -270,6 +270,7 @@ impl BlockWatcher { continue; }; let latest_number = latest.observation.number; + let latest_tx_count = latest.tx_hashes.len(); let blocks_advanced = last_seen_block.map_or(1, |seen| latest_number.saturating_sub(seen).max(1)); let pulse_expected_boundary = expected_boundary @@ -349,9 +350,19 @@ impl BlockWatcher { } else if last_progress_log.elapsed() >= Duration::from_secs(15) && self.results_tracker.pending_count() > 0 { + let (measured_pending, measured_pending_gas) = + self.results_tracker.measured_unconfirmed_inventory(); warn!( pending = self.results_tracker.pending_count(), + in_flight = self.results_tracker.total_in_flight(), + measured_pending, + measured_pending_gas, latest = latest_number, + latest_txs = latest_tx_count, + latest_gas_used = latest.gas_used, + latest_gas_limit = latest.gas_limit, + blocks_advanced, + block_has_measured_pending = has_measured_pending, "block watcher scanned block but matched no pending hashes" ); last_progress_log = Instant::now(); diff --git a/crates/infra/load-tests/src/runner/status.rs b/crates/infra/load-tests/src/runner/status.rs index 768c7959b0..08522f1848 100644 --- a/crates/infra/load-tests/src/runner/status.rs +++ b/crates/infra/load-tests/src/runner/status.rs @@ -59,19 +59,19 @@ pub struct DisplaySnapshot { pub senders_blocked: usize, /// Total number of senders. pub total_senders: usize, - /// Rolling 30s TPS. + /// Rolling 10-second TPS. pub rolling_tps: f64, - /// Rolling 30s GPS. + /// Rolling 10-second GPS. pub rolling_gps: f64, /// Configured target GPS (`None` means unbounded). pub target_gps: Option, - /// Rolling 30s p50 block landing latency. + /// Rolling 10-second p50 block landing latency. pub p50_latency: Duration, - /// Rolling 30s p99 block landing latency. + /// Rolling 10-second p99 block landing latency. pub p99_latency: Duration, - /// Rolling 30s flashblocks p50 latency. + /// Rolling 10-second flashblocks p50 latency. pub flashblocks_p50_latency: Duration, - /// Rolling 30s flashblocks p99 latency. + /// Rolling 10-second flashblocks p99 latency. pub flashblocks_p99_latency: Duration, /// Current gas price in gwei. pub gas_price_gwei: f64, @@ -245,7 +245,7 @@ impl LoadTestDisplay { 100.0 }; self.rate.set_message(format!( - "rate {:.2}% success tps {:.1} gps {} / {} (30s window)", + "rate {:.2}% success tps {:.1} gps {} / {} (10s window)", success_rate, snap.rolling_tps, fmt_num(snap.rolling_gps as u64), diff --git a/docs/guides/SNAPSHOT_BENCHMARKS.md b/docs/guides/SNAPSHOT_BENCHMARKS.md index cff94f2c1f..b7034939b2 100644 --- a/docs/guides/SNAPSHOT_BENCHMARKS.md +++ b/docs/guides/SNAPSHOT_BENCHMARKS.md @@ -70,7 +70,11 @@ rollup configuration is selected automatically and `--rollup-config` is optional `base-bench snapshot` launches the snapshot builder and validator, funds an ephemeral load-test account, runs the workload, verifies that both roles have -matching canonical blocks for the measured window, and then shuts down. +matching canonical blocks for the measured window, writes the result artifacts, +and terminates the process without running node or database cleanup. Snapshot +datadirs are expected to be disposable writable clones. Pass a nonzero +`--shutdown-timeout-seconds` (for example, `10`) to request the old bounded +graceful teardown. Each `--output-dir` is a self-contained `base/benchmark` input directory: diff --git a/etc/systems/README.md b/etc/systems/README.md index bd6756a362..e1476c5d33 100644 --- a/etc/systems/README.md +++ b/etc/systems/README.md @@ -119,6 +119,21 @@ the captured boundary differs. ## Run a snapshot benchmark +## Run a quick local transfer benchmark + +For a no-configuration smoke benchmark, `base-bench` starts a fresh temporary +local devnet, runs the default 60-second plain-transfer profile, prints its +load-test summary, and shuts everything down: + +```bash +cargo run --release -p base-system-tests --bin base-bench +``` + +It needs Docker, but it needs neither a snapshot nor a funded key. The generated +datadirs are temporary and are removed during shutdown. Use the explicit +`base-bench snapshot` arguments below for reproducible snapshot benchmarks and +report artifacts. + `base-bench snapshot` owns the process lifecycle around one load test: it generates an ephemeral funder, deposits funds to it in the first local descendant, replaces placeholder endpoints in the YAML with dynamically allocated builder endpoints, runs the load generator, writes JSON, and shuts diff --git a/etc/systems/src/benchmark_cli.rs b/etc/systems/src/benchmark_cli.rs index 21b30594f4..c092c47f16 100644 --- a/etc/systems/src/benchmark_cli.rs +++ b/etc/systems/src/benchmark_cli.rs @@ -26,24 +26,26 @@ use clap::{Args, Parser, Subcommand}; use eyre::{Result, WrapErr}; use crate::{ - DevnetBlockInterval, DevnetConfig, DevnetL2State, DevnetPrefund, PrometheusBlockCollector, - SnapshotBenchmarkReportConfig, SnapshotBenchmarkResult, SnapshotBlockMetrics, - SnapshotChainConfig, SnapshotL2Stack, SystemTestStackBuilder, VisualizerMetadata, - VisualizerRun, + ANVIL_ACCOUNT_1, DevnetBlockInterval, DevnetConfig, DevnetL2State, DevnetPrefund, + PrometheusBlockCollector, SnapshotBenchmarkReportConfig, SnapshotBenchmarkResult, + SnapshotBlockMetrics, SnapshotChainConfig, SnapshotL2Stack, SystemTestStackBuilder, + VisualizerMetadata, VisualizerRun, }; /// Base benchmark launcher. #[derive(Debug, Parser)] #[command(author, version, about = "Benchmark a Base development network")] pub struct BenchmarkCli { - /// Benchmark target. + /// Benchmark target. Omitting it runs the default fresh-devnet transfer benchmark. #[command(subcommand)] - pub command: BenchmarkCommand, + pub command: Option, } /// Supported benchmark targets. #[derive(Debug, Subcommand)] pub enum BenchmarkCommand { + /// Run the default transfer benchmark against a fresh temporary local devnet. + Local, /// Run one load test against a Base snapshot continuation. Snapshot(SnapshotBenchmarkArgs), /// Aggregate selected snapshot run artifacts into one report metadata file. @@ -98,14 +100,17 @@ pub struct SnapshotBenchmarkArgs { /// Stable build identifier for visualizer comparisons. #[arg(long, env = "BASE_BENCH_CLIENT_VERSION")] pub client_version: Option, - /// Maximum time to wait for graceful in-process stack shutdown after result artifacts have - /// been written. Expiry terminates the process because snapshot datadirs are disposable. - #[arg(long, default_value_t = 10)] + /// Maximum time to wait for graceful shutdown after writing results. Zero terminates the + /// process immediately because snapshot datadirs are disposable. + #[arg(long, default_value_t = 0)] pub shutdown_timeout_seconds: u64, } /// Wei minted to the benchmark's ephemeral funder in the first local descendant (1000 ETH). const PREFUND_AMOUNT_WEI: u128 = 1_000_000_000_000_000_000_000; +/// Use the full block gas limit as the EIP-1559 target in snapshot benchmarks, so synthetic +/// benchmark load cannot raise the base fee and strand already-submitted transaction nonce lanes. +const SNAPSHOT_BENCHMARK_EIP1559_ELASTICITY: u32 = 1; const RESULT_FILE_NAME: &str = "benchmark-result.json"; impl BenchmarkCli { @@ -113,10 +118,70 @@ impl BenchmarkCli { pub async fn run(self) -> Result<()> { let _progress = LoadTestDisplay::init_tracing(); match self.command { - BenchmarkCommand::Snapshot(args) => args.run().await, - BenchmarkCommand::Aggregate(args) => args.run(), + None | Some(BenchmarkCommand::Local) => Self::run_default_local().await, + Some(BenchmarkCommand::Snapshot(args)) => args.run().await, + Some(BenchmarkCommand::Aggregate(args)) => args.run(), } } + + /// Starts an isolated fresh devnet, runs the portable default transfer profile, and tears the + /// stack down. This deliberately has no flags or persistent datadir: it is the quick local + /// smoke benchmark. Snapshot benchmarks retain their explicit arguments for repeatable, + /// shareable performance runs. + async fn run_default_local() -> Result<()> { + let devnet = DevnetConfig::standard(); + let chain_id = devnet.l2_chain_id; + let funder_key = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_1.private_key) + .wrap_err("failed to construct the fresh-devnet funder key")?; + let stack = SystemTestStackBuilder::new().with_devnet_config(devnet).build().await?; + + println!("running default transfer benchmark against a fresh temporary devnet"); + let run_result: Result<()> = async { + let builder_rpc = stack.l2_rpc_url()?; + let client_rpc = stack.l2_client_rpc_url()?; + let flashblocks_ws = stack + .l2_stack() + .builder() + .flashblocks_url() + .parse() + .wrap_err("invalid fresh-devnet Flashblocks URL")?; + let test_config = TestConfig { + transaction_submission_rpcs: vec![builder_rpc], + query_rpc: Some(client_rpc), + flashblocks_ws: Some(flashblocks_ws), + chain_id: Some(chain_id), + skip_drain: true, + ..Default::default() + }; + + let load_config = test_config.to_load_config(None)?; + let output = LoadTestExecutor::run_prepared( + test_config, + load_config, + funder_key, + LoadTestRunOptions { + continuous: false, + install_signal_handler: true, + skip_drain: true, + }, + LoadTestRunHooks { + display: None, + before_cleanup: (|_: &MetricsSummary| {}) as fn(&MetricsSummary), + }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&output.summary)?); + if let Some(error) = output.run_error { + return Err(error.into()); + } + Ok(()) + } + .await; + + let shutdown_result = stack.shutdown().await; + run_result?; + shutdown_result + } } impl AggregateBenchmarkArgs { @@ -265,6 +330,7 @@ impl SnapshotBenchmarkArgs { unreachable!("snapshot constructor must create snapshot state") }; snapshot.block_interval = block_interval; + snapshot.eip1559_elasticity_override = Some(SNAPSHOT_BENCHMARK_EIP1559_ELASTICITY); snapshot.prefund = Some(DevnetPrefund { address: funder_key.address(), amount: PREFUND_AMOUNT_WEI }); @@ -282,10 +348,6 @@ impl SnapshotBenchmarkArgs { client_version, ); - eyre::ensure!( - self.shutdown_timeout_seconds > 0, - "shutdown timeout must be greater than zero" - ); let mut stack = SystemTestStackBuilder::new() .with_devnet_config(devnet) .build_snapshot_sequencer() @@ -336,6 +398,14 @@ impl SnapshotBenchmarkArgs { eprintln!("benchmark result processing failed before shutdown: {error:?}"); let _ = std::io::stderr().flush(); } + + if self.shutdown_timeout_seconds == 0 { + // The benchmark artifacts or error diagnostics are complete and flushed. Exit from + // inside the async entrypoint so neither the stack nor the outer Tokio runtime runs + // destructors that can wait for non-cancellable Reth work or database cleanup. + std::process::exit(if output_result.is_ok() { 0 } else { 1 }); + } + let shutdown_result = Self::shutdown_with_deadline(stack, self.shutdown_timeout_seconds).await; output_result?; @@ -580,7 +650,7 @@ mod tests { "example-scenario", ]); - let BenchmarkCommand::Snapshot(args) = cli.command else { + let Some(BenchmarkCommand::Snapshot(args)) = cli.command else { panic!("expected snapshot benchmark command"); }; assert_eq!(args.chain, "sepolia"); @@ -589,7 +659,7 @@ mod tests { assert_eq!(args.benchmark_run, "snapshot-throughput"); assert!(args.run_id.is_none()); assert!(args.client_version.is_none()); - assert_eq!(args.shutdown_timeout_seconds, 10); + assert_eq!(args.shutdown_timeout_seconds, 0); } #[test] @@ -615,7 +685,7 @@ mod tests { "30", ]); - let BenchmarkCommand::Snapshot(args) = cli.command else { + let Some(BenchmarkCommand::Snapshot(args)) = cli.command else { panic!("expected snapshot benchmark command"); }; assert_eq!(args.output_dir.to_string_lossy(), "result-dir"); @@ -694,13 +764,27 @@ mod tests { "results", "results/run-a", ]); - let BenchmarkCommand::Aggregate(args) = cli.command else { + let Some(BenchmarkCommand::Aggregate(args)) = cli.command else { panic!("expected aggregate benchmark command"); }; assert_eq!(args.output_dir, std::path::PathBuf::from("results")); assert_eq!(args.run_output_dirs, vec![std::path::PathBuf::from("results/run-a")]); } + #[test] + fn defaults_to_fresh_local_benchmark_without_arguments() { + let cli = BenchmarkCli::parse_from(["base-bench"]); + + assert!(cli.command.is_none()); + } + + #[test] + fn parses_explicit_fresh_local_benchmark() { + let cli = BenchmarkCli::parse_from(["base-bench", "local"]); + + assert!(matches!(cli.command, Some(BenchmarkCommand::Local))); + } + #[test] fn aggregate_keeps_latest_run_per_normalized_tag_set() { let root = tempfile::tempdir().unwrap(); diff --git a/etc/systems/src/l2/in_process_builder.rs b/etc/systems/src/l2/in_process_builder.rs index 3fb26978be..d541373801 100644 --- a/etc/systems/src/l2/in_process_builder.rs +++ b/etc/systems/src/l2/in_process_builder.rs @@ -178,6 +178,11 @@ impl InProcessBuilder { let mut node_config = create_node_config(chain_spec, &data_path, &jwt_path, &config)?; node_config.metrics = MetricArgs { prometheus: Some(metrics_addr), ..Default::default() }; + // In-process system-test datadirs are disposable and may be restored from snapshots. + // Never reinsert a transaction journal captured in the source snapshot or write a new + // journal that can contaminate a later benchmark clone. + node_config.txpool.disable_transactions_backup = true; + node_config.txpool.transactions_backup_path = None; let db_path = node_config.datadir().db(); let db = if config.datadir.is_some() { init_db(db_path, node_config.db.database_args()) diff --git a/etc/systems/src/l2/in_process_client.rs b/etc/systems/src/l2/in_process_client.rs index f73483bd9a..4c8adae30d 100644 --- a/etc/systems/src/l2/in_process_client.rs +++ b/etc/systems/src/l2/in_process_client.rs @@ -217,6 +217,11 @@ impl InProcessClient { if config.datadir.is_some() { node_config.debug.startup_sync_state_idle = true; } + // In-process system-test datadirs are disposable and may be restored from snapshots. + // Never reinsert a transaction journal captured in the source snapshot or write a new + // journal that can contaminate a later benchmark clone. + node_config.txpool.disable_transactions_backup = true; + node_config.txpool.transactions_backup_path = None; let metrics_addr = SocketAddr::new( std::net::Ipv4Addr::LOCALHOST.into(), config.metrics_port.unwrap_or_else(get_available_port), diff --git a/etc/systems/src/l2/snapshot_stack.rs b/etc/systems/src/l2/snapshot_stack.rs index eaa4268880..ef55692f73 100644 --- a/etc/systems/src/l2/snapshot_stack.rs +++ b/etc/systems/src/l2/snapshot_stack.rs @@ -8,7 +8,7 @@ use std::{ use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types_engine::JwtSecret; use alloy_rpc_types_eth::SyncStatus as EthSyncStatus; -use base_common_genesis::{BaseUpgrade, RollupConfig}; +use base_common_genesis::{BaseUpgrade, RollupConfig, SystemConfig}; use base_common_network::Base; use base_consensus_node::StandalonePrefund; use base_execution_chainspec::BaseChainSpec; @@ -124,6 +124,10 @@ impl SnapshotL2Stack { first_block_timestamp > boundary.head.timestamp, "snapshot boundary timestamp must be earlier than the local schedule anchor" ); + let system_config = Self::with_eip1559_elasticity_override( + boundary.system_config, + config.snapshot.eip1559_elasticity_override, + ); let rollup_config = Arc::new(Self::anchored_rollup_config( (*canonical_rollup_config).clone(), boundary.head.number, @@ -182,7 +186,7 @@ impl SnapshotL2Stack { jwt_secret, l2_engine_url: builder.engine_url()?, l1_info: boundary.l1_info, - system_config: boundary.system_config, + system_config, prefund, }) .await @@ -292,6 +296,17 @@ impl SnapshotL2Stack { chain_spec } + /// Applies the benchmark-only EIP-1559 elasticity override to locally sequenced payloads. + const fn with_eip1559_elasticity_override( + mut system_config: SystemConfig, + elasticity_override: Option, + ) -> SystemConfig { + if let Some(elasticity) = elasticity_override { + system_config.eip1559_elasticity = Some(elasticity); + } + system_config + } + fn schedule_anchor(now: SystemTime) -> Result { now.duration_since(UNIX_EPOCH) .wrap_err("system clock is before Unix epoch")? @@ -419,7 +434,7 @@ impl SnapshotL2Stack { #[cfg(test)] mod tests { use base_common_chains::{ChainConfig, Upgrades}; - use base_common_genesis::BaseUpgrade; + use base_common_genesis::{BaseUpgrade, SystemConfig}; use base_execution_chainspec::BaseChainSpec; use reth_ethereum_forks::ForkCondition; @@ -495,6 +510,24 @@ mod tests { assert_eq!(chain_spec.fork(BaseUpgrade::Denim), ForkCondition::Timestamp(activation)); } + #[test] + fn snapshot_elasticity_override_preserves_other_system_config_fields() { + let original = SystemConfig { + gas_limit: 1_200_000_000, + eip1559_denominator: Some(50), + eip1559_elasticity: Some(6), + min_base_fee: Some(5_000_000), + ..Default::default() + }; + + let overridden = SnapshotL2Stack::with_eip1559_elasticity_override(original, Some(1)); + + assert_eq!(overridden.eip1559_elasticity, Some(1)); + assert_eq!(overridden.eip1559_denominator, original.eip1559_denominator); + assert_eq!(overridden.gas_limit, original.gas_limit); + assert_eq!(overridden.min_base_fee, original.min_base_fee); + } + #[test] fn schedule_anchor_uses_startup_lead() { let now = std::time::UNIX_EPOCH + std::time::Duration::from_secs(2_000_000_000); diff --git a/etc/systems/src/system_config.rs b/etc/systems/src/system_config.rs index a2fb20e535..25b1a6fb50 100644 --- a/etc/systems/src/system_config.rs +++ b/etc/systems/src/system_config.rs @@ -174,6 +174,12 @@ pub struct DevnetSnapshotConfig { /// Block interval for locally produced descendants. #[serde(default)] pub block_interval: DevnetBlockInterval, + /// Optional elasticity override for locally sequenced snapshot descendants. + /// + /// This changes only the synthetic standalone sequencer's payload attributes; it does not + /// modify the captured snapshot's system configuration. + #[serde(default)] + pub eip1559_elasticity_override: Option, } /// Initial execution state used by a devnet stack. @@ -349,6 +355,7 @@ impl DevnetConfig { expected_head: None, prefund: None, block_interval: DevnetBlockInterval::default(), + eip1559_elasticity_override: None, })), stable: StableSystemTestConfig::standard(), use_stable_ports: false, @@ -376,6 +383,10 @@ impl DevnetConfig { ); ensure!(snapshot.builder_datadir.is_dir(), "builder snapshot datadir does not exist"); ensure!(snapshot.client_datadir.is_dir(), "client snapshot datadir does not exist"); + ensure!( + snapshot.eip1559_elasticity_override != Some(0), + "snapshot EIP-1559 elasticity override must be greater than zero" + ); let builder_datadir = std::fs::canonicalize(&snapshot.builder_datadir) .wrap_err("Failed to resolve builder snapshot datadir")?; @@ -455,6 +466,10 @@ mod tests { assert_eq!(config.l1_chain_id, 1); assert_eq!(config.l2_chain_id, 8453); assert_eq!(config.l1_mode, DevnetL1Mode::None); + let DevnetL2State::Snapshot(snapshot) = &config.l2_state else { + panic!("snapshot constructor must create snapshot state") + }; + assert_eq!(snapshot.eip1559_elasticity_override, None); config.validate().expect("Base mainnet snapshot config should be valid"); } From 90821ee4528718392762806e8839ded001e23688 Mon Sep 17 00:00:00 2001 From: William Law Date: Tue, 15 Sep 2026 16:34:43 +0000 Subject: [PATCH 004/299] feat(simulation): add inline-simulation config (1/n) (#4597) * feat(tx-forwarding): add inline-simulation config flag (off by default) Scaffold CLI/config for mempool in-process meter_bundle so later PRs can wire workers without a behavior change. Co-authored-by: Cursor * fix(tx-forwarding): backtick meter_bundle in docs for clippy Co-authored-by: Cursor --------- Co-authored-by: Cursor --- crates/execution/cli/src/standard_node.rs | 122 ++++++++++++++++++- crates/execution/tx-forwarding/src/config.rs | 87 +++++++++++++ crates/execution/tx-forwarding/src/lib.rs | 4 +- etc/systems/tests/tx_forwarding.rs | 12 +- 4 files changed, 218 insertions(+), 7 deletions(-) diff --git a/crates/execution/cli/src/standard_node.rs b/crates/execution/cli/src/standard_node.rs index fcb7c75375..470773081c 100644 --- a/crates/execution/cli/src/standard_node.rs +++ b/crates/execution/cli/src/standard_node.rs @@ -26,8 +26,9 @@ use base_shadow_indexer_db::{ DEFAULT_DATABASE, DEFAULT_PORT, DEFAULT_USERNAME, PgConnectionParams, ShadowDbConfig, }; use base_tx_forwarding::{ - DEFAULT_MAX_BATCH_SIZE, DEFAULT_MAX_RPS, DEFAULT_RESEND_AFTER_MS, TxForwardingConfig, - TxForwardingExtension, + DEFAULT_INLINE_SIMULATION_QUEUE_CAPACITY, DEFAULT_INLINE_SIMULATION_TIMEOUT_MS, + DEFAULT_INLINE_SIMULATION_WORKERS, DEFAULT_MAX_BATCH_SIZE, DEFAULT_MAX_RPS, + DEFAULT_RESEND_AFTER_MS, TxForwardingConfig, TxForwardingExtension, }; use base_txpool_rpc::{ DEFAULT_MAX_VALIDITY_EXPIRY_SECS, DEFAULT_MAX_VALIDITY_PREDICATES, @@ -410,6 +411,39 @@ pub struct RpcStandardNodeArgs { requires = "enable_tx_forwarding" )] pub tx_forwarding_max_rps: u32, + + /// 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. + #[arg(long = "enable-inline-simulation", requires = "enable_tx_forwarding")] + pub enable_inline_simulation: bool, + + /// Number of in-process `meter_bundle` workers. + #[arg( + long = "inline-simulation-workers", + value_name = "INLINE_SIMULATION_WORKERS", + default_value_t = DEFAULT_INLINE_SIMULATION_WORKERS, + requires = "enable_inline_simulation" + )] + pub inline_simulation_workers: usize, + + /// Bounded queue of txs waiting for `meter_bundle`. + #[arg( + long = "inline-simulation-queue-capacity", + value_name = "INLINE_SIMULATION_QUEUE_CAPACITY", + default_value_t = DEFAULT_INLINE_SIMULATION_QUEUE_CAPACITY, + requires = "enable_inline_simulation" + )] + pub inline_simulation_queue_capacity: usize, + + /// Per-transaction `meter_bundle` timeout in milliseconds. + #[arg( + long = "inline-simulation-timeout-ms", + value_name = "INLINE_SIMULATION_TIMEOUT_MS", + default_value_t = DEFAULT_INLINE_SIMULATION_TIMEOUT_MS, + requires = "enable_inline_simulation" + )] + pub inline_simulation_timeout_ms: u64, } impl From for StandardNodeArgs { @@ -528,6 +562,10 @@ impl From<&StandardNodeArgs> for TxForwardingConfig { .with_resend_after_ms(args.rpc.tx_forwarding_resend_after_ms) .with_max_batch_size(args.rpc.tx_forwarding_batch_size) .with_max_rps(args.rpc.tx_forwarding_max_rps) + .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) + .with_inline_simulation_timeout_ms(args.rpc.inline_simulation_timeout_ms) } } @@ -937,6 +975,10 @@ mod tests { tx_forwarding_resend_after_ms: DEFAULT_RESEND_AFTER_MS, tx_forwarding_batch_size: DEFAULT_MAX_BATCH_SIZE, tx_forwarding_max_rps: DEFAULT_MAX_RPS, + enable_inline_simulation: false, + inline_simulation_workers: DEFAULT_INLINE_SIMULATION_WORKERS, + inline_simulation_queue_capacity: DEFAULT_INLINE_SIMULATION_QUEUE_CAPACITY, + inline_simulation_timeout_ms: DEFAULT_INLINE_SIMULATION_TIMEOUT_MS, } } @@ -1065,6 +1107,7 @@ mod tests { ); assert!(!config.enabled); assert!(config.builder_urls.is_empty()); + assert!(!config.inline_simulation); } #[test] @@ -1143,6 +1186,81 @@ mod tests { assert_eq!(args.rpc.experimental_validity_max_predicates, DEFAULT_MAX_VALIDITY_PREDICATES); } + #[test] + fn inline_simulation_requires_forwarding() { + let error = CommandParser::::try_parse_from([ + "base-reth", + "--enable-inline-simulation", + ]) + .expect_err("inline simulation should require forwarding"); + + assert!(error.to_string().contains("--enable-tx-forwarding")); + } + + #[test] + fn inline_simulation_workers_require_enable_flag() { + let error = CommandParser::::try_parse_from([ + "base-reth", + "--enable-tx-forwarding", + "--builder-rpc-urls", + "http://localhost:8545", + "--inline-simulation-workers", + "8", + ]) + .expect_err("worker count should require --enable-inline-simulation"); + + assert!(error.to_string().contains("--enable-inline-simulation")); + } + + #[test] + fn forwarding_without_inline_simulation_stays_off() { + let args = CommandParser::::parse_from([ + "base-reth", + "--enable-tx-forwarding", + "--builder-rpc-urls", + "http://localhost:8545", + ]) + .args; + let config = TxForwardingConfig::from(&args); + + assert!(config.enabled); + assert!(!config.inline_simulation); + assert_eq!(config.inline_simulation_workers, DEFAULT_INLINE_SIMULATION_WORKERS); + assert_eq!( + config.inline_simulation_queue_capacity, + DEFAULT_INLINE_SIMULATION_QUEUE_CAPACITY + ); + assert_eq!(config.inline_simulation_timeout_ms, DEFAULT_INLINE_SIMULATION_TIMEOUT_MS); + } + + #[test] + fn parses_inline_simulation_flags() { + let args = CommandParser::::parse_from([ + "base-reth", + "--enable-tx-forwarding", + "--builder-rpc-urls", + "http://localhost:8545", + "--enable-inline-simulation", + "--inline-simulation-workers", + "8", + "--inline-simulation-queue-capacity", + "32", + "--inline-simulation-timeout-ms", + "500", + ]) + .args; + let config = TxForwardingConfig::from(&args); + + assert!(args.rpc.enable_inline_simulation); + assert_eq!(args.rpc.inline_simulation_workers, 8); + assert_eq!(args.rpc.inline_simulation_queue_capacity, 32); + assert_eq!(args.rpc.inline_simulation_timeout_ms, 500); + assert!(config.inline_simulation); + assert_eq!(config.inline_simulation_workers, 8); + assert_eq!(config.inline_simulation_queue_capacity, 32); + assert_eq!(config.inline_simulation_timeout_ms, 500); + } + #[test] fn programmatic_validity_config_without_forwarding_is_valid() { let mut args = StandardNodeArgs::from(default_rpc_standard_node_args()); diff --git a/crates/execution/tx-forwarding/src/config.rs b/crates/execution/tx-forwarding/src/config.rs index 428594c6e3..b1f5736025 100644 --- a/crates/execution/tx-forwarding/src/config.rs +++ b/crates/execution/tx-forwarding/src/config.rs @@ -12,6 +12,13 @@ pub const DEFAULT_RESEND_AFTER_MS: u64 = 4000; pub const DEFAULT_MAX_BATCH_SIZE: usize = 100; /// Default maximum RPC requests per second per forwarder. pub const DEFAULT_MAX_RPS: u32 = 200; +/// Default number of in-process `meter_bundle` workers. +pub const DEFAULT_INLINE_SIMULATION_WORKERS: usize = 16; +/// Default capacity of the pre-sim queue. +pub const DEFAULT_INLINE_SIMULATION_QUEUE_CAPACITY: usize = 10_000; +/// Default per-transaction `meter_bundle` timeout in milliseconds. +pub const DEFAULT_INLINE_SIMULATION_TIMEOUT_MS: u64 = 2_000; + /// Full configuration for the transaction forwarding extension. #[derive(Debug, Clone)] pub struct TxForwardingConfig { @@ -25,6 +32,17 @@ pub struct TxForwardingConfig { pub max_batch_size: usize, /// Maximum RPC requests per second per forwarder (0 = unlimited). pub max_rps: u32, + /// When true, `meter_bundle` runs on the mempool node before pool insert. + /// + /// Stored only until the sim-worker path is wired; forwarding behavior is + /// unchanged while this flag is unused. + pub inline_simulation: bool, + /// Number of `meter_bundle` worker tasks. + pub inline_simulation_workers: usize, + /// Bounded pre-sim queue capacity. + pub inline_simulation_queue_capacity: usize, + /// Per-transaction `meter_bundle` timeout in milliseconds. + pub inline_simulation_timeout_ms: u64, } impl Default for TxForwardingConfig { @@ -36,6 +54,10 @@ impl Default for TxForwardingConfig { resend_after_ms: DEFAULT_RESEND_AFTER_MS, max_batch_size: DEFAULT_MAX_BATCH_SIZE, max_rps: DEFAULT_MAX_RPS, + inline_simulation: false, + inline_simulation_workers: DEFAULT_INLINE_SIMULATION_WORKERS, + inline_simulation_queue_capacity: DEFAULT_INLINE_SIMULATION_QUEUE_CAPACITY, + inline_simulation_timeout_ms: DEFAULT_INLINE_SIMULATION_TIMEOUT_MS, } } } @@ -69,6 +91,30 @@ impl TxForwardingConfig { self } + /// Enables or disables in-process `meter_bundle` before pool insert. + pub const fn with_inline_simulation(mut self, enabled: bool) -> Self { + self.inline_simulation = enabled; + self + } + + /// Sets the number of `meter_bundle` worker tasks. + pub const fn with_inline_simulation_workers(mut self, workers: usize) -> Self { + self.inline_simulation_workers = workers; + self + } + + /// Sets the pre-sim queue capacity. + pub const fn with_inline_simulation_queue_capacity(mut self, capacity: usize) -> Self { + self.inline_simulation_queue_capacity = capacity; + self + } + + /// Sets the per-transaction `meter_bundle` timeout in milliseconds. + pub const fn with_inline_simulation_timeout_ms(mut self, ms: u64) -> Self { + self.inline_simulation_timeout_ms = ms; + self + } + /// Builds the per-destination reader configuration. pub(crate) fn reader_config(&self) -> ReaderConfig { ReaderConfig { @@ -86,3 +132,44 @@ impl TxForwardingConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inline_simulation_defaults_off() { + let config = TxForwardingConfig::default(); + + assert!(!config.inline_simulation); + assert_eq!(config.inline_simulation_workers, DEFAULT_INLINE_SIMULATION_WORKERS); + assert_eq!( + config.inline_simulation_queue_capacity, + DEFAULT_INLINE_SIMULATION_QUEUE_CAPACITY + ); + assert_eq!(config.inline_simulation_timeout_ms, DEFAULT_INLINE_SIMULATION_TIMEOUT_MS); + } + + #[test] + fn new_enables_forwarding_without_inline_simulation() { + let config = TxForwardingConfig::new(vec!["http://builder.test".parse().unwrap()]); + + assert!(config.enabled); + assert!(!config.inline_simulation); + assert_eq!(config.inline_simulation_workers, DEFAULT_INLINE_SIMULATION_WORKERS); + } + + #[test] + fn inline_simulation_builders_override_defaults() { + let config = TxForwardingConfig::new(vec!["http://builder.test".parse().unwrap()]) + .with_inline_simulation(true) + .with_inline_simulation_workers(8) + .with_inline_simulation_queue_capacity(32) + .with_inline_simulation_timeout_ms(500); + + assert!(config.inline_simulation); + assert_eq!(config.inline_simulation_workers, 8); + assert_eq!(config.inline_simulation_queue_capacity, 32); + assert_eq!(config.inline_simulation_timeout_ms, 500); + } +} diff --git a/crates/execution/tx-forwarding/src/lib.rs b/crates/execution/tx-forwarding/src/lib.rs index 4dca2f6684..2c205304c9 100644 --- a/crates/execution/tx-forwarding/src/lib.rs +++ b/crates/execution/tx-forwarding/src/lib.rs @@ -5,7 +5,9 @@ mod config; pub use config::{ - DEFAULT_MAX_BATCH_SIZE, DEFAULT_MAX_RPS, DEFAULT_RESEND_AFTER_MS, TxForwardingConfig, + DEFAULT_INLINE_SIMULATION_QUEUE_CAPACITY, DEFAULT_INLINE_SIMULATION_TIMEOUT_MS, + DEFAULT_INLINE_SIMULATION_WORKERS, DEFAULT_MAX_BATCH_SIZE, DEFAULT_MAX_RPS, + DEFAULT_RESEND_AFTER_MS, TxForwardingConfig, }; mod extension; diff --git a/etc/systems/tests/tx_forwarding.rs b/etc/systems/tests/tx_forwarding.rs index 76a9ede077..90cd623a2e 100644 --- a/etc/systems/tests/tx_forwarding.rs +++ b/etc/systems/tests/tx_forwarding.rs @@ -242,13 +242,17 @@ async fn test_insert_validated_transaction_single() -> Result<()> { async fn test_tx_forwarding_pipeline_system() -> Result<()> { // Build a system test stack with tx forwarding enabled on the client. // The client will forward transactions to the builder's RPC endpoint + let forwarding = + TxForwardingConfig::new(vec![]).with_resend_after_ms(2000).with_max_batch_size(100); + assert!( + !forwarding.inline_simulation, + "inline simulation must stay off so this path still inserts then forwards" + ); + let system = SystemTestStackBuilder::new() .with_l1_chain_id(L1_CHAIN_ID) .with_l2_chain_id(L2_CHAIN_ID) - .with_tx_forwarding( - // Empty vector here because the stack will populate it with the builder RPC URL on start - TxForwardingConfig::new(vec![]).with_resend_after_ms(2000).with_max_batch_size(100), - ) + .with_tx_forwarding(forwarding) .build() .await?; From 21ef4294a11494a7c7a6415c83d940837932ec7c Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Tue, 15 Sep 2026 16:40:20 +0000 Subject: [PATCH 005/299] experiment(rpc): simulate Denim without Reth changes (#5043) --- Cargo.lock | 4 + Cargo.toml | 2 + crates/execution/rpc/Cargo.toml | 4 + crates/execution/rpc/src/eth/call.rs | 457 ++++++++++++++++++++++++++- 4 files changed, 465 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c4ff95e06b..336c011cf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4542,6 +4542,7 @@ version = "0.0.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", + "alloy-evm", "alloy-json-rpc 2.4.1", "alloy-primitives", "alloy-rpc-client", @@ -4580,6 +4581,7 @@ dependencies = [ "reth-basic-payload-builder", "reth-chain-state", "reth-chainspec", + "reth-errors", "reth-evm", "reth-node-api", "reth-node-builder", @@ -4589,6 +4591,7 @@ dependencies = [ "reth-revm", "reth-rpc", "reth-rpc-api", + "reth-rpc-convert", "reth-rpc-engine-api", "reth-rpc-eth-api", "reth-rpc-eth-types", @@ -4598,6 +4601,7 @@ dependencies = [ "reth-transaction-pool", "reth-trie-common", "revm", + "revm-inspectors", "serde", "thiserror 2.0.19", "tokio", diff --git a/Cargo.toml b/Cargo.toml index b1e6f61973..6e3b7bc604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -325,6 +325,7 @@ alloy-rlp = { version = "0.3.16", default-features = false } alloy-trie = { version = "0.9.4", default-features = false } alloy-eips = { version = "2.3.0", default-features = false } alloy-evm = { version = "0.38.0", default-features = false } +revm-inspectors = { version = "0.42.2", default-features = false } alloy-serde = { version = "2.3.0", default-features = false } alloy-chains = { version = "0.2.33", default-features = false } alloy-genesis = { version = "2.3.0", default-features = false } @@ -348,6 +349,7 @@ reth-ipc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-evm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-rpc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-revm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-trie = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-exex = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-tasks = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } diff --git a/crates/execution/rpc/Cargo.toml b/crates/execution/rpc/Cargo.toml index 6db18c2e71..b172e1b3dd 100644 --- a/crates/execution/rpc/Cargo.toml +++ b/crates/execution/rpc/Cargo.toml @@ -16,6 +16,7 @@ workspace = true reth-evm.workspace = true reth-rpc.workspace = true reth-revm.workspace = true +reth-errors.workspace = true reth-rpc-api.workspace = true reth-provider.workspace = true reth-node-api.workspace = true @@ -23,6 +24,7 @@ reth-chainspec.workspace = true reth-storage-api.workspace = true reth-trie-common.workspace = true reth-chain-state.workspace = true +reth-rpc-convert.workspace = true reth-rpc-eth-api.workspace = true reth-payload-util.workspace = true reth-node-builder.workspace = true @@ -45,6 +47,7 @@ base-execution-payload-builder.workspace = true # ethereum revm.workspace = true +alloy-evm.workspace = true alloy-eips.workspace = true alloy-serde.workspace = true alloy-json-rpc.workspace = true @@ -58,6 +61,7 @@ alloy-rpc-types-eth.workspace = true alloy-transport-http.workspace = true alloy-rpc-types-debug.workspace = true alloy-rpc-types-engine.workspace = true +revm-inspectors.workspace = true base-common-consensus = { workspace = true, features = ["reth"] } base-common-rpc-types = { workspace = true, features = ["reth"] } base-common-rpc-types-engine = { workspace = true, features = ["reth"] } diff --git a/crates/execution/rpc/src/eth/call.rs b/crates/execution/rpc/src/eth/call.rs index ddc5ae1578..715aa40cba 100644 --- a/crates/execution/rpc/src/eth/call.rs +++ b/crates/execution/rpc/src/eth/call.rs @@ -1,7 +1,36 @@ +use std::collections::BTreeMap; + +use alloy_consensus::BlockHeader; +use alloy_evm::{ + env::BlockEnvironment, + overrides::{OverrideBlockHashes, apply_block_overrides, apply_state_overrides}, +}; +use alloy_primitives::{B256, U256}; +use alloy_rpc_types_eth::{ + BlockId, BlockOverrides, + simulate::{SimBlock, SimulatePayload, SimulatedBlock}, +}; +use base_common_chains::BaseUpgrade; +use reth_chainspec::{ChainSpecProvider, EthChainSpec, EthereumHardforks, Hardforks}; +use reth_errors::RethError; +use reth_evm::{ConfigureEvm, Evm, execute::BlockBuilder}; +use reth_primitives_traits::SealedHeader; +use reth_revm::{database::StateProviderDatabase, db::State}; +use reth_rpc_convert::RpcTxReq; use reth_rpc_eth_api::{ - FromEvmError, RpcConvert, - helpers::{Call, EthCall, estimate::EstimateCall}, + EthApiTypes, FromEvmError, RpcBlock, RpcConvert, + helpers::{ + Call, EthCall, LoadBlock, SpawnBlocking, estimate::EstimateCall, + pending_block::LoadPendingBlock, + }, }; +use reth_rpc_eth_types::{ + EthApiError, + error::{AsEthApiError, FromEthApiError}, + simulate::{self, EthSimulateError}, +}; +use revm::{context::Block, context_interface::Cfg}; +use revm_inspectors::transfer::TransferInspector; use crate::{BaseEthApi, BaseEthApiError, eth::RpcNodeCore}; @@ -11,6 +40,210 @@ where BaseEthApiError: FromEvmError, Rpc: RpcConvert, { + async fn simulate_v1( + &self, + payload: SimulatePayload::Network>>, + block: Option, + ) -> Result>>, Self::Error> { + if payload.block_state_calls.len() > self.max_simulate_blocks() as usize { + return Err(EthApiError::other(EthSimulateError::TooManyBlocks).into()); + } + + let block = block.unwrap_or_default(); + + let SimulatePayload { + block_state_calls, + trace_transfers, + validation, + return_full_transactions, + } = payload; + + if block_state_calls.is_empty() { + return Err(EthApiError::InvalidParams(String::from("calls are empty.")).into()); + } + + let _permit = self.acquire_owned_blocking_io().await; + + let base_block = self + .recovered_block(block) + .await? + .ok_or_else(|| EthApiError::other(EthSimulateError::BlockNotFound { block }))?; + let parent = base_block.sealed_header().clone(); + let max_simulate_blocks = self.max_simulate_blocks(); + + self.spawn_with_state_at_block(block, move |this, db| { + let state_provider = db.database.0.0; + let mut db = State::builder() + .with_database(StateProviderDatabase::new(&state_provider)) + .with_bundle_update() + .build(); + let mut parent = parent; + + let chain_spec = this.provider().chain_spec(); + let genesis = chain_spec.genesis_header(); + let denim_timestamp = chain_spec.fork(BaseUpgrade::Denim).as_timestamp(); + + // Validate block ordering and fill gaps with empty blocks so every entry has an + // explicit `number` and `time` override and the chain is contiguous (see the + // execution-apis spec note: "If the block number is increased more than 1 compared + // to the previous block, new empty blocks are generated in between."). + let block_state_calls = sanitize_base_chain( + block_state_calls, + &parent, + genesis.number(), + genesis.timestamp(), + denim_timestamp, + max_simulate_blocks, + )?; + + let mut blocks: Vec>> = + Vec::with_capacity(block_state_calls.len()); + + let call_gas_limit = this.call_gas_limit(); + let mut remaining_call_gas_limit = (call_gas_limit > 0).then_some(call_gas_limit); + + for block in block_state_calls { + let SimBlock { block_overrides, state_overrides, calls } = block; + + let attributes = this + .pending_env_builder() + .pending_env_attributes(&parent, block_overrides.as_ref()) + .map_err(Self::Error::from_eth_err)?; + + let mut evm_env = this + .evm_config() + .next_evm_env(&parent, &attributes) + .map_err(RethError::other) + .map_err(Self::Error::from_eth_err)?; + + // Always disable EIP-3607 + evm_env.cfg_env.disable_eip3607 = true; + + // EIP-7825's transaction gas cap is only active with Amsterdam's + // regular/state-gas accounting. + if !evm_env.cfg_env.is_amsterdam_eip8037_enabled() { + evm_env.cfg_env.tx_gas_limit_cap = Some(u64::MAX); + } + + if !validation { + // If not explicitly required, we disable nonce check + evm_env.cfg_env.disable_nonce_check = true; + evm_env.cfg_env.disable_base_fee = true; + evm_env.block_env.inner_mut().basefee = 0; + } + + // Set prevrandao to zero for simulated blocks by default, + // matching spec behavior where MixDigest is zero-initialized. + // If user provides an override, it will be applied by apply_block_overrides. + evm_env.block_env.inner_mut().prevrandao = Some(B256::ZERO); + if !this + .provider() + .chain_spec() + .is_paris_active_at_block(evm_env.block_env.number().saturating_to()) + { + evm_env.block_env.inner_mut().difficulty = parent.difficulty(); + } + + if let Some(block_overrides) = block_overrides { + // ensure we don't allow uncapped gas limit per block + if let Some(gas_limit_override) = block_overrides.gas_limit + && gas_limit_override > evm_env.block_env.gas_limit() + && gas_limit_override > this.call_gas_limit() + { + return Err(EthApiError::other(EthSimulateError::GasLimitReached).into()); + } + apply_block_overrides(block_overrides, &mut db, evm_env.block_env.inner_mut()); + } + if let Some(ref state_overrides) = state_overrides { + apply_state_overrides(state_overrides.clone(), &mut db) + .map_err(Self::Error::from_eth_err)?; + } + + let chain_id = evm_env.cfg_env.chain_id; + + let ctx = this + .evm_config() + .context_for_next_block(&parent, attributes) + .map_err(RethError::other) + .map_err(Self::Error::from_eth_err)?; + let map_err = |e: EthApiError| -> Self::Error { + e.as_simulate_error().map_or_else( + || Self::Error::from_eth_err(e), + |sim_err| Self::Error::from_eth_err(EthApiError::other(sim_err)), + ) + }; + + let (result, results) = if trace_transfers { + // prepare inspector to capture transfer inside the evm so they are recorded + // and included in logs + let inspector = TransferInspector::new(false).with_logs(true); + let evm = + this.evm_config().evm_with_env_and_inspector(&mut db, evm_env, inspector); + let mut builder = this.evm_config().create_block_builder(evm, &parent, ctx); + + if let Some(ref state_overrides) = state_overrides { + simulate::apply_precompile_overrides( + state_overrides, + builder.evm_mut().precompiles_mut(), + ) + .map_err(|e| Self::Error::from_eth_err(EthApiError::other(e)))?; + } + + simulate::execute_transactions( + builder, + &state_provider, + calls, + &mut remaining_call_gas_limit, + chain_id, + this.compute_state_root_for_eth_simulate(), + this.converter(), + ) + .map_err(map_err)? + } else { + let evm = this.evm_config().evm_with_env(&mut db, evm_env); + let mut builder = this.evm_config().create_block_builder(evm, &parent, ctx); + + if let Some(ref state_overrides) = state_overrides { + simulate::apply_precompile_overrides( + state_overrides, + builder.evm_mut().precompiles_mut(), + ) + .map_err(|e| Self::Error::from_eth_err(EthApiError::other(e)))?; + } + + simulate::execute_transactions( + builder, + &state_provider, + calls, + &mut remaining_call_gas_limit, + chain_id, + this.compute_state_root_for_eth_simulate(), + this.converter(), + ) + .map_err(map_err)? + }; + + let simulated_header = result.block.clone_sealed_header(); + db.override_block_hashes(BTreeMap::from([( + simulated_header.number(), + simulated_header.hash(), + )])); + parent = simulated_header; + + let block = simulate::build_simulated_block::( + result.block, + results, + return_full_transactions.into(), + this.converter(), + )?; + + blocks.push(block); + } + + Ok(blocks) + }) + .await + } } impl EstimateCall for BaseEthApi @@ -47,3 +280,223 @@ where self.inner.eth_api.compute_state_root_for_eth_simulate() } } + +fn sanitize_base_chain( + blocks: Vec>, + parent: &SealedHeader, + genesis_number: u64, + genesis_timestamp: u64, + denim_timestamp: Option, + max_simulate_blocks: u64, +) -> Result>, EthApiError> +where + H: BlockHeader, +{ + const LEGACY_BLOCK_TIME: u64 = 2; + const DENIM_BLOCK_TIME_MS: u64 = 200; + + let activation_block = denim_timestamp.map(|timestamp| { + genesis_number + .saturating_add(timestamp.saturating_sub(genesis_timestamp).div_ceil(LEGACY_BLOCK_TIME)) + }); + let activation_timestamp = activation_block.map(|block| { + genesis_timestamp + .saturating_add(block.saturating_sub(genesis_number).saturating_mul(LEGACY_BLOCK_TIME)) + }); + let timestamp_for_block = |block_number: u64| { + if let (Some(activation_block), Some(activation_timestamp)) = + (activation_block, activation_timestamp) + && block_number >= activation_block + { + return activation_timestamp.saturating_add( + block_number.saturating_sub(activation_block).saturating_mul(DENIM_BLOCK_TIME_MS) + / 1_000, + ); + } + + genesis_timestamp.saturating_add( + block_number.saturating_sub(genesis_number).saturating_mul(LEGACY_BLOCK_TIME), + ) + }; + let next_timestamp = |block_number: u64, prev_timestamp: u64| { + let scheduled = timestamp_for_block(block_number); + if scheduled > prev_timestamp { + Some(scheduled) + } else if denim_timestamp.is_some_and(|activation| prev_timestamp >= activation) { + Some(prev_timestamp) + } else { + prev_timestamp.checked_add(LEGACY_BLOCK_TIME) + } + }; + + let mut out = Vec::with_capacity(blocks.len()); + let base_number = parent.number(); + let mut prev_number = base_number; + let mut prev_timestamp = parent.timestamp(); + + for mut block in blocks { + let overrides = block.block_overrides.get_or_insert_with(BlockOverrides::default); + let target_number = if let Some(number) = overrides.number { + u64::try_from(number).unwrap_or(u64::MAX) + } else { + let number = prev_number.saturating_add(1); + overrides.number = Some(U256::from(number)); + number + }; + + if target_number <= prev_number { + return Err(EthApiError::other(EthSimulateError::BlockNumberInvalid { + got: target_number, + parent: prev_number, + })); + } + if target_number.saturating_sub(base_number) > max_simulate_blocks { + return Err(EthApiError::other(EthSimulateError::TooManyBlocks)); + } + + let gap = target_number - prev_number; + if gap > 1 { + for i in 1..gap { + let filler_number = prev_number + i; + let filler_time = + next_timestamp(filler_number, prev_timestamp).ok_or_else(|| { + EthApiError::other(EthSimulateError::BlockTimestampInvalid { + got: prev_timestamp, + parent: prev_timestamp, + }) + })?; + out.push(SimBlock { + block_overrides: Some(BlockOverrides { + number: Some(U256::from(filler_number)), + time: Some(filler_time), + ..Default::default() + }), + state_overrides: None, + calls: Vec::new(), + }); + prev_timestamp = filler_time; + } + } + + prev_number = target_number; + let block_time = if let Some(timestamp) = overrides.time { + let allows_same_timestamp = denim_timestamp + .is_some_and(|activation| timestamp >= activation && prev_timestamp >= activation); + if timestamp < prev_timestamp || (timestamp == prev_timestamp && !allows_same_timestamp) + { + return Err(EthApiError::other(EthSimulateError::BlockTimestampInvalid { + got: timestamp, + parent: prev_timestamp, + })); + } + timestamp + } else { + let timestamp = next_timestamp(target_number, prev_timestamp).ok_or_else(|| { + EthApiError::other(EthSimulateError::BlockTimestampInvalid { + got: prev_timestamp, + parent: prev_timestamp, + }) + })?; + overrides.time = Some(timestamp); + timestamp + }; + prev_timestamp = block_time; + out.push(block); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use alloy_consensus::Header; + + use super::*; + + #[test] + fn sanitize_base_chain_uses_denim_schedule() { + let parent = + SealedHeader::seal_slow(Header { number: 5, timestamp: 10, ..Default::default() }); + let blocks = vec![SimBlock::<()>::default(), SimBlock::default(), SimBlock::default()]; + + let blocks = sanitize_base_chain(blocks, &parent, 0, 0, Some(10), 256).unwrap(); + let timestamps: Vec<_> = blocks + .iter() + .map(|block| block.block_overrides.as_ref().unwrap().time.unwrap()) + .collect(); + + assert_eq!(timestamps, vec![10, 10, 10]); + } + + #[test] + fn sanitize_base_chain_accepts_repeated_timestamp_after_denim() { + let parent = + SealedHeader::seal_slow(Header { number: 5, timestamp: 10, ..Default::default() }); + let blocks = vec![SimBlock::<()> { + block_overrides: Some(BlockOverrides { time: Some(10), ..Default::default() }), + ..Default::default() + }]; + + assert!(sanitize_base_chain(blocks, &parent, 0, 0, Some(10), 256).is_ok()); + } + + #[test] + fn sanitize_base_chain_rejects_repeated_timestamp_before_denim() { + let parent = + SealedHeader::seal_slow(Header { number: 4, timestamp: 8, ..Default::default() }); + let blocks = vec![SimBlock::<()> { + block_overrides: Some(BlockOverrides { time: Some(8), ..Default::default() }), + ..Default::default() + }]; + + assert!(sanitize_base_chain(blocks, &parent, 0, 0, Some(10), 256).is_err()); + } + + #[test] + fn sanitize_base_chain_preserves_user_timestamp_for_following_default() { + let parent = + SealedHeader::seal_slow(Header { number: 5, timestamp: 10, ..Default::default() }); + let blocks = vec![ + SimBlock::<()> { + block_overrides: Some(BlockOverrides { time: Some(100), ..Default::default() }), + ..Default::default() + }, + SimBlock::default(), + ]; + + let blocks = sanitize_base_chain(blocks, &parent, 0, 0, Some(10), 256).unwrap(); + let timestamps: Vec<_> = blocks + .iter() + .map(|block| block.block_overrides.as_ref().unwrap().time.unwrap()) + .collect(); + + assert_eq!(timestamps, vec![100, 100]); + } + + #[test] + fn sanitize_base_chain_preserves_user_timestamp_for_fillers() { + let parent = + SealedHeader::seal_slow(Header { number: 5, timestamp: 10, ..Default::default() }); + let blocks = vec![ + SimBlock::<()> { + block_overrides: Some(BlockOverrides { time: Some(100), ..Default::default() }), + ..Default::default() + }, + SimBlock { + block_overrides: Some(BlockOverrides { + number: Some(U256::from(9)), + ..Default::default() + }), + ..Default::default() + }, + ]; + + let blocks = sanitize_base_chain(blocks, &parent, 0, 0, Some(10), 256).unwrap(); + let timestamps: Vec<_> = blocks + .iter() + .map(|block| block.block_overrides.as_ref().unwrap().time.unwrap()) + .collect(); + + assert_eq!(timestamps, vec![100, 100, 100, 100]); + } +} From a8934ff4d2eb51093451b1e2048adce19db3b8f6 Mon Sep 17 00:00:00 2001 From: refcell Date: Tue, 15 Sep 2026 17:01:29 +0000 Subject: [PATCH 006/299] fix(cli): restore unified upgrade metrics (#5047) Co-authored-by: Codex --- Cargo.lock | 2 + bin/base/Cargo.toml | 2 + bin/base/src/commands/command.rs | 127 +++++++++++++++++++++++++++-- bin/base/src/commands/follow.rs | 18 ++-- bin/base/src/commands/rpc.rs | 18 ++-- bin/base/src/commands/sequencer.rs | 18 ++-- 6 files changed, 147 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 336c011cf8..a0fdaf5fda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2925,10 +2925,12 @@ dependencies = [ "clap", "eyre", "figment", + "reqwest 0.13.4", "reth-cli-commands", "reth-cli-runner", "reth-cli-util", "serde", + "tempfile", "tokio", "tokio-util", "tracing", diff --git a/bin/base/Cargo.toml b/bin/base/Cargo.toml index 9c5954a38a..915b5d08b2 100644 --- a/bin/base/Cargo.toml +++ b/bin/base/Cargo.toml @@ -56,6 +56,8 @@ serde = { workspace = true, features = ["derive"] } figment = { workspace = true, features = ["env", "toml"] } [dev-dependencies] +reqwest.workspace = true +tempfile.workspace = true base-common-genesis.workspace = true figment = { workspace = true, features = ["test"] } diff --git a/bin/base/src/commands/command.rs b/bin/base/src/commands/command.rs index f67034a146..77064a0209 100644 --- a/bin/base/src/commands/command.rs +++ b/bin/base/src/commands/command.rs @@ -60,11 +60,9 @@ impl BaseCommand { RuntimeManager::new().run_until_ctrl_c((*batcher).exec(metrics_enabled)) } Self::Bootnode(bootnode) => (*bootnode).run(chain_resolver.resolve()?, metrics_enabled), - Self::Rpc(rpc) => (*rpc).run(chain_resolver.resolve()?, metrics_enabled), - Self::Follow(follow) => (*follow).run(chain_resolver.resolve()?, metrics_enabled), - Self::Sequencer(sequencer) => { - (*sequencer).run(chain_resolver.resolve()?, metrics_enabled) - } + Self::Rpc(rpc) => (*rpc).run(chain_resolver.resolve()?), + Self::Follow(follow) => (*follow).run(chain_resolver.resolve()?), + Self::Sequencer(sequencer) => (*sequencer).run(chain_resolver.resolve()?), Self::Update(update) => (*update).run(), Self::Reth(reth) => { chain_resolver.reject_for_reth_command("base reth")?; @@ -86,6 +84,8 @@ impl BaseCommand { #[cfg(test)] mod tests { + use std::{net::TcpListener, process::Command, thread, time::Duration}; + use clap::Parser; use crate::{cli::BaseCli, config::ChainResolver}; @@ -137,4 +137,121 @@ mod tests { assert!(err.to_string().contains("base reth")); assert!(err.to_string().contains("base --chain")); } + + #[test] + fn unified_upgrade_metrics_without_standalone_endpoint() { + // Each launch needs its own process: reth owns global recorder/thread-pool state. + // Keep temporary files alive until the child (including its node thread) has exited. + for flavor in ["rpc", "sequencer", "follow"] { + let dir = tempfile::tempdir().unwrap(); + let mut command = Command::new(std::env::current_exe().unwrap()); + for (key, _) in std::env::vars() { + if key.starts_with("BASE_") || key.starts_with("OP_RETH_") { + command.env_remove(key); + } + } + let output = command + .current_dir(dir.path()) + .args([ + "--exact", + "commands::command::tests::unified_upgrade_metrics_child", + "--ignored", + "--nocapture", + ]) + .env("BASE_NODE_METRICS_ENABLED", "false") + .env("BASE_METRICS_TEST_FLAVOR", flavor) + .env("BASE_METRICS_TEST_DIR", dir.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{flavor} metrics test failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + } + + #[tokio::test] + #[ignore = "spawned by unified_upgrade_metrics_without_standalone_endpoint"] + async fn unified_upgrade_metrics_child() { + let flavor = std::env::var("BASE_METRICS_TEST_FLAVOR").unwrap(); + let dir = std::env::var("BASE_METRICS_TEST_DIR").unwrap(); + let metrics_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let metrics_addr = metrics_listener.local_addr().unwrap().to_string(); + // Leave upstream requests pending locally: this test needs execution startup and a + // metrics scrape, not a live L1, beacon node, or follow source. + let upstream = TcpListener::bind("127.0.0.1:0").unwrap(); + let upstream_url = format!("http://{}", upstream.local_addr().unwrap()); + let ipc = format!("{dir}/rpc.ipc"); + let mut args = vec![ + "base", + "--chain", + "dev", + &flavor, + "--datadir", + &dir, + "--ipcpath", + &ipc, + "--metrics", + &metrics_addr, + "--port", + "0", + "--authrpc.port", + "0", + "--rpc.port", + "0", + "--disable-discovery", + "--l1-eth-rpc", + &upstream_url, + "--l1-beacon", + &upstream_url, + ]; + if flavor == "follow" { + args.extend(["--source-l2-rpc", &upstream_url, "--l2-rpc-url", &upstream_url]); + } else { + args.extend(["--p2p.listen.tcp", "0", "--p2p.listen.udp", "0"]); + } + if flavor == "sequencer" { + args.extend([ + "--p2p.sequencer.key", + "bcc617ea05150ff60490d3c6058630ba94ae9f12a02a87efd291349ca0e54e0a", + "--flashblocks.port", + "0", + ]); + } + let cli = BaseCli::parse_from(args); + assert!(!cli.metrics.enabled); + drop(metrics_listener); + // Debug builds of the unified launch future need more than the default worker stack. + let node = + thread::Builder::new().stack_size(32 * 1024 * 1024).spawn(move || cli.run()).unwrap(); + let client = reqwest::Client::builder().timeout(Duration::from_secs(1)).build().unwrap(); + let metrics_url = format!("http://{metrics_addr}/metrics"); + let scrape = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node.is_finished() { + panic!( + "node exited before emitting upgrade metrics: {:?}", + node.join().unwrap() + ); + } + if let Ok(response) = client.get(&metrics_url).send().await + && let Ok(body) = response.text().await + && [("Azul", 0), ("Beryl", -1), ("Cobalt", -1)].iter().all(|(upgrade, time)| { + body.lines().any(|line| { + line == format!( + "reth_base_node_upgrades{{upgrade=\"{upgrade}\"}} {time}" + ) + }) + }) + { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await; + assert!(scrape.is_ok(), "missing startup upgrade gauges on the reth metrics endpoint"); + } } diff --git a/bin/base/src/commands/follow.rs b/bin/base/src/commands/follow.rs index fbb0372e56..1dfd32971f 100644 --- a/bin/base/src/commands/follow.rs +++ b/bin/base/src/commands/follow.rs @@ -48,11 +48,7 @@ pub(crate) struct FollowCommand { impl FollowCommand { /// Runs the `follow` flavor. - pub(crate) fn run( - self, - resolved_chain: ResolvedChainConfig, - metrics_enabled: bool, - ) -> eyre::Result<()> { + pub(crate) fn run(self, resolved_chain: ResolvedChainConfig) -> eyre::Result<()> { let Self { execution_chain, execution, follow } = self; let mut execution_chain = match execution_chain { Some(chain) => chain, @@ -83,12 +79,6 @@ impl FollowCommand { ) .await?; - if metrics_enabled { - CliMetrics::init_rollup_config(&rollup_config); - } - let _upgrade_countdown_metrics = metrics_enabled - .then(|| CliMetrics::spawn_upgrade_countdown_recorder(rollup_config.clone())); - let execution = execution .into_launch_config(execution_chain) .with_unified_auth_endpoint() @@ -96,6 +86,12 @@ impl FollowCommand { let l2_engine_rpc = engine_ipc_url(execution.auth_ipc_path())?; let task_executor = ctx.task_executor.clone(); let launched = execution.launch_default(ctx).await?; + // Execution launch installs the shared reth recorder. The standalone metrics flag + // controls a separate endpoint, not emission into this recorder. + CliMetrics::init_rollup_config(&rollup_config); + let _upgrade_countdown_metrics = + CliMetrics::spawn_upgrade_countdown_recorder(rollup_config.clone()); + let handle = launched.handle; // Keep the execution node handle alive until both services have coordinated shutdown. let execution_node = handle.node; diff --git a/bin/base/src/commands/rpc.rs b/bin/base/src/commands/rpc.rs index 14f24e22b2..333d08cfc1 100644 --- a/bin/base/src/commands/rpc.rs +++ b/bin/base/src/commands/rpc.rs @@ -45,11 +45,7 @@ pub(crate) struct RpcCommand { impl RpcCommand { /// Runs the `rpc` flavor. - pub(crate) fn run( - self, - resolved_chain: ResolvedChainConfig, - metrics_enabled: bool, - ) -> eyre::Result<()> { + pub(crate) fn run(self, resolved_chain: ResolvedChainConfig) -> eyre::Result<()> { let Self { execution_chain, execution, consensus } = self; let mut execution_chain = match execution_chain { Some(chain) => chain, @@ -81,12 +77,6 @@ impl RpcCommand { ) .await?; - if metrics_enabled { - CliMetrics::init_rollup_config(&rollup_config); - } - let _upgrade_countdown_metrics = metrics_enabled - .then(|| CliMetrics::spawn_upgrade_countdown_recorder(rollup_config.clone())); - let upgrade_signal_l1_rpc = execution.standard.rollup_args.upgrade_signal_l1_rpc.upgrade_signal_l1_rpc.clone(); let execution = execution @@ -96,6 +86,12 @@ impl RpcCommand { let l2_engine_rpc = engine_ipc_url(execution.auth_ipc_path())?; let task_executor = ctx.task_executor.clone(); let launched = execution.launch_default(ctx).await?; + // Execution launch installs the shared reth recorder. The standalone metrics flag + // controls a separate endpoint, not emission into this recorder. + CliMetrics::init_rollup_config(&rollup_config); + let _upgrade_countdown_metrics = + CliMetrics::spawn_upgrade_countdown_recorder(rollup_config.clone()); + let handle = launched.handle; // Keep the execution node handle alive until both services have coordinated shutdown. let execution_node = handle.node; diff --git a/bin/base/src/commands/sequencer.rs b/bin/base/src/commands/sequencer.rs index 1e65f0fc74..a969abeed4 100644 --- a/bin/base/src/commands/sequencer.rs +++ b/bin/base/src/commands/sequencer.rs @@ -49,11 +49,7 @@ pub(crate) struct SequencerCommand { impl SequencerCommand { /// Runs the `sequencer` flavor with execution, builder, and consensus in one process. - pub(crate) fn run( - self, - resolved_chain: ResolvedChainConfig, - metrics_enabled: bool, - ) -> eyre::Result<()> { + pub(crate) fn run(self, resolved_chain: ResolvedChainConfig) -> eyre::Result<()> { let Self { execution_chain, execution, mut builder, consensus } = self; let mut execution_chain = match execution_chain { Some(chain) => chain, @@ -97,12 +93,6 @@ impl SequencerCommand { ) .await?; - if metrics_enabled { - CliMetrics::init_rollup_config(&rollup_config); - } - let _upgrade_countdown_metrics = metrics_enabled - .then(|| CliMetrics::spawn_upgrade_countdown_recorder(rollup_config.clone())); - let upgrade_signal_l1_rpc = rollup_args.upgrade_signal_l1_rpc.upgrade_signal_l1_rpc.clone(); let execution = @@ -138,6 +128,12 @@ impl SequencerCommand { )?; let launched = runner.launch(builder).await?; + // Execution launch installs the shared reth recorder. The standalone metrics flag + // controls a separate endpoint, not emission into this recorder. + CliMetrics::init_rollup_config(&rollup_config); + let _upgrade_countdown_metrics = + CliMetrics::spawn_upgrade_countdown_recorder(rollup_config.clone()); + let handle = launched.handle; // Keep the execution node handle alive until both services have coordinated shutdown. let execution_node = handle.node; From 8c7127b13315412991c1ab1c93ef1e0fd43d98b3 Mon Sep 17 00:00:00 2001 From: William Law Date: Tue, 15 Sep 2026 17:08:07 +0000 Subject: [PATCH 007/299] fix(metering): treat missing senders as empty accounts (#5052) Match reth's pool validator (`basic_account()?.unwrap_or_default()`) so meter_bundle fails on funds instead of "Account is none for tx". Co-authored-by: Cursor --- crates/execution/metering/src/meter.rs | 103 ++++++++++++++++++------- 1 file changed, 77 insertions(+), 26 deletions(-) diff --git a/crates/execution/metering/src/meter.rs b/crates/execution/metering/src/meter.rs index 7da0b4651d..f20bac8db1 100644 --- a/crates/execution/metering/src/meter.rs +++ b/crates/execution/metering/src/meter.rs @@ -684,34 +684,35 @@ where first_nonces.entry(tx.signer()).or_insert_with(|| tx.nonce()); } - let mut account_infos: HashMap> = HashMap::default(); + let mut account_infos: HashMap = HashMap::default(); for (&addr, &nonce) in &first_nonces { let cache_account = db.load_cache_account(addr)?; - if let Some(ref mut account) = cache_account.account { - let max_nonce = account.info.nonce.saturating_add(MAX_NONCE_AHEAD); - if nonce > max_nonce { - return Err(eyre!( - "transaction nonce {} for {} exceeds max allowed (on-chain {} + {})", - nonce, - addr, - account.info.nonce, - MAX_NONCE_AHEAD, - )); - } - account.info.nonce = nonce; - - account_infos.insert( + // Missing senders match reth's pool validator: `basic_account()?.unwrap_or_default()`. + let mut account = cache_account + .account + .as_ref() + .map(|account| Account { + nonce: account.info.nonce, + balance: account.info.balance, + bytecode_hash: (account.info.code_hash != KECCAK_EMPTY) + .then_some(account.info.code_hash), + }) + .unwrap_or_default(); + let max_nonce = account.nonce.saturating_add(MAX_NONCE_AHEAD); + if nonce > max_nonce { + return Err(eyre!( + "transaction nonce {} for {} exceeds max allowed (on-chain {} + {})", + nonce, addr, - Some(Account { - nonce: account.info.nonce, - balance: account.info.balance, - bytecode_hash: (account.info.code_hash != KECCAK_EMPTY) - .then_some(account.info.code_hash), - }), - ); - } else { - account_infos.insert(addr, None); + account.nonce, + MAX_NONCE_AHEAD, + )); } + account.nonce = nonce; + if let Some(ref mut cached) = cache_account.account { + cached.info.nonce = nonce; + } + account_infos.insert(addr, account); } // Set up next block attributes @@ -773,8 +774,8 @@ where }; let account = account_infos .get(&from) - .ok_or_else(|| eyre!("Account not found for address: {from}"))? - .ok_or_else(|| eyre!("Account is none for tx: {tx_hash}"))?; + .copied() + .ok_or_else(|| eyre!("Account not found for address: {from}"))?; validate_tx(account, tx, &mut l1_block_info, spec) .map_err(|e| eyre!("Transaction {tx_hash} validation failed: {e}"))?; @@ -2636,4 +2637,54 @@ mod tests { Ok(()) } + + /// A sender with no state entry is an empty account, same as reth's + /// `basic_account()?.unwrap_or_default()`, then fails funds — not "Account is none". + #[tokio::test] + async fn meter_bundle_missing_sender_defaults_then_fails_funds() -> eyre::Result<()> { + let harness = TestHarness::new().await?; + let latest = harness.latest_block(); + let header = latest.sealed_header().clone(); + + let signed_tx = TransactionBuilder::default() + .signer(B256::random()) + .chain_id(harness.chain_id()) + .nonce(0) + .to(Address::random()) + .value(1) + .gas_limit(21_000) + .max_fee_per_gas(MIN_BASEFEE as u128) + .max_priority_fee_per_gas(0) + .into_eip1559(); + + let tx = BaseTransactionSigned::Eip1559( + signed_tx.as_eip1559().expect("eip1559 transaction").clone(), + ); + + let state_provider = harness + .blockchain_provider() + .state_by_block_hash(latest.hash()) + .context("getting state provider")?; + + let result = meter_bundle(MeterBundleInput { + state_provider, + chain_spec: harness.chain_spec(), + bundle: create_parsed_bundle(vec![tx])?, + header, + l1_block_info: L1BlockInfo::default(), + metered_opcodes: Arc::new(MeteredOpcodes::default()), + }); + + let err = result.expect_err("missing sender must not simulate successfully").to_string(); + assert!( + err.contains("Insufficient funds"), + "missing sender should fail funds after defaulting, got {err}" + ); + assert!( + !err.contains("Account is none"), + "missing sender must not use the old none-account error, got {err}" + ); + + Ok(()) + } } From 980f63fc4dff18518a8d4a50f66834fd31c41468 Mon Sep 17 00:00:00 2001 From: refcell Date: Tue, 15 Sep 2026 17:25:44 +0000 Subject: [PATCH 008/299] fix(ci): parallelize release candidate builds (#5055) Co-authored-by: Codex --- .github/workflows/create-rc.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/create-rc.yml b/.github/workflows/create-rc.yml index e52a85030b..970fa40156 100644 --- a/.github/workflows/create-rc.yml +++ b/.github/workflows/create-rc.yml @@ -12,13 +12,13 @@ permissions: id-token: write attestations: write -concurrency: - group: release-${{ github.ref_name }} - cancel-in-progress: false - jobs: create-tag: runs-on: ubuntu-latest + # Serialize RC number allocation, not the long-running builds. + concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false outputs: tag: ${{ steps.read-tag.outputs.tag }} skip: ${{ steps.version-check.outputs.skip }} @@ -32,7 +32,8 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.ref_name }} + # Tag the triggering commit even if the branch advances while queued. + ref: ${{ github.sha }} fetch-depth: 0 fetch-tags: true From ba87c843021f309706d7e3fada79131b63fc5ae4 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Tue, 15 Sep 2026 17:43:52 +0000 Subject: [PATCH 009/299] chore(common): schedule Sepolia Cobalt upgrade (#5056) --- crates/common/chains/src/chain.rs | 14 +++++++++++++- crates/common/chains/src/config.rs | 2 +- crates/common/chains/src/upgrade.rs | 5 +++++ crates/common/genesis/src/chain/upgrade.rs | 13 ++++++------- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/common/chains/src/chain.rs b/crates/common/chains/src/chain.rs index bd0555ddfb..cd624718d9 100644 --- a/crates/common/chains/src/chain.rs +++ b/crates/common/chains/src/chain.rs @@ -227,7 +227,10 @@ mod tests { base_sepolia_forks[Beryl], ForkCondition::Timestamp(ChainConfig::sepolia().beryl_timestamp.unwrap()) ); - assert_eq!(base_sepolia_forks[Cobalt], ForkCondition::Never); + assert_eq!( + base_sepolia_forks[Cobalt], + ForkCondition::Timestamp(ChainConfig::sepolia().cobalt_timestamp.unwrap()) + ); assert_eq!(base_sepolia_forks[Denim], ForkCondition::Never); assert_eq!(base_sepolia_forks[Zenith], ForkCondition::Never); } @@ -312,6 +315,15 @@ mod tests { assert!(zeronet_forks.is_beryl_active_at_timestamp(u64::MAX)); } + #[test] + fn is_cobalt_active_at_timestamp() { + let base_sepolia_forks = ChainUpgrades::sepolia(); + assert!(!base_sepolia_forks.is_cobalt_active_at_timestamp(0)); + assert!(!base_sepolia_forks.is_cobalt_active_at_timestamp(1_790_186_399)); + assert!(base_sepolia_forks.is_cobalt_active_at_timestamp(1_790_186_400)); + assert!(base_sepolia_forks.is_cobalt_active_at_timestamp(u64::MAX)); + } + #[test] fn is_zenith_active_at_timestamp() { let base_mainnet_forks = ChainUpgrades::mainnet(); diff --git a/crates/common/chains/src/config.rs b/crates/common/chains/src/config.rs index 8b8ef7c0fa..653e863bb9 100644 --- a/crates/common/chains/src/config.rs +++ b/crates/common/chains/src/config.rs @@ -527,7 +527,7 @@ const SEPOLIA: ChainConfig = ChainConfig { jovian_timestamp: 1_763_568_001, azul_timestamp: Some(1_776_708_000), beryl_timestamp: Some(1_781_805_600), - cobalt_timestamp: None, + cobalt_timestamp: Some(1_790_186_400), denim_timestamp: None, genesis_l1_hash: b256!("cac9a83291d4dec146d6f7f69ab2304f23f5be87b1789119a0c5b1e4482444ed"), diff --git a/crates/common/chains/src/upgrade.rs b/crates/common/chains/src/upgrade.rs index 4d780d4a42..df4ea5dd0b 100644 --- a/crates/common/chains/src/upgrade.rs +++ b/crates/common/chains/src/upgrade.rs @@ -335,6 +335,11 @@ mod tests { ChainConfig::sepolia().beryl_timestamp.unwrap(), BaseUpgrade::Beryl, ), + ( + Chain::base_sepolia(), + ChainConfig::sepolia().cobalt_timestamp.unwrap(), + BaseUpgrade::Cobalt, + ), ( Chain::from_id(ChainConfig::zeronet().chain_id), ChainConfig::zeronet().beryl_timestamp.unwrap(), diff --git a/crates/common/genesis/src/chain/upgrade.rs b/crates/common/genesis/src/chain/upgrade.rs index fac3be9d87..ab2134ac7c 100644 --- a/crates/common/genesis/src/chain/upgrade.rs +++ b/crates/common/genesis/src/chain/upgrade.rs @@ -691,7 +691,7 @@ impl UpgradeConfig { base: BaseUpgradeConfig { azul: Some(1_776_708_000), beryl: Some(1_781_805_600), - cobalt: None, + cobalt: Some(1_790_186_400), denim: None, zenith: None, }, @@ -1127,11 +1127,10 @@ mod runtime_tests { let _guard = RUNTIME_REGISTRY_TEST_LOCK.lock(); RuntimeUpgradeRegistry::clear_chain(8453); RuntimeUpgradeRegistry::clear_chain(84532); - for chain_id in [8453, 84532] { - assert_eq!( - BaseUpgrade::from_chain_and_timestamp(chain_id, u64::MAX), - Some(BaseUpgrade::Beryl) - ); - } + assert_eq!(BaseUpgrade::from_chain_and_timestamp(8453, u64::MAX), Some(BaseUpgrade::Beryl)); + assert_eq!( + BaseUpgrade::from_chain_and_timestamp(84532, u64::MAX), + Some(BaseUpgrade::Cobalt) + ); } } From ccbe98d44af9405c2e7e2366c267c5cd096d8b8a Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Tue, 15 Sep 2026 18:54:57 +0000 Subject: [PATCH 010/299] feat(builder): record build-loop state-read latency (#5025) Times the payload builder's account, storage, and code reads so time blocked on state during block building can be measured. Off by default. Reth's InstrumentedStateProvider is constructed in exactly one place, the engine's newPayload validation path, hardcoded to a source label of "engine". Enabling --engine.state-provider-metrics on a sequencer therefore measures block validation and reports nothing about the loop that builds blocks. Its constructor already accepts a source label, so both Base payload builders now wrap their own state provider and report under sync.state_provider with a "builder" label. The wrapper is applied outside CachedStateProvider so the recorded latency is the build loop's total per-read cost, cache hits included. Wrapping it inside would measure only reads that fall through to the database, and a working cache would then show no improvement. Scope, deliberately narrow. The reth per-job read cache (CachedReads) sits above this wrapper, so what is timed is reads that miss that cache rather than every state read the build loop issues. Those are the reads that can actually touch storage, but the measured population shrinks as CachedReads warms, so means are not directly comparable across configurations without accounting for it. The consumer today is the snapshot benchmark harness, which scrapes the Prometheus endpoint directly and can group on the source label. Datadog currently applies an empty tag allowlist to the reth metric families for cardinality control, so builder and engine series would aggregate together there; separating them in dashboards needs a tag-configuration change that is not part of this commit. Driven by reth's existing --engine.state-provider-metrics rather than a new flag, so the builder and the validation path are instrumented by one switch. Co-authored-by: OpenCode --- Cargo.lock | 2 + Cargo.toml | 1 + crates/builder/cli/src/args.rs | 1 + crates/builder/core/Cargo.toml | 1 + crates/builder/core/src/config.rs | 17 ++++ .../builder/core/src/flashblocks/payload.rs | 15 ++- .../builder/multiplex/src/service_builder.rs | 16 +++- crates/execution/node/src/node.rs | 1 + crates/execution/payload/Cargo.toml | 1 + crates/execution/payload/src/builder.rs | 95 ++++++++++++++++--- crates/execution/payload/src/config.rs | 86 +++++++++++++++++ crates/execution/payload/src/lib.rs | 2 +- 12 files changed, 211 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0fdaf5fda..a6b3d70a74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3274,6 +3274,7 @@ dependencies = [ "reth-cli-commands", "reth-cli-util", "reth-db", + "reth-engine-tree", "reth-evm", "reth-execution-cache", "reth-execution-types", @@ -4515,6 +4516,7 @@ dependencies = [ "moka", "reth-basic-payload-builder", "reth-chainspec", + "reth-engine-tree", "reth-ethereum-forks", "reth-evm", "reth-execution-cache", diff --git a/Cargo.toml b/Cargo.toml index 6e3b7bc604..c13f5b42c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -379,6 +379,7 @@ reth-storage-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" reth-rpc-eth-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-chain-state = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-trie-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-engine-tree = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-payload-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-node-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } reth-node-metrics = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } diff --git a/crates/builder/cli/src/args.rs b/crates/builder/cli/src/args.rs index 029e9e61b9..038370fbaf 100644 --- a/crates/builder/cli/src/args.rs +++ b/crates/builder/cli/src/args.rs @@ -404,6 +404,7 @@ impl Args { ); Ok(BuilderConfig { + state_provider_metrics: false, block_time: Duration::from_millis(self.chain_block_time), block_time_leeway: Duration::from_secs(self.extra_block_deadline_secs), da_config: Default::default(), diff --git a/crates/builder/core/Cargo.toml b/crates/builder/core/Cargo.toml index 36af8f109e..5cd3a6a95d 100644 --- a/crates/builder/core/Cargo.toml +++ b/crates/builder/core/Cargo.toml @@ -36,6 +36,7 @@ reth-chainspec.workspace = true reth-rpc-layer.workspace = true reth-storage-api.workspace = true reth-chain-state.workspace = true +reth-engine-tree.workspace = true reth-payload-util.workspace = true reth-node-builder.workspace = true reth-tracing-otlp.workspace = true diff --git a/crates/builder/core/src/config.rs b/crates/builder/core/src/config.rs index d854867600..b61be4253e 100644 --- a/crates/builder/core/src/config.rs +++ b/crates/builder/core/src/config.rs @@ -86,6 +86,14 @@ pub struct BuilderConfig { /// Whether to drop EIP-8130 transactions whose captured authorization /// predicates are positively stale before executing them. pub manifest_precheck_enabled: bool, + + /// Whether to record per-call state fetch latency for the build loop. + /// + /// Wraps the builder's state provider so account, storage, and code reads are timed and + /// reported under `sync.state_provider` with a `builder` source label, separating build-loop + /// IO from the engine's validation-path IO. Adds overhead to every state read, so this is + /// driven by reth's `--engine.state-provider-metrics` and stays off by default. + pub state_provider_metrics: bool, } impl BuilderConfig { @@ -121,6 +129,7 @@ impl core::fmt::Debug for BuilderConfig { .field("rejected_tx_channel_size", &self.rejected_tx_channel_size) .field("max_rejected_txs_per_block", &self.max_rejected_txs_per_block) .field("manifest_precheck_enabled", &self.manifest_precheck_enabled) + .field("state_provider_metrics", &self.state_provider_metrics) .finish() } } @@ -148,6 +157,7 @@ impl Default for BuilderConfig { rejected_tx_channel_size: 500, max_rejected_txs_per_block: 500, manifest_precheck_enabled: true, + state_provider_metrics: false, } } } @@ -213,6 +223,13 @@ impl BuilderConfig { self } + /// Sets whether build-loop state reads are timed. + #[must_use] + pub const fn with_state_provider_metrics(mut self, enabled: bool) -> Self { + self.state_provider_metrics = enabled; + self + } + /// Toggles the EIP-8130 manifest precheck. #[must_use] pub const fn with_manifest_precheck_enabled(mut self, enabled: bool) -> Self { diff --git a/crates/builder/core/src/flashblocks/payload.rs b/crates/builder/core/src/flashblocks/payload.rs index 9c9be5ffd9..7c5c48aa79 100644 --- a/crates/builder/core/src/flashblocks/payload.rs +++ b/crates/builder/core/src/flashblocks/payload.rs @@ -34,7 +34,6 @@ use base_observability_events::{GlobalTransactionEventWriter, TransactionEventTy use eyre::WrapErr as _; use reth_basic_payload_builder::BuildOutcome; use reth_evm::{ConfigureEvm, execute::BlockBuilder}; -use reth_execution_cache::{CachedStateMetrics, CachedStateMetricsSource, CachedStateProvider}; use reth_execution_types::ChangedAccount; use reth_node_api::{Block, BuiltPayloadExecutedBlock, PayloadBuilderError}; use reth_payload_primitives::PayloadAttributes; @@ -288,14 +287,12 @@ where ) .map_err(|e| PayloadBuilderError::Other(e.into()))?; - let mut state_provider = self.client.state_by_block_hash(ctx.parent().hash())?; - if let Some(execution_cache) = execution_cache { - state_provider = Box::new(CachedStateProvider::new( - state_provider, - execution_cache.cache().clone(), - Some(CachedStateMetrics::zeroed(CachedStateMetricsSource::Builder)), - )); - } + let state_provider = base_execution_payload_builder::BuilderStateProvider::new( + self.client.state_by_block_hash(ctx.parent().hash())?, + execution_cache.map(|cache| cache.cache().clone()), + self.config.state_provider_metrics, + ) + .into_provider(); let db = StateProviderDatabase::new(state_provider); // 1. execute the pre steps and seal an early block with that diff --git a/crates/builder/multiplex/src/service_builder.rs b/crates/builder/multiplex/src/service_builder.rs index 837fe29f6d..27eb56fc14 100644 --- a/crates/builder/multiplex/src/service_builder.rs +++ b/crates/builder/multiplex/src/service_builder.rs @@ -64,7 +64,7 @@ impl MultiplexingServiceBuilder { /// cache and metering provider must be the same objects Flashblocks uses, /// otherwise permanently rejected hashes and `meterBundle` data diverge /// after cutover. - fn native_payload_config(&self) -> BaseBuilderConfig { + fn native_payload_config(&self, state_provider_metrics: bool) -> BaseBuilderConfig { BaseBuilderConfig { da_config: self.builder_config.da_config.clone(), gas_limit_config: self.builder_config.gas_limit_config.clone(), @@ -75,6 +75,7 @@ impl MultiplexingServiceBuilder { ..ResourceMeteringConfig::default() }, rejection_cache: self.builder_config.rejection_cache.clone(), + state_provider_metrics, } } } @@ -95,8 +96,13 @@ where "payload builder cutover and basic-only modes are mutually exclusive" ); + // Reth's own flag drives build-loop state-read timing, so the builder and the engine's + // validation path are instrumented by the same switch. + let mut builder_config = self.builder_config.clone(); + builder_config.state_provider_metrics = ctx.config().engine.state_provider_metrics; + if !self.routing_config.cutover_enabled && !self.basic_only { - return FlashblocksServiceBuilder::new(self.builder_config) + return FlashblocksServiceBuilder::new(builder_config) .spawn_payload_builder_service(ctx, pool, evm_config) .await; } @@ -106,7 +112,7 @@ where pool.clone(), ctx.provider().clone(), evm_config.clone(), - self.native_payload_config(), + self.native_payload_config(builder_config.state_provider_metrics), ); let payload_config = ctx.config().builder.clone(); @@ -138,7 +144,7 @@ where return Ok(basic_handle); } - let flashblocks_handle = FlashblocksServiceBuilder::new(self.builder_config) + let flashblocks_handle = FlashblocksServiceBuilder::new(builder_config) .spawn_payload_builder_service(ctx, pool, evm_config) .await?; @@ -209,7 +215,7 @@ mod tests { builder_config.rejection_cache.insert(hash); let provider = Arc::clone(&builder_config.metering_provider); - let native = MultiplexingServiceBuilder::new(builder_config).native_payload_config(); + let native = MultiplexingServiceBuilder::new(builder_config).native_payload_config(false); assert!(native.rejection_cache.contains_key(&hash)); assert!(Arc::ptr_eq(&native.resource_metering.provider, &provider)); } diff --git a/crates/execution/node/src/node.rs b/crates/execution/node/src/node.rs index 8557e046ee..0babdc6069 100644 --- a/crates/execution/node/src/node.rs +++ b/crates/execution/node/src/node.rs @@ -1179,6 +1179,7 @@ where predicate_eval_hard_cutoff: self.predicate_eval_hard_cutoff, resource_metering: self.resource_metering, rejection_cache: self.rejection_cache, + state_provider_metrics: ctx.config().engine.state_provider_metrics, }, ) .with_transactions(self.best_transactions); diff --git a/crates/execution/payload/Cargo.toml b/crates/execution/payload/Cargo.toml index 739f949d7e..45b6df1eec 100644 --- a/crates/execution/payload/Cargo.toml +++ b/crates/execution/payload/Cargo.toml @@ -16,6 +16,7 @@ workspace = true reth-evm.workspace = true reth-chainspec.workspace = true reth-storage-api.workspace = true +reth-engine-tree.workspace = true reth-payload-util.workspace = true reth-trie-parallel.workspace = true reth-execution-types.workspace = true diff --git a/crates/execution/payload/src/builder.rs b/crates/execution/payload/src/builder.rs index 2bc391adb4..ade957b307 100644 --- a/crates/execution/payload/src/builder.rs +++ b/crates/execution/payload/src/builder.rs @@ -35,7 +35,6 @@ use reth_evm::{ BlockBuilder, BlockBuilderOutcome, BlockExecutionError, BlockExecutor, BlockValidationError, }, }; -use reth_execution_cache::{CachedStateMetrics, CachedStateMetricsSource, CachedStateProvider}; use reth_execution_types::BlockExecutionOutput; use reth_payload_builder_primitives::PayloadBuilderError; use reth_payload_primitives::{BuildNextEnv, BuiltPayloadExecutedBlock}; @@ -234,23 +233,20 @@ where let _ = pool.remove_transactions(hashes); }); - let mut state_provider = self.client.state_by_block_hash(ctx.parent().hash())?; - if let Some(execution_cache) = execution_cache { - state_provider = Box::new(CachedStateProvider::new( - state_provider, - execution_cache.cache().clone(), - Some(CachedStateMetrics::zeroed(CachedStateMetricsSource::Builder)), - )); - } - let state = StateProviderDatabase::new(state_provider.as_ref()); + let state_provider = crate::BuilderStateProvider::new( + self.client.state_by_block_hash(ctx.parent().hash())?, + execution_cache.map(|cache| cache.cache().clone()), + self.config.state_provider_metrics, + ); + let state = StateProviderDatabase::new(state_provider.provider()); if ctx.attributes().no_tx_pool() { - builder.build(state, state_provider.as_ref(), state_root_handle, ctx) + builder.build(state, state_provider.provider(), state_root_handle, ctx) } else { // sequencer mode we can reuse cachedreads from previous runs builder.build( cached_reads.as_db_mut(state), - state_provider.as_ref(), + state_provider.provider(), state_root_handle, ctx, ) @@ -2676,4 +2672,79 @@ mod tests { assert!(evicted.lock().unwrap().is_empty()); assert!(invalid.lock().unwrap().is_empty()); } + + /// The build loop wraps its state provider in `InstrumentedStateProvider` *outside* + /// `CachedStateProvider`, so the recorded latency is the total cost of a read as the builder + /// experiences it. This asserts the consequence: a cache hit and a read-through are both + /// timed. Wrapping the other way round would record only the read-through, and a warm cache + /// would then look like no improvement rather than a faster one. + /// + /// Hits are told apart from read-throughs by value: the cache and the provider underneath + /// hold different balances for the same address, so the returned balance names the source. + /// `CachedStateProvider::new` is lookup-only and never populates on a miss, so the cached + /// entry here stands in for one the engine placed there. + #[test] + fn instrumentation_times_both_cache_hits_and_read_throughs() { + const FROM_CACHE: u64 = 1; + const FROM_PROVIDER: u64 = 999; + + let cached_address = Address::with_last_byte(0x11); + let uncached_address = Address::with_last_byte(0x22); + + let mut provider = reth_revm::test_utils::StateProviderTest::default(); + for address in [cached_address, uncached_address] { + provider.insert_account( + address, + reth_primitives_traits::Account { + balance: U256::from(FROM_PROVIDER), + ..Default::default() + }, + None, + HashMap::default(), + ); + } + + let cache = reth_execution_cache::ExecutionCache::new(1_000_000); + cache.insert_account( + cached_address, + Some(reth_primitives_traits::Account { + balance: U256::from(FROM_CACHE), + ..Default::default() + }), + ); + + let instrumented = crate::BuilderStateProvider::new(provider, Some(cache), true); + let stats = Arc::clone(instrumented.stats().expect("instrumentation enabled")); + + let hit = reth_storage_api::AccountReader::basic_account( + instrumented.provider(), + &cached_address, + ) + .unwrap() + .expect("cached account"); + assert_eq!( + hit.balance, + U256::from(FROM_CACHE), + "read should have been served by the cache, not the provider underneath" + ); + + let miss = reth_storage_api::AccountReader::basic_account( + instrumented.provider(), + &uncached_address, + ) + .unwrap() + .expect("uncached account"); + assert_eq!( + miss.balance, + U256::from(FROM_PROVIDER), + "read should have fallen through to the provider underneath" + ); + + assert_eq!( + stats.total_account_fetches(), + 2, + "the cache hit must be timed as well as the read-through; recording only the \ + read-through would hide the benefit of a warm cache" + ); + } } diff --git a/crates/execution/payload/src/config.rs b/crates/execution/payload/src/config.rs index 576c8d7f7a..8ba82b325b 100644 --- a/crates/execution/payload/src/config.rs +++ b/crates/execution/payload/src/config.rs @@ -1,12 +1,18 @@ //! Additional configuration for the Base payload builder. use std::{ + fmt, path::Path, sync::{Arc, atomic::AtomicU64}, time::Duration, }; use alloy_primitives::TxHash; +use reth_engine_tree::tree::instrumented_state::{InstrumentedStateProvider, StateProviderStats}; +use reth_execution_cache::{ + CachedStateMetrics, CachedStateMetricsSource, CachedStateProvider, ExecutionCache, +}; +use reth_storage_api::{StateProvider, StateProviderBox}; use revm::state::EvmState; use tracing::{debug, warn}; @@ -38,6 +44,13 @@ pub struct BaseBuilderConfig { /// skipped for the current scan via `PayloadTransactions::mark_invalid`; /// skipping those descendants across later jobs is Flashblocks-only. pub rejection_cache: RejectionCache, + /// Whether to record per-call state fetch latency for the build loop. + /// + /// Mirrors the Flashblocks builder: wraps the payload builder's state provider so account, + /// storage, and code reads are timed under `sync.state_provider` with a `builder` source + /// label. Adds overhead to every state read, so it follows reth's + /// `--engine.state-provider-metrics` and stays off by default. + pub state_provider_metrics: bool, } impl Default for BaseBuilderConfig { @@ -49,6 +62,7 @@ impl Default for BaseBuilderConfig { predicate_eval_hard_cutoff: Duration::from_millis(10), resource_metering: ResourceMeteringConfig::default(), rejection_cache: RejectionCache::default(), + state_provider_metrics: false, } } } @@ -67,9 +81,17 @@ impl BaseBuilderConfig { predicate_eval_hard_cutoff: Duration::from_millis(10), resource_metering: ResourceMeteringConfig::default(), rejection_cache: RejectionCache::default(), + state_provider_metrics: false, } } + /// Sets whether build-loop state reads are timed. + #[must_use] + pub const fn with_state_provider_metrics(mut self, enabled: bool) -> Self { + self.state_provider_metrics = enabled; + self + } + /// Sets resource-unit metering used to throttle transactions in the native /// payload builder. pub fn with_resource_metering(mut self, resource_metering: ResourceMeteringConfig) -> Self { @@ -409,6 +431,70 @@ impl GasLimitConfig { } } +/// The state provider a payload builder reads through, plus the handles needed to observe it. +/// +/// Both Base payload builders compose the same stack, so it is constructed here to keep them +/// from drifting apart. +pub struct BuilderStateProvider { + provider: StateProviderBox, + stats: Option>, +} + +impl fmt::Debug for BuilderStateProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BuilderStateProvider") + .field("instrumented", &self.stats.is_some()) + .finish_non_exhaustive() + } +} + +impl BuilderStateProvider { + /// Composes the decorators over `state_provider`. + pub fn new( + state_provider: S, + execution_cache: Option, + instrument: bool, + ) -> Self + where + S: StateProvider + Send + 'static, + { + let mut provider: StateProviderBox = Box::new(state_provider); + if let Some(cache) = execution_cache { + provider = Box::new(CachedStateProvider::new( + provider, + cache, + Some(CachedStateMetrics::zeroed(CachedStateMetricsSource::Builder)), + )); + } + + // `InstrumentedStateProvider` must stay outside `CachedStateProvider` so cache hits are + // timed as well as read-throughs, otherwise a warm cache would look like no improvement. + let mut stats = None; + if instrument { + let instrumented = InstrumentedStateProvider::new(provider, "builder"); + stats = Some(instrumented.stats()); + provider = Box::new(instrumented); + } + + Self { provider, stats } + } + + /// Accumulated read counts and latencies, present only when instrumentation is enabled. + pub const fn stats(&self) -> Option<&Arc> { + self.stats.as_ref() + } + + /// Borrows the composed provider. + pub fn provider(&self) -> &dyn StateProvider { + self.provider.as_ref() + } + + /// Consumes the wrapper, yielding the composed provider. + pub fn into_provider(self) -> StateProviderBox { + self.provider + } +} + #[cfg(test)] mod tests { use std::{collections::HashMap, path::Path}; diff --git a/crates/execution/payload/src/lib.rs b/crates/execution/payload/src/lib.rs index 5457011e63..bb3d39309b 100644 --- a/crates/execution/payload/src/lib.rs +++ b/crates/execution/payload/src/lib.rs @@ -14,7 +14,7 @@ pub use affordability::CoinbaseTipAffordability; pub mod builder; pub use builder::BasePayloadBuilder; pub mod config; -pub use config::ResourceMeteringConfig; +pub use config::{BuilderStateProvider, ResourceMeteringConfig}; mod rejection_cache; pub use rejection_cache::{REJECTION_CACHE_MAX_CAPACITY, REJECTION_CACHE_TTL, RejectionCache}; pub mod error; From c8520c03f217283a9ea8c47b586f13ad58142c9d Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 15 Sep 2026 20:05:43 +0000 Subject: [PATCH 011/299] feat(precompiles): scaffold Policy Registry V3 at Denim (#5054) * feat(precompiles): scaffold Policy Registry V3 at Denim Add a behavior-identical V3 copy of Cobalt V2 so Denim can evolve without touching frozen versions. Reuses PolicyAbi::V2 until a later PR adds selectors. Co-authored-by: Cursor * Correct comment version reference to V3 Update comment to reflect version change from V1 to V3. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Cursor Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/common/precompiles/Cargo.toml | 4 + crates/common/precompiles/src/lib.rs | 2 +- .../precompiles/src/policy/logic/mod.rs | 9 +- .../common/precompiles/src/policy/logic/v3.rs | 1601 +++++++++++++++++ crates/common/precompiles/src/policy/mod.rs | 2 +- .../common/precompiles/src/policy/versions.rs | 19 +- .../precompiles/tests/b20_policy_v3_golden.rs | 1229 +++++++++++++ 7 files changed, 2859 insertions(+), 7 deletions(-) create mode 100644 crates/common/precompiles/src/policy/logic/v3.rs create mode 100644 crates/common/precompiles/tests/b20_policy_v3_golden.rs diff --git a/crates/common/precompiles/Cargo.toml b/crates/common/precompiles/Cargo.toml index 2c489c2c6e..9f3e46cfeb 100644 --- a/crates/common/precompiles/Cargo.toml +++ b/crates/common/precompiles/Cargo.toml @@ -59,6 +59,10 @@ required-features = ["test-utils"] name = "b20_policy_v1_golden" required-features = ["test-utils"] +[[test]] +name = "b20_policy_v3_golden" +required-features = ["test-utils"] + [[test]] name = "b20_factory_v1_golden" required-features = ["test-utils"] diff --git a/crates/common/precompiles/src/lib.rs b/crates/common/precompiles/src/lib.rs index a72c7953ae..987f143376 100644 --- a/crates/common/precompiles/src/lib.rs +++ b/crates/common/precompiles/src/lib.rs @@ -83,7 +83,7 @@ mod policy; pub use policy::{ IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, PackedPolicy, PolicyAbi, PolicyAccounting, PolicyRegistryLogic, PolicyRegistryPrecompile, PolicyRegistryStorage, - PolicyRegistryV1, PolicyRegistryV2, PolicyVersion, PolicyVersions, + PolicyRegistryV1, PolicyRegistryV2, PolicyRegistryV3, PolicyVersion, PolicyVersions, }; mod tx_context; diff --git a/crates/common/precompiles/src/policy/logic/mod.rs b/crates/common/precompiles/src/policy/logic/mod.rs index ca8c472ac9..8f983d36e1 100644 --- a/crates/common/precompiles/src/policy/logic/mod.rs +++ b/crates/common/precompiles/src/policy/logic/mod.rs @@ -2,9 +2,9 @@ //! //! [`PolicyRegistryLogic`] (in [`interface`](self)) is the append-only business-logic //! interface each version implements; [`PolicyRegistryV1`] is the first frozen -//! implementation (activated at Beryl) and [`PolicyRegistryV2`] the second (activated at -//! Cobalt). Logic methods take a [`crate::PolicyAccounting`] storage port directly — there -//! is no separate runtime wrapper. +//! implementation (activated at Beryl), [`PolicyRegistryV2`] the second (activated at +//! Cobalt), and [`PolicyRegistryV3`] the third (activated at Denim). Logic methods take a +//! [`crate::PolicyAccounting`] storage port directly — there is no separate runtime wrapper. mod interface; pub use interface::PolicyRegistryLogic; @@ -14,3 +14,6 @@ pub use v1::PolicyRegistryV1; mod v2; pub use v2::PolicyRegistryV2; + +mod v3; +pub use v3::PolicyRegistryV3; diff --git a/crates/common/precompiles/src/policy/logic/v3.rs b/crates/common/precompiles/src/policy/logic/v3.rs new file mode 100644 index 0000000000..abf300bd81 --- /dev/null +++ b/crates/common/precompiles/src/policy/logic/v3.rs @@ -0,0 +1,1601 @@ +//! Version 3 of the `PolicyRegistry` precompile logic, activated at Denim. + +use alloc::vec::Vec; + +use alloy_primitives::{Address, U256}; +use alloy_sol_types::SolEvent; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{ + IPolicyRegistry, IPolicyRegistry::PolicyType, PackedPolicy, PolicyAccounting, + PolicyRegistryLogic, +}; + +/// Third `PolicyRegistry` implementation. Activated at Denim, behavior-identical to V2 (scaffold seam for future changes). +#[derive(Debug, Default, Clone, Copy)] +pub struct PolicyRegistryV3; + +impl PolicyRegistryV3 { + /// Built-in policy ID that always authorizes every account. + /// + /// Encoded as BLOCKLIST (type=0) with counter=0 — an empty blocklist authorizes + /// everyone. Also the EVM zero default: zero-initialized policy ID fields map here. + pub const ALWAYS_ALLOW_ID: u64 = 0; + + /// Built-in policy ID that always rejects every account. + /// + /// Encoded as ALLOWLIST (type=1) with counter=1 and an empty member set, so no account + /// is on the allowlist and nobody passes. + pub const ALWAYS_BLOCK_ID: u64 = (1u64 << Self::POLICY_ID_TYPE_SHIFT) | 1; + + /// Number of built-in policies; the counter lands here after initialization. + pub const BUILTIN_POLICY_COUNT: u64 = 2; + + /// Mask covering the low 56 bits of a policy ID (the counter space). + pub const COUNTER_MASK: u64 = (1u64 << 56) - 1; + + /// Maximum number of accounts per membership batch (`createPolicyWithAccounts`, + /// `updateAllowlist`, `updateBlocklist`). + pub const MAX_ACCOUNTS_PER_BATCH: usize = 64; + + /// Minimum number of child policies a composite must reference. + pub const MIN_CHILD_POLICIES: usize = 2; + + /// Maximum number of child policies a composite may reference. Distinct from + /// [`Self::MAX_ACCOUNTS_PER_BATCH`], which caps account-membership batches. + pub const MAX_CHILD_POLICIES: usize = 4; + + const ALLOWLIST_TYPE: u8 = PolicyType::ALLOWLIST as u8; + const BLOCKLIST_TYPE: u8 = PolicyType::BLOCKLIST as u8; + const UNION_TYPE: u8 = PolicyType::UNION as u8; + const INTERSECT_TYPE: u8 = PolicyType::INTERSECT as u8; + const POLICY_ID_TYPE_SHIFT: usize = 56; + + /// Returns the policy type encoded in the top byte of `policy_id`. + const fn policy_id_type(policy_id: u64) -> u8 { + (policy_id >> Self::POLICY_ID_TYPE_SHIFT) as u8 + } + + /// Encodes a policy ID from its type discriminant and counter. + pub const fn make_id(policy_type: u8, counter: u64) -> u64 { + (policy_type as u64) << Self::POLICY_ID_TYPE_SHIFT | (counter & Self::COUNTER_MASK) + } + + /// Reads a custom (non-built-in) policy word, reverting `PolicyNotFound` if absent. + fn require_existing_policy( + &self, + storage: &S, + policy_id: u64, + ) -> Result { + let packed = PackedPolicy::from_raw(storage.read_policy_word(policy_id)?); + if !packed.exists() { + return Err(BasePrecompileError::revert(IPolicyRegistry::PolicyNotFound {})); + } + Ok(packed) + } + + /// Reverts `BatchSizeTooLarge` when a membership batch exceeds the limit. + fn require_account_batch_size(accounts: &[Address]) -> Result<()> { + if accounts.len() > Self::MAX_ACCOUNTS_PER_BATCH { + return Err(BasePrecompileError::revert(IPolicyRegistry::BatchSizeTooLarge { + maxBatchSize: U256::from(Self::MAX_ACCOUNTS_PER_BATCH), + })); + } + Ok(()) + } + + /// Validates the policy exists and the caller is its current admin. + /// Returns `(packed, caller)` on success. + fn require_admin( + &self, + storage: &S, + policy_id: u64, + ) -> Result<(PackedPolicy, Address)> { + let packed = self.require_existing_policy(storage, policy_id)?; + let caller = storage.caller(); + if packed.admin() != caller { + return Err(BasePrecompileError::revert(IPolicyRegistry::Unauthorized {})); + } + Ok((packed, caller)) + } + + /// Returns whether `policy_id`'s top byte encodes a composite gate (UNION or INTERSECT). + const fn is_composite(policy_id: u64) -> bool { + let policy_type = Self::policy_id_type(policy_id); + policy_type == Self::UNION_TYPE || policy_type == Self::INTERSECT_TYPE + } + + /// Returns whether `policy_id`'s top byte is a supported policy type (simple or composite). + /// Type bytes above INTERSECT are malformed. + const fn is_well_formed(policy_id: u64) -> bool { + Self::policy_id_type(policy_id) <= Self::INTERSECT_TYPE + } + + /// Returns whether `policy_id` is a built-in sentinel (`ALWAYS_ALLOW` / `ALWAYS_BLOCK`). + const fn is_builtin(policy_id: u64) -> bool { + policy_id == Self::ALWAYS_ALLOW_ID || policy_id == Self::ALWAYS_BLOCK_ID + } + + /// Reverts `ChildPoliciesOutsideOfRange` when the child count is outside `[2, 4]`. + fn require_child_policy_in_range(child_policy_ids: &[u64]) -> Result<()> { + let count = child_policy_ids.len(); + if !(Self::MIN_CHILD_POLICIES..=Self::MAX_CHILD_POLICIES).contains(&count) { + return Err(BasePrecompileError::revert( + IPolicyRegistry::ChildPoliciesOutsideOfRange {}, + )); + } + Ok(()) + } + + /// Requires every composite child to be a created, custom, simple policy. Two passes so + /// `PolicyNotFound` (any non-existent child) takes precedence over `InvalidChildPolicy` + /// (a built-in sentinel or a composite) across the whole set — the canonical revert order. + fn validate_composite_child_policies( + &self, + storage: &S, + child_policy_ids: &[u64], + ) -> Result<()> { + for &child in child_policy_ids { + // Reverts PolicyNotFound when the child does not exist. + self.require_existing_policy(storage, child)?; + } + for &child in child_policy_ids { + if Self::is_builtin(child) || Self::is_composite(child) { + return Err(BasePrecompileError::revert(IPolicyRegistry::InvalidChildPolicy { + childPolicyId: child, + })); + } + } + Ok(()) + } + + /// Evaluates a UNION (OR) composite over its live child set: authorized if any child + /// authorizes. An empty set is unauthorized. + fn is_authorized_union( + &self, + storage: &S, + policy_id: u64, + account: Address, + ) -> Result { + for child in storage.read_children(policy_id)? { + if self.is_authorized(storage, child, account)? { + return Ok(true); + } + } + Ok(false) + } + + /// Evaluates an INTERSECT (AND) composite over its live child set: authorized only if every + /// child authorizes. An empty set is authorized. + fn is_authorized_intersect( + &self, + storage: &S, + policy_id: u64, + account: Address, + ) -> Result { + for child in storage.read_children(policy_id)? { + if !self.is_authorized(storage, child, account)? { + return Ok(false); + } + } + Ok(true) + } + + /// Validates policy-creation inputs and returns the raw policy type discriminator. + /// + /// Only the simple leaf types are admissible here; `UNION`/`INTERSECT` must go through + /// [`Self::create_composite_policy`], which is the only path that writes a child set. Inlined + /// rather than shared with [`super::PolicyRegistryV1`] so that widening the rule for a later + /// fork cannot reach back and change what that frozen version accepts. + fn validate_create_policy_inputs(admin: Address, policy_type: PolicyType) -> Result { + if admin == Address::ZERO { + return Err(BasePrecompileError::revert(IPolicyRegistry::ZeroAddress {})); + } + if !matches!(policy_type, PolicyType::BLOCKLIST | PolicyType::ALLOWLIST) { + return Err(BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {})); + } + Ok(policy_type.as_discriminant()) + } + + /// First-touch setup for the registry: writes the bytecode marker and the two built-in + /// policies, then leaves the counter at [`Self::BUILTIN_POLICY_COUNT`]. + /// + /// Gated on the counter, so subsequent calls cost a single read and bail. The bytecode + /// marker must precede any storage write because the EVM path can prune writes made + /// under an empty native-precompile account. Kept inherent to V3 (off the trait) so it + /// stays frozen with this version — it is an internal bootstrap primitive, not an ABI op. + /// + /// Both built-ins have a renounced (zero) admin: + /// - [`Self::ALWAYS_ALLOW_ID`] (counter=0, BLOCKLIST): no members blocked — everyone authorized. + /// - [`Self::ALWAYS_BLOCK_ID`] (counter=1, ALLOWLIST): no members allowed — nobody authorized. + pub(crate) fn ensure_initialized_and_get_counter( + &self, + storage: &mut S, + ) -> Result { + let counter = storage.read_next_counter()?; + if counter >= Self::BUILTIN_POLICY_COUNT { + return Ok(counter); + } + // Assert that the ID constants match the enum discriminants and counter slots, + // catching any future drift from enum reordering or constant changes. + debug_assert_eq!( + Self::make_id(PolicyType::BLOCKLIST.as_discriminant(), 0), + Self::ALWAYS_ALLOW_ID + ); + debug_assert_eq!( + Self::make_id(PolicyType::ALLOWLIST.as_discriminant(), 1), + Self::ALWAYS_BLOCK_ID + ); + storage.mark_initialized()?; + let builtin = PackedPolicy::new(Address::ZERO).into_u256(); + storage.write_policy_word(Self::ALWAYS_ALLOW_ID, builtin)?; + storage.write_policy_word(Self::ALWAYS_BLOCK_ID, builtin)?; + storage.write_next_counter(Self::BUILTIN_POLICY_COUNT)?; + Ok(Self::BUILTIN_POLICY_COUNT) + } + + /// Shared creation core after inputs have been validated. + fn create_policy_inner( + &self, + storage: &mut S, + admin: Address, + policy_type: PolicyType, + policy_type_u8: u8, + ) -> Result { + let counter = self.ensure_initialized_and_get_counter(storage)?; + let is_counter_overflowed = counter >= Self::COUNTER_MASK; + if is_counter_overflowed { + return Err(BasePrecompileError::under_overflow()); + } + storage.write_next_counter(counter + 1)?; + let policy_id = Self::make_id(policy_type_u8, counter); + storage.write_policy_word(policy_id, PackedPolicy::new(admin).into_u256())?; + + let caller = storage.caller(); + storage.emit_event( + IPolicyRegistry::PolicyCreated { + policyId: policy_id, + creator: caller, + policyType: policy_type, + } + .encode_log_data(), + )?; + storage.emit_event( + IPolicyRegistry::PolicyAdminUpdated { + policyId: policy_id, + previousAdmin: Address::ZERO, + newAdmin: admin, + } + .encode_log_data(), + )?; + + Ok(policy_id) + } + + /// Adds/removes `accounts` for `policy_id`, enforcing type, admin, and batch-size guards. + /// Returns the caller on success. + fn update_membership( + &self, + storage: &mut S, + policy_id: u64, + expected_type: u8, + add: bool, + accounts: &[Address], + ) -> Result
{ + // Check order matches Solidity canonical: existence → type → admin → batch size. + let packed = self.require_existing_policy(storage, policy_id)?; + if Self::policy_id_type(policy_id) != expected_type { + return Err(BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {})); + } + let caller = storage.caller(); + if packed.admin() != caller { + return Err(BasePrecompileError::revert(IPolicyRegistry::Unauthorized {})); + } + Self::require_account_batch_size(accounts)?; + for account in accounts { + if add { + storage.set_member(policy_id, *account)?; + } else { + storage.delete_member(policy_id, *account)?; + } + } + Ok(caller) + } +} + +impl PolicyRegistryLogic for PolicyRegistryV3 { + fn create_policy( + &self, + storage: &mut S, + admin: Address, + policy_type: PolicyType, + ) -> Result { + let policy_type_u8 = Self::validate_create_policy_inputs(admin, policy_type)?; + self.create_policy_inner(storage, admin, policy_type, policy_type_u8) + } + + fn create_policy_with_accounts( + &self, + storage: &mut S, + admin: Address, + policy_type: PolicyType, + accounts: Vec
, + ) -> Result { + let policy_type_u8 = Self::validate_create_policy_inputs(admin, policy_type)?; + Self::require_account_batch_size(&accounts)?; + let policy_id = self.create_policy_inner(storage, admin, policy_type, policy_type_u8)?; + let caller = storage.caller(); + for account in &accounts { + storage.set_member(policy_id, *account)?; + } + match policy_type { + PolicyType::ALLOWLIST => storage.emit_event( + IPolicyRegistry::AllowlistUpdated { + policyId: policy_id, + updater: caller, + allowed: true, + accounts, + } + .encode_log_data(), + )?, + PolicyType::BLOCKLIST => storage.emit_event( + IPolicyRegistry::BlocklistUpdated { + policyId: policy_id, + updater: caller, + blocked: true, + accounts, + } + .encode_log_data(), + )?, + _ => return Err(BasePrecompileError::enum_conversion_error()), + } + Ok(policy_id) + } + + fn create_composite_policy( + &self, + storage: &mut S, + admin: Address, + policy_type: PolicyType, + child_policy_ids: Vec, + ) -> Result { + if admin == Address::ZERO { + return Err(BasePrecompileError::revert(IPolicyRegistry::ZeroAddress {})); + } + if !matches!(policy_type, PolicyType::UNION | PolicyType::INTERSECT) { + return Err(BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {})); + } + Self::require_child_policy_in_range(&child_policy_ids)?; + self.validate_composite_child_policies(storage, &child_policy_ids)?; + + // Reuse the simple create core for counter/ID/PolicyCreated+PolicyAdminUpdated events. + let policy_id = + self.create_policy_inner(storage, admin, policy_type, policy_type.as_discriminant())?; + storage.write_children(policy_id, &child_policy_ids)?; + storage.emit_event( + IPolicyRegistry::CompositePolicyUpdated { + policyId: policy_id, + updater: storage.caller(), + childPolicyIds: child_policy_ids, + } + .encode_log_data(), + )?; + Ok(policy_id) + } + + fn update_composite( + &self, + storage: &mut S, + policy_id: u64, + child_policy_ids: Vec, + ) -> Result<()> { + let packed = self.require_existing_policy(storage, policy_id)?; + if !Self::is_composite(policy_id) { + return Err(BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {})); + } + if packed.admin() != storage.caller() { + return Err(BasePrecompileError::revert(IPolicyRegistry::Unauthorized {})); + } + Self::require_child_policy_in_range(&child_policy_ids)?; + self.validate_composite_child_policies(storage, &child_policy_ids)?; + + storage.write_children(policy_id, &child_policy_ids)?; + storage.emit_event( + IPolicyRegistry::CompositePolicyUpdated { + policyId: policy_id, + updater: storage.caller(), + childPolicyIds: child_policy_ids, + } + .encode_log_data(), + )?; + Ok(()) + } + + fn stage_update_admin( + &self, + storage: &mut S, + policy_id: u64, + new_admin: Address, + ) -> Result<()> { + let (_, caller) = self.require_admin(storage, policy_id)?; + if new_admin == Address::ZERO { + storage.delete_pending_admin(policy_id)?; + } else { + storage.write_pending_admin(policy_id, new_admin)?; + } + storage.emit_event( + IPolicyRegistry::PolicyAdminStaged { + policyId: policy_id, + currentAdmin: caller, + pendingAdmin: new_admin, + } + .encode_log_data(), + )?; + Ok(()) + } + + fn finalize_update_admin(&self, storage: &mut S, policy_id: u64) -> Result<()> { + let packed = self.require_existing_policy(storage, policy_id)?; + let pending = storage.read_pending_admin(policy_id)?; + if pending == Address::ZERO { + return Err(BasePrecompileError::revert(IPolicyRegistry::NoPendingAdmin {})); + } + let caller = storage.caller(); + if pending != caller { + return Err(BasePrecompileError::revert(IPolicyRegistry::Unauthorized {})); + } + let previous_admin = packed.admin(); + storage.write_policy_word(policy_id, packed.with_admin(caller).into_u256())?; + storage.delete_pending_admin(policy_id)?; + storage.emit_event( + IPolicyRegistry::PolicyAdminUpdated { + policyId: policy_id, + previousAdmin: previous_admin, + newAdmin: caller, + } + .encode_log_data(), + )?; + Ok(()) + } + + fn renounce_admin(&self, storage: &mut S, policy_id: u64) -> Result<()> { + let (packed, caller) = self.require_admin(storage, policy_id)?; + storage.write_policy_word(policy_id, packed.with_admin(Address::ZERO).into_u256())?; + storage.delete_pending_admin(policy_id)?; + storage.emit_event( + IPolicyRegistry::PolicyAdminUpdated { + policyId: policy_id, + previousAdmin: caller, + newAdmin: Address::ZERO, + } + .encode_log_data(), + )?; + Ok(()) + } + + fn update_allowlist( + &self, + storage: &mut S, + policy_id: u64, + allowed: bool, + accounts: Vec
, + ) -> Result<()> { + let caller = + self.update_membership(storage, policy_id, Self::ALLOWLIST_TYPE, allowed, &accounts)?; + storage.emit_event( + IPolicyRegistry::AllowlistUpdated { + policyId: policy_id, + updater: caller, + allowed, + accounts, + } + .encode_log_data(), + ) + } + + fn update_blocklist( + &self, + storage: &mut S, + policy_id: u64, + blocked: bool, + accounts: Vec
, + ) -> Result<()> { + let caller = + self.update_membership(storage, policy_id, Self::BLOCKLIST_TYPE, blocked, &accounts)?; + storage.emit_event( + IPolicyRegistry::BlocklistUpdated { + policyId: policy_id, + updater: caller, + blocked, + accounts, + } + .encode_log_data(), + ) + } + + fn is_authorized(&self, storage: &S, policy_id: u64, account: Address) -> Result { + // Built-in short-circuits precede any storage read: ALWAYS_ALLOW_ID = 0 is the EVM + // default for any uninitialized policy field, so this must work before init has run. + if policy_id == Self::ALWAYS_ALLOW_ID { + return Ok(true); + } + if policy_id == Self::ALWAYS_BLOCK_ID { + return Ok(false); + } + // Malformed IDs (type byte > INTERSECT) are treated as unauthorized rather than reverting. + if !Self::is_well_formed(policy_id) { + return Ok(false); + } + match Self::policy_id_type(policy_id) { + Self::UNION_TYPE => self.is_authorized_union(storage, policy_id, account), + Self::INTERSECT_TYPE => self.is_authorized_intersect(storage, policy_id, account), + Self::ALLOWLIST_TYPE => storage.read_member(policy_id, account), + Self::BLOCKLIST_TYPE => Ok(!storage.read_member(policy_id, account)?), + _ => unreachable!("is_well_formed rejects type bytes > INTERSECT"), + } + } + + fn policy_exists(&self, storage: &S, policy_id: u64) -> Result { + if policy_id == Self::ALWAYS_ALLOW_ID || policy_id == Self::ALWAYS_BLOCK_ID { + return Ok(true); + } + // Malformed IDs (type byte > INTERSECT) are not well-formed, so they do not exist. + if !Self::is_well_formed(policy_id) { + return Ok(false); + } + Ok(PackedPolicy::from_raw(storage.read_policy_word(policy_id)?).exists()) + } + + fn get_policy_admin(&self, storage: &S, policy_id: u64) -> Result
{ + if !Self::is_well_formed(policy_id) { + return Ok(Address::ZERO); + } + let packed = PackedPolicy::from_raw(storage.read_policy_word(policy_id)?); + if !packed.exists() { + return Ok(Address::ZERO); + } + Ok(packed.admin()) + } + + fn pending_policy_admin(&self, storage: &S, policy_id: u64) -> Result
{ + if !Self::is_well_formed(policy_id) { + return Ok(Address::ZERO); + } + if policy_id == Self::ALWAYS_ALLOW_ID || policy_id == Self::ALWAYS_BLOCK_ID { + return Ok(Address::ZERO); + } + storage.read_pending_admin(policy_id) + } + + fn composite_policy_child_ids(&self, storage: &S, policy_id: u64) -> Result> { + if !Self::is_well_formed(policy_id) { + return Ok(Vec::new()); + } + if !Self::is_composite(policy_id) { + return Ok(Vec::new()); + } + storage.read_children(policy_id) + } +} + +#[cfg(test)] +mod tests { + use alloc::{collections::BTreeMap, vec, vec::Vec}; + + use alloy_primitives::{Address, LogData, U256, address}; + use alloy_sol_types::SolEvent; + use base_precompile_storage::{BasePrecompileError, Result}; + + use crate::{ + IPolicyRegistry, IPolicyRegistry::PolicyType, PolicyAccounting, PolicyRegistryLogic, + PolicyRegistryV3, + }; + + const REGISTRY: Address = address!("0x8453000000000000000000000000000000000002"); + const ADMIN: Address = address!("0x1000000000000000000000000000000000000001"); + const ALICE: Address = address!("0xA000000000000000000000000000000000000001"); + const BOB: Address = address!("0xB000000000000000000000000000000000000001"); + const NEW_ADMIN: Address = address!("0x2000000000000000000000000000000000000002"); + const LOGIC: PolicyRegistryV3 = PolicyRegistryV3; + + // --- Self-contained in-memory fake (no dependency on `common::test_utils`, so shared + // test scaffolding can never drift this frozen version's coverage) --- + + /// Minimal [`PolicyAccounting`] backed by in-memory maps. `delete_*` removes the key so + /// its read semantics match `Mapping::delete` (a zeroed slot), not a written zero value. + #[derive(Debug)] + struct FakePolicyAccounting { + caller: Address, + initialized: bool, + policies: BTreeMap, + members: BTreeMap<(u64, Address), bool>, + pending_admins: BTreeMap, + next_counter: u64, + children: BTreeMap>, + events: Vec, + } + + impl FakePolicyAccounting { + fn new() -> Self { + Self { + caller: ADMIN, + initialized: false, + policies: BTreeMap::new(), + members: BTreeMap::new(), + pending_admins: BTreeMap::new(), + next_counter: 0, + children: BTreeMap::new(), + events: Vec::new(), + } + } + } + + impl PolicyAccounting for FakePolicyAccounting { + fn registry_address(&self) -> Address { + REGISTRY + } + fn caller(&self) -> Address { + self.caller + } + fn read_policy_word(&self, policy_id: u64) -> Result { + Ok(self.policies.get(&policy_id).copied().unwrap_or(U256::ZERO)) + } + fn write_policy_word(&mut self, policy_id: u64, word: U256) -> Result<()> { + self.policies.insert(policy_id, word); + Ok(()) + } + fn read_member(&self, policy_id: u64, account: Address) -> Result { + Ok(self.members.get(&(policy_id, account)).copied().unwrap_or(false)) + } + fn set_member(&mut self, policy_id: u64, account: Address) -> Result<()> { + self.members.insert((policy_id, account), true); + Ok(()) + } + fn delete_member(&mut self, policy_id: u64, account: Address) -> Result<()> { + self.members.remove(&(policy_id, account)); + Ok(()) + } + fn read_pending_admin(&self, policy_id: u64) -> Result
{ + Ok(self.pending_admins.get(&policy_id).copied().unwrap_or(Address::ZERO)) + } + fn write_pending_admin(&mut self, policy_id: u64, admin: Address) -> Result<()> { + self.pending_admins.insert(policy_id, admin); + Ok(()) + } + fn delete_pending_admin(&mut self, policy_id: u64) -> Result<()> { + self.pending_admins.remove(&policy_id); + Ok(()) + } + fn read_next_counter(&self) -> Result { + Ok(self.next_counter) + } + fn write_next_counter(&mut self, counter: u64) -> Result<()> { + self.next_counter = counter; + Ok(()) + } + fn emit_event(&mut self, log: LogData) -> Result<()> { + self.events.push(log); + Ok(()) + } + fn mark_initialized(&mut self) -> Result<()> { + self.initialized = true; + Ok(()) + } + fn read_children(&self, policy_id: u64) -> Result> { + Ok(self.children.get(&policy_id).cloned().unwrap_or_default()) + } + fn write_children(&mut self, policy_id: u64, child_policy_ids: &[u64]) -> Result<()> { + self.children.insert(policy_id, child_policy_ids.to_vec()); + Ok(()) + } + } + + type Storage = FakePolicyAccounting; + + /// Bare storage (no built-ins seeded), caller = `ADMIN`. + fn bare() -> Storage { + FakePolicyAccounting::new() + } + + /// Storage with both built-in policies seeded and the counter at 2. + fn initialized() -> Storage { + let mut storage = bare(); + LOGIC.ensure_initialized_and_get_counter(&mut storage).unwrap(); + storage + } + + fn set_caller(storage: &mut Storage, caller: Address) { + storage.caller = caller; + } + + fn create_allowlist(storage: &mut Storage) -> u64 { + set_caller(storage, ADMIN); + LOGIC.create_policy(storage, ADMIN, PolicyType::ALLOWLIST).unwrap() + } + + fn create_blocklist(storage: &mut Storage) -> u64 { + set_caller(storage, ADMIN); + LOGIC.create_policy(storage, ADMIN, PolicyType::BLOCKLIST).unwrap() + } + + fn is_authorized(storage: &Storage, policy_id: u64, account: Address) -> bool { + LOGIC.is_authorized(storage, policy_id, account).unwrap() + } + + fn many_accounts(count: usize) -> Vec
{ + (0..count).map(|i| Address::from_word(U256::from(i as u64 + 1).into())).collect() + } + + // --- built-in IDs --- + + #[test] + fn always_allow_id_authorizes_any_account() { + let rt = initialized(); + assert!(is_authorized(&rt, PolicyRegistryV3::ALWAYS_ALLOW_ID, ALICE)); + assert!(is_authorized(&rt, PolicyRegistryV3::ALWAYS_ALLOW_ID, BOB)); + } + + #[test] + fn always_block_id_rejects_any_account() { + let rt = initialized(); + assert!(!is_authorized(&rt, PolicyRegistryV3::ALWAYS_BLOCK_ID, ALICE)); + assert!(!is_authorized(&rt, PolicyRegistryV3::ALWAYS_BLOCK_ID, BOB)); + } + + #[test] + fn unknown_blocklist_policy_id_authorizes_account() { + // 0xdeadbeef has type byte 0 (BLOCKLIST); no members blocked => authorized. + let rt = initialized(); + assert!(is_authorized(&rt, 0xdeadbeef, ALICE)); + } + + #[test] + fn unknown_allowlist_policy_id_does_not_authorize_account() { + let unknown_allowlist = PolicyRegistryV3::make_id(PolicyType::ALLOWLIST as u8, 9999); + let rt = initialized(); + assert!(!is_authorized(&rt, unknown_allowlist, ALICE)); + } + + #[test] + fn malformed_policy_id_is_authorized_returns_false() { + // Type byte 4 is above INTERSECT (3), so it is malformed (not a composite). + let malformed: u64 = (4u64 << 56) | 42; + let rt = initialized(); + assert!(!LOGIC.is_authorized(&rt, malformed, ALICE).unwrap()); + } + + #[test] + fn malformed_policy_id_policy_exists_returns_false() { + let malformed: u64 = (5u64 << 56) | 100; + let rt = initialized(); + assert!(!LOGIC.policy_exists(&rt, malformed).unwrap()); + } + + // --- ensure_initialized_and_get_counter --- + + #[test] + fn first_create_policy_initializes_builtins_and_starts_counter_at_two() { + let mut rt = bare(); + let id = LOGIC.create_policy(&mut rt, ADMIN, PolicyType::ALLOWLIST).unwrap(); + assert_eq!(id & PolicyRegistryV3::COUNTER_MASK, 2); + assert!(LOGIC.policy_exists(&rt, PolicyRegistryV3::ALWAYS_ALLOW_ID).unwrap()); + assert!(LOGIC.policy_exists(&rt, PolicyRegistryV3::ALWAYS_BLOCK_ID).unwrap()); + assert!(rt.initialized); + } + + #[test] + fn ensure_initialized_and_get_counter_is_idempotent() { + let mut rt = bare(); + for _ in 0..3 { + LOGIC.ensure_initialized_and_get_counter(&mut rt).unwrap(); + } + assert_eq!(rt.next_counter, PolicyRegistryV3::BUILTIN_POLICY_COUNT); + } + + // --- createPolicy --- + + #[test] + fn create_policy_zero_admin_reverts() { + let mut rt = initialized(); + let err = LOGIC.create_policy(&mut rt, Address::ZERO, PolicyType::ALLOWLIST).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::ZeroAddress {})); + } + + #[test] + fn create_policy_ids_encode_type_in_top_byte_and_increment_counter() { + let mut rt = initialized(); + let id1 = create_allowlist(&mut rt); + let id2 = create_blocklist(&mut rt); + assert_eq!((id1 >> 56) as u8, PolicyType::ALLOWLIST as u8); + assert_eq!((id2 >> 56) as u8, PolicyType::BLOCKLIST as u8); + assert_eq!(id1 & PolicyRegistryV3::COUNTER_MASK, 2); + assert_eq!(id2 & PolicyRegistryV3::COUNTER_MASK, 3); + } + + #[test] + fn create_policy_at_counter_mask_reverts_with_under_overflow() { + let mut rt = initialized(); + rt.next_counter = PolicyRegistryV3::COUNTER_MASK; + let err = LOGIC.create_policy(&mut rt, ADMIN, PolicyType::ALLOWLIST).unwrap_err(); + assert_eq!(err, BasePrecompileError::under_overflow()); + } + + #[test] + fn create_policy_at_counter_mask_minus_one_consumes_last_slot_then_reverts() { + let mut rt = initialized(); + rt.next_counter = PolicyRegistryV3::COUNTER_MASK - 1; + let id = LOGIC.create_policy(&mut rt, ADMIN, PolicyType::ALLOWLIST).unwrap(); + assert_eq!(id & PolicyRegistryV3::COUNTER_MASK, PolicyRegistryV3::COUNTER_MASK - 1); + let err = LOGIC.create_policy(&mut rt, ADMIN, PolicyType::ALLOWLIST).unwrap_err(); + assert_eq!(err, BasePrecompileError::under_overflow()); + } + + #[test] + fn create_policy_emits_policy_created_and_admin_updated_events() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + let events = &rt.events; + assert_eq!(events.len(), 2); + let created = IPolicyRegistry::PolicyCreated::decode_log_data(&events[0]).unwrap(); + assert_eq!(created.policyId, id); + assert_eq!(created.creator, ADMIN); + assert_eq!(created.policyType, PolicyType::ALLOWLIST); + } + + #[test] + fn update_allowlist_emits_allowlist_updated_event() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + LOGIC.update_allowlist(&mut rt, id, true, vec![ALICE]).unwrap(); + let updated = + IPolicyRegistry::AllowlistUpdated::decode_log_data(rt.events.last().unwrap()).unwrap(); + assert_eq!(updated.policyId, id); + assert_eq!(updated.updater, ADMIN); + assert!(updated.allowed); + assert_eq!(updated.accounts, vec![ALICE]); + } + + // --- ALLOWLIST membership --- + + #[test] + fn allowlist_non_member_is_not_authorized() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + assert!(!is_authorized(&rt, id, ALICE)); + } + + #[test] + fn allowlist_add_then_remove_member() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + LOGIC.update_allowlist(&mut rt, id, true, vec![ALICE]).unwrap(); + assert!(is_authorized(&rt, id, ALICE)); + LOGIC.update_allowlist(&mut rt, id, false, vec![ALICE]).unwrap(); + assert!(!is_authorized(&rt, id, ALICE)); + } + + #[test] + fn allowlist_batch_update_flips_all_accounts() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + LOGIC.update_allowlist(&mut rt, id, true, vec![ALICE, BOB]).unwrap(); + assert!(is_authorized(&rt, id, ALICE)); + assert!(is_authorized(&rt, id, BOB)); + LOGIC.update_allowlist(&mut rt, id, false, vec![ALICE, BOB]).unwrap(); + assert!(!is_authorized(&rt, id, ALICE)); + assert!(!is_authorized(&rt, id, BOB)); + } + + #[test] + fn update_allowlist_too_many_accounts_reverts() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + let accounts = many_accounts(PolicyRegistryV3::MAX_ACCOUNTS_PER_BATCH + 1); + let err = LOGIC.update_allowlist(&mut rt, id, true, accounts).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::BatchSizeTooLarge { + maxBatchSize: U256::from(PolicyRegistryV3::MAX_ACCOUNTS_PER_BATCH), + }) + ); + } + + #[test] + fn update_allowlist_max_batch_size_succeeds() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + let accounts = many_accounts(PolicyRegistryV3::MAX_ACCOUNTS_PER_BATCH); + LOGIC.update_allowlist(&mut rt, id, true, accounts).unwrap(); + } + + #[test] + fn allowlist_readding_existing_member_is_idempotent() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + for _ in 0..2 { + LOGIC.update_allowlist(&mut rt, id, true, vec![ALICE]).unwrap(); + } + assert!(is_authorized(&rt, id, ALICE)); + } + + #[test] + fn allowlist_removing_non_member_is_idempotent() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + LOGIC.update_allowlist(&mut rt, id, false, vec![ALICE]).unwrap(); + assert!(!is_authorized(&rt, id, ALICE)); + } + + #[test] + fn update_allowlist_on_blocklist_policy_reverts() { + let mut rt = initialized(); + let id = create_blocklist(&mut rt); + let err = LOGIC.update_allowlist(&mut rt, id, true, vec![ALICE]).unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + // --- BLOCKLIST membership --- + + #[test] + fn blocklist_non_member_is_authorized() { + let mut rt = initialized(); + let id = create_blocklist(&mut rt); + assert!(is_authorized(&rt, id, ALICE)); + } + + #[test] + fn blocklist_block_then_unblock_member() { + let mut rt = initialized(); + let id = create_blocklist(&mut rt); + LOGIC.update_blocklist(&mut rt, id, true, vec![ALICE]).unwrap(); + assert!(!is_authorized(&rt, id, ALICE)); + LOGIC.update_blocklist(&mut rt, id, false, vec![ALICE]).unwrap(); + assert!(is_authorized(&rt, id, ALICE)); + } + + #[test] + fn update_blocklist_on_allowlist_policy_reverts() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + let err = LOGIC.update_blocklist(&mut rt, id, true, vec![ALICE]).unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + #[test] + fn update_blocklist_too_many_accounts_reverts() { + let mut rt = initialized(); + let id = create_blocklist(&mut rt); + let accounts = many_accounts(PolicyRegistryV3::MAX_ACCOUNTS_PER_BATCH + 1); + let err = LOGIC.update_blocklist(&mut rt, id, true, accounts).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::BatchSizeTooLarge { + maxBatchSize: U256::from(PolicyRegistryV3::MAX_ACCOUNTS_PER_BATCH), + }) + ); + } + + #[test] + fn update_allowlist_on_blocklist_policy_by_non_admin_reverts_with_incompatible_type() { + let mut rt = initialized(); + let id = create_blocklist(&mut rt); + set_caller(&mut rt, ALICE); + let err = LOGIC.update_allowlist(&mut rt, id, true, vec![BOB]).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {})); + } + + #[test] + fn update_blocklist_on_allowlist_policy_by_non_admin_reverts_with_incompatible_type() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + set_caller(&mut rt, ALICE); + let err = LOGIC.update_blocklist(&mut rt, id, true, vec![BOB]).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {})); + } + + // --- createPolicyWithAccounts --- + + #[test] + fn create_policy_with_accounts_seeds_members() { + let mut rt = initialized(); + let id = LOGIC + .create_policy_with_accounts(&mut rt, ADMIN, PolicyType::ALLOWLIST, vec![ALICE, BOB]) + .unwrap(); + assert!(is_authorized(&rt, id, ALICE)); + assert!(is_authorized(&rt, id, BOB)); + } + + #[test] + fn create_policy_with_accounts_empty_batch_emits_seed_event() { + let mut rt = initialized(); + let id = LOGIC + .create_policy_with_accounts(&mut rt, ADMIN, PolicyType::ALLOWLIST, Vec::new()) + .unwrap(); + let events = &rt.events; + assert_eq!(events.len(), 3); + let updated = + IPolicyRegistry::AllowlistUpdated::decode_log_data(events.last().unwrap()).unwrap(); + assert_eq!(updated.policyId, id); + assert_eq!(updated.updater, ADMIN); + assert!(updated.allowed); + assert!(updated.accounts.is_empty()); + } + + #[test] + fn create_policy_with_accounts_zero_account_is_seeded() { + let mut rt = initialized(); + let id = LOGIC + .create_policy_with_accounts( + &mut rt, + ADMIN, + PolicyType::ALLOWLIST, + vec![ALICE, Address::ZERO], + ) + .unwrap(); + assert!(LOGIC.is_authorized(&rt, id, Address::ZERO).unwrap()); + } + + #[test] + fn create_policy_with_accounts_too_many_accounts_reverts() { + let mut rt = initialized(); + let accounts = many_accounts(PolicyRegistryV3::MAX_ACCOUNTS_PER_BATCH + 1); + let err = LOGIC + .create_policy_with_accounts(&mut rt, ADMIN, PolicyType::ALLOWLIST, accounts) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::BatchSizeTooLarge { + maxBatchSize: U256::from(PolicyRegistryV3::MAX_ACCOUNTS_PER_BATCH), + }) + ); + } + + #[test] + fn create_policy_with_accounts_zero_admin_precedes_batch_size_revert() { + let mut rt = initialized(); + let accounts = many_accounts(PolicyRegistryV3::MAX_ACCOUNTS_PER_BATCH + 1); + let err = LOGIC + .create_policy_with_accounts(&mut rt, Address::ZERO, PolicyType::ALLOWLIST, accounts) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::ZeroAddress {})); + } + + /// Precedence pin for the shared validator on the `createPolicyWithAccounts` path: a composite + /// type is rejected with `IncompatiblePolicyType` before the batch-size guard runs, matching + /// base-std's `ZeroAddress` -> `IncompatiblePolicyType` -> `BatchSizeTooLarge` order. Composite + /// discriminants (2/3) are the only invalid-type values that decode at V2 and reach the logic; + /// out-of-range bytes are rejected earlier at ABI decode (see `dispatch` tests). + #[test] + fn create_policy_with_accounts_composite_type_precedes_batch_size_revert() { + let mut rt = initialized(); + let accounts = many_accounts(PolicyRegistryV3::MAX_ACCOUNTS_PER_BATCH + 1); + for policy_type in [PolicyType::UNION, PolicyType::INTERSECT] { + let err = LOGIC + .create_policy_with_accounts(&mut rt, ADMIN, policy_type, accounts.clone()) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {}) + ); + } + } + + /// `createPolicy` admits only simple leaf types. `create_composite_policy` is the sole path + /// that validates children and writes a child set, so admitting a composite here would mint a + /// gate whose ID says UNION/INTERSECT over an empty child list. + #[test] + fn create_policy_rejects_composite_types() { + let mut rt = initialized(); + for policy_type in [PolicyType::UNION, PolicyType::INTERSECT] { + let err = LOGIC.create_policy(&mut rt, ADMIN, policy_type).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {}) + ); + } + } + + /// Precedence pin: a zero admin is rejected before the composite-type check, matching the + /// base-std natspec order (`ZeroAddress` before `IncompatiblePolicyType`). + #[test] + fn create_policy_zero_admin_precedes_incompatible_type() { + let mut rt = initialized(); + for policy_type in [PolicyType::UNION, PolicyType::INTERSECT] { + let err = LOGIC.create_policy(&mut rt, Address::ZERO, policy_type).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::ZeroAddress {})); + } + } + + #[test] + fn create_policy_with_accounts_blocklist_seeds_blocked_members() { + let mut rt = initialized(); + let id = LOGIC + .create_policy_with_accounts(&mut rt, ADMIN, PolicyType::BLOCKLIST, vec![ALICE, BOB]) + .unwrap(); + assert!(!is_authorized(&rt, id, ALICE)); + assert!(!is_authorized(&rt, id, BOB)); + } + + // --- two-step admin transfer --- + + #[test] + fn admin_transfer_two_step() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + LOGIC.stage_update_admin(&mut rt, id, NEW_ADMIN).unwrap(); + set_caller(&mut rt, NEW_ADMIN); + LOGIC.finalize_update_admin(&mut rt, id).unwrap(); + LOGIC.update_allowlist(&mut rt, id, true, vec![ALICE]).unwrap(); + assert!(is_authorized(&rt, id, ALICE)); + assert_eq!(LOGIC.get_policy_admin(&rt, id).unwrap(), NEW_ADMIN); + } + + #[test] + fn finalize_update_admin_without_pending_reverts() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + let err = LOGIC.finalize_update_admin(&mut rt, id).unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + #[test] + fn stage_update_admin_unauthorized_reverts() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + set_caller(&mut rt, ALICE); + let err = LOGIC.stage_update_admin(&mut rt, id, NEW_ADMIN).unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + #[test] + fn finalize_update_admin_unauthorized_reverts() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + LOGIC.stage_update_admin(&mut rt, id, NEW_ADMIN).unwrap(); + set_caller(&mut rt, ALICE); + let err = LOGIC.finalize_update_admin(&mut rt, id).unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + // --- renounceAdmin --- + + #[test] + fn renounce_admin_freezes_policy() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + LOGIC.renounce_admin(&mut rt, id).unwrap(); + let err = LOGIC.update_allowlist(&mut rt, id, true, vec![ALICE]).unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + #[test] + fn renounce_admin_unauthorized_reverts() { + let mut rt = initialized(); + let id = create_allowlist(&mut rt); + set_caller(&mut rt, ALICE); + let err = LOGIC.renounce_admin(&mut rt, id).unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + #[test] + fn builtin_policies_reject_admin_mutations() { + let mut rt = initialized(); + for policy_id in [PolicyRegistryV3::ALWAYS_ALLOW_ID, PolicyRegistryV3::ALWAYS_BLOCK_ID] { + let err = LOGIC.stage_update_admin(&mut rt, policy_id, ALICE).unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + } + + // --- read helpers for built-in / unknown / malformed IDs --- + + #[test] + fn policy_exists_builtin_ids_always_return_true() { + let rt = bare(); + assert!(LOGIC.policy_exists(&rt, PolicyRegistryV3::ALWAYS_ALLOW_ID).unwrap()); + assert!(LOGIC.policy_exists(&rt, PolicyRegistryV3::ALWAYS_BLOCK_ID).unwrap()); + } + + #[test] + fn get_policy_admin_builtin_ids_return_zero_address() { + let rt = initialized(); + assert_eq!( + LOGIC.get_policy_admin(&rt, PolicyRegistryV3::ALWAYS_ALLOW_ID).unwrap(), + Address::ZERO + ); + assert_eq!( + LOGIC.get_policy_admin(&rt, PolicyRegistryV3::ALWAYS_BLOCK_ID).unwrap(), + Address::ZERO + ); + } + + #[test] + fn get_policy_admin_malformed_policy_id_returns_zero_address() { + let rt = initialized(); + let malformed: u64 = (4u64 << 56) | 42; + assert_eq!(LOGIC.get_policy_admin(&rt, malformed).unwrap(), Address::ZERO); + } + + #[test] + fn get_policy_admin_nonexistent_policy_returns_zero_address() { + let rt = initialized(); + assert_eq!(LOGIC.get_policy_admin(&rt, 0xdeadbeef).unwrap(), Address::ZERO); + } + + #[test] + fn pending_policy_admin_builtin_ids_return_zero_address() { + let rt = initialized(); + assert_eq!( + LOGIC.pending_policy_admin(&rt, PolicyRegistryV3::ALWAYS_ALLOW_ID).unwrap(), + Address::ZERO + ); + assert_eq!( + LOGIC.pending_policy_admin(&rt, PolicyRegistryV3::ALWAYS_BLOCK_ID).unwrap(), + Address::ZERO + ); + } + + #[test] + fn pending_policy_admin_builtin_ids_short_circuit_staged_slot() { + let mut rt = initialized(); + for policy_id in [PolicyRegistryV3::ALWAYS_ALLOW_ID, PolicyRegistryV3::ALWAYS_BLOCK_ID] { + rt.pending_admins.insert(policy_id, NEW_ADMIN); + assert_eq!( + LOGIC.pending_policy_admin(&rt, policy_id).unwrap(), + Address::ZERO, + "built-in policy {policy_id} must ignore a staged pending slot" + ); + } + } + + #[test] + fn pending_policy_admin_counter_one_blocklist_reads_staged_slot() { + // BLOCKLIST counter=1 is not ALWAYS_BLOCK_ID, which is ALLOWLIST counter=1. + let counter_one_blocklist = PolicyRegistryV3::make_id(PolicyType::BLOCKLIST as u8, 1); + assert_ne!(counter_one_blocklist, PolicyRegistryV3::ALWAYS_BLOCK_ID); + let mut rt = initialized(); + rt.pending_admins.insert(counter_one_blocklist, NEW_ADMIN); + assert_eq!(LOGIC.pending_policy_admin(&rt, counter_one_blocklist).unwrap(), NEW_ADMIN); + } + + #[test] + fn pending_policy_admin_unknown_id_returns_zero_address() { + let rt = initialized(); + assert_eq!(LOGIC.pending_policy_admin(&rt, 0xdeadbeef).unwrap(), Address::ZERO); + } + + #[test] + fn pending_policy_admin_malformed_policy_id_returns_zero_address() { + let rt = initialized(); + let malformed: u64 = (4u64 << 56) | 42; + assert_eq!(LOGIC.pending_policy_admin(&rt, malformed).unwrap(), Address::ZERO); + } + + #[test] + fn composite_policy_child_ids_malformed_policy_id_returns_empty() { + let rt = initialized(); + let malformed: u64 = (4u64 << 56) | 42; + assert!(LOGIC.composite_policy_child_ids(&rt, malformed).unwrap().is_empty()); + } + + #[test] + fn pending_policy_admin_nonexistent_well_formed_policy_returns_zero_address() { + let rt = initialized(); + let nonexistent = PolicyRegistryV3::make_id(0, 999); + assert_eq!(LOGIC.pending_policy_admin(&rt, nonexistent).unwrap(), Address::ZERO); + } + + // --- composite policies (UNION / INTERSECT) --- + + fn create_union(rt: &mut Storage, children: Vec) -> u64 { + set_caller(rt, ADMIN); + LOGIC.create_composite_policy(rt, ADMIN, PolicyType::UNION, children).unwrap() + } + + fn create_intersect(rt: &mut Storage, children: Vec) -> u64 { + set_caller(rt, ADMIN); + LOGIC.create_composite_policy(rt, ADMIN, PolicyType::INTERSECT, children).unwrap() + } + + #[test] + fn create_composite_encodes_gate_in_top_byte_and_is_observable() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let id = create_union(&mut rt, vec![a, b]); + assert_eq!((id >> 56) as u8, PolicyType::UNION as u8); + assert!(LOGIC.policy_exists(&rt, id).unwrap()); + assert_eq!(LOGIC.get_policy_admin(&rt, id).unwrap(), ADMIN); + } + + #[test] + fn create_composite_emits_created_admin_and_composite_updated_events() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let before = rt.events.len(); + let id = create_union(&mut rt, vec![a, b]); + let events = &rt.events[before..]; + assert_eq!(events.len(), 3); + assert_eq!( + IPolicyRegistry::PolicyCreated::decode_log_data(&events[0]).unwrap().policyId, + id + ); + assert_eq!( + IPolicyRegistry::PolicyAdminUpdated::decode_log_data(&events[1]).unwrap().newAdmin, + ADMIN + ); + let updated = IPolicyRegistry::CompositePolicyUpdated::decode_log_data(&events[2]).unwrap(); + assert_eq!(updated.policyId, id); + assert_eq!(updated.updater, ADMIN); + assert_eq!(updated.childPolicyIds, vec![a, b]); + } + + #[test] + fn union_is_authorized_if_any_child_authorizes() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + LOGIC.update_allowlist(&mut rt, a, true, vec![ALICE]).unwrap(); + LOGIC.update_allowlist(&mut rt, b, true, vec![BOB]).unwrap(); + let id = create_union(&mut rt, vec![a, b]); + assert!(LOGIC.is_authorized(&rt, id, ALICE).unwrap()); + assert!(LOGIC.is_authorized(&rt, id, BOB).unwrap()); + assert!(!LOGIC.is_authorized(&rt, id, NEW_ADMIN).unwrap()); + } + + #[test] + fn intersect_is_authorized_only_if_all_children_authorize() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + LOGIC.update_allowlist(&mut rt, a, true, vec![ALICE, BOB]).unwrap(); + LOGIC.update_allowlist(&mut rt, b, true, vec![ALICE]).unwrap(); + let id = create_intersect(&mut rt, vec![a, b]); + assert!(LOGIC.is_authorized(&rt, id, ALICE).unwrap()); + assert!(!LOGIC.is_authorized(&rt, id, BOB).unwrap()); + } + + #[test] + fn composite_evaluates_children_live() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + LOGIC.update_allowlist(&mut rt, a, true, vec![ALICE]).unwrap(); + let id = create_intersect(&mut rt, vec![a, b]); + // ALICE is in A but not B, so the intersection rejects. + assert!(!LOGIC.is_authorized(&rt, id, ALICE).unwrap()); + // Adding ALICE to B flips the result live, without touching the composite. + LOGIC.update_allowlist(&mut rt, b, true, vec![ALICE]).unwrap(); + assert!(LOGIC.is_authorized(&rt, id, ALICE).unwrap()); + } + + #[test] + fn update_composite_replaces_child_set() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let c = create_allowlist(&mut rt); + LOGIC.update_allowlist(&mut rt, a, true, vec![ALICE]).unwrap(); + LOGIC.update_allowlist(&mut rt, c, true, vec![BOB]).unwrap(); + let id = create_union(&mut rt, vec![a, b]); + assert!(LOGIC.is_authorized(&rt, id, ALICE).unwrap()); + assert!(!LOGIC.is_authorized(&rt, id, BOB).unwrap()); + // Replace [a, b] with [b, c]: ALICE (only in a) drops out, BOB (in c) joins. + set_caller(&mut rt, ADMIN); + LOGIC.update_composite(&mut rt, id, vec![b, c]).unwrap(); + assert!(!LOGIC.is_authorized(&rt, id, ALICE).unwrap()); + assert!(LOGIC.is_authorized(&rt, id, BOB).unwrap()); + } + + #[test] + fn composite_policy_child_ids_returns_the_set_in_creation_order() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let c = create_allowlist(&mut rt); + let id = create_union(&mut rt, vec![c, a, b]); + // Order is preserved verbatim; the registry never sorts or dedupes the child set. + assert_eq!(LOGIC.composite_policy_child_ids(&rt, id).unwrap(), vec![c, a, b]); + } + + #[test] + fn composite_policy_child_ids_tracks_update_composite() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let c = create_allowlist(&mut rt); + let id = create_intersect(&mut rt, vec![a, b]); + assert_eq!(LOGIC.composite_policy_child_ids(&rt, id).unwrap(), vec![a, b]); + set_caller(&mut rt, ADMIN); + LOGIC.update_composite(&mut rt, id, vec![b, c]).unwrap(); + // Full replacement, not a merge — `a` is gone rather than retained as a stale tail. + assert_eq!(LOGIC.composite_policy_child_ids(&rt, id).unwrap(), vec![b, c]); + } + + #[test] + fn composite_policy_child_ids_matches_the_emitted_event() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let before = rt.events.len(); + let id = create_union(&mut rt, vec![a, b]); + let emitted = + IPolicyRegistry::CompositePolicyUpdated::decode_log_data(&rt.events[before + 2]) + .unwrap() + .childPolicyIds; + assert_eq!(LOGIC.composite_policy_child_ids(&rt, id).unwrap(), emitted); + } + + #[test] + fn composite_policy_child_ids_returns_empty_for_non_composites() { + let mut rt = initialized(); + let simple = create_allowlist(&mut rt); + let malformed = PolicyRegistryV3::make_id(9, 1); + let uncreated_union = PolicyRegistryV3::make_id(PolicyType::UNION as u8, 999); + + for policy_id in [ + simple, + PolicyRegistryV3::ALWAYS_ALLOW_ID, + PolicyRegistryV3::ALWAYS_BLOCK_ID, + malformed, + uncreated_union, + ] { + assert!( + LOGIC.composite_policy_child_ids(&rt, policy_id).unwrap().is_empty(), + "expected an empty child set for policy {policy_id}" + ); + } + } + + #[test] + fn create_composite_zero_admin_reverts() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let err = LOGIC + .create_composite_policy(&mut rt, Address::ZERO, PolicyType::UNION, vec![a, b]) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::ZeroAddress {})); + } + + #[test] + fn create_composite_non_composite_type_reverts() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let err = LOGIC + .create_composite_policy(&mut rt, ADMIN, PolicyType::ALLOWLIST, vec![a, b]) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {})); + } + + #[test] + fn create_composite_child_count_out_of_range_reverts() { + let mut rt = initialized(); + let ids: Vec = (0..5).map(|_| create_allowlist(&mut rt)).collect(); + let range_err = + BasePrecompileError::revert(IPolicyRegistry::ChildPoliciesOutsideOfRange {}); + // Too few (1) and too many (5) both revert with the same range error. + let too_few = LOGIC + .create_composite_policy(&mut rt, ADMIN, PolicyType::UNION, vec![ids[0]]) + .unwrap_err(); + let too_many = + LOGIC.create_composite_policy(&mut rt, ADMIN, PolicyType::UNION, ids).unwrap_err(); + assert_eq!(too_few, range_err); + assert_eq!(too_many, range_err); + } + + #[test] + fn create_composite_nonexistent_child_reverts_policy_not_found() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let missing = PolicyRegistryV3::make_id(PolicyType::ALLOWLIST as u8, 999); + let err = LOGIC + .create_composite_policy(&mut rt, ADMIN, PolicyType::UNION, vec![a, missing]) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::PolicyNotFound {})); + } + + #[test] + fn create_composite_builtin_child_reverts_invalid_child() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let err = LOGIC + .create_composite_policy( + &mut rt, + ADMIN, + PolicyType::UNION, + vec![a, PolicyRegistryV3::ALWAYS_ALLOW_ID], + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::InvalidChildPolicy { + childPolicyId: PolicyRegistryV3::ALWAYS_ALLOW_ID, + }) + ); + } + + #[test] + fn create_composite_composite_child_reverts_invalid_child() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let inner = create_union(&mut rt, vec![a, b]); + let c = create_allowlist(&mut rt); + let err = LOGIC + .create_composite_policy(&mut rt, ADMIN, PolicyType::INTERSECT, vec![c, inner]) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::InvalidChildPolicy { + childPolicyId: inner + }) + ); + } + + #[test] + fn create_composite_nonexistent_child_precedes_invalid_child() { + // Two-pass validation: a missing child reverts PolicyNotFound even when another child + // is an invalid built-in (which would otherwise revert InvalidChildPolicy). + let mut rt = initialized(); + let missing = PolicyRegistryV3::make_id(PolicyType::ALLOWLIST as u8, 999); + let err = LOGIC + .create_composite_policy( + &mut rt, + ADMIN, + PolicyType::UNION, + vec![PolicyRegistryV3::ALWAYS_ALLOW_ID, missing], + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::PolicyNotFound {})); + } + + #[test] + fn update_composite_nonexistent_reverts_policy_not_found() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let missing = PolicyRegistryV3::make_id(PolicyType::UNION as u8, 999); + let err = LOGIC.update_composite(&mut rt, missing, vec![a, b]).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::PolicyNotFound {})); + } + + #[test] + fn update_composite_on_simple_policy_reverts_incompatible_type() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let err = LOGIC.update_composite(&mut rt, a, vec![a, b]).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {})); + } + + #[test] + fn update_composite_unauthorized_reverts() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let id = create_union(&mut rt, vec![a, b]); + set_caller(&mut rt, ALICE); + let err = LOGIC.update_composite(&mut rt, id, vec![a, b]).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IPolicyRegistry::Unauthorized {})); + } + + #[test] + fn update_composite_invalid_child_reverts() { + let mut rt = initialized(); + let a = create_allowlist(&mut rt); + let b = create_allowlist(&mut rt); + let id = create_union(&mut rt, vec![a, b]); + set_caller(&mut rt, ADMIN); + let err = LOGIC + .update_composite(&mut rt, id, vec![a, PolicyRegistryV3::ALWAYS_BLOCK_ID]) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::InvalidChildPolicy { + childPolicyId: PolicyRegistryV3::ALWAYS_BLOCK_ID, + }) + ); + } +} diff --git a/crates/common/precompiles/src/policy/mod.rs b/crates/common/precompiles/src/policy/mod.rs index d199efde81..881edc8db2 100644 --- a/crates/common/precompiles/src/policy/mod.rs +++ b/crates/common/precompiles/src/policy/mod.rs @@ -12,7 +12,7 @@ mod versions; pub use versions::{PolicyAbi, PolicyVersion, PolicyVersions}; mod logic; -pub use logic::{PolicyRegistryLogic, PolicyRegistryV1, PolicyRegistryV2}; +pub use logic::{PolicyRegistryLogic, PolicyRegistryV1, PolicyRegistryV2, PolicyRegistryV3}; mod precompile; pub use precompile::PolicyRegistryPrecompile; diff --git a/crates/common/precompiles/src/policy/versions.rs b/crates/common/precompiles/src/policy/versions.rs index 9cd942a71f..67568700e2 100644 --- a/crates/common/precompiles/src/policy/versions.rs +++ b/crates/common/precompiles/src/policy/versions.rs @@ -15,7 +15,7 @@ use base_precompile_storage::{BasePrecompileError, Result}; use crate::{ IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, PolicyAccounting, PolicyRegistryLogic, - PolicyRegistryV1, PolicyRegistryV2, + PolicyRegistryV1, PolicyRegistryV2, PolicyRegistryV3, }; /// An activated version of the `PolicyRegistry` precompile logic. @@ -27,6 +27,8 @@ pub enum PolicyVersion { V1, /// Introduced at Cobalt, superseding [`Self::V1`]. V2, + /// Introduced at Denim, superseding [`Self::V2`]. + V3, } impl PolicyVersion { @@ -37,16 +39,19 @@ impl PolicyVersion { { static V1: PolicyRegistryV1 = PolicyRegistryV1; static V2: PolicyRegistryV2 = PolicyRegistryV2; + static V3: PolicyRegistryV3 = PolicyRegistryV3; match self { Self::V1 => &V1, Self::V2 => &V2, + Self::V3 => &V3, } } /// Returns the wire (ABI) surface frozen for this version. pub const fn abi(self) -> PolicyAbi { match self { Self::V1 => PolicyAbi::V1, - Self::V2 => PolicyAbi::V2, + // V3 reuses the Cobalt wire surface until a later Denim PR adds selectors. + Self::V2 | Self::V3 => PolicyAbi::V2, } } } @@ -120,6 +125,7 @@ impl PolicyVersions { /// Returns the version active at `upgrade`, or `None` before the introduction pub fn from_base_upgrade(upgrade: BaseUpgrade) -> Option { match upgrade { + u if u >= BaseUpgrade::Denim => Some(PolicyVersion::V3), u if u >= BaseUpgrade::Cobalt => Some(PolicyVersion::V2), u if u >= BaseUpgrade::Beryl => Some(PolicyVersion::V1), _ => None, @@ -154,17 +160,26 @@ mod tests { assert_eq!(PolicyVersions::from_base_upgrade(BaseUpgrade::Cobalt), Some(PolicyVersion::V2)); } + #[test] + fn resolves_v3_from_denim() { + assert_eq!(PolicyVersions::from_base_upgrade(BaseUpgrade::Denim), Some(PolicyVersion::V3)); + assert_eq!(PolicyVersions::from_base_upgrade(BaseUpgrade::Zenith), Some(PolicyVersion::V3)); + } + /// The logic axis and the wire axis meet only here. Driven from the fork ladder so the whole /// chain (upgrade -> version -> surface) is pinned, not just the inner lookup. #[test] fn each_fork_resolves_to_its_wire_surface() { assert_eq!(PolicyVersion::V1.abi(), PolicyAbi::V1); assert_eq!(PolicyVersion::V2.abi(), PolicyAbi::V2); + assert_eq!(PolicyVersion::V3.abi(), PolicyAbi::V2); let beryl = PolicyVersions::from_base_upgrade(BaseUpgrade::Beryl).unwrap(); let cobalt = PolicyVersions::from_base_upgrade(BaseUpgrade::Cobalt).unwrap(); + let denim = PolicyVersions::from_base_upgrade(BaseUpgrade::Denim).unwrap(); assert_eq!(beryl.abi(), PolicyAbi::V1); assert_eq!(cobalt.abi(), PolicyAbi::V2); + assert_eq!(denim.abi(), PolicyAbi::V2); } /// The dispatcher re-decodes against the canonical surface after a frozen surface accepts, so diff --git a/crates/common/precompiles/tests/b20_policy_v3_golden.rs b/crates/common/precompiles/tests/b20_policy_v3_golden.rs new file mode 100644 index 0000000000..3d1784ace6 --- /dev/null +++ b/crates/common/precompiles/tests/b20_policy_v3_golden.rs @@ -0,0 +1,1229 @@ +//! Golden tests pinning Policy Registry **V3** behavior of the B-20 precompile. +//! +//! V3 is activated at Denim as a behavior-preserving copy of V2 (a scaffold seam for future +//! Denim-era changes). Its logic and storage layout are identical to V2, so this suite pins its +//! own roots to lock V3's behavior independently: a future edit that alters V3 must re-bless these +//! roots. These goldens run production Cobalt storage features (Denim inherits them via +//! `upgrade >= Cobalt`) while the V1 suite runs Legacy, so a root matches V2's only when the op +//! does not diverge; ops that write shrinking dynamic values can legitimately diverge from V1's +//! Legacy pin. +//! +//! Every op (policy creation, admin lifecycle, allow/block membership, and evaluation reads) is +//! driven through the real `PolicyRegistryStorage` entry (version-resolver-gated `dispatch`, +//! `BaseUpgrade::Denim` -> `PolicyVersion::V3`) over `HashMapStorageProvider` configured with +//! `StorageFeatures::Cobalt` (the production storage config at Denim). +//! Each case asserts: +//! 1. exact returned ABI bytes (or the typed revert), +//! 2. resulting registry state, +//! 3. emitted events, and +//! 4. a per-case keccak storage **hash** snapshot (the frozen-manifest baseline), scoped to the +//! registry address so it is independent of activation-registry scaffolding. +//! +//! Write ops require the `PolicyRegistry` activation feature; `fresh()` activates it. Read ops +//! bypass activation. Custom policy IDs encode `(type << 56) | counter`, with the shared counter +//! starting at 2 (built-ins `ALWAYS_ALLOW` = 0 and `ALWAYS_BLOCK` consume 0 and 1). +//! +//! ## Blessing storage hashes +//! State-hash constants below are pinned. To (re)generate them after an intentional change, run: +//! `BLESS_GOLDEN=1 cargo test -p base-common-precompiles --features test-utils \ +//! --test b20_policy_v3_golden -- --nocapture` and copy the printed `GOLDEN_ROOT` values. + +use IPolicyRegistry::PolicyType; +use alloy_primitives::{Address, B256, Bytes, LogData, U256, b256, keccak256}; +use alloy_sol_types::{SolCall, SolError, SolEvent}; +use base_common_genesis::BaseUpgrade; +use base_common_precompiles::{ + ActivationAdminConfig, ActivationFeature, ActivationRegistryStorage, IPolicyRegistry, + PolicyRegistryStorage, PolicyVersion, PolicyVersions, UpgradeGatedStorageFeatures, +}; +use base_precompile_storage::{HashMapStorageProvider, StorageCtx}; + +mod common; +use common::{ + ACTIVATION_ADMIN, ADMIN, ALICE, BOB, CHAIN_ID, bless_or_assert_gas, bless_or_assert_root, +}; + +// --- fixtures --------------------------------------------------------------- + +const ADMIN2: Address = Address::repeat_byte(0xA2); +const OUTSIDER: Address = Address::repeat_byte(0x0F); + +/// First custom BLOCKLIST id in a fresh registry: `(0 << 56) | 2`. +const BLOCKLIST_ID: u64 = 2; +/// First custom ALLOWLIST id in a fresh registry: `(1 << 56) | 2`. +const ALLOWLIST_ID: u64 = (1u64 << 56) | 2; + +// --- pinned storage hashes (bless with BLESS_GOLDEN=1; see module docs) -------- +// +// V3 is a behavior- and layout-preserving copy of V2, so these track V2's roots — but pinned +// independently here: these goldens run `StorageFeatures::Cobalt` (inherited at Denim) while the +// V1 suite runs Legacy, so an op that triggers Cobalt dynamic tail cleanup can diverge from its +// V1 pin. Re-blessing reflects the true Denim snapshot for each. + +const ROOT_CREATE_BLOCKLIST: B256 = + b256!("5ff0dab60b6daec34cbc6135f09097ddbbe31c6f662d4cdd9c6c4c7b5a589556"); +const ROOT_CREATE_ALLOWLIST: B256 = + b256!("d3897ed8f0dd86ed6a036e2c1386e43d3a54adfc3cf79a05bdd69fbabcdf402f"); +const ROOT_CREATE_WITH_ACCOUNTS: B256 = + b256!("5d5964977121364f0c16ef145e1edcafe296cfed6e5a932506cb10a35fa969c1"); +const ROOT_CREATE_WITH_ACCOUNTS_BLOCKLIST: B256 = + b256!("a7935cb41217a504a2ca9bd9517aebaaef9d54fe58f685bd569a590749f3d569"); +const ROOT_UPDATE_ALLOWLIST: B256 = + b256!("ae7e56989c3a1dab0b1de0b272dad0381a62414917d9fd82541f35764087748e"); +const ROOT_UPDATE_ALLOWLIST_REMOVE: B256 = + b256!("26874cf1f9e3b7a06e33cc25b500e0fe8fc03c084bb2d6eee390e6adb5cb879d"); +const ROOT_UPDATE_BLOCKLIST: B256 = + b256!("a7935cb41217a504a2ca9bd9517aebaaef9d54fe58f685bd569a590749f3d569"); +const ROOT_STAGE_FINALIZE_ADMIN: B256 = + b256!("8af193788c98e688ce3bb069b241f1fea51bd8dc6d9aa95995bcd956071a111d"); +const ROOT_RENOUNCE_ADMIN: B256 = + b256!("5d31ed7e17637df947521d71d43d5d9b93e75b070e6ffeee6e442ac9a2ce0e5b"); +// Composite roots are V2-only (no V1 equivalent). Bless with BLESS_GOLDEN=1. +const ROOT_CREATE_COMPOSITE_UNION: B256 = + b256!("f76b88e4cd8e2379cd8fe082a8530e187eca346599b679abb5038aaf62d048ee"); +const ROOT_UPDATE_COMPOSITE: B256 = + b256!("fa1912098beb387418d4c28bf4d9cc499f0f2cf8a623e6d57a768bcf062c9094"); + +// --- harness ---------------------------------------------------------------- + +/// The policy registry precompile's singleton address. +const fn registry() -> Address { + PolicyRegistryStorage::ADDRESS +} + +/// Activates the `PolicyRegistry` feature so write ops are permitted. +fn activate(storage: &mut HashMapStorageProvider) { + storage.set_caller(ACTIVATION_ADMIN); + StorageCtx::enter(storage, |ctx| { + ActivationRegistryStorage::new(ctx) + .activate( + ActivationFeature::PolicyRegistry.id(), + ActivationAdminConfig::static_fallback(Some(ACTIVATION_ADMIN)), + ) + .unwrap(); + }); +} + +/// A fresh provider with the policy registry activated. +fn fresh() -> HashMapStorageProvider { + let mut storage = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + activate(&mut storage); + storage +} + +/// Drives one registry call through `dispatch`, returning `(is_revert, bytes)`. +fn call_policy( + storage: &mut HashMapStorageProvider, + caller: Address, + calldata: Vec, +) -> (bool, Bytes) { + storage.set_caller(caller); + StorageCtx::enter(storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata, BaseUpgrade::Denim) + }) + .map(|out| (out.is_revert(), out.bytes)) + .expect("dispatch must not fatally error") +} + +/// Creates a policy and returns its decoded id. +fn create( + storage: &mut HashMapStorageProvider, + caller: Address, + admin: Address, + policy_type: PolicyType, +) -> u64 { + let (rev, bytes) = call_policy( + storage, + caller, + IPolicyRegistry::createPolicyCall { admin, policyType: policy_type }.abi_encode(), + ); + assert!(!rev, "createPolicy reverted"); + IPolicyRegistry::createPolicyCall::abi_decode_returns(&bytes).unwrap() +} + +/// Reads `isAuthorized(policy_id, account)`. +fn is_authorized(storage: &mut HashMapStorageProvider, policy_id: u64, account: Address) -> bool { + let (rev, bytes) = call_policy( + storage, + OUTSIDER, + IPolicyRegistry::isAuthorizedCall { policyId: policy_id, account }.abi_encode(), + ); + assert!(!rev); + IPolicyRegistry::isAuthorizedCall::abi_decode_returns(&bytes).unwrap() +} + +/// Deterministic keccak hash of the per-case snapshot, scoped to the registry address: its emitted +/// events (topics + data) followed by its sorted `(slot, value)` storage entries. Scoping excludes +/// activation-registry scaffolding, so the pin captures only the registry's own effect. +fn hash_state(storage: HashMapStorageProvider) -> B256 { + let mut buf = Vec::new(); + let events: Vec = storage.get_events(registry()).clone(); + for log in &events { + for topic in log.topics() { + buf.extend_from_slice(topic.as_slice()); + } + buf.extend_from_slice(&log.data); + } + let mut triples: Vec<(Address, U256, U256)> = + storage.into_storage().filter(|(a, _, _)| *a == registry()).collect(); + triples.sort(); + for (_addr, slot, value) in triples { + buf.extend_from_slice(&slot.to_be_bytes::<32>()); + buf.extend_from_slice(&value.to_be_bytes::<32>()); + } + keccak256(&buf) +} + +/// Asserts the storage hash, or prints it under `BLESS_GOLDEN` for (re)pinning. +#[track_caller] +fn assert_root(label: &str, storage: HashMapStorageProvider, expected: B256) { + bless_or_assert_root(label, hash_state(storage), expected); +} + +// ============================================================================ +// Version resolver +// ============================================================================ + +#[test] +fn resolver_maps_forks_to_versions() { + assert_eq!(PolicyVersions::from_base_upgrade(BaseUpgrade::Azul), None); + assert_eq!(PolicyVersions::from_base_upgrade(BaseUpgrade::Beryl), Some(PolicyVersion::V1)); + assert_eq!(PolicyVersions::from_base_upgrade(BaseUpgrade::Cobalt), Some(PolicyVersion::V2)); + assert_eq!(PolicyVersions::from_base_upgrade(BaseUpgrade::Denim), Some(PolicyVersion::V3)); +} + +#[test] +fn dispatch_reverts_before_beryl() { + let mut s = fresh(); + let calldata = IPolicyRegistry::isAuthorizedCall { policyId: 0, account: ALICE }.abi_encode(); + s.set_caller(ALICE); + let (rev, bytes) = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata, BaseUpgrade::Azul) + }) + .map(|out| (out.is_revert(), out.bytes)) + .expect("dispatch must not fatally error"); + assert!(rev); + assert!(bytes.is_empty()); +} + +// ============================================================================ +// evaluation reads (isAuthorized / built-ins / empty policies) +// ============================================================================ + +#[test] +fn golden_is_authorized_builtins_and_malformed() { + let mut s = fresh(); + // ALWAYS_ALLOW (0) authorizes everyone; ALWAYS_BLOCK rejects everyone. + assert!(is_authorized(&mut s, PolicyRegistryStorage::ALWAYS_ALLOW_ID, ALICE)); + assert!(!is_authorized(&mut s, PolicyRegistryStorage::ALWAYS_BLOCK_ID, ALICE)); + // Malformed id (type byte > INTERSECT) is unauthorized, never reverts. Type byte 2 is now + // UNION, so use 4 to stay above the composite range. + assert!(!is_authorized(&mut s, 4u64 << 56, ALICE)); +} + +#[test] +fn golden_is_authorized_empty_policies() { + let mut s = fresh(); + // Empty BLOCKLIST authorizes everyone; empty ALLOWLIST rejects everyone. + let block = create(&mut s, ADMIN, ADMIN, PolicyType::BLOCKLIST); + let allow = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + assert!(is_authorized(&mut s, block, ALICE)); + assert!(!is_authorized(&mut s, allow, ALICE)); +} + +#[test] +fn golden_reads_for_nonexistent_and_builtins() { + let mut s = fresh(); + // policyExists: false for unknown custom id, true for built-ins. + let (_, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::policyExistsCall { policyId: 999 }.abi_encode(), + ); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::policyExistsCall::abi_encode_returns(&false))); + let (_, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::policyExistsCall { policyId: PolicyRegistryStorage::ALWAYS_ALLOW_ID } + .abi_encode(), + ); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::policyExistsCall::abi_encode_returns(&true))); + // policyAdmin / pendingPolicyAdmin: zero for nonexistent, never revert. + let (_, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::policyAdminCall { policyId: 999 }.abi_encode(), + ); + assert_eq!( + bytes, + Bytes::from(IPolicyRegistry::policyAdminCall::abi_encode_returns(&Address::ZERO)) + ); + let (_, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::pendingPolicyAdminCall { policyId: 999 }.abi_encode(), + ); + assert_eq!( + bytes, + Bytes::from(IPolicyRegistry::pendingPolicyAdminCall::abi_encode_returns(&Address::ZERO)) + ); +} + +// ============================================================================ +// createPolicy +// ============================================================================ + +#[test] +fn golden_create_blocklist() { + let mut s = fresh(); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::createPolicyCall { admin: ADMIN, policyType: PolicyType::BLOCKLIST } + .abi_encode(), + ); + assert!(!rev); + assert_eq!( + bytes, + Bytes::from(IPolicyRegistry::createPolicyCall::abi_encode_returns(&BLOCKLIST_ID)) + ); + let (_, exists) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::policyExistsCall { policyId: BLOCKLIST_ID }.abi_encode(), + ); + assert_eq!(exists, Bytes::from(IPolicyRegistry::policyExistsCall::abi_encode_returns(&true))); + // createPolicy emits PolicyCreated then PolicyAdminUpdated (0 -> admin). + let events = s.get_events(registry()); + assert_eq!( + events[events.len() - 2].topics()[0], + IPolicyRegistry::PolicyCreated::SIGNATURE_HASH + ); + assert_eq!( + events[events.len() - 1].topics()[0], + IPolicyRegistry::PolicyAdminUpdated::SIGNATURE_HASH + ); + assert_root("create_blocklist", s, ROOT_CREATE_BLOCKLIST); +} + +#[test] +fn golden_create_allowlist() { + let mut s = fresh(); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::createPolicyCall { admin: ADMIN, policyType: PolicyType::ALLOWLIST } + .abi_encode(), + ); + assert!(!rev); + assert_eq!( + bytes, + Bytes::from(IPolicyRegistry::createPolicyCall::abi_encode_returns(&ALLOWLIST_ID)) + ); + let (_, admin) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::policyAdminCall { policyId: ALLOWLIST_ID }.abi_encode(), + ); + assert_eq!(admin, Bytes::from(IPolicyRegistry::policyAdminCall::abi_encode_returns(&ADMIN))); + let events = s.get_events(registry()); + assert_eq!( + events[events.len() - 2].topics()[0], + IPolicyRegistry::PolicyCreated::SIGNATURE_HASH + ); + assert_eq!( + events[events.len() - 1].topics()[0], + IPolicyRegistry::PolicyAdminUpdated::SIGNATURE_HASH + ); + assert_root("create_allowlist", s, ROOT_CREATE_ALLOWLIST); +} + +#[test] +fn golden_create_reverts_zero_admin() { + let mut s = fresh(); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::createPolicyCall { + admin: Address::ZERO, + policyType: PolicyType::BLOCKLIST, + } + .abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::ZeroAddress {}.abi_encode())); +} + +/// A composite discriminant (UNION/INTERSECT) decodes at V2 (the enum widened at Cobalt) and +/// reaches the logic, which rejects it with `IncompatiblePolicyType` rather than a `Panic(0x21)`. +/// The revert body is exactly the 4-byte selector (no args); this is a revert-only path, so no +/// state-root golden moves. +#[test] +fn golden_create_policy_composite_type_is_incompatible_policy_type_in_v2() { + for policy_type in [PolicyType::UNION, PolicyType::INTERSECT] { + let calldata = IPolicyRegistry::createPolicyCall { admin: ADMIN, policyType: policy_type } + .abi_encode(); + let mut s = fresh(); + let (rev, bytes) = call_policy(&mut s, ADMIN, calldata); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::IncompatiblePolicyType::SELECTOR.as_ref())); + } +} + +// ============================================================================ +// composite policies (V2: UNION / INTERSECT) +// ============================================================================ + +/// Adds `account` to allowlist policy `id` as its admin (`ADMIN`). +fn set_allow(s: &mut HashMapStorageProvider, id: u64, account: Address) { + let (rev, _) = call_policy( + s, + ADMIN, + IPolicyRegistry::updateAllowlistCall { + policyId: id, + allowed: true, + accounts: vec![account], + } + .abi_encode(), + ); + assert!(!rev, "updateAllowlist setup unexpectedly reverted"); +} + +/// Creates a composite policy as `ADMIN`, returning its decoded id. +fn create_composite(s: &mut HashMapStorageProvider, gate: PolicyType, children: Vec) -> u64 { + let (rev, bytes) = call_policy( + s, + ADMIN, + IPolicyRegistry::createCompositePolicyCall { + admin: ADMIN, + policyType: gate, + childPolicyIds: children, + } + .abi_encode(), + ); + assert!(!rev, "createCompositePolicy unexpectedly reverted"); + IPolicyRegistry::createCompositePolicyCall::abi_decode_returns(&bytes).unwrap() +} + +#[test] +fn golden_create_composite_union() { + let mut s = fresh(); + let a = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let b = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + set_allow(&mut s, a, ALICE); + set_allow(&mut s, b, BOB); + + let id = create_composite(&mut s, PolicyType::UNION, vec![a, b]); + assert_eq!((id >> 56) as u8, PolicyType::UNION as u8); + + // Live UNION: authorized if ANY child authorizes. + assert!(is_authorized(&mut s, id, ALICE)); + assert!(is_authorized(&mut s, id, BOB)); + assert!(!is_authorized(&mut s, id, OUTSIDER)); + + // Creation emits PolicyCreated, PolicyAdminUpdated, then CompositePolicyUpdated last. + assert_eq!( + s.get_events(registry()).last().unwrap().topics()[0], + IPolicyRegistry::CompositePolicyUpdated::SIGNATURE_HASH + ); + assert_root("create_composite_union", s, ROOT_CREATE_COMPOSITE_UNION); +} + +#[test] +fn golden_update_composite() { + let mut s = fresh(); + let a = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let b = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let c = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + set_allow(&mut s, a, ALICE); + set_allow(&mut s, c, BOB); + + let id = create_composite(&mut s, PolicyType::UNION, vec![a, b]); + assert!(is_authorized(&mut s, id, ALICE)); + assert!(!is_authorized(&mut s, id, BOB)); + + // Replace [a, b] with [b, c]: ALICE (only in a) drops out, BOB (in c) joins. + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateCompositeCall { policyId: id, childPolicyIds: vec![b, c] } + .abi_encode(), + ); + assert!(!rev); + assert!(bytes.is_empty()); + assert!(!is_authorized(&mut s, id, ALICE)); + assert!(is_authorized(&mut s, id, BOB)); + assert_eq!( + s.get_events(registry()).last().unwrap().topics()[0], + IPolicyRegistry::CompositePolicyUpdated::SIGNATURE_HASH + ); + assert_root("update_composite", s, ROOT_UPDATE_COMPOSITE); +} + +/// Reads a composite's child set over the wire, asserting the call never reverts. +fn composite_child_ids(storage: &mut HashMapStorageProvider, policy_id: u64) -> Vec { + let (rev, bytes) = call_policy( + storage, + OUTSIDER, + IPolicyRegistry::compositePolicyChildIdsCall { policyId: policy_id }.abi_encode(), + ); + assert!(!rev, "compositePolicyChildIds unexpectedly reverted"); + IPolicyRegistry::compositePolicyChildIdsCall::abi_decode_returns(&bytes).unwrap() +} + +#[test] +fn golden_composite_policy_child_ids() { + let mut s = fresh(); + let a = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let b = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let c = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let id = create_composite(&mut s, PolicyType::INTERSECT, vec![c, a]); + + // Order is preserved verbatim — the registry neither sorts nor de-dupes. + assert_eq!(composite_child_ids(&mut s, id), vec![c, a]); + + // The view tracks a full replacement, leaving no stale tail behind. + let (rev, _) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateCompositeCall { policyId: id, childPolicyIds: vec![a, b, c] } + .abi_encode(), + ); + assert!(!rev); + assert_eq!(composite_child_ids(&mut s, id), vec![a, b, c]); + + // The view agrees with the payload of the event it mirrors. + let emitted = IPolicyRegistry::CompositePolicyUpdated::decode_log_data( + s.get_events(registry()).last().unwrap(), + ) + .unwrap() + .childPolicyIds; + assert_eq!(composite_child_ids(&mut s, id), emitted); +} + +/// The view is total: every non-composite input yields an empty array rather than a revert, +/// matching the never-reverts contract the rest of the read surface documents. +#[test] +fn golden_composite_policy_child_ids_empty_for_non_composites() { + let mut s = fresh(); + let simple = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + // Type byte above INTERSECT — malformed, and never a valid stored policy. + let malformed = (9u64 << 56) | 7; + // Well-formed UNION id that was never created. + let uncreated = ((PolicyType::UNION as u64) << 56) | 999; + + for policy_id in [ + simple, + PolicyRegistryStorage::ALWAYS_ALLOW_ID, + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + malformed, + uncreated, + ] { + assert_eq!( + composite_child_ids(&mut s, policy_id), + Vec::::new(), + "expected an empty child set for policy {policy_id}" + ); + } +} + +/// The view bypasses the activation gate, like every other read on this surface. +#[test] +fn golden_composite_policy_child_ids_reads_before_activation() { + // No activation, matching `golden_write_reverts_when_not_activated`. + let mut s = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + let (rev, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::compositePolicyChildIdsCall { policyId: 999 }.abi_encode(), + ); + assert!(!rev, "view must not be gated on activation"); + assert_eq!( + bytes, + Bytes::from(IPolicyRegistry::compositePolicyChildIdsCall::abi_encode_returns(&Vec::new())) + ); +} + +#[test] +fn golden_create_composite_reverts_incompatible_type() { + let mut s = fresh(); + let a = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let b = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::createCompositePolicyCall { + admin: ADMIN, + policyType: PolicyType::ALLOWLIST, + childPolicyIds: vec![a, b], + } + .abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::IncompatiblePolicyType {}.abi_encode())); +} + +#[test] +fn golden_update_composite_reverts_unauthorized() { + let mut s = fresh(); + let a = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let b = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let id = create_composite(&mut s, PolicyType::UNION, vec![a, b]); + // OUTSIDER is not the composite's admin. + let (rev, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::updateCompositeCall { policyId: id, childPolicyIds: vec![a, b] } + .abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::Unauthorized {}.abi_encode())); +} + +#[test] +fn golden_create_composite_reverts_child_count_outside_of_range() { + let mut s = fresh(); + let a = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::createCompositePolicyCall { + admin: ADMIN, + policyType: PolicyType::UNION, + childPolicyIds: vec![a], + } + .abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::ChildPoliciesOutsideOfRange {}.abi_encode())); +} + +/// Reads `MIN_COMPOSITE_CHILD_POLICIES()` over the wire. +fn min_composite_child_policies(storage: &mut HashMapStorageProvider) -> U256 { + let (rev, bytes) = call_policy( + storage, + OUTSIDER, + IPolicyRegistry::MIN_COMPOSITE_CHILD_POLICIESCall {}.abi_encode(), + ); + assert!(!rev, "MIN_COMPOSITE_CHILD_POLICIES unexpectedly reverted"); + IPolicyRegistry::MIN_COMPOSITE_CHILD_POLICIESCall::abi_decode_returns(&bytes).unwrap() +} + +/// Reads `MAX_COMPOSITE_CHILD_POLICIES()` over the wire. +fn max_composite_child_policies(storage: &mut HashMapStorageProvider) -> U256 { + let (rev, bytes) = call_policy( + storage, + OUTSIDER, + IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall {}.abi_encode(), + ); + assert!(!rev, "MAX_COMPOSITE_CHILD_POLICIES unexpectedly reverted"); + IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall::abi_decode_returns(&bytes).unwrap() +} + +#[test] +fn golden_min_max_composite_child_policies() { + let mut s = fresh(); + assert_eq!(min_composite_child_policies(&mut s), U256::from(2)); + assert_eq!(max_composite_child_policies(&mut s), U256::from(4)); +} + +#[test] +fn golden_min_max_composite_child_policies_read_before_activation() { + // No activation, matching `golden_composite_policy_child_ids_reads_before_activation`. + let mut s = HashMapStorageProvider::new(CHAIN_ID); + let (min_rev, min_bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::MIN_COMPOSITE_CHILD_POLICIESCall {}.abi_encode(), + ); + assert!(!min_rev, "view must not be gated on activation"); + assert_eq!( + min_bytes, + Bytes::from(IPolicyRegistry::MIN_COMPOSITE_CHILD_POLICIESCall::abi_encode_returns( + &U256::from(2) + )) + ); + + let (max_rev, max_bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall {}.abi_encode(), + ); + assert!(!max_rev, "view must not be gated on activation"); + assert_eq!( + max_bytes, + Bytes::from(IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall::abi_encode_returns( + &U256::from(4) + )) + ); +} + +#[test] +fn golden_create_with_accounts() { + let mut s = fresh(); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::createPolicyWithAccountsCall { + admin: ADMIN, + policyType: PolicyType::ALLOWLIST, + accounts: vec![ALICE, BOB], + } + .abi_encode(), + ); + assert!(!rev); + let id = IPolicyRegistry::createPolicyWithAccountsCall::abi_decode_returns(&bytes).unwrap(); + assert_eq!(id, ALLOWLIST_ID); + assert!(is_authorized(&mut s, id, ALICE)); + assert!(is_authorized(&mut s, id, BOB)); + assert!(!is_authorized(&mut s, id, OUTSIDER)); + assert_eq!( + s.get_events(registry()).last().unwrap().topics()[0], + IPolicyRegistry::AllowlistUpdated::SIGNATURE_HASH + ); + assert_root("create_with_accounts", s, ROOT_CREATE_WITH_ACCOUNTS); +} + +#[test] +fn golden_create_with_accounts_reverts_batch_too_large() { + let mut s = fresh(); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::createPolicyWithAccountsCall { + admin: ADMIN, + policyType: PolicyType::ALLOWLIST, + accounts: vec![ALICE; 65], + } + .abi_encode(), + ); + assert!(rev); + assert_eq!( + bytes, + Bytes::from( + IPolicyRegistry::BatchSizeTooLarge { maxBatchSize: U256::from(64) }.abi_encode() + ) + ); +} + +#[test] +fn golden_create_with_accounts_blocklist() { + let mut s = fresh(); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::createPolicyWithAccountsCall { + admin: ADMIN, + policyType: PolicyType::BLOCKLIST, + accounts: vec![ALICE], + } + .abi_encode(), + ); + assert!(!rev); + let id = IPolicyRegistry::createPolicyWithAccountsCall::abi_decode_returns(&bytes).unwrap(); + assert_eq!(id, BLOCKLIST_ID); + // Blocked account rejected; everyone else authorized (empty-blocklist default). + assert!(!is_authorized(&mut s, id, ALICE)); + assert!(is_authorized(&mut s, id, BOB)); + assert_eq!( + s.get_events(registry()).last().unwrap().topics()[0], + IPolicyRegistry::BlocklistUpdated::SIGNATURE_HASH + ); + assert_root("create_with_accounts_blocklist", s, ROOT_CREATE_WITH_ACCOUNTS_BLOCKLIST); +} + +// ============================================================================ +// updateAllowlist / updateBlocklist +// ============================================================================ + +#[test] +fn golden_update_allowlist() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + // Add ALICE. + let (rev, _) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateAllowlistCall { policyId: id, allowed: true, accounts: vec![ALICE] } + .abi_encode(), + ); + assert!(!rev); + assert!(is_authorized(&mut s, id, ALICE)); + assert!(!is_authorized(&mut s, id, BOB)); + assert_eq!( + s.get_events(registry()).last().unwrap().topics()[0], + IPolicyRegistry::AllowlistUpdated::SIGNATURE_HASH + ); + assert_root("update_allowlist", s, ROOT_UPDATE_ALLOWLIST); +} + +#[test] +fn golden_update_allowlist_remove() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + // Add then remove ALICE (exercises the membership-delete path). + let (rev, _) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateAllowlistCall { policyId: id, allowed: true, accounts: vec![ALICE] } + .abi_encode(), + ); + assert!(!rev, "setup: add ALICE to allowlist must succeed"); + assert!(is_authorized(&mut s, id, ALICE)); + let (rev, _) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateAllowlistCall { + policyId: id, + allowed: false, + accounts: vec![ALICE], + } + .abi_encode(), + ); + assert!(!rev); + assert!(!is_authorized(&mut s, id, ALICE)); + assert_root("update_allowlist_remove", s, ROOT_UPDATE_ALLOWLIST_REMOVE); +} + +#[test] +fn golden_update_blocklist() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::BLOCKLIST); + let (rev, _) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateBlocklistCall { policyId: id, blocked: true, accounts: vec![ALICE] } + .abi_encode(), + ); + assert!(!rev); + // Blocked account rejected; others still authorized (empty-blocklist default). + assert!(!is_authorized(&mut s, id, ALICE)); + assert!(is_authorized(&mut s, id, BOB)); + assert_eq!( + s.get_events(registry()).last().unwrap().topics()[0], + IPolicyRegistry::BlocklistUpdated::SIGNATURE_HASH + ); + assert_root("update_blocklist", s, ROOT_UPDATE_BLOCKLIST); +} + +#[test] +fn golden_update_allowlist_reverts_incompatible_type() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::BLOCKLIST); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateAllowlistCall { policyId: id, allowed: true, accounts: vec![ALICE] } + .abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::IncompatiblePolicyType {}.abi_encode())); +} + +#[test] +fn golden_update_blocklist_reverts_incompatible_type() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateBlocklistCall { policyId: id, blocked: true, accounts: vec![ALICE] } + .abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::IncompatiblePolicyType {}.abi_encode())); +} + +#[test] +fn golden_update_allowlist_reverts_unauthorized() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let (rev, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::updateAllowlistCall { policyId: id, allowed: true, accounts: vec![ALICE] } + .abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::Unauthorized {}.abi_encode())); +} + +#[test] +fn golden_update_allowlist_reverts_policy_not_found() { + let mut s = fresh(); + // A well-formed ALLOWLIST id that was never created. + let ghost = (1u64 << 56) | 42; + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateAllowlistCall { + policyId: ghost, + allowed: true, + accounts: vec![ALICE], + } + .abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::PolicyNotFound {}.abi_encode())); +} + +#[test] +fn golden_update_blocklist_reverts_batch_too_large() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::BLOCKLIST); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::updateBlocklistCall { + policyId: id, + blocked: true, + accounts: vec![ALICE; 65], + } + .abi_encode(), + ); + assert!(rev); + assert_eq!( + bytes, + Bytes::from( + IPolicyRegistry::BatchSizeTooLarge { maxBatchSize: U256::from(64) }.abi_encode() + ) + ); +} + +// ============================================================================ +// admin lifecycle (stage / finalize / renounce) +// ============================================================================ + +#[test] +fn golden_stage_and_finalize_update_admin() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + // Stage ADMIN2 as pending. + let (rev, _) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::stageUpdateAdminCall { policyId: id, newAdmin: ADMIN2 }.abi_encode(), + ); + assert!(!rev); + let (_, pending) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::pendingPolicyAdminCall { policyId: id }.abi_encode(), + ); + assert_eq!( + pending, + Bytes::from(IPolicyRegistry::pendingPolicyAdminCall::abi_encode_returns(&ADMIN2)) + ); + assert_eq!( + s.get_events(registry()).last().unwrap().topics()[0], + IPolicyRegistry::PolicyAdminStaged::SIGNATURE_HASH + ); + // Finalize by the pending admin. + let (rev, _) = call_policy( + &mut s, + ADMIN2, + IPolicyRegistry::finalizeUpdateAdminCall { policyId: id }.abi_encode(), + ); + assert!(!rev); + let (_, admin) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::policyAdminCall { policyId: id }.abi_encode(), + ); + assert_eq!(admin, Bytes::from(IPolicyRegistry::policyAdminCall::abi_encode_returns(&ADMIN2))); + assert_eq!( + s.get_events(registry()).last().unwrap().topics()[0], + IPolicyRegistry::PolicyAdminUpdated::SIGNATURE_HASH + ); + assert_root("stage_finalize_admin", s, ROOT_STAGE_FINALIZE_ADMIN); +} + +#[test] +fn golden_stage_update_admin_reverts_unauthorized() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let (rev, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::stageUpdateAdminCall { policyId: id, newAdmin: ADMIN2 }.abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::Unauthorized {}.abi_encode())); +} + +#[test] +fn golden_finalize_reverts_no_pending() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let (rev, bytes) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::finalizeUpdateAdminCall { policyId: id }.abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::NoPendingAdmin {}.abi_encode())); +} + +#[test] +fn golden_finalize_reverts_unauthorized() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let (rev, _) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::stageUpdateAdminCall { policyId: id, newAdmin: ADMIN2 }.abi_encode(), + ); + assert!(!rev, "setup: staging pending admin must succeed"); + // A caller who is neither admin nor the pending admin. + let (rev, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::finalizeUpdateAdminCall { policyId: id }.abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::Unauthorized {}.abi_encode())); +} + +#[test] +fn golden_renounce_admin() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let (rev, _) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::renounceAdminCall { policyId: id }.abi_encode(), + ); + assert!(!rev); + let (_, admin) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::policyAdminCall { policyId: id }.abi_encode(), + ); + assert_eq!( + admin, + Bytes::from(IPolicyRegistry::policyAdminCall::abi_encode_returns(&Address::ZERO)) + ); + assert_eq!( + s.get_events(registry()).last().unwrap().topics()[0], + IPolicyRegistry::PolicyAdminUpdated::SIGNATURE_HASH + ); + assert_root("renounce_admin", s, ROOT_RENOUNCE_ADMIN); +} + +#[test] +fn golden_renounce_admin_reverts_unauthorized() { + let mut s = fresh(); + let id = create(&mut s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + let (rev, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::renounceAdminCall { policyId: id }.abi_encode(), + ); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::Unauthorized {}.abi_encode())); +} + +// ============================================================================ +// dispatch envelope +// ============================================================================ + +#[test] +fn golden_reverts_nonzero_value() { + let mut s = fresh(); + let calldata = IPolicyRegistry::isAuthorizedCall { policyId: 0, account: ALICE }.abi_encode(); + s.set_caller(ALICE); + s.set_call_value(U256::ONE); + let (rev, bytes) = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata, BaseUpgrade::Denim) + }) + .map(|out| (out.is_revert(), out.bytes)) + .expect("dispatch must not fatally error"); + assert!(rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::NonPayable {}.abi_encode())); +} + +#[test] +fn golden_write_reverts_when_not_activated() { + // No activation: a write op must revert; a read op still works. + let mut s = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + let (rev, _) = call_policy( + &mut s, + ADMIN, + IPolicyRegistry::createPolicyCall { admin: ADMIN, policyType: PolicyType::ALLOWLIST } + .abi_encode(), + ); + assert!(rev); + // Reads bypass the activation gate. + let (rev, bytes) = call_policy( + &mut s, + OUTSIDER, + IPolicyRegistry::isAuthorizedCall { policyId: 0, account: ALICE }.abi_encode(), + ); + assert!(!rev); + assert_eq!(bytes, Bytes::from(IPolicyRegistry::isAuthorizedCall::abi_encode_returns(&true))); +} + +// ============================================================================ +// gas: storage-access footprint per op +// ============================================================================ + +/// Runs `calldata` as `caller` after `setup`, returning `(sload, sstore, keccak256)` counts. +fn gas( + setup: impl FnOnce(&mut HashMapStorageProvider), + caller: Address, + calldata: Vec, +) -> (u64, u64, u64) { + let mut s = fresh(); + setup(&mut s); + s.set_caller(caller); + s.reset_counters(); + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata, BaseUpgrade::Denim) + }) + .expect("gas-footprint op must succeed"); + (s.counter_sload(), s.counter_sstore(), s.counter_keccak256()) +} + +#[test] +fn golden_gas_footprints() { + let allow_id = ALLOWLIST_ID; + let actual: Vec<(&str, (u64, u64, u64))> = vec![ + ( + "create_policy", + gas( + |_s| {}, + ADMIN, + IPolicyRegistry::createPolicyCall { + admin: ADMIN, + policyType: PolicyType::ALLOWLIST, + } + .abi_encode(), + ), + ), + ( + "update_allowlist", + gas( + |s| { + create(s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + }, + ADMIN, + IPolicyRegistry::updateAllowlistCall { + policyId: allow_id, + allowed: true, + accounts: vec![ALICE], + } + .abi_encode(), + ), + ), + ( + "is_authorized", + gas( + |s| { + create(s, ADMIN, ADMIN, PolicyType::ALLOWLIST); + }, + OUTSIDER, + IPolicyRegistry::isAuthorizedCall { policyId: allow_id, account: ALICE } + .abi_encode(), + ), + ), + ]; + + let expected: &[(&str, (u64, u64, u64))] = &[ + ("create_policy", (2, 5, 0)), + ("update_allowlist", (2, 1, 0)), + ("is_authorized", (1, 0, 0)), + ]; + + bless_or_assert_gas(&actual, expected); +} + +// ============================================================================ +// meta: op coverage checklist +// ============================================================================ + +/// Compile-time coverage checklist — never called; its exhaustive `match` (no `_` arm) names the +/// golden `#[test]` fn(s) pinning each op. Adding an ABI op fails the build until a golden is added. +#[allow(dead_code)] +fn v2_op_coverage_checklist(call: IPolicyRegistry::IPolicyRegistryCalls) { + use IPolicyRegistry::IPolicyRegistryCalls as C; + + fn covered(_goldens: &[fn()]) {} + + match call { + C::createPolicy(_) => covered(&[ + golden_create_blocklist, + golden_create_allowlist, + golden_create_reverts_zero_admin, + golden_create_policy_composite_type_is_incompatible_policy_type_in_v2, + ]), + C::createPolicyWithAccounts(_) => covered(&[ + golden_create_with_accounts, + golden_create_with_accounts_blocklist, + golden_create_with_accounts_reverts_batch_too_large, + ]), + C::updateAllowlist(_) => covered(&[ + golden_update_allowlist, + golden_update_allowlist_remove, + golden_update_allowlist_reverts_incompatible_type, + golden_update_allowlist_reverts_unauthorized, + golden_update_allowlist_reverts_policy_not_found, + ]), + C::updateBlocklist(_) => covered(&[ + golden_update_blocklist, + golden_update_blocklist_reverts_incompatible_type, + golden_update_blocklist_reverts_batch_too_large, + ]), + C::stageUpdateAdmin(_) => covered(&[ + golden_stage_and_finalize_update_admin, + golden_stage_update_admin_reverts_unauthorized, + ]), + C::finalizeUpdateAdmin(_) => covered(&[ + golden_stage_and_finalize_update_admin, + golden_finalize_reverts_no_pending, + golden_finalize_reverts_unauthorized, + ]), + C::renounceAdmin(_) => { + covered(&[golden_renounce_admin, golden_renounce_admin_reverts_unauthorized]) + } + C::isAuthorized(_) => covered(&[ + golden_is_authorized_builtins_and_malformed, + golden_is_authorized_empty_policies, + ]), + C::policyExists(_) => { + covered(&[golden_reads_for_nonexistent_and_builtins, golden_create_blocklist]) + } + C::policyAdmin(_) => { + covered(&[golden_reads_for_nonexistent_and_builtins, golden_create_allowlist]) + } + C::pendingPolicyAdmin(_) => covered(&[ + golden_reads_for_nonexistent_and_builtins, + golden_stage_and_finalize_update_admin, + ]), + C::createCompositePolicy(_) => covered(&[ + golden_create_composite_union, + golden_create_composite_reverts_incompatible_type, + golden_create_composite_reverts_child_count_outside_of_range, + ]), + C::updateComposite(_) => { + covered(&[golden_update_composite, golden_update_composite_reverts_unauthorized]) + } + C::compositePolicyChildIds(_) => covered(&[ + golden_composite_policy_child_ids, + golden_composite_policy_child_ids_empty_for_non_composites, + golden_composite_policy_child_ids_reads_before_activation, + ]), + C::MIN_COMPOSITE_CHILD_POLICIES(_) | C::MAX_COMPOSITE_CHILD_POLICIES(_) => covered(&[ + golden_min_max_composite_child_policies, + golden_min_max_composite_child_policies_read_before_activation, + ]), + } +} From 9112aca09aff097d84db583d10e8db819438f050 Mon Sep 17 00:00:00 2001 From: William Law Date: Tue, 15 Sep 2026 21:17:09 +0000 Subject: [PATCH 012/299] feat(snapshotter): make consensus container optional for unified nodes (#5061) * feat(snapshotter): make consensus container optional for unified nodes Split EL/CL still stops CL then EL and restarts in reverse; unified snapshots omit CL stop/start entirely. Co-authored-by: Cursor * docs(snapshotter): note --container-name applies to unified nodes Co-authored-by: Cursor --------- Co-authored-by: Cursor --- bin/snapshotter/main.rs | 36 +++++++++++++ crates/infra/snapshotter/README.md | 19 +++---- crates/infra/snapshotter/src/config.rs | 6 ++- crates/infra/snapshotter/src/orchestrator.rs | 57 ++++++++++++-------- crates/infra/snapshotter/tests/e2e_test.rs | 50 ++++++++++++++++- 5 files changed, 134 insertions(+), 34 deletions(-) diff --git a/bin/snapshotter/main.rs b/bin/snapshotter/main.rs index 75c4df0973..4fc247ea43 100644 --- a/bin/snapshotter/main.rs +++ b/bin/snapshotter/main.rs @@ -126,4 +126,40 @@ mod tests { .unwrap(); assert_eq!(format.get_env(), Some(OsStr::new("SNAPSHOTTER_LOG_FORMAT"))); } + + #[test] + fn omits_consensus_container_name_when_flag_absent() { + let cli = Cli::try_parse_from([ + "base-snapshotter", + "--container-name=execution", + "--el-rpc-url=http://execution:8545", + "--source-datadir=/data", + "--bucket=snapshots", + ]) + .unwrap(); + + assert_eq!( + cli.snapshotter.consensus_container_name, None, + "omitting --consensus-container-name should leave the field unset" + ); + } + + #[test] + fn parses_consensus_container_name_when_flag_present() { + let cli = Cli::try_parse_from([ + "base-snapshotter", + "--container-name=execution", + "--consensus-container-name=consensus", + "--el-rpc-url=http://execution:8545", + "--source-datadir=/data", + "--bucket=snapshots", + ]) + .unwrap(); + + assert_eq!( + cli.snapshotter.consensus_container_name.as_deref(), + Some("consensus"), + "--consensus-container-name=consensus should parse as Some" + ); + } } diff --git a/crates/infra/snapshotter/README.md b/crates/infra/snapshotter/README.md index 1449602f4c..9c4648cc62 100644 --- a/crates/infra/snapshotter/README.md +++ b/crates/infra/snapshotter/README.md @@ -6,15 +6,16 @@ Sidecar for generating and uploading reth node snapshots to S3-compatible storag Runs alongside a Base execution layer node (base-node-reth) and orchestrates periodic snapshot creation. `Snapshotter` coordinates the full lifecycle: fetching the EL's latest block and verifying -it is at chain tip (within a configurable freshness window, default 10s), stopping the CL and EL -containers via the Docker socket, generating a snapshot manifest and chunk archives for the -captured block height using Base's shared snapshot generator, uploading all artifacts to an -S3-compatible store (e.g. Cloudflare R2), then restarting the EL followed by the CL so it reconnects -to the EL. Existing static-file archives are reused only after their compressed size and the BLAKE3 -hashes of every uncompressed source file match the previous manifest. - -If the EL is not at tip when a run begins, the snapshot is skipped and both containers are left -running untouched. +it is at chain tip (within a configurable freshness window, default 10s), stopping the EL container +via the Docker socket (and the CL container first when `--consensus-container-name` is set), +generating a snapshot manifest and chunk archives for the captured block height using Base's shared +snapshot generator, uploading all artifacts to an S3-compatible store (e.g. Cloudflare R2), then +restarting the EL (followed by the CL when configured, so it reconnects to the EL). Existing +static-file archives are reused only after their compressed size and the BLAKE3 hashes of every +uncompressed source file match the previous manifest. + +If the EL is not at tip when a run begins, the snapshot is skipped and containers are left running +untouched. The Docker socket (`/var/run/docker.sock`) is volume-mounted into the sidecar container, giving it control over sibling containers on the host. diff --git a/crates/infra/snapshotter/src/config.rs b/crates/infra/snapshotter/src/config.rs index 60e88266ed..f93b17de81 100644 --- a/crates/infra/snapshotter/src/config.rs +++ b/crates/infra/snapshotter/src/config.rs @@ -29,13 +29,15 @@ pub enum S3ConfigType { /// Configuration for the snapshotter sidecar. #[derive(Debug, Args)] pub struct SnapshotterConfig { - /// Docker container name of the execution layer node to stop/start. + /// Docker container name of the execution layer or unified node to stop/start. #[arg(long)] pub container_name: String, /// Docker container name of the consensus layer node to stop/start. + /// + /// Required for split EL/CL deployments. Omit on unified nodes. #[arg(long)] - pub consensus_container_name: String, + pub consensus_container_name: Option, /// HTTP JSON-RPC URL of the execution layer node. /// diff --git a/crates/infra/snapshotter/src/orchestrator.rs b/crates/infra/snapshotter/src/orchestrator.rs index df9bd1a2b5..055f135862 100644 --- a/crates/infra/snapshotter/src/orchestrator.rs +++ b/crates/infra/snapshotter/src/orchestrator.rs @@ -13,11 +13,12 @@ use crate::{ upload::{SnapshotUploader, StreamingS3ArchiveSink}, }; -/// Orchestrates the full snapshot flow: stop CL and EL → generate → upload → restart EL and CL. +/// Orchestrates the full snapshot flow: optionally stop CL, stop EL → generate → +/// upload → restart EL, then optionally restart CL. /// -/// Both containers are always restarted, even if snapshot generation or upload -/// fails. This prevents leaving the node in a stopped state on errors and ensures -/// the CL reconnects to the EL after the snapshot. +/// The EL is always restarted, even if snapshot generation or upload fails. When a +/// CL container is configured, it is stopped first and restarted last so it can +/// reconnect to the EL. This prevents leaving the node in a stopped state on errors. pub struct Snapshotter { container_manager: C, tip_checker: T, @@ -46,12 +47,12 @@ impl Snapshotter { /// Executes the full snapshot lifecycle. /// /// 0. Captures the EL's latest block and verifies it is at chain tip; skips the run if it is not - /// 1. Stops the CL and EL containers - /// 2. Verifies both containers are stopped + /// 1. Stops the CL (when configured) then the EL + /// 2. Verifies stopped containers are no longer running /// 3. Generates snapshot archives /// 4. Uploads to S3/R2 /// 5. Clears reth's persisted peer list (best effort) - /// 6. Restarts the EL and then the CL (always, even on failure) + /// 6. Restarts the EL and then the CL when configured (always, even on failure) pub async fn run(&self) -> Result<()> { // Only snapshot when the EL is caught up to tip. Snapshotting a lagging // node would publish stale data and pause a node that is still syncing. @@ -74,10 +75,14 @@ impl Snapshotter { return Ok(()); } - // Stop the dependent CL first, then the EL. Restarting in the reverse - // order below ensures the EL is available when the CL reconnects. - let cl_stop_result = - self.container_manager.stop(&self.config.consensus_container_name).await; + // Stop the dependent CL first when configured, then the EL. Restarting + // in the reverse order below ensures the EL is available when the CL + // reconnects. + let cl_stop_result = if let Some(ref cl_name) = self.config.consensus_container_name { + self.container_manager.stop(cl_name).await + } else { + Ok(()) + }; let result = match cl_stop_result { Ok(()) => match self.container_manager.stop(&self.config.container_name).await { Ok(()) => self.generate_and_upload(tip.block_number).await, @@ -102,16 +107,19 @@ impl Snapshotter { ); } - let cl_restart_result = - self.container_manager.start(&self.config.consensus_container_name).await; - - if let Err(ref restart_err) = cl_restart_result { - error!( - error = %restart_err, - container = %self.config.consensus_container_name, - "CRITICAL: failed to restart CL container after snapshot" - ); - } + let cl_restart_result = if let Some(ref cl_name) = self.config.consensus_container_name { + let cl_restart_result = self.container_manager.start(cl_name).await; + if let Err(ref restart_err) = cl_restart_result { + error!( + error = %restart_err, + container = %cl_name, + "CRITICAL: failed to restart CL container after snapshot" + ); + } + cl_restart_result + } else { + Ok(()) + }; let restart_result = match (el_restart_result, cl_restart_result) { (Ok(()), Ok(())) => Ok(()), @@ -128,7 +136,12 @@ impl Snapshotter { Ok(()) } (Err(snapshot_err), Ok(())) => { - Err(snapshot_err).context("snapshot failed but EL and CL containers were restarted") + let restarted = if self.config.consensus_container_name.is_some() { + "snapshot failed but EL and CL containers were restarted" + } else { + "snapshot failed but EL container was restarted" + }; + Err(snapshot_err).context(restarted) } (Ok(()), Err(restart_err)) => { bail!( diff --git a/crates/infra/snapshotter/tests/e2e_test.rs b/crates/infra/snapshotter/tests/e2e_test.rs index 85b8667660..73021e3c60 100644 --- a/crates/infra/snapshotter/tests/e2e_test.rs +++ b/crates/infra/snapshotter/tests/e2e_test.rs @@ -95,6 +95,14 @@ impl MockContainerManager { .iter() .any(|name| name == container_name) } + + fn stopped_container_names(&self) -> Vec { + self.stopped_containers.lock().expect("lock should not be poisoned").clone() + } + + fn started_container_names(&self) -> Vec { + self.started_containers.lock().expect("lock should not be poisoned").clone() + } } #[async_trait] @@ -147,7 +155,7 @@ impl TipChecker for MockTipChecker { fn test_config(bucket: &str, tmp: &Path) -> base_snapshotter::SnapshotterConfig { base_snapshotter::SnapshotterConfig { container_name: "fake-el".to_string(), - consensus_container_name: "fake-cl".to_string(), + consensus_container_name: Some("fake-cl".to_string()), el_rpc_url: "http://127.0.0.1:8545".parse().expect("valid test URL"), tip_threshold_secs: base_snapshotter::DEFAULT_TIP_THRESHOLD_SECS, source_datadir: tmp.join("nonexistent-datadir"), @@ -1498,6 +1506,46 @@ async fn orchestrator_proceeds_when_at_tip() -> Result<()> { Ok(()) } +/// When no CL container is configured, only the EL is stopped and restarted. +#[tokio::test] +#[serial] +async fn orchestrator_skips_cl_when_consensus_container_name_is_none() -> Result<()> { + let harness = TestHarness::new().await?; + let manager = std::sync::Arc::new(MockContainerManager::new()); + let uploader = SnapshotUploader::new( + harness.storage_client.clone(), + harness.bucket_name.clone(), + "test".to_string(), + None, + ); + + let tmp = tempfile::tempdir()?; + let mut config = test_config(&harness.bucket_name, tmp.path()); + config.consensus_container_name = None; + + let snapshotter = base_snapshotter::Snapshotter::new( + std::sync::Arc::clone(&manager), + MockTipChecker::new(true), + uploader, + config, + ); + + let result = snapshotter.run().await; + assert!(result.is_err(), "should fail because source_datadir doesn't exist"); + assert_eq!( + manager.stopped_container_names(), + ["fake-el"], + "unified snapshot should stop only the EL container" + ); + assert_eq!( + manager.started_container_names(), + ["fake-el"], + "unified snapshot should start only the EL container" + ); + + Ok(()) +} + /// E2E test: spins up a real Docker container, stops it via bollard, /// creates fake snapshot artifacts, uploads to `MinIO`, then restarts the container. /// Verifies the container is running again after the full lifecycle. From 73bc1c5263f3ebab1d25763f1bdadc256b600e74 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Wed, 16 Sep 2026 02:22:31 +0000 Subject: [PATCH 013/299] fix(evm): preserve BaseTime implementation prestate (#5069) --- crates/common/evm/src/base_time.rs | 48 ++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/common/evm/src/base_time.rs b/crates/common/evm/src/base_time.rs index dac477f369..417518da8a 100644 --- a/crates/common/evm/src/base_time.rs +++ b/crates/common/evm/src/base_time.rs @@ -127,11 +127,10 @@ impl BaseTime { } let code = Bytecode::new_raw(Self::implementation_bytecode()); - let mut implementation_info = db.basic(Self::IMPLEMENTATION_ADDRESS)?.unwrap_or_default(); - implementation_info.code_hash = code.hash_slow(); - implementation_info.code = Some(code); - + let implementation_info = db.basic(Self::IMPLEMENTATION_ADDRESS)?.unwrap_or_default(); let mut implementation_account: revm::state::Account = implementation_info.into(); + implementation_account.info.code_hash = code.hash_slow(); + implementation_account.info.code = Some(code); implementation_account.mark_touch(); let mut proxy_account: revm::state::Account = proxy_info.into(); @@ -185,7 +184,11 @@ mod tests { use alloy_hardforks::{EthereumHardfork, EthereumHardforks, ForkCondition}; use alloy_primitives::{address, keccak256}; use base_common_genesis::BaseUpgrade; - use revm::{Database as _, database::InMemoryDB, state::AccountInfo}; + use revm::{ + Database as _, + database::{InMemoryDB, State}, + state::AccountInfo, + }; use super::*; @@ -308,6 +311,41 @@ mod tests { ); } + #[test] + fn active_transition_preserves_implementation_prestate() { + let mut backing = base_time_db(600); + let proxy_code = Bytecode::new_raw(BaseTime::proxy_bytecode()); + backing.insert_account_info( + Predeploys::BASE_TIME, + AccountInfo { + code_hash: proxy_code.hash_slow(), + code: Some(proxy_code), + ..Default::default() + }, + ); + backing + .insert_account_storage( + Predeploys::BASE_TIME, + BaseTime::ADMIN_SLOT, + U256::from_be_slice(Predeploys::PROXY_ADMIN.as_slice()), + ) + .unwrap(); + let mut db = State::builder().with_database(backing).with_bal_builder().build(); + + BaseTime::ensure_predeploy(TestUpgrades(true), 100, &mut db).unwrap(); + + let bal = db.take_built_alloy_bal().expect("BAL builder is enabled"); + let implementation = bal + .iter() + .find(|changes| changes.address == BaseTime::IMPLEMENTATION_ADDRESS) + .expect("BaseTime implementation must be present in the BAL"); + assert_eq!(implementation.code_changes.len(), 1); + assert_eq!( + keccak256(&implementation.code_changes[0].new_code), + BaseTime::IMPLEMENTATION_CODE_HASH + ); + } + #[test] fn inactive_transition_is_noop() { let mut db = InMemoryDB::default(); From d409f64eaf5f94ad079cd6d1fda2c64d086527a1 Mon Sep 17 00:00:00 2001 From: William Law Date: Wed, 16 Sep 2026 14:53:49 +0000 Subject: [PATCH 014/299] fix(cli): discover proofs snapshots from the snapshot API (#5067) --proofs was appending the chain ID onto the mainnet default base URL, so zeronet fetched a 404 instead of the same manifest reth already downloaded. Co-authored-by: Cursor --- crates/execution/cli/src/commands/download.rs | 242 +++++++++++++++++- 1 file changed, 238 insertions(+), 4 deletions(-) diff --git a/crates/execution/cli/src/commands/download.rs b/crates/execution/cli/src/commands/download.rs index c547ca0e4d..4c5a4aee58 100644 --- a/crates/execution/cli/src/commands/download.rs +++ b/crates/execution/cli/src/commands/download.rs @@ -95,6 +95,96 @@ fn resolve_datadir_args(args: impl IntoIterator) -> DatadirArgs datadir_args } +/// Extracts `--manifest-url` so proofs reuse the same snapshot reth downloaded. +fn resolve_manifest_url_arg(args: impl IntoIterator) -> Option { + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + let Some(arg) = arg.to_str() else { continue }; + + if arg == "--manifest-url" { + return args.next().and_then(|value| value.into_string().ok()); + } + + if let Some(value) = arg.strip_prefix("--manifest-url=") { + return Some(value.to_string()); + } + } + + None +} + +/// Reads a snapshot API field that may be a JSON number or a numeric string. +fn json_u64(value: Option<&serde_json::Value>) -> Option { + match value { + Some(serde_json::Value::Number(n)) => n.as_u64(), + Some(serde_json::Value::String(s)) => s.parse().ok(), + _ => None, + } +} + +/// Discovers the latest modular snapshot manifest URL for `chain_id`. +/// +/// Reth's download pipeline queries the snapshot API (`metadataUrl`) rather than +/// concatenating `{default_base_url}/{chain_id}/manifest.json`. `default_base_url` +/// is already chain-specific (`https://chain.base.org/8453`), so appending another +/// chain ID produced 404s such as `https://chain.base.org/8453/763360/manifest.json`. +async fn discover_latest_manifest_url(api_url: &str, chain_id: u64) -> Result { + info!( + target: "reth::cli", + api_url = %api_url, + chain_id, + "Discovering latest snapshot manifest for proofs" + ); + + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(60)) + .build()?; + + let listing: serde_json::Value = client + .get(api_url) + .send() + .await + .map_err(|e| eyre::eyre!("failed to fetch snapshot listing from {api_url}: {e}"))? + .error_for_status() + .map_err(|e| eyre::eyre!("failed to fetch snapshot listing from {api_url}: {e}"))? + .json() + .await + .map_err(|e| eyre::eyre!("failed to parse snapshot listing from {api_url}: {e}"))?; + + let entries = listing + .as_array() + .ok_or_else(|| eyre::eyre!("snapshot listing from {api_url} is not a JSON array"))?; + + let (block, metadata_url) = entries + .iter() + .filter_map(|entry| { + let id = json_u64(entry.get("chainId"))?; + if id != chain_id { + return None; + } + let metadata_url = entry.get("metadataUrl").and_then(|v| v.as_str())?; + if !metadata_url.ends_with("manifest.json") { + return None; + } + let block = json_u64(entry.get("block"))?; + Some((block, metadata_url.to_string())) + }) + .max_by_key(|(block, _)| *block) + .ok_or_else(|| { + eyre::eyre!("no modular snapshot manifest found for chain {chain_id} at {api_url}") + })?; + + info!( + target: "reth::cli", + block, + url = %metadata_url, + "Found latest snapshot manifest for proofs" + ); + + Ok(metadata_url) +} + impl BaseDownloadCommand { /// Returns the underlying chain spec. pub fn chain_spec(&self) -> Option<&Arc> { @@ -120,10 +210,13 @@ struct ProofsDownloader; impl ProofsDownloader { /// Runs the full proofs download pipeline for the given chain. async fn run(target_dir: &Path, chain_id: u64) -> Result<()> { - let defaults = DownloadDefaults::get_global(); - let base_url = - defaults.default_chain_aware_base_url.as_deref().unwrap_or(&defaults.default_base_url); - let manifest_url = format!("{base_url}/{chain_id}/manifest.json"); + let manifest_url = match resolve_manifest_url_arg(std::env::args_os()) { + Some(url) => url, + None => { + let api_url = DownloadDefaults::get_global().snapshot_api_url.as_ref(); + discover_latest_manifest_url(api_url, chain_id).await? + } + }; Self::run_from_manifest(target_dir, &manifest_url).await } @@ -480,6 +573,147 @@ mod tests { ); } + #[test] + fn resolve_manifest_url_arg_reads_separate_flag() { + let url = resolve_manifest_url_arg([ + OsString::from("test"), + OsString::from("--manifest-url"), + OsString::from("https://zeronet-v2-snapshots.base.org/1789516802/manifest.json"), + ]); + + assert_eq!( + url.as_deref(), + Some("https://zeronet-v2-snapshots.base.org/1789516802/manifest.json"), + "proofs should reuse the same --manifest-url as reth's downloader" + ); + } + + #[test] + fn resolve_manifest_url_arg_reads_equals_syntax() { + let url = resolve_manifest_url_arg([ + OsString::from("test"), + OsString::from( + "--manifest-url=https://zeronet-v2-snapshots.base.org/1789516802/manifest.json", + ), + ]); + + assert_eq!( + url.as_deref(), + Some("https://zeronet-v2-snapshots.base.org/1789516802/manifest.json"), + "proofs should reuse --manifest-url=VALUE" + ); + } + + #[test] + fn resolve_manifest_url_arg_is_none_without_flag() { + let url = resolve_manifest_url_arg([ + OsString::from("test"), + OsString::from("--proofs"), + OsString::from("--chain"), + OsString::from("base-zeronet"), + ]); + + assert_eq!(url, None, "missing --manifest-url should fall back to snapshot API discovery"); + } + + async fn start_snapshot_api_server( + listing: serde_json::Value, + ) -> (String, tokio::task::JoinHandle<()>) { + let listing_bytes = serde_json::to_vec(&listing).unwrap(); + let app = Router::new().route( + "/api/snapshots", + get(move || { + let data = listing_bytes.clone(); + async move { ([(axum::http::header::CONTENT_TYPE, "application/json")], data) } + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let api_url = format!("http://127.0.0.1:{}/api/snapshots", addr.port()); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + + (api_url, handle) + } + + #[tokio::test] + async fn discover_latest_manifest_url_picks_latest_modular_for_chain() { + let listing = serde_json::json!([ + { + "chainId": "8453", + "block": "100", + "metadataUrl": "https://mainnet-v2-snapshots.base.org/old/manifest.json" + }, + { + "chainId": "763360", + "block": "10", + "metadataUrl": "https://zeronet-v2-snapshots.base.org/old/manifest.json" + }, + { + "chainId": "763360", + "block": "20", + "metadataUrl": "https://zeronet-v2-snapshots.base.org/new/manifest.json" + }, + { + "chainId": "763360", + "block": "15", + "metadataUrl": "https://zeronet-v2-snapshots.base.org/not-a-manifest.tar.zst" + } + ]); + + let (api_url, handle) = start_snapshot_api_server(listing).await; + let url = discover_latest_manifest_url(&api_url, 763360).await.unwrap(); + + assert_eq!( + url, "https://zeronet-v2-snapshots.base.org/new/manifest.json", + "proofs must use the latest modular metadataUrl for the requested chain" + ); + + handle.abort(); + } + + #[tokio::test] + async fn discover_latest_manifest_url_accepts_numeric_ids() { + let listing = serde_json::json!([{ + "chainId": 763360, + "block": 3584106, + "metadataUrl": "https://zeronet-v2-snapshots.base.org/1789516802/manifest.json" + }]); + + let (api_url, handle) = start_snapshot_api_server(listing).await; + let url = discover_latest_manifest_url(&api_url, 763360).await.unwrap(); + + assert_eq!( + url, "https://zeronet-v2-snapshots.base.org/1789516802/manifest.json", + "snapshot API numeric chainId/block fields should be accepted" + ); + + handle.abort(); + } + + #[tokio::test] + async fn discover_latest_manifest_url_fails_when_chain_missing() { + let listing = serde_json::json!([{ + "chainId": "8453", + "block": "100", + "metadataUrl": "https://mainnet-v2-snapshots.base.org/old/manifest.json" + }]); + + let (api_url, handle) = start_snapshot_api_server(listing).await; + let result = discover_latest_manifest_url(&api_url, 763360).await; + + assert!(result.is_err(), "missing chain should fail discovery"); + assert!( + result.unwrap_err().to_string().contains("no modular snapshot manifest"), + "error should name the missing modular snapshot" + ); + + handle.abort(); + } + #[tokio::test] async fn fetch_manifest_entry_extracts_proofs_metadata() { let archive = create_proofs_archive(&[("proofs/data.mdb", b"data")]); From 2488a2cb0a8e1c532d1a3325f32b1b415b760c0f Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 16 Sep 2026 15:39:34 +0000 Subject: [PATCH 015/299] perf(precompiles): decode B20 factory createB20 args without copying (#5066) * perf(precompiles): decode B20 factory createB20 args without copying Add a zero-copy, borrowed ABI decode for the B20 factory's `createB20` call so its `bytes[] initCalls` argument is read as slices into the source calldata instead of being materialized into owned `Bytes`. - Add a generic `BorrowedCallDecode` trait (blanket-implemented for every `SolCall`) that decodes a call's arguments into the borrowed `SolCall::Token`, running the same `decode_sequence` and `valid_token` checks as alloy's owned `abi_decode_validate` but stopping before the copying `detokenize`. Its accept-set matches the owned decoder exactly. - Route `createB20` through the borrowed decode in `B20FactoryStorage`, falling through to the owned decoder (whose error bytes stay authoritative) when the borrowed decode does not apply. - Thread the decoded `params`/`initCalls` as borrowed slices through `Factory::create_b20_decoded` and `FactoryV1`'s token-init helpers; the existing owned `Factory::create_b20` is kept as a thin convenience. Behavior is unchanged: the golden hash suite passes with no changes, and an oracle test asserts the borrowed decode accepts a call iff the owned decoder does. Removal is marked with the `alloy-aliasing` tag at every site for when a newer alloy makes the borrowed path unnecessary. Co-Authored-By: Claude * docs(precompiles): trim create_b20_decoded doc comment Co-Authored-By: Claude * refactor(precompiles): drop Factory::create_b20 default, borrow via helper Remove the trait's default create_b20 forwarding body so the Factory interface carries no default logic; create_b20_decoded is now the sole creation method. The owned-call convenience B20FactoryStorage::create_b20 builds its borrowed view via DecodedCreateB20::from_owned instead of hand-rolling the conversion. Co-Authored-By: Claude * refactor(precompiles): simplify createB20 fast-path decode result Return Option from decode_if_create_b20 instead of Option>: no version produces the error arm today, so a rejected borrowed decode is a plain None that falls through to the owned decoder. Collapses the let-else/match into `?` and drops the dead Some(Err) arm in route. Add two error-class rows to the oracle parity test (out-of-range variant discriminant, non-canonical variant padding) covering the variant enum the fast path detokenizes; both reject byte-identically to the owned decoder. Co-Authored-By: Claude * refactor(precompiles): inline borrowed createB20 decode, drop helper The BorrowedCallDecode trait had a single caller and announce kept its own inline copy, so the generic module was abstraction ahead of need. Inline the decode_sequence + valid_token borrowed decode into decode_if_create_b20 and delete crates/common/precompiles/src/borrowed_call.rs along with its lib.rs export. The accept-set equivalence with alloy's owned abi_decode_validate is now documented at the call site; the factory oracle test still guards it, so no coverage is lost. Co-Authored-By: Claude * refactor(precompiles): name borrowed factory init calls Co-authored-by: Codex * docs(precompiles): trim factory decode commentary Co-authored-by: Codex * docs(precompiles): simplify factory decode comments Co-authored-by: Codex * feat: initial dispatch --------- Co-authored-by: Claude Co-authored-by: Codex --- .../precompiles/src/b20_factory/dispatch.rs | 583 +++++++++++++++++- .../src/b20_factory/logic/interface.rs | 15 +- .../precompiles/src/b20_factory/logic/mod.rs | 2 +- .../precompiles/src/b20_factory/logic/v1.rs | 29 +- .../common/precompiles/src/b20_factory/mod.rs | 2 +- crates/common/precompiles/src/lib.rs | 2 +- 6 files changed, 583 insertions(+), 50 deletions(-) diff --git a/crates/common/precompiles/src/b20_factory/dispatch.rs b/crates/common/precompiles/src/b20_factory/dispatch.rs index 1cccf54f87..7a78fb1b9a 100644 --- a/crates/common/precompiles/src/b20_factory/dispatch.rs +++ b/crates/common/precompiles/src/b20_factory/dispatch.rs @@ -5,8 +5,10 @@ //! `createB20`'s business logic to it. `getB20Address`, `isB20`, and //! `isB20Initialized` are answered via version-invariant computations/pass-throughs. -use alloy_primitives::{Address, Bytes, keccak256}; -use alloy_sol_types::{SolCall, SolValue}; +use alloc::{string::String, vec::Vec}; + +use alloy_primitives::{Address, B256, Bytes, keccak256}; +use alloy_sol_types::{SolCall, SolType, SolValue, abi}; use base_common_genesis::BaseUpgrade; use base_precompile_storage::{BasePrecompileError, PrecompileResult, Result, StorageCtx}; @@ -73,10 +75,17 @@ impl<'a> B20FactoryStorage<'a> { upgrade: BaseUpgrade, ) -> Result
{ let address_hash = keccak256((caller, call.salt).abi_encode()); - FactoryV1.create_b20(self, call, address_hash, upgrade) + let request = CreateB20Request::from_call(&call); + FactoryV1.create_b20_decoded( + self, + request.variant, + request.params, + &request.init_calls, + address_hash, + upgrade, + ) } - /// Decodes calldata against the active wire surface and routes it to `version`'s logic. fn route( &mut self, ctx: StorageCtx<'_>, @@ -88,27 +97,25 @@ impl<'a> B20FactoryStorage<'a> { where O: PrecompileCallObserver, { + let should_revert_selector = + ctx.storage_features().selector_only_abi_decode_errors_enabled(); + match CreateB20Request::try_from_calldata(calldata, version, should_revert_selector) { + Some(Ok(request)) => { + return self.run_create_b20(ctx, version, upgrade, &observer, request); + } + Some(Err(error)) => return Err(error), + None => {} + } + let logic = version.implementation(); match version.abi().decode(calldata)? { - IB20Factory::IB20FactoryCalls::createB20(call) => { - let caller = ctx.caller(); - // abi_decode_validate rejects non-canonical discriminants before dispatch, - // so from_abi returning None here would be an internal invariant violation. - let variant = B20Variant::from_abi(call.variant).expect( - "abi_decode_validate rejects non-canonical discriminants before dispatch", - ); - let address_hash = ctx.metered_keccak256(&(caller, call.salt).abi_encode())?; - let internal_call_count = call.initCalls.len(); - let internal_call_bytes = call.initCalls.iter().map(|c| c.len()).sum(); - let token = logic.create_b20(self, call, address_hash, upgrade)?; - observer.record_internal_calls( - &PrecompileAuxiliaryMetrics::singleton("factory", "createB20"), - internal_call_count, - internal_call_bytes, - ); - observer.record_b20_created(variant.as_label()); - Ok(IB20Factory::createB20Call::abi_encode_returns(&token).into()) - } + IB20Factory::IB20FactoryCalls::createB20(call) => self.run_create_b20( + ctx, + version, + upgrade, + &observer, + CreateB20Request::from_call(&call), + ), IB20Factory::IB20FactoryCalls::getB20Address(call) => { let v = B20Variant::from_abi(call.variant).expect( "abi_decode_validate rejects non-canonical discriminants before dispatch", @@ -127,6 +134,94 @@ impl<'a> B20FactoryStorage<'a> { } } } + + fn run_create_b20( + &mut self, + ctx: StorageCtx<'_>, + version: FactoryVersion, + upgrade: BaseUpgrade, + observer: &O, + request: CreateB20Request<'_>, + ) -> Result + where + O: PrecompileCallObserver, + { + let logic = version.implementation(); + let caller = ctx.caller(); + let variant = B20Variant::from_abi(request.variant) + .expect("decode paths reject non-canonical discriminants before dispatch"); + let address_hash = ctx.metered_keccak256(&(caller, request.salt).abi_encode())?; + let internal_call_count = request.init_calls.len(); + let internal_call_bytes = request.init_calls.iter().map(|c| c.len()).sum(); + let token = logic.create_b20_decoded( + self, + request.variant, + request.params, + &request.init_calls, + address_hash, + upgrade, + )?; + observer.record_internal_calls( + &PrecompileAuxiliaryMetrics::singleton("factory", "createB20"), + internal_call_count, + internal_call_bytes, + ); + observer.record_b20_created(variant.as_label()); + Ok(IB20Factory::createB20Call::abi_encode_returns(&token).into()) + } +} + +struct CreateB20Request<'a> { + variant: IB20Factory::B20Variant, + salt: B256, + params: &'a [u8], + init_calls: Vec<&'a [u8]>, +} + +impl<'a> CreateB20Request<'a> { + fn try_from_calldata( + calldata: &'a [u8], + version: FactoryVersion, + should_revert_selector: bool, + ) -> Option> { + let selector = calldata.first_chunk::<4>().copied()?; + if selector != IB20Factory::createB20Call::SELECTOR { + return None; + } + if !version.abi().valid_selector(selector) { + return None; + } + let rest = &calldata[4..]; + let token = + abi::decode_sequence::<::Token<'a>>(rest) + .ok()?; + if !<::Parameters<'a> as SolType>::valid_token( + &token, + ) { + if !should_revert_selector { + return None; + } + return Some(Err(BasePrecompileError::AbiDecodeFailed { + selector, + error: String::from("createB20: malformed payload"), + })); + } + Some(Ok(Self { + variant: ::detokenize(token.0), + salt: token.1.0, + params: token.2.0, + init_calls: token.3.0.iter().map(|c| c.0).collect(), + })) + } + + fn from_call(call: &'a IB20Factory::createB20Call) -> Self { + Self { + variant: call.variant, + salt: call.salt, + params: call.params.as_ref(), + init_calls: call.initCalls.iter().map(|call| call.as_ref()).collect(), + } + } } #[cfg(test)] @@ -136,12 +231,15 @@ mod tests { use alloy_primitives::{Address, B256, Bytes, U256, address}; use alloy_sol_types::{SolCall, SolError, SolEvent, SolValue}; use base_common_genesis::BaseUpgrade; - use base_precompile_storage::{Handler, HashMapStorageProvider, StorageCtx}; + use base_precompile_storage::{ + BasePrecompileError, Handler, HashMapStorageProvider, StorageCtx, + }; use crate::{ ActivationAdminConfig, ActivationFeature, ActivationRegistryStorage, AssetAccounting, - B20AssetStorage, B20AssetToken, B20FactoryStorage, B20StablecoinStorage, B20Variant, IB20, - IB20Factory, PolicyRegistryStorage, PolicyVersion, + B20AssetStorage, B20AssetToken, B20FactoryStorage, B20StablecoinStorage, B20Variant, + FactoryVersion, IB20, IB20Factory, NoopPrecompileCallObserver, PolicyRegistryStorage, + PolicyVersion, }; const ACTIVATION_ADMIN: Address = address!("0xcb00000000000000000000000000000000000000"); @@ -800,4 +898,437 @@ mod tests { "createB20 must call keccak256 exactly once for a valid variant" ); } + + fn aliased_create_b20_calldata( + n: usize, + tail: &[u8], + params: Bytes, + salt: B256, + variant: IB20Factory::B20Variant, + ) -> Vec { + assert!(n >= 1, "need at least one aliased entry"); + let base = IB20Factory::createB20Call { + variant, + salt, + params, + initCalls: alloc::vec![Bytes::copy_from_slice(tail)], + } + .abi_encode(); + + let args = &base[4..]; + let read_off = |at: usize| -> usize { + let mut buf = [0u8; 8]; + buf.copy_from_slice(&args[at + 24..at + 32]); + u64::from_be_bytes(buf) as usize + }; + let write_off = |out: &mut [u8], at: usize, v: usize| { + out[at..at + 32].fill(0); + out[at + 24..at + 32].copy_from_slice(&(v as u64).to_be_bytes()); + }; + + let off_init_calls = read_off(96); + assert_eq!(read_off(off_init_calls), 1, "base encoding must be one element"); + + let blob_off = read_off(off_init_calls + 32); + let blob = &args[off_init_calls + 32 + blob_off..]; + + let shared_elem_off = n * 32; + + let mut out = base[..4 + off_init_calls + 32].to_vec(); + out.resize(4 + off_init_calls + 32 + n * 32 + blob.len(), 0); + let a = &mut out[4..]; + write_off(a, off_init_calls, n); + for i in 0..n { + write_off(a, off_init_calls + 32 + i * 32, shared_elem_off); + } + let blob_at = off_init_calls + 32 + n * 32; + a[blob_at..blob_at + blob.len()].copy_from_slice(blob); + out + } + + #[test] + fn aliased_create_b20_redispatches_each_entry() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let creator = Address::repeat_byte(0xCA); + let bob = Address::repeat_byte(0xBB); + let salt = B256::repeat_byte(0x40); + let (token_addr, _) = B20Variant::Asset.compute_address(creator, salt); + + let params = token_params("Aliased Token", "ALS").abi_encode().into(); + let tail = IB20::mintCall { to: bob, amount: U256::ONE }.abi_encode(); + let calldata = + aliased_create_b20_calldata(8, &tail, params, salt, IB20Factory::B20Variant::ASSET); + + storage.set_caller(creator); + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let out = factory.dispatch(ctx, &calldata, BaseUpgrade::Beryl).unwrap(); + assert!(!out.is_revert(), "aliased createB20 must succeed: {:?}", out.bytes); + + assert_output( + dispatch_b20_success(ctx, token_addr, IB20::balanceOfCall { account: bob }), + U256::from(8u64).abi_encode(), + ); + }); + } + + #[test] + fn aliased_create_b20_matches_non_aliased_output() { + let creator = Address::repeat_byte(0xCA); + let bob = Address::repeat_byte(0xBB); + let n = 5usize; + let salt = B256::repeat_byte(0x41); + let params: Bytes = token_params("Parity Token", "PAR").abi_encode().into(); + let tail = IB20::mintCall { to: bob, amount: U256::ONE }.abi_encode(); + + let aliased = aliased_create_b20_calldata( + n, + &tail, + params.clone(), + salt, + IB20Factory::B20Variant::ASSET, + ); + let honest = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::ASSET, + salt, + params, + initCalls: alloc::vec![Bytes::copy_from_slice(&tail); n], + } + .abi_encode(); + + let run = |calldata: &[u8]| -> Bytes { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + storage.set_caller(creator); + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let out = factory.dispatch(ctx, calldata, BaseUpgrade::Beryl).unwrap(); + assert!(!out.is_revert(), "createB20 must succeed: {:?}", out.bytes); + out.bytes + }) + }; + + assert_eq!( + run(&aliased), + run(&honest), + "aliased and honestly-encoded initCalls must return identical output" + ); + } + + #[test] + fn large_aliased_create_b20_succeeds() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let creator = Address::repeat_byte(0xCA); + let bob = Address::repeat_byte(0xBB); + let salt = B256::repeat_byte(0x42); + let (token_addr, _) = B20Variant::Asset.compute_address(creator, salt); + + let params = token_params("Large Aliased Token", "LGA").abi_encode().into(); + let tail = IB20::mintCall { to: bob, amount: U256::ONE }.abi_encode(); + let calldata = + aliased_create_b20_calldata(1_024, &tail, params, salt, IB20Factory::B20Variant::ASSET); + + storage.set_caller(creator); + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let out = factory.dispatch(ctx, &calldata, BaseUpgrade::Beryl).unwrap(); + assert!(!out.is_revert(), "large aliased createB20 must succeed: {:?}", out.bytes); + + assert_output( + dispatch_b20_success(ctx, token_addr, IB20::balanceOfCall { account: bob }), + U256::from(1_024u64).abi_encode(), + ); + }); + } + + #[test] + fn create_b20_dispatch_matches_owned_abi_oracle() { + let salt = B256::repeat_byte(0x50); + let params: Bytes = token_params("Oracle Token", "ORC").abi_encode().into(); + let tail = + IB20::mintCall { to: Address::repeat_byte(0xEE), amount: U256::ONE }.abi_encode(); + + let one_element = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::ASSET, + salt, + params: params.clone(), + initCalls: alloc::vec![Bytes::copy_from_slice(&tail)], + } + .abi_encode(); + let multi_element = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::ASSET, + salt, + params: params.clone(), + initCalls: alloc::vec![ + Bytes::copy_from_slice(&tail), + Bytes::copy_from_slice(&tail), + Bytes::copy_from_slice(&tail), + ], + } + .abi_encode(); + + let aliased = + aliased_create_b20_calldata(1_024, &tail, params, salt, IB20Factory::B20Variant::ASSET); + + let read_off = |bytes: &[u8], at: usize| -> usize { + let mut buf = [0u8; 8]; + buf.copy_from_slice(&bytes[at + 24..at + 32]); + u64::from_be_bytes(buf) as usize + }; + let off_init_calls = read_off(&one_element, 4 + 96); + let mut past_end_length = one_element.clone(); + past_end_length[4 + off_init_calls..4 + off_init_calls + 32].fill(0xff); + + let mut trailing_garbage = one_element.clone(); + trailing_garbage.extend_from_slice(&[0u8; 16]); + + let mut invalid_variant = one_element.clone(); + invalid_variant[35] = 0x07; + + let mut dirty_variant_padding = one_element.clone(); + dirty_variant_padding[4] = 0xff; + + let truncated_head = IB20Factory::createB20Call::SELECTOR.to_vec(); + let no_calldata: Vec = Vec::new(); + + let rows: alloc::vec::Vec<(&'static str, Vec, bool)> = alloc::vec![ + ("honest single-element valid", one_element, true), + ("honest multi-element valid", multi_element, true), + ("aliased offsets valid", aliased, true), + ("length word overruns buffer", past_end_length, false), + ("out-of-range variant discriminant", invalid_variant, false), + ("non-canonical variant padding", dirty_variant_padding, false), + ("trailing garbage after valid payload", trailing_garbage, true), + ("truncated head (only selector)", truncated_head, false), + ("no calldata at all", no_calldata, false), + ]; + + for (name, calldata, must_accept) in rows { + let oracle_accepts = IB20Factory::createB20Call::abi_decode_validate(&calldata).is_ok(); + assert_eq!( + oracle_accepts, must_accept, + "row `{name}`: oracle disagrees; refresh the test if the payload changed" + ); + + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + storage.set_caller(Address::repeat_byte(0x01)); + let outcome = StorageCtx::enter(&mut storage, |ctx| { + B20FactoryStorage::new(ctx).route( + ctx, + &calldata, + FactoryVersion::V1, + BaseUpgrade::Beryl, + NoopPrecompileCallObserver, + ) + }); + + assert_eq!( + outcome.is_ok(), + must_accept, + "row `{name}`: accept-set disagrees with the oracle", + ); + + if let Err(err) = outcome { + let control = FactoryVersion::V1.abi().decode(&calldata).unwrap_err(); + assert_eq!(err, control, "row `{name}`: error bytes must match owned decode"); + match err { + BasePrecompileError::AbiDecodeFailed { selector, .. } => { + assert_eq!(selector, IB20Factory::createB20Call::SELECTOR) + } + BasePrecompileError::UnknownFunctionSelector(_) => {} + other => panic!("row `{name}`: unexpected error {other:?}"), + } + } + } + } + + #[test] + fn aliased_reject_diagnostic_is_bounded_at_cobalt() { + let salt = B256::repeat_byte(0x77); + let params: Bytes = token_params("Amp", "AMP").abi_encode().into(); + let tail = alloc::vec![0xcd_u8; 256]; + + // Aliased `initCalls` with a dirtied variant word: `decode_sequence` succeeds but + // `valid_token` fails, so this is exactly the reject vector the owned decoder re-encodes. + let dirty_aliased = |n: usize| { + let mut calldata = aliased_create_b20_calldata( + n, + &tail, + params.clone(), + salt, + IB20Factory::B20Variant::ASSET, + ); + calldata[4] = 0xff; + calldata + }; + + let reject_error = |calldata: &[u8], features, upgrade| { + let mut storage = HashMapStorageProvider::new_with_storage_features(1, features); + activate_precompiles(&mut storage); + storage.set_caller(Address::repeat_byte(0x01)); + StorageCtx::enter(&mut storage, |ctx| { + B20FactoryStorage::new(ctx) + .route(ctx, calldata, FactoryVersion::V1, upgrade, NoopPrecompileCallObserver) + .unwrap_err() + }) + }; + + let error_string = |err| match err { + BasePrecompileError::AbiDecodeFailed { selector, error } => { + assert_eq!(selector, IB20Factory::createB20Call::SELECTOR); + error + } + other => panic!("expected AbiDecodeFailed, got {other:?}"), + }; + + // Cobalt: identical fixed diagnostic for tiny and huge payloads — the O(N*M) re-encode never ran. + let cobalt_small = error_string(reject_error( + &dirty_aliased(8), + base_precompile_storage::StorageFeatures::Cobalt, + BaseUpgrade::Cobalt, + )); + let cobalt_large = error_string(reject_error( + &dirty_aliased(1_024), + base_precompile_storage::StorageFeatures::Cobalt, + BaseUpgrade::Cobalt, + )); + assert_eq!(cobalt_small, "createB20: malformed payload"); + assert_eq!( + cobalt_small, cobalt_large, + "Cobalt reject diagnostic must not scale with the aliased element count" + ); + + // Beryl: revert bytes are consensus-frozen, so the amplifying owned diagnostic is preserved. + let n = 1_024usize; + let logical = n * tail.len(); + let beryl_large = error_string(reject_error( + &dirty_aliased(n), + base_precompile_storage::StorageFeatures::Legacy, + BaseUpgrade::Beryl, + )); + assert!( + beryl_large.len() > logical, + "Beryl diagnostic must still track logical N*M (logical={logical}, got={})", + beryl_large.len() + ); + } + + /// End-to-end at Cobalt: an aliased reject reverts with selector-only bytes (the amplified string + /// never reaches consensus output), while a valid aliased payload still creates the token — the + /// accept/reject decision is unchanged, only the reject *diagnostic* is bounded. + #[test] + fn aliased_reject_output_is_selector_only_at_cobalt() { + let creator = Address::repeat_byte(0xCA); + let salt = B256::repeat_byte(0x78); + let params: Bytes = token_params("Amp", "AMP").abi_encode().into(); + let tail = + IB20::mintCall { to: Address::repeat_byte(0xBB), amount: U256::ONE }.abi_encode(); + + let valid = + aliased_create_b20_calldata(64, &tail, params, salt, IB20Factory::B20Variant::ASSET); + let mut rejected = valid.clone(); + rejected[4] = 0xff; + + let mut storage = HashMapStorageProvider::new_with_storage_features( + 1, + base_precompile_storage::StorageFeatures::Cobalt, + ); + activate_precompiles(&mut storage); + storage.set_caller(creator); + StorageCtx::enter(&mut storage, |ctx| { + let ok = + B20FactoryStorage::new(ctx).dispatch(ctx, &valid, BaseUpgrade::Cobalt).unwrap(); + assert!(!ok.is_revert(), "valid aliased createB20 must still succeed at Cobalt"); + + let revert = + B20FactoryStorage::new(ctx).dispatch(ctx, &rejected, BaseUpgrade::Cobalt).unwrap(); + assert!(revert.is_revert()); + assert_eq!( + revert.bytes, + Bytes::from(IB20Factory::createB20Call::SELECTOR), + "Cobalt reject must emit only the selector, not the amplified diagnostic" + ); + }); + } + + /// Reject-path diagnostic on the frozen fork: dirty variant padding plus aliased `initCalls`. + /// + /// On Beryl (`should_revert_selector == false`) the borrowed decoder must NOT emit a bounded + /// fast error — it returns `None` and falls through to alloy's owned `type_check` failure, which + /// re-encodes the token (`type_check_fail_token`) and hex-encodes that blob into the error + /// string, giving an O(N·M) diagnostic from O(32N+M) physical calldata. Those revert bytes are + /// consensus-frozen, so this pins that they are preserved exactly; only Cobalt+ bounds them (see + /// `aliased_reject_diagnostic_is_bounded_at_cobalt`). Invert these bounds if Beryl is ever + /// allowed to short-circuit. + #[test] + fn aliased_init_calls_reject_path_diagnostic_amplification() { + let n = 128usize; + let tail = alloc::vec![0xcd_u8; 256]; + let params: Bytes = token_params("Amp", "AMP").abi_encode().into(); + let salt = B256::repeat_byte(0x79); + let mut calldata = + aliased_create_b20_calldata(n, &tail, params, salt, IB20Factory::B20Variant::ASSET); + calldata[4] = 0xff; + + let physical = calldata.len(); + let logical = n * tail.len(); + assert!( + logical > physical, + "probe must be aliased: logical {logical} vs physical {physical}" + ); + + // Beryl gate: no fast error — the reject misses the borrowed path and falls through. + assert!( + super::CreateB20Request::try_from_calldata(&calldata, FactoryVersion::V1, false) + .is_none(), + "Beryl reject must miss the borrowed path (no bounded short-circuit)" + ); + + let owned_msg = + IB20Factory::createB20Call::abi_decode_validate(&calldata).unwrap_err().to_string(); + + let factory_err = FactoryVersion::V1.abi().decode(&calldata).unwrap_err(); + let BasePrecompileError::AbiDecodeFailed { selector, error } = &factory_err else { + panic!("expected AbiDecodeFailed, got {factory_err:?}"); + }; + assert_eq!(*selector, IB20Factory::createB20Call::SELECTOR); + + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + storage.set_caller(Address::repeat_byte(0x01)); + let route_err = StorageCtx::enter(&mut storage, |ctx| { + B20FactoryStorage::new(ctx) + .route( + ctx, + &calldata, + FactoryVersion::V1, + BaseUpgrade::Beryl, + NoopPrecompileCallObserver, + ) + .unwrap_err() + }); + assert_eq!(route_err, factory_err); + + let revert = route_err.into_precompile_result(0, 0).unwrap(); + assert!(revert.is_revert()); + let revert_len = revert.bytes.len(); + + // The owned `type_check_fail_token` re-encodes every aliased `bytes[]` entry, then hex-encodes + // it into `AbiDecodeFailed` — so the diagnostic dwarfs the physical calldata. + assert!( + error.len() > logical, + "Beryl diagnostic should track logical N·M (physical={physical}, logical={logical}, error={})", + error.len() + ); + assert!( + error.len() > physical * 4, + "Beryl diagnostic should dwarf physical calldata (physical={physical}, error={})", + error.len() + ); + assert_eq!(owned_msg.len(), error.len(), "SolCall and FactoryAbi diagnostics must match"); + assert_eq!(revert_len, 4 + error.len(), "Beryl revert is selector || error string"); + } } diff --git a/crates/common/precompiles/src/b20_factory/logic/interface.rs b/crates/common/precompiles/src/b20_factory/logic/interface.rs index 39026170de..a352907a94 100644 --- a/crates/common/precompiles/src/b20_factory/logic/interface.rs +++ b/crates/common/precompiles/src/b20_factory/logic/interface.rs @@ -6,20 +6,21 @@ use base_precompile_storage::Result; use crate::{B20FactoryStorage, IB20Factory}; +/// Borrowed `createB20` initialization calldata. +pub type InitCalls<'a> = [&'a [u8]]; + /// The B-20 token factory logic interface. /// /// This trait is append-only: new versions add methods, never remove or change the /// signature of an existing one. pub trait Factory { - /// Creates a token at a deterministic address derived from `(caller, variant, salt)`. - /// - /// `address_hash` must be `keccak256(abi_encode(caller, call.salt))`. Computing (and - /// metering) that hash is the dispatcher's responsibility; this method only consumes - /// the result. `upgrade` selects the policy-logic version the created token is bound to. - fn create_b20( + /// Creates a token from decoded `createB20` fields. + fn create_b20_decoded( &self, storage: &mut B20FactoryStorage<'_>, - call: IB20Factory::createB20Call, + variant: IB20Factory::B20Variant, + params: &[u8], + init_calls: &InitCalls<'_>, address_hash: B256, upgrade: BaseUpgrade, ) -> Result
; diff --git a/crates/common/precompiles/src/b20_factory/logic/mod.rs b/crates/common/precompiles/src/b20_factory/logic/mod.rs index 59c3bf628c..de0a3cab33 100644 --- a/crates/common/precompiles/src/b20_factory/logic/mod.rs +++ b/crates/common/precompiles/src/b20_factory/logic/mod.rs @@ -1,7 +1,7 @@ //! Versioned business logic for the B-20 token factory precompile. mod interface; -pub use interface::Factory; +pub use interface::{Factory, InitCalls}; mod v1; pub use v1::{CommonParams, FactoryV1, TokenCreateParams}; diff --git a/crates/common/precompiles/src/b20_factory/logic/v1.rs b/crates/common/precompiles/src/b20_factory/logic/v1.rs index e9c72d8385..1ff45b1568 100644 --- a/crates/common/precompiles/src/b20_factory/logic/v1.rs +++ b/crates/common/precompiles/src/b20_factory/logic/v1.rs @@ -1,6 +1,6 @@ //! Version 1 of the B-20 token factory precompile logic, activated at Beryl. -use alloc::{string::ToString, vec::Vec}; +use alloc::string::ToString; use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; @@ -11,7 +11,7 @@ use revm::state::Bytecode; use crate::{ ActivationRegistryStorage, AssetVersions, B20AssetInit, B20AssetStorage, B20AssetToken, B20FactoryStorage, B20StablecoinInit, B20StablecoinStorage, B20StablecoinToken, B20TokenRole, - B20Variant, Factory, IB20Factory, NoopPrecompileCallObserver, PolicyRegistryStorage, + B20Variant, Factory, IB20Factory, InitCalls, NoopPrecompileCallObserver, PolicyRegistryStorage, PolicyVersions, StablecoinVersions, Token, }; @@ -43,7 +43,7 @@ impl FactoryV1 { token_address: Address, common: CommonParams, init: B20StablecoinInit, - init_calls: Vec, + init_calls: &InitCalls<'_>, upgrade: BaseUpgrade, ) -> Result<()> { let policy_version = PolicyVersions::from_base_upgrade(upgrade) @@ -84,11 +84,11 @@ impl FactoryV1 { let stablecoin_version = StablecoinVersions::from_base_upgrade(upgrade) .ok_or_else(|| BasePrecompileError::Revert(Bytes::new()))?; storage.storage().with_caller(B20FactoryStorage::ADDRESS, || { - for (index, calldata) in init_calls.into_iter().enumerate() { + for (index, &calldata) in init_calls.iter().enumerate() { token .route( storage.storage(), - &calldata, + calldata, stablecoin_version, true, NoopPrecompileCallObserver, @@ -106,7 +106,7 @@ impl FactoryV1 { token_address: Address, common: CommonParams, init: B20AssetInit, - init_calls: Vec, + init_calls: &InitCalls<'_>, upgrade: BaseUpgrade, ) -> Result<()> { let policy_version = PolicyVersions::from_base_upgrade(upgrade) @@ -144,11 +144,11 @@ impl FactoryV1 { let asset_version = AssetVersions::from_base_upgrade(upgrade) .ok_or_else(|| BasePrecompileError::Revert(Bytes::new()))?; storage.storage().with_caller(B20FactoryStorage::ADDRESS, || { - for (index, calldata) in init_calls.into_iter().enumerate() { + for (index, &calldata) in init_calls.iter().enumerate() { token .route( storage.storage(), - &calldata, + calldata, asset_version, true, NoopPrecompileCallObserver, @@ -184,18 +184,20 @@ impl FactoryV1 { } impl Factory for FactoryV1 { - fn create_b20( + fn create_b20_decoded( &self, storage: &mut B20FactoryStorage<'_>, - call: IB20Factory::createB20Call, + variant: IB20Factory::B20Variant, + params: &[u8], + init_calls: &InitCalls<'_>, address_hash: B256, upgrade: BaseUpgrade, ) -> Result
{ - let variant = B20Variant::from_abi(call.variant) + let variant = B20Variant::from_abi(variant) .ok_or_else(|| BasePrecompileError::revert(IB20Factory::InvalidVariant {}))?; ActivationRegistryStorage::new(storage.storage()) .ensure_activated(variant.activation_feature().id())?; - let params = TokenCreateParams::decode(variant, &call.params)?; + let params = TokenCreateParams::decode(variant, params)?; Self::check_version(params.version(), variant)?; params.validate()?; let (token_address, _) = variant.compute_address_from_hash(address_hash); @@ -213,7 +215,6 @@ impl Factory for FactoryV1 { let stub = Bytecode::new_legacy(Bytes::from_static(&[0xef])); storage.storage().set_code(token_address, stub)?; - let init_calls = call.initCalls; match params { TokenCreateParams::Stablecoin { common, init } => { self.init_stablecoin(storage, token_address, common, init, init_calls, upgrade)?; @@ -261,7 +262,7 @@ pub enum TokenCreateParams { impl TokenCreateParams { /// Decodes ABI-encoded creation parameters for `variant`. - pub fn decode(variant: B20Variant, params: &Bytes) -> Result { + pub fn decode(variant: B20Variant, params: &[u8]) -> Result { match variant { B20Variant::Stablecoin => { let p = IB20Factory::B20StablecoinCreateParams::abi_decode_validate(params) diff --git a/crates/common/precompiles/src/b20_factory/mod.rs b/crates/common/precompiles/src/b20_factory/mod.rs index 8f3df1c2d2..efec6d5e26 100644 --- a/crates/common/precompiles/src/b20_factory/mod.rs +++ b/crates/common/precompiles/src/b20_factory/mod.rs @@ -6,7 +6,7 @@ pub use abi::{IB20Factory, IB20FactoryV1}; mod dispatch; mod logic; -pub use logic::{CommonParams, Factory, FactoryV1, TokenCreateParams}; +pub use logic::{CommonParams, Factory, FactoryV1, InitCalls, TokenCreateParams}; mod precompile; pub use precompile::B20Factory; diff --git a/crates/common/precompiles/src/lib.rs b/crates/common/precompiles/src/lib.rs index 987f143376..72be9f650e 100644 --- a/crates/common/precompiles/src/lib.rs +++ b/crates/common/precompiles/src/lib.rs @@ -76,7 +76,7 @@ pub use b20_stablecoin::{ mod b20_factory; pub use b20_factory::{ B20Factory, B20FactoryStorage, B20Variant, CommonParams, Factory, FactoryAbi, FactoryV1, - FactoryVersion, FactoryVersions, IB20Factory, IB20FactoryV1, TokenCreateParams, + FactoryVersion, FactoryVersions, IB20Factory, IB20FactoryV1, InitCalls, TokenCreateParams, }; mod policy; From 76f8dea8b9669d2d33603b9a743c4b47b2c6e794 Mon Sep 17 00:00:00 2001 From: refcell Date: Wed, 16 Sep 2026 16:30:34 +0000 Subject: [PATCH 016/299] fix(payload): bound historical SSZ payload decoding (#5071) --- .../common/rpc-types-engine/src/envelope.rs | 17 +- crates/common/rpc-types-engine/src/lib.rs | 5 +- .../rpc-types-engine/src/payload/bounded.rs | 211 ++++++++++++++ .../rpc-types-engine/src/payload/mod.rs | 8 + .../common/rpc-types-engine/src/payload/v4.rs | 10 +- .../tests/decode_allocation_bound.rs | 259 +++++++++++++----- .../service/src/actors/network/actor.rs | 8 +- 7 files changed, 431 insertions(+), 87 deletions(-) create mode 100644 crates/common/rpc-types-engine/src/payload/bounded.rs diff --git a/crates/common/rpc-types-engine/src/envelope.rs b/crates/common/rpc-types-engine/src/envelope.rs index 13a4f9cc39..eac2795317 100644 --- a/crates/common/rpc-types-engine/src/envelope.rs +++ b/crates/common/rpc-types-engine/src/envelope.rs @@ -13,6 +13,8 @@ use alloy_rpc_types_engine::{ }; use crate::{BaseExecutionPayload, BaseExecutionPayloadSidecar, BaseExecutionPayloadV4}; +#[cfg(feature = "std")] +use crate::{BoundedExecutionPayloadV1, BoundedExecutionPayloadV2, BoundedExecutionPayloadV3}; /// Maximum allowed decoded size for a snappy-compressed [`NetworkPayloadEnvelope`]. /// @@ -317,9 +319,8 @@ impl NetworkPayloadEnvelope { let signature = Signature::try_from(sig_data)?; let hash = PayloadHash::from(block_data); - let payload = BaseExecutionPayload::V1( - alloy_rpc_types_engine::ExecutionPayloadV1::from_ssz_bytes(block_data)?, - ); + let payload = + BaseExecutionPayload::V1(BoundedExecutionPayloadV1::from_ssz_bytes(block_data)?.0); Ok(Self { payload, signature, payload_hash: hash, parent_beacon_block_root: None }) } @@ -360,9 +361,8 @@ impl NetworkPayloadEnvelope { let signature = Signature::try_from(sig_data)?; let hash = PayloadHash::from(block_data); - let payload = BaseExecutionPayload::V2( - alloy_rpc_types_engine::ExecutionPayloadV2::from_ssz_bytes(block_data)?, - ); + let payload = + BaseExecutionPayload::V2(BoundedExecutionPayloadV2::from_ssz_bytes(block_data)?.0); Ok(Self { payload, signature, payload_hash: hash, parent_beacon_block_root: None }) } @@ -407,9 +407,8 @@ impl NetworkPayloadEnvelope { [parent_beacon_block_root.as_slice(), block_data].concat().as_slice(), ); - let payload = BaseExecutionPayload::V3( - alloy_rpc_types_engine::ExecutionPayloadV3::from_ssz_bytes(block_data)?, - ); + let payload = + BaseExecutionPayload::V3(BoundedExecutionPayloadV3::from_ssz_bytes(block_data)?.0); Ok(Self { payload, diff --git a/crates/common/rpc-types-engine/src/lib.rs b/crates/common/rpc-types-engine/src/lib.rs index 40f8ef3709..76203974d0 100644 --- a/crates/common/rpc-types-engine/src/lib.rs +++ b/crates/common/rpc-types-engine/src/lib.rs @@ -29,7 +29,10 @@ pub use payload::{ MAX_TRANSACTIONS_PER_PAYLOAD, MAX_WITHDRAWALS_PER_PAYLOAD, }; #[cfg(feature = "std")] -pub use payload::{BoundedTransactions, BoundedWithdrawals}; +pub use payload::{ + BoundedExecutionPayloadV1, BoundedExecutionPayloadV2, BoundedExecutionPayloadV3, + BoundedTransactions, BoundedWithdrawals, +}; #[cfg(feature = "reth")] mod reth; diff --git a/crates/common/rpc-types-engine/src/payload/bounded.rs b/crates/common/rpc-types-engine/src/payload/bounded.rs new file mode 100644 index 0000000000..7f6d9a1a63 --- /dev/null +++ b/crates/common/rpc-types-engine/src/payload/bounded.rs @@ -0,0 +1,211 @@ +//! Bounded SSZ decoding for historical execution payload versions. + +use alloy_primitives::{Address, B256, Bloom, Bytes, U256}; +use alloy_rpc_types_engine::{ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3}; + +use crate::{BoundedTransactions, BoundedWithdrawals}; + +/// SSZ decoder for [`ExecutionPayloadV1`] with Base's payload list bounds. +/// +/// Preserves the Alloy payload representation while rejecting oversized lists +/// before allocating their elements. Use this instead of Alloy's unbounded SSZ +/// decoder for untrusted network payloads. +#[derive(Debug)] +pub struct BoundedExecutionPayloadV1(pub ExecutionPayloadV1); + +impl ssz::Decode for BoundedExecutionPayloadV1 { + fn is_ssz_fixed_len() -> bool { + false + } + + fn from_ssz_bytes(bytes: &[u8]) -> Result { + let mut builder = ssz::SszDecoderBuilder::new(bytes); + + builder.register_type::()?; + builder.register_type::
()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + + let mut decoder = builder.build()?; + Ok(Self(ExecutionPayloadV1 { + parent_hash: decoder.decode_next()?, + fee_recipient: decoder.decode_next()?, + state_root: decoder.decode_next()?, + receipts_root: decoder.decode_next()?, + logs_bloom: decoder.decode_next()?, + prev_randao: decoder.decode_next()?, + block_number: decoder.decode_next()?, + gas_limit: decoder.decode_next()?, + gas_used: decoder.decode_next()?, + timestamp: decoder.decode_next()?, + extra_data: decoder.decode_next()?, + base_fee_per_gas: decoder.decode_next()?, + block_hash: decoder.decode_next()?, + transactions: decoder.decode_next::()?.0, + })) + } +} + +/// SSZ decoder for [`ExecutionPayloadV2`] with Base's payload list bounds. +/// +/// Preserves the Alloy payload representation while rejecting oversized lists +/// before allocating their elements. Use this instead of Alloy's unbounded SSZ +/// decoder for untrusted network payloads. +#[derive(Debug)] +pub struct BoundedExecutionPayloadV2(pub ExecutionPayloadV2); + +impl ssz::Decode for BoundedExecutionPayloadV2 { + fn is_ssz_fixed_len() -> bool { + false + } + + fn from_ssz_bytes(bytes: &[u8]) -> Result { + let mut builder = ssz::SszDecoderBuilder::new(bytes); + + builder.register_type::()?; + builder.register_type::
()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + + let mut decoder = builder.build()?; + Ok(Self(ExecutionPayloadV2 { + payload_inner: ExecutionPayloadV1 { + parent_hash: decoder.decode_next()?, + fee_recipient: decoder.decode_next()?, + state_root: decoder.decode_next()?, + receipts_root: decoder.decode_next()?, + logs_bloom: decoder.decode_next()?, + prev_randao: decoder.decode_next()?, + block_number: decoder.decode_next()?, + gas_limit: decoder.decode_next()?, + gas_used: decoder.decode_next()?, + timestamp: decoder.decode_next()?, + extra_data: decoder.decode_next()?, + base_fee_per_gas: decoder.decode_next()?, + block_hash: decoder.decode_next()?, + transactions: decoder.decode_next::()?.0, + }, + withdrawals: decoder.decode_next::()?.0, + })) + } +} + +/// SSZ decoder for [`ExecutionPayloadV3`] with Base's payload list bounds. +/// +/// Preserves the Alloy payload representation while rejecting oversized lists +/// before allocating their elements. Use this instead of Alloy's unbounded SSZ +/// decoder for untrusted network payloads. +#[derive(Debug)] +pub struct BoundedExecutionPayloadV3(pub ExecutionPayloadV3); + +impl ssz::Decode for BoundedExecutionPayloadV3 { + fn is_ssz_fixed_len() -> bool { + false + } + + fn from_ssz_bytes(bytes: &[u8]) -> Result { + let mut builder = ssz::SszDecoderBuilder::new(bytes); + + builder.register_type::()?; + builder.register_type::
()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + builder.register_type::()?; + + let mut decoder = builder.build()?; + Ok(Self(ExecutionPayloadV3 { + payload_inner: ExecutionPayloadV2 { + payload_inner: ExecutionPayloadV1 { + parent_hash: decoder.decode_next()?, + fee_recipient: decoder.decode_next()?, + state_root: decoder.decode_next()?, + receipts_root: decoder.decode_next()?, + logs_bloom: decoder.decode_next()?, + prev_randao: decoder.decode_next()?, + block_number: decoder.decode_next()?, + gas_limit: decoder.decode_next()?, + gas_used: decoder.decode_next()?, + timestamp: decoder.decode_next()?, + extra_data: decoder.decode_next()?, + base_fee_per_gas: decoder.decode_next()?, + block_hash: decoder.decode_next()?, + transactions: decoder.decode_next::()?.0, + }, + withdrawals: decoder.decode_next::()?.0, + }, + blob_gas_used: decoder.decode_next()?, + excess_blob_gas: decoder.decode_next()?, + })) + } +} + +#[cfg(test)] +mod tests { + use ssz::{Decode, Encode}; + + use super::*; + + #[test] + fn bounded_decoders_preserve_payload_fields() { + let v1 = ExecutionPayloadV1 { + parent_hash: B256::repeat_byte(1), + fee_recipient: Address::repeat_byte(2), + state_root: B256::repeat_byte(3), + receipts_root: B256::repeat_byte(4), + logs_bloom: Bloom::from([5; 256]), + prev_randao: B256::repeat_byte(6), + block_number: 7, + gas_limit: 8, + gas_used: 9, + timestamp: 10, + extra_data: Bytes::from_static(b"extra data"), + base_fee_per_gas: U256::from(12), + block_hash: B256::repeat_byte(13), + transactions: vec![ + Bytes::from_static(b"first"), + Bytes::new(), + Bytes::from_static(b"second"), + ], + }; + assert_eq!(BoundedExecutionPayloadV1::from_ssz_bytes(&v1.as_ssz_bytes()).unwrap().0, v1); + + let v2 = ExecutionPayloadV2 { payload_inner: v1, withdrawals: vec![] }; + assert_eq!(BoundedExecutionPayloadV2::from_ssz_bytes(&v2.as_ssz_bytes()).unwrap().0, v2); + + let v3 = ExecutionPayloadV3 { payload_inner: v2, blob_gas_used: 14, excess_blob_gas: 15 }; + assert_eq!(BoundedExecutionPayloadV3::from_ssz_bytes(&v3.as_ssz_bytes()).unwrap().0, v3); + } +} diff --git a/crates/common/rpc-types-engine/src/payload/mod.rs b/crates/common/rpc-types-engine/src/payload/mod.rs index 68d34384ba..a961705111 100644 --- a/crates/common/rpc-types-engine/src/payload/mod.rs +++ b/crates/common/rpc-types-engine/src/payload/mod.rs @@ -1,5 +1,12 @@ //! Versioned execution payloads +#[cfg(feature = "std")] +mod bounded; +#[cfg(feature = "std")] +pub use bounded::{ + BoundedExecutionPayloadV1, BoundedExecutionPayloadV2, BoundedExecutionPayloadV3, +}; + mod error; pub use error::BasePayloadError; @@ -767,6 +774,7 @@ impl BaseExecutionPayload { #[cfg(test)] mod tests { + #[cfg(feature = "serde")] use super::*; #[test] diff --git a/crates/common/rpc-types-engine/src/payload/v4.rs b/crates/common/rpc-types-engine/src/payload/v4.rs index d4ad43f4c0..f2ea53d277 100644 --- a/crates/common/rpc-types-engine/src/payload/v4.rs +++ b/crates/common/rpc-types-engine/src/payload/v4.rs @@ -84,9 +84,9 @@ pub const MAX_TRANSACTIONS_PER_PAYLOAD: usize = 1 << 20; /// The maximum number of withdrawals in a Base execution payload. /// -/// Base sets the withdrawals root directly and requires the SSZ `withdrawals` -/// list to be present but empty (see [`BaseExecutionPayloadV4`]), so any -/// non-empty list is invalid and is rejected during decode. +/// Base requires the SSZ `withdrawals` list to be present but empty from V2 +/// onward, so any non-empty list is invalid and is rejected during decode. +/// V4 additionally sets the withdrawals root directly (see [`BaseExecutionPayloadV4`]). pub const MAX_WITHDRAWALS_PER_PAYLOAD: usize = 0; /// SSZ `transactions` list bounded to [`MAX_TRANSACTIONS_PER_PAYLOAD`]. @@ -94,8 +94,8 @@ pub const MAX_WITHDRAWALS_PER_PAYLOAD: usize = 0; /// Transactions are variable-length SSZ items, so the decoder derives their /// count from the list's leading offset. Bounding the count rejects a frame that /// declares more transactions than the protocol allows *before* one entry per -/// declared element is allocated, closing the pre-authentication -/// allocation-amplification vector. +/// declared element is allocated. Counts within the protocol limit may still +/// require substantial allocation before authentication. #[cfg(feature = "std")] #[derive(Debug)] pub struct BoundedTransactions(pub Vec); diff --git a/crates/common/rpc-types-engine/tests/decode_allocation_bound.rs b/crates/common/rpc-types-engine/tests/decode_allocation_bound.rs index ca35494ae1..bd74191fc6 100644 --- a/crates/common/rpc-types-engine/tests/decode_allocation_bound.rs +++ b/crates/common/rpc-types-engine/tests/decode_allocation_bound.rs @@ -1,11 +1,10 @@ -//! Pre-authentication allocation-amplification regression test for -//! [`NetworkPayloadEnvelope::decode_v4`]. +//! Pre-authentication allocation regressions for all network payload versions. //! //! This lives in its own integration test binary rather than a unit test so the //! `#[global_allocator]` below is isolated to a single binary: a crate-wide //! allocator declared inside the crate's `#[cfg(test)] mod tests` would apply to //! every test in that binary (and would collide with any other test that -//! declared its own allocator). Here it governs only this one measurement. +//! declared its own allocator). Measurements are isolated per calling thread. #![cfg(feature = "std")] @@ -14,7 +13,13 @@ use std::{ cell::Cell, }; -use base_common_rpc_types_engine::{MAX_DECOMPRESSED_ENVELOPE_BYTES, NetworkPayloadEnvelope}; +use alloy_eips::eip4895::Withdrawal; +use alloy_primitives::Bytes; +use base_common_rpc_types_engine::{ + MAX_DECOMPRESSED_ENVELOPE_BYTES, MAX_TRANSACTIONS_PER_PAYLOAD, NetworkPayloadEnvelope, + PayloadEnvelopeError, +}; +use ssz::Decode; // Running total of bytes allocated on the current thread. Const-initialized so // reading it never allocates (which would recurse through the allocator). @@ -26,7 +31,8 @@ thread_local! { /// system allocator. Lets the test measure how much heap a single decode forces. /// Only allocation (growth) is counted, which is what a resource-exhaustion /// bound cares about. -struct CountingAllocator; +#[derive(Debug)] +pub struct CountingAllocator; // SAFETY: every call is forwarded to the system allocator with an unchanged // layout, so all `GlobalAlloc` invariants are those of `System`; the wrapper @@ -52,71 +58,186 @@ unsafe impl GlobalAlloc for CountingAllocator { #[global_allocator] static COUNTING_ALLOCATOR: CountingAllocator = CountingAllocator; -/// Regression test for the pre-authentication allocation-amplification -/// denial-of-service (CWE-770). `decode_v4` is the gossip block-decode entry -/// point that `BlockHandler::handle` runs for every message arriving on the -/// public P2P port, before any signature check. -/// -/// A frame only ~0.5 `MiB` on the wire can declare a `transactions` list of -/// ~2.6M entries. An unbounded decode honors that count and allocates ~125 `MiB` -/// of heap per frame; a length-bounded list must instead keep the per-frame heap -/// within a small multiple of the decompressed size. This measures the actual -/// bytes the decode allocates and holds it to that bound. +/// Wire layouts from the versioned SSZ execution payload schemas. +#[derive(Clone, Copy, Debug)] +pub struct PayloadVersion { + /// Payload version used in assertion diagnostics. + pub name: &'static str, + /// Size of the SSZ container's fixed region. + pub fixed_len: usize, + /// Signature and optional parent beacon block root preceding the container. + pub envelope_prefix: usize, + /// Whether this version has a withdrawals list. + pub has_withdrawals: bool, + /// Public network decoder exercised by this case. + pub decode: fn(&[u8]) -> Result, +} + +impl PayloadVersion { + /// Every subscribed block payload version. + pub const ALL: [Self; 4] = [ + Self { + name: "v1", + fixed_len: 508, + envelope_prefix: 65, + has_withdrawals: false, + decode: NetworkPayloadEnvelope::decode_v1, + }, + Self { + name: "v2", + fixed_len: 512, + envelope_prefix: 65, + has_withdrawals: true, + decode: NetworkPayloadEnvelope::decode_v2, + }, + Self { + name: "v3", + fixed_len: 528, + envelope_prefix: 97, + has_withdrawals: true, + decode: NetworkPayloadEnvelope::decode_v3, + }, + Self { + name: "v4", + fixed_len: 560, + envelope_prefix: 97, + has_withdrawals: true, + decode: NetworkPayloadEnvelope::decode_v4, + }, + ]; + + /// Creates a compressed envelope without allocating a transaction object per element. + /// + /// All transaction offsets point to the end of the list, encoding empty items. + /// Withdrawals are zero-filled; non-multiple lengths exercise truncated items. + pub fn frame(self, transaction_count: usize, withdrawals_len: usize) -> Vec { + const EXTRA_DATA_OFFSET: usize = 436; + const TRANSACTIONS_OFFSET: usize = 504; + const WITHDRAWALS_OFFSET: usize = 508; + + let transactions_len = transaction_count * ssz::BYTES_PER_LENGTH_OFFSET; + let decoded_len = + self.envelope_prefix + self.fixed_len + transactions_len + withdrawals_len; + assert!(decoded_len <= MAX_DECOMPRESSED_ENVELOPE_BYTES); + assert!(self.has_withdrawals || withdrawals_len == 0); + + let mut data = Vec::with_capacity(decoded_len); + data.resize(self.envelope_prefix + self.fixed_len, 0); + // Structurally parseable signature; no sequencer authentication is needed to decode. + data[31] = 1; + data[63] = 1; + let container = &mut data[self.envelope_prefix..]; + container[EXTRA_DATA_OFFSET..][..4].copy_from_slice(&(self.fixed_len as u32).to_le_bytes()); + container[TRANSACTIONS_OFFSET..][..4] + .copy_from_slice(&(self.fixed_len as u32).to_le_bytes()); + if self.has_withdrawals { + container[WITHDRAWALS_OFFSET..][..4] + .copy_from_slice(&((self.fixed_len + transactions_len) as u32).to_le_bytes()); + } + for _ in 0..transaction_count { + data.extend_from_slice(&(transactions_len as u32).to_le_bytes()); + } + data.resize(decoded_len, 0); + snap::raw::Encoder::new().compress_vec(&data).unwrap() + } + + /// Measures cumulative requested allocation, excluding fixture construction. + pub fn measured_decode( + self, + frame: &[u8], + max_allocation: usize, + ) -> Result { + ALLOCATED.with(|c| c.set(0)); + let decoded = (self.decode)(frame); + let allocated = ALLOCATED.with(|c| c.get()); + assert!( + allocated < max_allocation, + "{} allocated {allocated} bytes (budget {max_allocation})", + self.name + ); + decoded + } +} + +/// Oversized counts must fail without allocating the declared transaction objects. #[test] -fn decode_v4_transaction_bomb_allocation_is_bounded() { - // SSZ container fixed-region byte offsets (see the `from_ssz_bytes` - // regression test in the payload module for the full field layout). - const FIXED_LEN: usize = 560; - const EXTRA_DATA_OFFSET: usize = 436; - const TRANSACTIONS_OFFSET: usize = 504; - const WITHDRAWALS_OFFSET: usize = 508; - // Each envelope carries a 65-byte signature and 32-byte parent beacon root - // ahead of the SSZ container; the whole decompressed envelope must stay under - // the decompression cap. - const ENVELOPE_PREFIX: usize = 65 + 32; - - let txs_len = ((MAX_DECOMPRESSED_ENVELOPE_BYTES - ENVELOPE_PREFIX - FIXED_LEN) - / ssz::BYTES_PER_LENGTH_OFFSET) - * ssz::BYTES_PER_LENGTH_OFFSET; - let declared_txs = txs_len / ssz::BYTES_PER_LENGTH_OFFSET; - - // Container: zeroed fixed fields, empty extra_data/withdrawals, and a - // transactions region whose leading offset declares `declared_txs` empty - // items (every offset points past the end of the list). - let mut container = Vec::with_capacity(FIXED_LEN + txs_len); - container.resize(FIXED_LEN, 0); - container[EXTRA_DATA_OFFSET..][..4].copy_from_slice(&(FIXED_LEN as u32).to_le_bytes()); - container[TRANSACTIONS_OFFSET..][..4].copy_from_slice(&(FIXED_LEN as u32).to_le_bytes()); - container[WITHDRAWALS_OFFSET..][..4] - .copy_from_slice(&((FIXED_LEN + txs_len) as u32).to_le_bytes()); - for _ in 0..declared_txs { - container.extend_from_slice(&(txs_len as u32).to_le_bytes()); +pub fn transaction_count_allocation_is_bounded_in_every_version() { + for version in PayloadVersion::ALL { + let maximum_wire_count = + (MAX_DECOMPRESSED_ENVELOPE_BYTES - version.envelope_prefix - version.fixed_len) + / ssz::BYTES_PER_LENGTH_OFFSET; + for count in [MAX_TRANSACTIONS_PER_PAYLOAD + 1, maximum_wire_count] { + let frame = version.frame(count, 0); + let declared = snap::raw::decompress_len(&frame).unwrap(); + assert!(frame.len() < MAX_DECOMPRESSED_ENVELOPE_BYTES); + // Decompression plus the V3/V4 hashing copy, with room for container bookkeeping. + // No per-transaction allocation is permitted on this rejection path. + let decoded = version.measured_decode(&frame, 2 * declared + 64 * 1024); + assert_eq!( + decoded, + Err(PayloadEnvelopeError::BrokenSszEncoding), + "{} count {count}", + version.name + ); + } } +} - // Envelope = signature (r=1, s=1, v=0 so it parses and the decode reaches the - // SSZ sink) + parent beacon root + container, snappy-compressed as it arrives - // on the wire. - let mut decompressed = Vec::with_capacity(ENVELOPE_PREFIX + container.len()); - let mut signature = [0u8; 65]; - signature[31] = 1; - signature[63] = 1; - decompressed.extend_from_slice(&signature); - decompressed.extend_from_slice(&[0u8; 32]); - decompressed.extend_from_slice(&container); - let frame = snap::raw::Encoder::new().compress_vec(&decompressed).unwrap(); - - // Measure only the heap the decode itself grows on this thread. - ALLOCATED.with(|c| c.set(0)); - let _ = NetworkPayloadEnvelope::decode_v4(&frame); - let allocated = ALLOCATED.with(|c| c.get()); - - // A legitimate decode allocates the decompressed buffer plus the hashing - // buffer (~2x the frame); anything approaching the bomb's ~125 MiB means the - // transaction list honored the attacker's element count. - let max_allocation = 4 * MAX_DECOMPRESSED_ENVELOPE_BYTES; - assert!( - allocated < max_allocation, - "decoding one frame declaring {declared_txs} transactions allocated {allocated} bytes \ - pre-authentication (limit {max_allocation}); the transaction list is not length-bounded", - ); +/// Preserve the protocol maximum while making its remaining allocation budget explicit. +#[test] +pub fn permitted_transaction_counts_decode_in_every_version() { + for version in PayloadVersion::ALL { + for count in [0, MAX_TRANSACTIONS_PER_PAYLOAD - 1, MAX_TRANSACTIONS_PER_PAYLOAD] { + let frame = version.frame(count, 0); + let declared = snap::raw::decompress_len(&frame).unwrap(); + let budget = 2 * declared + count * std::mem::size_of::() + 64 * 1024; + let decoded = version.measured_decode(&frame, budget).unwrap(); + assert_eq!(decoded.payload.transactions().len(), count, "{}", version.name); + assert!( + decoded.payload.transactions().iter().all(|transaction| transaction.is_empty()) + ); + } + } +} + +/// Base requires empty withdrawals from V2 onward, including before Isthmus. +#[test] +pub fn nonempty_withdrawals_are_rejected_without_list_allocation() { + for version in PayloadVersion::ALL.into_iter().filter(|version| version.has_withdrawals) { + let per_item = Withdrawal::ssz_fixed_len(); + let maximum_wire_len = + (MAX_DECOMPRESSED_ENVELOPE_BYTES - version.envelope_prefix - version.fixed_len) + / per_item + * per_item; + for len in [1, per_item - 1, per_item, maximum_wire_len] { + let frame = version.frame(0, len); + let declared = snap::raw::decompress_len(&frame).unwrap(); + let decoded = version.measured_decode(&frame, 2 * declared + 64 * 1024); + assert_eq!( + decoded, + Err(PayloadEnvelopeError::BrokenSszEncoding), + "{} withdrawals bytes {len}", + version.name + ); + } + } +} + +/// A count within the limit must not bypass SSZ offset validation. +#[test] +pub fn malformed_transaction_offsets_are_rejected_in_every_version() { + for version in PayloadVersion::ALL { + let frame = version.frame(3, 0); + let mut data = snap::raw::Decoder::new().decompress_vec(&frame).unwrap(); + let last_offset = + version.envelope_prefix + version.fixed_len + 2 * ssz::BYTES_PER_LENGTH_OFFSET; + data[last_offset..][..4].copy_from_slice(&0u32.to_le_bytes()); + let frame = snap::raw::Encoder::new().compress_vec(&data).unwrap(); + assert_eq!( + (version.decode)(&frame), + Err(PayloadEnvelopeError::BrokenSszEncoding), + "{}", + version.name + ); + } } diff --git a/crates/consensus/service/src/actors/network/actor.rs b/crates/consensus/service/src/actors/network/actor.rs index 6c8eb12db8..afa7ca803b 100644 --- a/crates/consensus/service/src/actors/network/actor.rs +++ b/crates/consensus/service/src/actors/network/actor.rs @@ -285,10 +285,12 @@ mod tests { let expected_address = pubkey.address(); const CHAIN_ID: u64 = 1337; + let mut execution_payload = + ExecutionPayloadV3::arbitrary(&mut arbitrary::Unstructured::new(&bytes)).unwrap(); + // Base payloads require an empty withdrawals list, now enforced during SSZ decode. + execution_payload.payload_inner.withdrawals.clear(); let block = BaseExecutionPayloadEnvelope { - execution_payload: BaseExecutionPayload::V3( - ExecutionPayloadV3::arbitrary(&mut arbitrary::Unstructured::new(&bytes)).unwrap(), - ), + execution_payload: BaseExecutionPayload::V3(execution_payload), parent_beacon_block_root: Some(B256::random()), }; From 4c6c1131965d407129860471f548932ebe0a33f0 Mon Sep 17 00:00:00 2001 From: refcell Date: Wed, 16 Sep 2026 17:27:53 +0000 Subject: [PATCH 017/299] fix(ci): Decouple RC Creation From Builds (#5078) * fix(ci): decouple RC creation from artifact builds Co-authored-by: Codex * chore(ci): remove release tag test script Co-authored-by: Codex --------- Co-authored-by: Codex --- .github/workflows/build-rc.yml | 39 ++++++++++++++++++++++ .github/workflows/create-rc.yml | 55 +++++++++++++++---------------- docs/guides/RELEASE.md | 37 +++++++++++++++++---- etc/scripts/release/create-tag.sh | 10 ++++-- 4 files changed, 104 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/build-rc.yml diff --git a/.github/workflows/build-rc.yml b/.github/workflows/build-rc.yml new file mode 100644 index 0000000000..bf4d9a5e6b --- /dev/null +++ b/.github/workflows/build-rc.yml @@ -0,0 +1,39 @@ +name: Build RC +run-name: Build RC ${{ github.ref_name }} + +# Create RC dispatches this workflow at the immutable RC tag, not the branch. +# No concurrency group: newer RCs must not cancel or wait for older builds. +on: + workflow_dispatch: + +permissions: + actions: read + contents: write + packages: write + id-token: write + attestations: write + +jobs: + validate-tag: + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Validate RC tag + run: | + if [[ "$GITHUB_REF_TYPE" != "tag" || ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "::error::Build RC must be dispatched at an RC tag (vX.Y.Z-rc.N), not a branch or final tag" + exit 1 + fi + + build: + needs: [validate-tag] + uses: ./.github/workflows/build-release.yml + with: + tag: ${{ github.ref_name }} + is_final: false + secrets: inherit diff --git a/.github/workflows/create-rc.yml b/.github/workflows/create-rc.yml index 970fa40156..81cf5fa457 100644 --- a/.github/workflows/create-rc.yml +++ b/.github/workflows/create-rc.yml @@ -6,22 +6,18 @@ on: - 'releases/v*' permissions: - actions: read + actions: write contents: write - packages: write - id-token: write - attestations: write jobs: create-tag: runs-on: ubuntu-latest - # Serialize RC number allocation, not the long-running builds. + # Serialize RC number allocation and dispatch, never the artifact builds. + # The default single pending slot cancels older queued RCs on rapid pushes. concurrency: group: release-${{ github.ref_name }} cancel-in-progress: false - outputs: - tag: ${{ steps.read-tag.outputs.tag }} - skip: ${{ steps.version-check.outputs.skip }} + queue: max steps: - name: Harden the runner (Audit all outbound calls) @@ -44,9 +40,9 @@ jobs: VERSION=$(get_cargo_version) if [[ "$VERSION" == "0.0.0" ]]; then echo "::notice::Cargo.toml version is 0.0.0 — version sync PR not yet merged. Skipping RC." - echo "skip=true" >> $GITHUB_OUTPUT + echo "skip=true" >> "$GITHUB_OUTPUT" else - echo "skip=false" >> $GITHUB_OUTPUT + echo "skip=false" >> "$GITHUB_OUTPUT" fi - name: Create and push tag @@ -63,29 +59,32 @@ jobs: exit 1 fi TAG=$(cat "$TAG_FILE") - echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "Release tag: $TAG" + # GITHUB_TOKEN tag pushes do not trigger workflows; dispatch explicitly. + # This only waits for the dispatch request, not for the build to finish. + - name: Dispatch RC artifact build + if: steps.version-check.outputs.skip == 'false' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.read-tag.outputs.tag }} + run: gh workflow run build-rc.yml --ref "$TAG" + - name: Summary run: | SKIP="${{ steps.version-check.outputs.skip }}" if [[ "$SKIP" == "true" ]]; then - echo "## RC Skipped" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Cargo.toml version is still 0.0.0. Merge the version sync PR first." >> $GITHUB_STEP_SUMMARY + echo "## RC Skipped" + echo "" + echo "Cargo.toml version is still 0.0.0. Merge the version sync PR first." else TAG="${{ steps.read-tag.outputs.tag }}" - echo "## Tag Created" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Tag**: \`$TAG\`" >> $GITHUB_STEP_SUMMARY - echo "**Type**: Release Candidate" >> $GITHUB_STEP_SUMMARY - fi - - build: - needs: [create-tag] - if: needs.create-tag.outputs.skip == 'false' - uses: ./.github/workflows/build-release.yml - with: - tag: ${{ needs.create-tag.outputs.tag }} - is_final: false - secrets: inherit + echo "## Tag Ready" + echo "" + echo "**Tag**: \`$TAG\`" + echo "**Type**: Release Candidate" + echo "" + echo "Artifact build dispatched to the independent **Build RC** workflow." + fi >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/guides/RELEASE.md b/docs/guides/RELEASE.md index 6c3d1127d2..c2611e63ac 100644 --- a/docs/guides/RELEASE.md +++ b/docs/guides/RELEASE.md @@ -29,9 +29,10 @@ Review and merge the auto-generated version sync PR targeting the release branch Every commit pushed to the release branch triggers the **Create RC** workflow, which: - Skips silently if `Cargo.toml` is still `0.0.0` (version sync PR not yet merged) -- Otherwise creates the next RC tag (e.g., `v0.6.0-rc.1`, `v0.6.0-rc.2`, …) -- Builds multi-arch Docker images and native binaries -- Pushes the Docker images tagged with the RC tag only (not `latest`) +- Otherwise creates the next RC tag (e.g., `v0.6.0-rc.1`, `v0.6.0-rc.2`, …) at the triggering commit +- Dispatches the independent **Build RC** workflow at that tag, then finishes without waiting for artifacts + +**Build RC** builds multi-arch Docker images and native binaries using **Build Release**. Docker images are tagged with the RC tag only (not `latest`). Each RC builds in its own workflow run, so newer merges do not cancel or wait for older artifact builds (subject to runner availability). To create additional RCs, simply push more commits (bug fixes, backports) to the release branch. @@ -52,19 +53,43 @@ The **Create RC** workflow triggers on every push to any `releases/v*` branch. I RC tags follow the pattern `vX.Y.Z-rc.N` where `N` increments automatically based on existing tags. +Only tag allocation and build dispatch are serialized per release branch. The tagging job uses [`queue: max`](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency#example-queueing-multiple-pending-runs), so up to 100 pending jobs wait rather than replace one another. `cancel-in-progress: false` alone does not preserve pending jobs. GitHub orders the queue by when jobs start waiting, not necessarily commit order; each job still tags its own triggering commit. A full queue cancels additional jobs. + +The build is explicitly dispatched using `GITHUB_TOKEN`, because tag pushes made with that token [do not trigger other workflows](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow#triggering-a-workflow-from-a-workflow). **Create RC** needs `actions: write` for dispatch, but artifact publishing permissions stay in **Build RC**. + +### Retrying RC builds + +If tag creation succeeds but dispatch fails, rerun **Create RC**: it reuses the RC tag at that commit. If an artifact build fails, rerun that **Build RC** run, or dispatch the existing tag directly: + +```sh +gh workflow run build-rc.yml --ref v1.4.0-rc.1 +``` + +**Build RC** accepts only RC tag refs, not release branches or final tags. Manually dispatching the same tag again starts another build; it does not cancel an existing build. The **Create RC** result covers tagging and dispatch only; check **Build RC** for artifact success. + +### Rolling out workflow changes + +Merge the new **Build RC** workflow into the default branch first: GitHub requires dispatched workflows to exist there. Then backport the workflow and release-script changes to each active release branch. Dispatch runs at the RC tag, so that commit must also contain `build-rc.yml` and the reusable build workflow. Tags predating this split do not support this dispatch command; retry their original build jobs instead. + +Updating only `main` does not change push-triggered workflows on existing release branches. Existing queued or running workflows also retain their original configuration; this change does not retroactively fix cancelled runs or release their concurrency locks. + +Before retrying a legacy **Create RC** run, check its checkout ref: older definitions checked out the moving release branch rather than the triggering SHA, so rerunning them can tag a newer commit instead of recovering the missed RC. + ## Quick reference | Action | Workflow | Trigger | Output | |--------|----------|---------|--------| | Create release branch | **Start Release** | Manual (bump type) | `releases/vX.Y.Z` branch | | Sync Cargo.toml version | **Release Version Sync** | Automatic on branch creation | PR targeting release branch | -| Build RC | **Create RC** | Automatic on push to `releases/v*` | RC tag + Docker image + binaries | +| Create RC tag | **Create RC** | Automatic on push to `releases/v*` | RC tag + independent build dispatch | +| Build RC artifacts | **Build RC** | Dispatched by Create RC at the RC tag | Docker image + binaries | | Publish final release | **Publish Release** | Manual (version number) | Final tag + Docker images + draft GitHub release | ## Workflows - **Start Release** — Creates the release branch from a bump-type dropdown - **Release Version Sync** — Opens a PR to update `Cargo.toml` when a release branch is created -- **Create RC** — Triggered on push to `releases/v*`; creates RC tags and builds artifacts -- **Build Release** — Reusable workflow (called by Create RC and Publish Release) that builds Docker images and binaries +- **Create RC** — Triggered on push to `releases/v*`; creates RC tags and dispatches builds without waiting for artifacts +- **Build RC** — Dispatched at an RC tag; validates the ref and builds artifacts independently of Create RC +- **Build Release** — Reusable workflow (called by Build RC and Publish Release) that builds Docker images and binaries - **Publish Release** — Manual workflow to create the final tag and publish the release diff --git a/etc/scripts/release/create-tag.sh b/etc/scripts/release/create-tag.sh index e0150c60bb..d0963d6a92 100755 --- a/etc/scripts/release/create-tag.sh +++ b/etc/scripts/release/create-tag.sh @@ -34,9 +34,13 @@ main() { local TAG if [[ "$RELEASE_TYPE" == "rc" ]]; then - RC_NUMBER=$(get_next_rc_number "$VERSION") - TAG="v${VERSION}-rc.${RC_NUMBER}" - echo "Creating RC tag: $TAG" + # Reuse the triggering commit's tag when retrying a failed build dispatch. + TAG=$(git tag --list "v${VERSION}-rc.*" --points-at HEAD --sort=version:refname | tail -1) + if [[ -z "$TAG" ]]; then + RC_NUMBER=$(get_next_rc_number "$VERSION") + TAG="v${VERSION}-rc.${RC_NUMBER}" + fi + echo "Using RC tag: $TAG" else TAG="v${VERSION}" echo "Creating final release tag: $TAG" From 040c4a80b8bc96eee6d70ad00ae86ede86596dbe Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 16 Sep 2026 18:26:44 +0000 Subject: [PATCH 018/299] feat(precompiles): scaffold Asset and Stablecoin V3 at Denim (#5065) Freeze a behavior-identical copy of V2 behind Denim so later Denim-era changes can land without rewriting Cobalt consensus. V3 reuses the Cobalt wire surface; no new selectors or storage. Co-authored-by: Cursor --- crates/common/precompiles/Cargo.toml | 8 + .../precompiles/src/b20_asset/dispatch.rs | 20 +- .../precompiles/src/b20_asset/logic/mod.rs | 8 +- .../precompiles/src/b20_asset/logic/v3.rs | 2686 ++++++++++++ .../common/precompiles/src/b20_asset/mod.rs | 2 +- .../precompiles/src/b20_asset/versions.rs | 54 +- .../src/b20_stablecoin/logic/mod.rs | 8 +- .../src/b20_stablecoin/logic/v3.rs | 1830 ++++++++ .../precompiles/src/b20_stablecoin/mod.rs | 2 +- .../src/b20_stablecoin/versions.rs | 39 +- crates/common/precompiles/src/lib.rs | 8 +- .../precompiles/tests/b20_asset_v3_golden.rs | 3811 +++++++++++++++++ .../tests/b20_stablecoin_v3_golden.rs | 2601 +++++++++++ 13 files changed, 11042 insertions(+), 35 deletions(-) create mode 100644 crates/common/precompiles/src/b20_asset/logic/v3.rs create mode 100644 crates/common/precompiles/src/b20_stablecoin/logic/v3.rs create mode 100644 crates/common/precompiles/tests/b20_asset_v3_golden.rs create mode 100644 crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs diff --git a/crates/common/precompiles/Cargo.toml b/crates/common/precompiles/Cargo.toml index 9f3e46cfeb..1be8671c6e 100644 --- a/crates/common/precompiles/Cargo.toml +++ b/crates/common/precompiles/Cargo.toml @@ -47,6 +47,10 @@ required-features = ["test-utils"] name = "b20_stablecoin_v2_golden" required-features = ["test-utils"] +[[test]] +name = "b20_stablecoin_v3_golden" +required-features = ["test-utils"] + [[test]] name = "b20_asset_v1_golden" required-features = ["test-utils"] @@ -55,6 +59,10 @@ required-features = ["test-utils"] name = "b20_asset_v2_golden" required-features = ["test-utils"] +[[test]] +name = "b20_asset_v3_golden" +required-features = ["test-utils"] + [[test]] name = "b20_policy_v1_golden" required-features = ["test-utils"] diff --git a/crates/common/precompiles/src/b20_asset/dispatch.rs b/crates/common/precompiles/src/b20_asset/dispatch.rs index 3ea3959384..f97c1cd19f 100644 --- a/crates/common/precompiles/src/b20_asset/dispatch.rs +++ b/crates/common/precompiles/src/b20_asset/dispatch.rs @@ -572,12 +572,14 @@ impl<'a> DecodedAnnounce<'a> { // V1/Beryl: revert bytes are already consensus-frozen — keep falling through to // the owned decoder's diagnostic, however expensive, unchanged. AssetVersion::V1 => None, - // V2/Cobalt: not scheduled on any network yet, so this can short-circuit cheaply - // without touching any live consensus bytes. - AssetVersion::V2 => Some(Err(BasePrecompileError::AbiDecodeFailed { - selector, - error: String::from("announce: malformed bytes[] payload"), - })), + // V2/Cobalt and V3/Denim: not scheduled on any network yet, so this can + // short-circuit cheaply without touching any live consensus bytes. + AssetVersion::V2 | AssetVersion::V3 => { + Some(Err(BasePrecompileError::AbiDecodeFailed { + selector, + error: String::from("announce: malformed bytes[] payload"), + })) + } }; } // Field `.0` of `PackedSeqToken<'a>` is `&'a [u8]`, so each iter yields a slice with the @@ -1169,7 +1171,7 @@ mod tests { "row `{name}`: oracle disagrees; refresh the test if the payload changed" ); - for version in [AssetVersion::V1, AssetVersion::V2] { + for version in [AssetVersion::V1, AssetVersion::V2, AssetVersion::V3] { let mut token = make_token(); token.accounting_mut().roles.insert((AssetV1::OPERATOR_ROLE, ALICE), true); let mut storage = storage_with_caller(ALICE); @@ -1185,7 +1187,9 @@ mod tests { if let Err(err) = outcome { let control = version.abi().decode(&calldata).unwrap_err(); - if rejects_via_valid_token && version == AssetVersion::V2 { + if rejects_via_valid_token + && matches!(version, AssetVersion::V2 | AssetVersion::V3) + { assert_ne!( err, control, "row `{name}` at {version:?}: expected the bounded V2 short-circuit \ diff --git a/crates/common/precompiles/src/b20_asset/logic/mod.rs b/crates/common/precompiles/src/b20_asset/logic/mod.rs index 5ee235215d..17b5c306dc 100644 --- a/crates/common/precompiles/src/b20_asset/logic/mod.rs +++ b/crates/common/precompiles/src/b20_asset/logic/mod.rs @@ -2,8 +2,9 @@ //! //! [`Asset`] (in [`interface`](self)) is the append-only business-logic //! interface each version implements; [`B20AssetToken`] is the minimal -//! storage + policy holder the logic operates on; and [`AssetV1`] is the -//! first frozen implementation. +//! storage + policy holder the logic operates on; [`AssetV1`] is the first +//! frozen implementation (activated at Beryl), [`AssetV2`] the second (activated at +//! Cobalt), and [`AssetV3`] the third (activated at Denim). use alloy_primitives::Address; @@ -18,6 +19,9 @@ pub use v1::AssetV1; mod v2; pub use v2::AssetV2; +mod v3; +pub use v3::AssetV3; + /// Storage + policy binding the asset logic operates on. /// /// A minimal `(accounting, policy, policy_version)` holder implementing [`Token`]; diff --git a/crates/common/precompiles/src/b20_asset/logic/v3.rs b/crates/common/precompiles/src/b20_asset/logic/v3.rs new file mode 100644 index 0000000000..293d53d0e5 --- /dev/null +++ b/crates/common/precompiles/src/b20_asset/logic/v3.rs @@ -0,0 +1,2686 @@ +//! Version 3 of the asset B-20 precompile logic, activated at Denim. +//! +//! V3 is a behavior-identical copy of V2 (a scaffold seam for future Denim-era changes). Every +//! method carries V2's verbatim body; V3 does not call into V2. Storage is append-only, so a token +//! created under V1 or V2 upgrades in place with no migration. + +use alloc::{ + string::{String, ToString}, + vec, + vec::Vec, +}; + +use alloy_primitives::{Address, B256, FixedBytes, U256, b256}; +use alloy_sol_types::{SolEvent, SolValue}; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{ + Asset, AssetAccounting, B20_MAX_SUPPLY_CAP, B20AssetStorage, B20AssetToken, B20Guards, + B20PausableFeature, B20PolicyType, B20TokenRole, Eip712Domain, IB20, IB20Asset, NonZeroAddress, + PermitArgs, PolicyAccounting, Token, TransferPolicyIds, +}; + +/// `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")` +const DOMAIN_TYPEHASH: B256 = + b256!("8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f"); + +/// EIP-712 domain version string pinned to `"1"`. +const VERSION: &[u8] = b"1"; + +/// Third asset B-20 implementation. Activated at Denim, behavior-identical to V2 (scaffold seam +/// for future changes). +#[derive(Debug, Default, Clone, Copy)] +pub struct AssetV3; + +impl AssetV3 { + const PAUSABLE_FEATURES: &[IB20::PausableFeature] = &[ + IB20::PausableFeature::TRANSFER, + IB20::PausableFeature::MINT, + IB20::PausableFeature::BURN, + IB20::PausableFeature::SEIZE, + ]; + + /// Role identifier for asset operators: `keccak256("OPERATOR_ROLE")`. + /// + /// Asset-specific (not part of [`B20TokenRole`]); kept inherent to V3 so it stays frozen with + /// this version. Required for `announce` and `updateMultiplier`. + pub(crate) const OPERATOR_ROLE: B256 = + b256!("97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929"); + + /// Upper bound the multiplier setters accept: `type(uint128).max`. With supply capped at + /// `type(uint128).max`, a `uint128` multiplier keeps `balance * multiplier` inside `uint256`, + /// so balance-derived reads never overflow. Single source of truth for the setter guards and + /// the `MAX_UI_MULTIPLIER()` getter. + pub const MAX_UI_MULTIPLIER: U256 = U256::from_limbs([u64::MAX, u64::MAX, 0, 0]); + + /// Balance-moving core of `transfer`/`transferFrom`, without the pause check. + /// + /// `from` / `to` are [`NonZeroAddress`]: callers validate zero addresses (and choose the + /// typed revert) before any policy SLOAD. `policies` carries the sender/receiver ids + /// pre-read from their shared slot by the caller; `Some` enforces both (unprivileged + /// path), `None` skips them (factory-privileged path). + fn transfer_inner( + &self, + token: &mut B20AssetToken, + from: NonZeroAddress, + to: NonZeroAddress, + amount: U256, + policies: Option<&TransferPolicyIds>, + ) -> Result<()> { + let from = from.get(); + let to = to.get(); + if let Some(policies) = policies { + B20Guards::ensure_authorized_by_id( + token, + B20PolicyType::TransferSender.id(), + policies.sender, + from, + )?; + B20Guards::ensure_authorized_by_id( + token, + B20PolicyType::TransferReceiver.id(), + policies.receiver, + to, + )?; + } + self.move_balance(token, from, to, amount) + } + + /// Debits `from`, credits `to`, and emits `Transfer(from, to, amount)`. + /// + /// The shared balance-move primitive: no policy, allowance, pause, or zero-address checks — + /// callers apply their own guards first. Both [`Self::transfer_inner`] and the seize path use + /// this, so seize never has to reuse the policy-bearing `transfer_inner` (nor the factory + /// privileged bypass). + fn move_balance( + &self, + token: &mut B20AssetToken, + from: Address, + to: Address, + amount: U256, + ) -> Result<()> { + let from_balance = token.accounting().balance_of(from)?; + if from_balance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientBalance { + sender: from, + balance: from_balance, + needed: amount, + })); + } + let new_from_balance = + from_balance.checked_sub(amount).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_balance(from, new_from_balance)?; + let to_balance = token.accounting().balance_of(to)?; + let new_to_balance = + to_balance.checked_add(amount).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_balance(to, new_to_balance)?; + token.accounting_mut().emit_event(IB20::Transfer { from, to, amount }.encode_log_data()) + } + + /// Supply-reducing core of the burn operations, without pause or role checks. + fn burn_inner( + &self, + token: &mut B20AssetToken, + from: Address, + amount: U256, + ) -> Result<()> { + let balance = token.accounting().balance_of(from)?; + if balance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientBalance { + sender: from, + balance, + needed: amount, + })); + } + token.accounting_mut().set_balance(from, balance - amount)?; + let supply = token.accounting().total_supply()?; + let new_supply = + supply.checked_sub(amount).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_total_supply(new_supply)?; + token + .accounting_mut() + .emit_event(IB20::Transfer { from, to: Address::ZERO, amount }.encode_log_data()) + } + + /// Revokes `role` from `account` without checking caller authorization. + fn revoke_role_unchecked( + &self, + token: &mut B20AssetToken, + role: B256, + account: Address, + sender: Address, + ) -> Result<()> { + if !token.accounting().has_role(role, account)? { + return Ok(()); + } + token.accounting_mut().set_role(role, account, false)?; + if role == B20TokenRole::DefaultAdmin.id() { + let current = token.accounting().role_member_count(role)?; + let next = + current.checked_sub(U256::ONE).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_role_member_count(role, next)?; + } + token + .accounting_mut() + .emit_event(IB20::RoleRevoked { role, account, sender }.encode_log_data()) + } + + /// Ensures role-admin mutations are still reachable. + fn ensure_role_admin_mutations_available( + &self, + token: &B20AssetToken, + caller: Address, + ) -> Result<()> { + let admin_role = B20TokenRole::DefaultAdmin.id(); + if token.accounting().role_member_count(admin_role)? == U256::ZERO { + return Err(BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: caller, + neededRole: admin_role, + })); + } + Ok(()) + } + + /// Ensures `policy_scope` names a built-in B-20 policy slot available on the V2 (Cobalt) common + /// surface, which adds the seize scopes (`SEIZE_EXEMPT_POLICY` / `SEIZE_RECEIVER_POLICY`) on top + /// of V1. + /// + /// The match is exhaustive on purpose: a policy scope added to `B20PolicyType` for a future fork + /// must not silently widen this frozen V2 surface — it should fail to compile until V2's stance + /// on it is decided explicitly. + fn ensure_supported_policy_type(policy_scope: B256) -> Result<()> { + match B20PolicyType::from_id(policy_scope) { + Some( + B20PolicyType::TransferSender + | B20PolicyType::TransferReceiver + | B20PolicyType::TransferExecutor + | B20PolicyType::MintReceiver + | B20PolicyType::SeizeExempt + | B20PolicyType::SeizeReceiver, + ) => Ok(()), + None => Err(BasePrecompileError::revert(IB20::UnsupportedPolicyType { + policyScope: policy_scope, + })), + } + } + + /// Ensures the caller holds the asset operator role (unless privileged). + fn ensure_operator_role( + &self, + token: &B20AssetToken, + caller: Address, + privileged: bool, + ) -> Result<()> { + if privileged { Ok(()) } else { B20Guards::ensure_role(token, caller, Self::OPERATOR_ROLE) } + } + + /// Ensures the caller holds the metadata role (unless privileged). + fn ensure_metadata_role( + &self, + token: &B20AssetToken, + caller: Address, + privileged: bool, + ) -> Result<()> { + if privileged { + Ok(()) + } else { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Metadata) + } + } +} + +impl Asset for AssetV3 { + fn transfer( + &self, + token: &mut B20AssetToken, + caller: Address, + to: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::TRANSFER)?; + let to = NonZeroAddress::new(to) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; + let from = NonZeroAddress::new(caller) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: caller }))?; + if privileged { + return self.transfer_inner(token, from, to, amount, None); + } + let policies = token.accounting().transfer_policy_ids()?; + self.transfer_inner(token, from, to, amount, Some(&policies)) + } + + fn transfer_from( + &self, + token: &mut B20AssetToken, + caller: Address, + from: Address, + to: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::TRANSFER)?; + // Validate before allowance / transfer-policy-id SLOADs. + let to = NonZeroAddress::new(to) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; + let from = NonZeroAddress::new(from) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: from }))?; + let allowance = token.accounting().allowance(from.get(), caller)?; + let is_infinite = allowance == U256::MAX; + if !is_infinite && allowance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientAllowance { + spender: caller, + allowance, + needed: amount, + })); + } + if privileged { + self.transfer_inner(token, from, to, amount, None)?; + } else { + // One SLOAD fetches all transfer policy ids, reused for the executor and + // sender/receiver checks. + let policies = token.accounting().transfer_policy_ids()?; + if caller != from.get() { + B20Guards::ensure_authorized_by_id( + token, + B20PolicyType::TransferExecutor.id(), + policies.executor, + caller, + )?; + } + self.transfer_inner(token, from, to, amount, Some(&policies))?; + } + if is_infinite { + return Ok(()); + } + token.accounting_mut().set_allowance(from.get(), caller, allowance - amount) + } + + fn approve( + &self, + token: &mut B20AssetToken, + caller: Address, + spender: Address, + amount: U256, + ) -> Result<()> { + if caller == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidApprover { approver: caller })); + } + if spender == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidSpender { spender })); + } + token.accounting_mut().set_allowance(caller, spender, amount)?; + token + .accounting_mut() + .emit_event(IB20::Approval { owner: caller, spender, amount }.encode_log_data()) + } + + fn emit_memo( + &self, + token: &mut B20AssetToken, + caller: Address, + memo: B256, + ) -> Result<()> { + token.accounting_mut().emit_event(IB20::Memo { caller, memo }.encode_log_data()) + } + + fn mint( + &self, + token: &mut B20AssetToken, + caller: Address, + to: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::MINT)?; + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Mint)?; + } + if to == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); + } + B20Guards::ensure_policy_type(token, B20PolicyType::MintReceiver, to)?; + let supply = token.accounting().total_supply()?; + let cap = token.accounting().supply_cap()?; + let new_supply = + supply.checked_add(amount).ok_or_else(BasePrecompileError::under_overflow)?; + if new_supply > cap { + return Err(BasePrecompileError::revert(IB20::SupplyCapExceeded { + cap, + attempted: new_supply, + })); + } + token.accounting_mut().set_total_supply(new_supply)?; + let to_balance = token.accounting().balance_of(to)?; + let new_balance = + to_balance.checked_add(amount).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_balance(to, new_balance)?; + token + .accounting_mut() + .emit_event(IB20::Transfer { from: Address::ZERO, to, amount }.encode_log_data()) + } + + fn burn(&self, token: &mut B20AssetToken, caller: Address, amount: U256) -> Result<()> { + // Self-burn: `from == caller`, never factory-privileged. + B20Guards::ensure_not_paused(token, IB20::PausableFeature::BURN)?; + B20Guards::ensure_token_role(token, caller, B20TokenRole::Burn)?; + self.burn_inner(token, caller, amount) + } + + fn burn_blocked( + &self, + token: &mut B20AssetToken, + caller: Address, + from: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::BURN)?; + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::BurnBlocked)?; + } + B20Guards::ensure_blocked(token, from)?; + self.burn_inner(token, from, amount)?; + token + .accounting_mut() + .emit_event(IB20::BurnedBlocked { caller, from, amount }.encode_log_data()) + } + + fn seize_with_memo( + &self, + token: &mut B20AssetToken, + caller: Address, + from: Address, + to: Address, + amount: U256, + memo: B256, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::SEIZE)?; + B20Guards::ensure_token_role(token, caller, B20TokenRole::Seize)?; + // `to != 0` guards against a disguised burn; `from != 0` guards against a disguised mint + // (`Transfer(0x0, to, ...)`), matching `transfer_inner`. + if to == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); + } + if from == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidSender { sender: from })); + } + if from == to { + return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); + } + B20Guards::ensure_seizable(token, from)?; + // Gate the destination like `mint` gates `MintReceiver`: an unset scope is always-allow, so a + // treasury need not be allowlisted by default. + B20Guards::ensure_policy_type(token, B20PolicyType::SeizeReceiver, to)?; + self.move_balance(token, from, to, amount)?; + // `Memo` must immediately follow the `Transfer` (from `move_balance`), before `Seized`. + self.emit_memo(token, caller, memo)?; + token + .accounting_mut() + .emit_event(IB20::Seized { caller, from, to, amount }.encode_log_data()) + } + + fn pause( + &self, + token: &mut B20AssetToken, + caller: Address, + features: Vec, + privileged: bool, + ) -> Result<()> { + for feature in &features { + B20PausableFeature::ensure_one_of(*feature, Self::PAUSABLE_FEATURES)?; + } + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Pause)?; + } + if features.is_empty() { + return Err(BasePrecompileError::revert(IB20::EmptyFeatureSet {})); + } + let mut next = token.accounting().paused()?; + for feature in &features { + next |= B20PausableFeature::mask(*feature); + } + token.accounting_mut().set_paused(next)?; + token + .accounting_mut() + .emit_event(IB20::Paused { updater: caller, features }.encode_log_data()) + } + + fn unpause( + &self, + token: &mut B20AssetToken, + caller: Address, + features: Vec, + privileged: bool, + ) -> Result<()> { + for feature in &features { + B20PausableFeature::ensure_one_of(*feature, Self::PAUSABLE_FEATURES)?; + } + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Unpause)?; + } + if features.is_empty() { + return Err(BasePrecompileError::revert(IB20::EmptyFeatureSet {})); + } + let mut next = token.accounting().paused()?; + for feature in &features { + next &= !B20PausableFeature::mask(*feature); + } + token.accounting_mut().set_paused(next)?; + token + .accounting_mut() + .emit_event(IB20::Unpaused { updater: caller, features }.encode_log_data()) + } + + fn update_supply_cap( + &self, + token: &mut B20AssetToken, + caller: Address, + new_cap: U256, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::DefaultAdmin)?; + } + let supply = token.accounting().total_supply()?; + if new_cap < supply || new_cap > B20_MAX_SUPPLY_CAP { + return Err(BasePrecompileError::revert(IB20::InvalidSupplyCap { + currentSupply: supply, + proposedCap: new_cap, + })); + } + let old = token.accounting().supply_cap()?; + token.accounting_mut().set_supply_cap(new_cap)?; + token.accounting_mut().emit_event( + IB20::SupplyCapUpdated { updater: caller, oldSupplyCap: old, newSupplyCap: new_cap } + .encode_log_data(), + ) + } + + fn update_name( + &self, + token: &mut B20AssetToken, + caller: Address, + name: String, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Metadata)?; + } + token.accounting_mut().set_name(name.clone())?; + token + .accounting_mut() + .emit_event(IB20::NameUpdated { updater: caller, newName: name }.encode_log_data())?; + token.accounting_mut().emit_event(IB20::EIP712DomainChanged {}.encode_log_data()) + } + + fn update_symbol( + &self, + token: &mut B20AssetToken, + caller: Address, + symbol: String, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Metadata)?; + } + token.accounting_mut().set_symbol(symbol.clone())?; + token.accounting_mut().emit_event( + IB20::SymbolUpdated { updater: caller, newSymbol: symbol }.encode_log_data(), + ) + } + + fn update_contract_uri( + &self, + token: &mut B20AssetToken, + caller: Address, + uri: String, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Metadata)?; + } + token.accounting_mut().set_contract_uri(uri)?; + token.accounting_mut().emit_event(IB20::ContractURIUpdated {}.encode_log_data()) + } + + fn grant_role( + &self, + token: &mut B20AssetToken, + caller: Address, + role: B256, + account: Address, + privileged: bool, + ) -> Result<()> { + if role == B20TokenRole::DefaultAdmin.id() || !privileged { + self.ensure_role_admin_mutations_available(token, caller)?; + } + if !privileged { + let admin = token.accounting().role_admin(role)?; + B20Guards::ensure_role(token, caller, admin)?; + } + self.grant_role_unchecked(token, role, account, caller) + } + + fn grant_role_unchecked( + &self, + token: &mut B20AssetToken, + role: B256, + account: Address, + sender: Address, + ) -> Result<()> { + if token.accounting().has_role(role, account)? { + return Ok(()); + } + token.accounting_mut().set_role(role, account, true)?; + if role == B20TokenRole::DefaultAdmin.id() { + let current = token.accounting().role_member_count(role)?; + let next = + current.checked_add(U256::ONE).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_role_member_count(role, next)?; + } + token + .accounting_mut() + .emit_event(IB20::RoleGranted { role, account, sender }.encode_log_data()) + } + + fn revoke_role( + &self, + token: &mut B20AssetToken, + caller: Address, + role: B256, + account: Address, + privileged: bool, + ) -> Result<()> { + if !privileged { + self.ensure_role_admin_mutations_available(token, caller)?; + let admin = token.accounting().role_admin(role)?; + B20Guards::ensure_role(token, caller, admin)?; + } + if role == B20TokenRole::DefaultAdmin.id() + && token.accounting().has_role(role, account)? + && token.accounting().role_member_count(role)? == U256::ONE + { + return Err(BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); + } + self.revoke_role_unchecked(token, role, account, caller) + } + + fn renounce_role( + &self, + token: &mut B20AssetToken, + caller: Address, + role: B256, + confirmation: Address, + ) -> Result<()> { + if confirmation != caller { + return Err(BasePrecompileError::revert(IB20::AccessControlBadConfirmation {})); + } + if role == B20TokenRole::DefaultAdmin.id() + && token.accounting().has_role(role, caller)? + && token.accounting().role_member_count(role)? == U256::ONE + { + return Err(BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); + } + self.revoke_role_unchecked(token, role, caller, caller) + } + + fn renounce_last_admin(&self, token: &mut B20AssetToken, caller: Address) -> Result<()> { + let admin_role = B20TokenRole::DefaultAdmin.id(); + B20Guards::ensure_role(token, caller, admin_role)?; + if token.accounting().role_member_count(admin_role)? != U256::ONE { + return Err(BasePrecompileError::revert(IB20::NotSoleAdmin {})); + } + self.revoke_role_unchecked(token, admin_role, caller, caller)?; + token + .accounting_mut() + .emit_event(IB20::LastAdminRenounced { previousAdmin: caller }.encode_log_data()) + } + + fn set_role_admin( + &self, + token: &mut B20AssetToken, + caller: Address, + role: B256, + new_admin_role: B256, + privileged: bool, + ) -> Result<()> { + let previous_admin_role = token.accounting().role_admin(role)?; + if !privileged { + self.ensure_role_admin_mutations_available(token, caller)?; + B20Guards::ensure_role(token, caller, previous_admin_role)?; + } + token.accounting_mut().set_role_admin(role, new_admin_role)?; + token.accounting_mut().emit_event( + IB20::RoleAdminChanged { + role, + previousAdminRole: previous_admin_role, + newAdminRole: new_admin_role, + } + .encode_log_data(), + ) + } + + fn update_policy( + &self, + token: &mut B20AssetToken, + caller: Address, + policy_scope: B256, + new_policy_id: u64, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::DefaultAdmin)?; + } + Self::ensure_supported_policy_type(policy_scope)?; + if !token.policy().policy_exists(token.policy_storage(), new_policy_id)? { + return Err(BasePrecompileError::revert(IB20::PolicyNotFound { + policyId: new_policy_id, + })); + } + let old_policy_id = token.accounting().policy_id(policy_scope)?; + token.accounting_mut().set_policy_id(policy_scope, new_policy_id)?; + token.accounting_mut().emit_event( + IB20::PolicyUpdated { + policyScope: policy_scope, + oldPolicyId: old_policy_id, + newPolicyId: new_policy_id, + } + .encode_log_data(), + ) + } + + fn permit( + &self, + token: &mut B20AssetToken, + chain_id: u64, + now: U256, + args: PermitArgs, + ) -> Result<()> { + if now > args.deadline { + return Err(BasePrecompileError::revert(IB20::ExpiredSignature { + deadline: args.deadline, + })); + } + let domain_sep = self.domain_separator(token, chain_id)?; + let nonce = token.accounting().nonce(args.owner)?; + let signing_hash = args.metered_signing_hash(token.accounting(), domain_sep, nonce)?; + let recovered = args.metered_recover_signer(token.accounting(), signing_hash)?; + PermitArgs::validate_recovered_address(recovered, args.owner)?; + token.accounting_mut().increment_nonce(args.owner)?; + self.approve(token, args.owner, args.spender, args.value) + } + + // --- Asset-specific mutations --- + + /// Instantaneous failsafe. Writes the current multiplier immediately, clearing any pending + /// update and (for a still-live schedule) emitting `UIMultiplierUpdateCancelled`. Emits BOTH + /// the deprecated V1 `MultiplierUpdated` event (kept for backward compatibility with existing + /// indexers) and the ERC-8056 `UIMultiplierUpdated` event. + fn update_multiplier( + &self, + token: &mut B20AssetToken, + caller: Address, + new_multiplier: U256, + privileged: bool, + ) -> Result<()> { + let now = token.accounting().timestamp()?; + self.ensure_operator_role(token, caller, privileged)?; + if new_multiplier.is_zero() || new_multiplier > Self::MAX_UI_MULTIPLIER { + return Err(BasePrecompileError::revert(IB20Asset::InvalidMultiplier {})); + } + let pending_multiplier = U256::from(token.accounting().pending_multiplier()?); + let pending_effective_at = U256::from(token.accounting().pending_effective_at()?); + let live_pending = pending_effective_at > now; + + let old = self.effective_multiplier(token)?; + token.accounting_mut().set_multiplier(new_multiplier)?; + if pending_effective_at != U256::ZERO { + token.accounting_mut().clear_pending_multiplier_and_effective_at()?; + } + if live_pending { + token.accounting_mut().emit_event( + IB20Asset::UIMultiplierUpdateCancelled { + cancelledMultiplier: pending_multiplier, + cancelledEffectiveAt: pending_effective_at, + } + .encode_log_data(), + )?; + } + // Emit the deprecated V1 event alongside the ERC-8056 event so indexers watching the legacy + // `MultiplierUpdated` topic keep working through the transition. + token.accounting_mut().emit_event( + IB20Asset::MultiplierUpdated { multiplier: new_multiplier }.encode_log_data(), + )?; + token.accounting_mut().emit_event( + IB20Asset::UIMultiplierUpdated { + oldMultiplier: old, + newMultiplier: new_multiplier, + effectiveAtTimestamp: now, + } + .encode_log_data(), + ) + } + + fn update_extra_metadata( + &self, + token: &mut B20AssetToken, + caller: Address, + key: String, + value: String, + privileged: bool, + ) -> Result<()> { + self.ensure_metadata_role(token, caller, privileged)?; + if key.is_empty() { + return Err(BasePrecompileError::revert(IB20Asset::InvalidMetadataKey {})); + } + token.accounting_mut().set_extra_metadata_value(key.as_str(), value.clone())?; + token + .accounting_mut() + .emit_event(IB20Asset::ExtraMetadataUpdated { key, value }.encode_log_data()) + } + + fn batch_mint( + &self, + token: &mut B20AssetToken, + caller: Address, + recipients: Vec
, + amounts: Vec, + privileged: bool, + ) -> Result<()> { + // The pause and role guards below are the sole authorization gate for the batch: the inner + // mints run privileged to avoid re-checking per recipient. Do not remove or conditionalize + // these guards. Check order: PAUSE -> ROLE -> INPUT -> BUSINESS. + B20Guards::ensure_not_paused(token, IB20::PausableFeature::MINT)?; + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Mint)?; + } + if recipients.len() != amounts.len() { + return Err(BasePrecompileError::revert(IB20Asset::LengthMismatch { + leftLen: U256::from(recipients.len()), + rightLen: U256::from(amounts.len()), + })); + } + if recipients.is_empty() { + return Err(BasePrecompileError::revert(IB20Asset::EmptyBatch {})); + } + for (recipient, amount) in recipients.into_iter().zip(amounts) { + self.mint(token, caller, recipient, amount, true)?; + } + Ok(()) + } + + fn begin_announce( + &self, + token: &mut B20AssetToken, + caller: Address, + id: String, + description: String, + uri: String, + privileged: bool, + ) -> Result<()> { + self.ensure_operator_role(token, caller, privileged)?; + if token.accounting().is_announcement_id_used(id.as_str())? { + return Err(BasePrecompileError::revert(IB20Asset::AnnouncementIdAlreadyUsed { id })); + } + token.accounting_mut().mark_announcement_id_used(id.as_str())?; + token + .accounting_mut() + .emit_event(IB20Asset::Announcement { caller, id, description, uri }.encode_log_data()) + } + + fn end_announce(&self, token: &mut B20AssetToken, id: String) -> Result<()> { + token.accounting_mut().emit_event(IB20Asset::EndAnnouncement { id }.encode_log_data()) + } + + // --- Computed reads --- + + fn is_paused( + &self, + token: &B20AssetToken, + feature: IB20::PausableFeature, + ) -> Result { + B20PausableFeature::ensure_one_of(feature, Self::PAUSABLE_FEATURES)?; + Ok((token.accounting().paused()? & B20PausableFeature::mask(feature)) != U256::ZERO) + } + + fn paused_features(&self, token: &B20AssetToken) -> Result> { + let paused = token.accounting().paused()?; + let mut features = Vec::new(); + for feature in [ + IB20::PausableFeature::TRANSFER, + IB20::PausableFeature::MINT, + IB20::PausableFeature::BURN, + IB20::PausableFeature::SEIZE, + ] { + if (paused & B20PausableFeature::mask(feature)) != U256::ZERO { + features.push(feature); + } + } + Ok(features) + } + + fn policy_id(&self, token: &B20AssetToken, policy_scope: B256) -> Result { + Self::ensure_supported_policy_type(policy_scope)?; + token.accounting().policy_id(policy_scope) + } + + fn domain_separator(&self, token: &B20AssetToken, chain_id: u64) -> Result { + let name = token.accounting().name()?; + let name_hash = token.accounting().metered_keccak256(name.as_bytes())?; + let version_hash = token.accounting().metered_keccak256(VERSION)?; + let encoded = + (DOMAIN_TYPEHASH, name_hash, version_hash, U256::from(chain_id), token.token_address()) + .abi_encode(); + token.accounting().metered_keccak256(&encoded) + } + + fn eip712_domain(&self, token: &B20AssetToken, chain_id: u64) -> Result { + let name = token.accounting().name()?; + Ok(( + // bits 0+1+2+3: name + version + chainId + verifyingContract + FixedBytes::<1>::from([0x0f]), + name, + "1".to_string(), + U256::from(chain_id), + token.token_address(), + B256::ZERO, + vec![], + )) + } + + fn to_scaled_balance(&self, token: &B20AssetToken, balance: U256) -> Result { + let multiplier = self.effective_multiplier(token)?; + let product = + balance.checked_mul(multiplier).ok_or_else(BasePrecompileError::under_overflow)?; + Ok(product / B20AssetStorage::WAD) + } + + fn to_raw_balance(&self, token: &B20AssetToken, balance: U256) -> Result { + let multiplier = self.effective_multiplier(token)?; + let product = balance + .checked_mul(B20AssetStorage::WAD) + .ok_or_else(BasePrecompileError::under_overflow)?; + Ok(product / multiplier) + } + + fn scaled_balance_of(&self, token: &B20AssetToken, account: Address) -> Result { + let balance = token.accounting().balance_of(account)?; + self.to_scaled_balance(token, balance) + } + + fn operator_role(&self) -> B256 { + Self::OPERATOR_ROLE + } + + // --- ERC-8056 scheduled multiplier (introduced at AssetV3, Cobalt) --- + + /// Lazy current multiplier: flips to a matured pending target once `now >= effectiveAt`. + fn multiplier(&self, token: &B20AssetToken) -> Result { + self.effective_multiplier(token) + } + + fn effective_multiplier(&self, token: &B20AssetToken) -> Result { + let now = token.accounting().timestamp()?; + let effective_at = token.accounting().pending_effective_at()?; + if effective_at != 0 && now >= U256::from(effective_at) { + let pending = token.accounting().pending_multiplier()?; + // Both setters reject zero, so a stored pending is never zero. Do not fall back to WAD: + // the Solidity reference also returns the raw pending value. + debug_assert!(pending != 0, "matured pending multiplier must be non-zero"); + return Ok(U256::from(pending)); + } + token.accounting().multiplier() + } + + fn ui_multiplier(&self, token: &B20AssetToken) -> Result { + self.multiplier(token) + } + + fn new_ui_multiplier(&self, token: &B20AssetToken) -> Result { + let now = token.accounting().timestamp()?; + let effective_at = token.accounting().pending_effective_at()?; + if U256::from(effective_at) > now { + return Ok(U256::from(token.accounting().pending_multiplier()?)); + } + self.multiplier(token) + } + + fn effective_at(&self, token: &B20AssetToken) -> Result { + Ok(U256::from(token.accounting().pending_effective_at()?)) + } + + fn balance_of_ui(&self, token: &B20AssetToken, account: Address) -> Result { + self.scaled_balance_of(token, account) + } + + fn total_supply_ui(&self, token: &B20AssetToken) -> Result { + let multiplier = self.effective_multiplier(token)?; + let supply = token.accounting().total_supply()?; + let product = + supply.checked_mul(multiplier).ok_or_else(BasePrecompileError::under_overflow)?; + Ok(product / B20AssetStorage::WAD) + } + + fn update_ui_multiplier( + &self, + token: &mut B20AssetToken, + caller: Address, + new_multiplier: U256, + effective_at: U256, + privileged: bool, + ) -> Result<()> { + let now = token.accounting().timestamp()?; + self.ensure_operator_role(token, caller, privileged)?; + if new_multiplier.is_zero() || new_multiplier > Self::MAX_UI_MULTIPLIER { + return Err(BasePrecompileError::revert(IB20Asset::InvalidMultiplier {})); + } + if effective_at <= now { + return Err(BasePrecompileError::revert(IB20Asset::EffectiveAtInPast { + effectiveAt: effective_at, + })); + } + if effective_at > U256::from(u64::MAX) { + return Err(BasePrecompileError::revert(IB20Asset::EffectiveAtTooFar { + effectiveAt: effective_at, + })); + } + + let pending_effective_at = token.accounting().pending_effective_at()?; + // A live pending blocks a new schedule. + if U256::from(pending_effective_at) > now { + return Err(BasePrecompileError::revert(IB20Asset::UIMultiplierUpdateExists { + effectiveAt: U256::from(pending_effective_at), + })); + } + // Fold a matured pending into the current multiplier before overwriting it. + if pending_effective_at != 0 { + let matured = U256::from(token.accounting().pending_multiplier()?); + token.accounting_mut().set_multiplier(matured)?; + } + + let old = token.accounting().multiplier()?; + // Narrowing is safe because the guards above enforce the storage field bounds. + token + .accounting_mut() + .set_pending_and_effective_at(new_multiplier.to::(), effective_at.to::())?; + token.accounting_mut().emit_event( + IB20Asset::UIMultiplierUpdated { + oldMultiplier: old, + newMultiplier: new_multiplier, + effectiveAtTimestamp: effective_at, + } + .encode_log_data(), + ) + } + + fn cancel_ui_multiplier_update( + &self, + token: &mut B20AssetToken, + caller: Address, + privileged: bool, + ) -> Result<()> { + let now = token.accounting().timestamp()?; + self.ensure_operator_role(token, caller, privileged)?; + let pending_multiplier = U256::from(token.accounting().pending_multiplier()?); + let pending_effective_at = U256::from(token.accounting().pending_effective_at()?); + // A pending maturing at exactly `now` has already taken effect and is not cancellable. + if pending_effective_at <= now { + return Err(BasePrecompileError::revert(IB20Asset::UIMultiplierUpdateDoesNotExist {})); + } + token.accounting_mut().clear_pending_multiplier_and_effective_at()?; + token.accounting_mut().emit_event( + IB20Asset::UIMultiplierUpdateCancelled { + cancelledMultiplier: pending_multiplier, + cancelledEffectiveAt: pending_effective_at, + } + .encode_log_data(), + ) + } + + fn supports_interface(&self, interface_id: FixedBytes<4>) -> Result { + Ok(interface_id == crate::ERC165_INTERFACE_ID + || crate::ERC8056_INTERFACE_IDS.contains(&interface_id)) + } + + fn max_ui_multiplier(&self) -> Result { + Ok(Self::MAX_UI_MULTIPLIER) + } +} + +#[cfg(test)] +mod tests { + use alloc::{ + collections::{BTreeMap, BTreeSet}, + string::{String, ToString}, + vec, + vec::Vec, + }; + + use alloy_primitives::{Address, B256, FixedBytes, LogData, U256, keccak256}; + use alloy_sol_types::SolEvent; + use base_precompile_storage::{BasePrecompileError, Result}; + use k256::ecdsa::SigningKey; + + use crate::{ + Asset, AssetAccounting, AssetV3, B20_MAX_SUPPLY_CAP, B20AssetStorage, B20AssetToken, + B20PolicyType, B20TokenRole, IB20, IB20Asset, PackedPolicy, PermitArgs, PolicyAccounting, + PolicyRegistryStorage, PolicyVersion, Token, TokenAccounting, TransferPolicyIds, + }; + + // --- Self-contained in-memory fakes (no dependency on `common::test_utils`, so shared test + // scaffolding can never drift this frozen version's coverage) --- + + const TOKEN: Address = Address::repeat_byte(0x21); + const ADMIN: Address = Address::repeat_byte(0xAD); + const ALICE: Address = Address::repeat_byte(0xA1); + const BOB: Address = Address::repeat_byte(0xB0); + const MEMO: B256 = B256::repeat_byte(0x77); + const CHAIN_ID: u64 = 8453; + const LOGIC: AssetV3 = AssetV3; + + // Anvil/Hardhat account 0 — well-known test key, never used in production. + const PRIVATE_KEY: [u8; 32] = + alloy_primitives::hex!("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + + /// Minimal `AssetAccounting` backed by in-memory maps. + #[derive(Debug)] + struct FakeAccounting { + initialized: bool, + balances: BTreeMap, + allowances: BTreeMap<(Address, Address), U256>, + total_supply: U256, + supply_cap: U256, + name: String, + symbol: String, + decimals: u8, + multiplier: U256, + pending_multiplier: u128, + pending_effective_at: u64, + timestamp: U256, + paused: U256, + nonces: BTreeMap, + contract_uri: String, + roles: BTreeMap<(B256, Address), bool>, + role_member_counts: BTreeMap, + role_admins: BTreeMap, + policy_ids: BTreeMap, + extra_metadata: BTreeMap, + used_announcement_ids: BTreeSet, + events: Vec, + /// Number of `metered_keccak256` calls (interior-mutable so `&self` metering can record). + keccak_charges: core::cell::Cell, + /// Cumulative gas passed to `deduct_gas`. + gas_deducted: core::cell::Cell, + } + + impl FakeAccounting { + fn new() -> Self { + Self { + initialized: true, + balances: BTreeMap::new(), + allowances: BTreeMap::new(), + total_supply: U256::ZERO, + supply_cap: B20_MAX_SUPPLY_CAP, + name: "Real World Asset".to_string(), + symbol: "RWA".to_string(), + decimals: 6, + multiplier: B20AssetStorage::WAD, + pending_multiplier: 0, + pending_effective_at: 0, + timestamp: U256::ZERO, + paused: U256::ZERO, + nonces: BTreeMap::new(), + contract_uri: String::new(), + roles: BTreeMap::new(), + role_member_counts: BTreeMap::new(), + role_admins: BTreeMap::new(), + policy_ids: BTreeMap::new(), + extra_metadata: BTreeMap::new(), + used_announcement_ids: BTreeSet::new(), + events: Vec::new(), + keccak_charges: core::cell::Cell::new(0), + gas_deducted: core::cell::Cell::new(0), + } + } + } + + impl TokenAccounting for FakeAccounting { + fn token_address(&self) -> Address { + TOKEN + } + fn is_initialized(&self) -> Result { + Ok(self.initialized) + } + fn balance_of(&self, account: Address) -> Result { + Ok(self.balances.get(&account).copied().unwrap_or(U256::ZERO)) + } + fn set_balance(&mut self, account: Address, balance: U256) -> Result<()> { + self.balances.insert(account, balance); + Ok(()) + } + fn allowance(&self, owner: Address, spender: Address) -> Result { + Ok(self.allowances.get(&(owner, spender)).copied().unwrap_or(U256::ZERO)) + } + fn set_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { + self.allowances.insert((owner, spender), amount); + Ok(()) + } + fn total_supply(&self) -> Result { + Ok(self.total_supply) + } + fn set_total_supply(&mut self, supply: U256) -> Result<()> { + self.total_supply = supply; + Ok(()) + } + fn supply_cap(&self) -> Result { + Ok(self.supply_cap) + } + fn set_supply_cap(&mut self, cap: U256) -> Result<()> { + self.supply_cap = cap; + Ok(()) + } + fn name(&self) -> Result { + Ok(self.name.clone()) + } + fn set_name(&mut self, name: String) -> Result<()> { + self.name = name; + Ok(()) + } + fn symbol(&self) -> Result { + Ok(self.symbol.clone()) + } + fn set_symbol(&mut self, symbol: String) -> Result<()> { + self.symbol = symbol; + Ok(()) + } + fn decimals(&self) -> Result { + Ok(self.decimals) + } + fn paused(&self) -> Result { + Ok(self.paused) + } + fn set_paused(&mut self, vectors: U256) -> Result<()> { + self.paused = vectors; + Ok(()) + } + fn nonce(&self, owner: Address) -> Result { + Ok(self.nonces.get(&owner).copied().unwrap_or(U256::ZERO)) + } + fn increment_nonce(&mut self, owner: Address) -> Result<()> { + let n = self.nonces.entry(owner).or_insert(U256::ZERO); + *n += U256::ONE; + Ok(()) + } + fn contract_uri(&self) -> Result { + Ok(self.contract_uri.clone()) + } + fn set_contract_uri(&mut self, uri: String) -> Result<()> { + self.contract_uri = uri; + Ok(()) + } + fn has_role(&self, role: B256, account: Address) -> Result { + Ok(self.roles.get(&(role, account)).copied().unwrap_or(false)) + } + fn set_role(&mut self, role: B256, account: Address, enabled: bool) -> Result<()> { + self.roles.insert((role, account), enabled); + Ok(()) + } + fn role_member_count(&self, role: B256) -> Result { + Ok(self.role_member_counts.get(&role).copied().unwrap_or(U256::ZERO)) + } + fn set_role_member_count(&mut self, role: B256, count: U256) -> Result<()> { + self.role_member_counts.insert(role, count); + Ok(()) + } + fn role_admin(&self, role: B256) -> Result { + Ok(self.role_admins.get(&role).copied().unwrap_or(B256::ZERO)) + } + fn set_role_admin(&mut self, role: B256, admin_role: B256) -> Result<()> { + self.role_admins.insert(role, admin_role); + Ok(()) + } + fn policy_id(&self, policy_scope: B256) -> Result { + Ok(self.policy_ids.get(&policy_scope).copied().unwrap_or(0)) + } + fn set_policy_id(&mut self, policy_scope: B256, policy_id: u64) -> Result<()> { + self.policy_ids.insert(policy_scope, policy_id); + Ok(()) + } + + fn transfer_policy_ids(&self) -> Result { + TransferPolicyIds::read_individually(self) + } + fn emit_event(&mut self, log: LogData) -> Result<()> { + self.events.push(log); + Ok(()) + } + fn metered_keccak256(&self, data: &[u8]) -> Result { + self.keccak_charges.set(self.keccak_charges.get() + 1); + Ok(keccak256(data)) + } + fn deduct_gas(&self, gas: u64) -> Result<()> { + self.gas_deducted.set(self.gas_deducted.get() + gas); + Ok(()) + } + } + + impl AssetAccounting for FakeAccounting { + fn timestamp(&self) -> Result { + Ok(self.timestamp) + } + fn multiplier(&self) -> Result { + Ok(self.multiplier) + } + fn set_multiplier(&mut self, multiplier: U256) -> Result<()> { + self.multiplier = multiplier; + Ok(()) + } + fn pending_multiplier(&self) -> Result { + Ok(self.pending_multiplier) + } + fn pending_effective_at(&self) -> Result { + Ok(self.pending_effective_at) + } + fn set_pending_and_effective_at( + &mut self, + multiplier: u128, + effective_at: u64, + ) -> Result<()> { + self.pending_multiplier = multiplier; + self.pending_effective_at = effective_at; + Ok(()) + } + fn clear_pending_multiplier_and_effective_at(&mut self) -> Result<()> { + self.pending_multiplier = 0; + self.pending_effective_at = 0; + Ok(()) + } + fn extra_metadata(&self, key: &str) -> Result { + Ok(self.extra_metadata.get(key).cloned().unwrap_or_default()) + } + fn set_extra_metadata_value(&mut self, key: &str, value: String) -> Result<()> { + if value.is_empty() { + self.extra_metadata.remove(key); + } else { + self.extra_metadata.insert(key.to_string(), value); + } + Ok(()) + } + fn is_announcement_id_used(&self, id: &str) -> Result { + Ok(self.used_announcement_ids.contains(id)) + } + fn mark_announcement_id_used(&mut self, id: &str) -> Result<()> { + self.used_announcement_ids.insert(id.to_string()); + Ok(()) + } + fn decimals(&self) -> Result { + Ok(self.decimals) + } + } + + /// Minimal [`PolicyAccounting`] backed by in-memory maps. + #[derive(Debug)] + struct FakePolicyAccounting { + caller: Address, + initialized: bool, + policies: BTreeMap, + members: BTreeMap<(u64, Address), bool>, + pending_admins: BTreeMap, + next_counter: u64, + events: Vec, + } + + impl FakePolicyAccounting { + fn new() -> Self { + Self { + caller: ADMIN, + initialized: false, + policies: BTreeMap::new(), + members: BTreeMap::new(), + pending_admins: BTreeMap::new(), + next_counter: 0, + events: Vec::new(), + } + } + + fn create_existing_policy(&mut self, policy_id: u64) { + self.policies.insert(policy_id, PackedPolicy::new(Address::ZERO).into_u256()); + } + } + + impl PolicyAccounting for FakePolicyAccounting { + fn registry_address(&self) -> Address { + Address::repeat_byte(0x02) + } + fn caller(&self) -> Address { + self.caller + } + fn read_policy_word(&self, policy_id: u64) -> Result { + Ok(self.policies.get(&policy_id).copied().unwrap_or(U256::ZERO)) + } + fn write_policy_word(&mut self, policy_id: u64, word: U256) -> Result<()> { + self.policies.insert(policy_id, word); + Ok(()) + } + fn read_member(&self, policy_id: u64, account: Address) -> Result { + Ok(self.members.get(&(policy_id, account)).copied().unwrap_or(false)) + } + fn set_member(&mut self, policy_id: u64, account: Address) -> Result<()> { + self.members.insert((policy_id, account), true); + Ok(()) + } + fn delete_member(&mut self, policy_id: u64, account: Address) -> Result<()> { + self.members.remove(&(policy_id, account)); + Ok(()) + } + fn read_pending_admin(&self, policy_id: u64) -> Result
{ + Ok(self.pending_admins.get(&policy_id).copied().unwrap_or(Address::ZERO)) + } + fn write_pending_admin(&mut self, policy_id: u64, admin: Address) -> Result<()> { + self.pending_admins.insert(policy_id, admin); + Ok(()) + } + fn delete_pending_admin(&mut self, policy_id: u64) -> Result<()> { + self.pending_admins.remove(&policy_id); + Ok(()) + } + fn read_next_counter(&self) -> Result { + Ok(self.next_counter) + } + fn write_next_counter(&mut self, counter: u64) -> Result<()> { + self.next_counter = counter; + Ok(()) + } + fn emit_event(&mut self, log: LogData) -> Result<()> { + self.events.push(log); + Ok(()) + } + fn mark_initialized(&mut self) -> Result<()> { + self.initialized = true; + Ok(()) + } + + // This fake does not exercise composite policies. + fn read_children(&self, _policy_id: u64) -> Result> { + Ok(Vec::new()) + } + + fn write_children(&mut self, _policy_id: u64, _child_policy_ids: &[u64]) -> Result<()> { + Ok(()) + } + } + + type Tok = B20AssetToken; + + fn token() -> Tok { + B20AssetToken::with_storage_and_policy( + FakeAccounting::new(), + FakePolicyAccounting::new(), + PolicyVersion::V1, + ) + } + + /// Grants `role` to `account` and keeps the admin member-count consistent. + fn grant(tok: &mut Tok, role: B256, account: Address) { + tok.accounting_mut().set_role(role, account, true).unwrap(); + let next = tok.accounting().role_member_count(role).unwrap() + U256::ONE; + tok.accounting_mut().set_role_member_count(role, next).unwrap(); + } + + /// Credits `account` with `amount` and grows total supply to match. + fn fund(tok: &mut Tok, account: Address, amount: U256) { + let bal = tok.accounting().balance_of(account).unwrap(); + tok.accounting_mut().set_balance(account, bal + amount).unwrap(); + let supply = tok.accounting().total_supply().unwrap(); + tok.accounting_mut().set_total_supply(supply + amount).unwrap(); + } + + fn last_event_sig(tok: &Tok) -> B256 { + tok.accounting().events.last().unwrap().topics()[0] + } + + fn last_event(tok: &Tok) -> LogData { + tok.accounting().events.last().unwrap().clone() + } + + fn wad() -> U256 { + B20AssetStorage::WAD + } + + fn set_now(tok: &mut Tok, now: U256) { + tok.accounting_mut().timestamp = now; + } + + fn anvil_owner() -> Address { + let key = SigningKey::from_slice(&PRIVATE_KEY).unwrap(); + let point = key.verifying_key().to_encoded_point(false); + Address::from_slice(&keccak256(&point.as_bytes()[1..])[12..]) + } + + /// Produces a validly-signed `PermitArgs` for the token's current domain and `owner` nonce. + fn signed_permit( + tok: &Tok, + owner: Address, + spender: Address, + value: U256, + deadline: U256, + ) -> PermitArgs { + let domain_sep = LOGIC.domain_separator(tok, CHAIN_ID).unwrap(); + let nonce = tok.accounting().nonce(owner).unwrap(); + let mut args = + PermitArgs { owner, spender, value, deadline, v: 0, r: B256::ZERO, s: B256::ZERO }; + let signing_hash = args.signing_hash(domain_sep, nonce); + let key = SigningKey::from_slice(&PRIVATE_KEY).unwrap(); + let (sig, recid) = key.sign_prehash_recoverable(signing_hash.as_slice()).unwrap(); + let bytes = sig.to_bytes(); + args.r = B256::from_slice(&bytes[..32]); + args.s = B256::from_slice(&bytes[32..]); + args.v = if recid.is_y_odd() { 28 } else { 27 }; + args + } + + // --- role identifiers --- + + #[test] + fn operator_role_matches_solidity_hash() { + assert_eq!(AssetV3::OPERATOR_ROLE, keccak256("OPERATOR_ROLE")); + } + + // --- transfer --- + + #[test] + fn transfer_moves_balance_and_emits_transfer() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + LOGIC.transfer(&mut tok, ALICE, BOB, U256::from(30u64), true).unwrap(); + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(70u64)); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(30u64)); + assert_eq!(last_event_sig(&tok), IB20::Transfer::SIGNATURE_HASH); + } + + #[test] + fn transfer_reverts_on_zero_receiver() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(10u64)); + let err = + LOGIC.transfer(&mut tok, ALICE, Address::ZERO, U256::from(1u64), true).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO }) + ); + } + + #[test] + fn transfer_reverts_when_paused() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(10u64)); + LOGIC.pause(&mut tok, ADMIN, vec![IB20::PausableFeature::TRANSFER], true).unwrap(); + let err = LOGIC.transfer(&mut tok, ALICE, BOB, U256::from(1u64), true).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::TRANSFER, + }) + ); + } + + // --- transfer_from --- + + #[test] + fn transfer_from_decrements_finite_allowance() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + tok.accounting_mut().set_allowance(ALICE, BOB, U256::from(40u64)).unwrap(); + LOGIC.transfer_from(&mut tok, BOB, ALICE, BOB, U256::from(30u64), true).unwrap(); + assert_eq!(tok.accounting().allowance(ALICE, BOB).unwrap(), U256::from(10u64)); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(30u64)); + } + + #[test] + fn transfer_from_infinite_allowance_is_not_decremented() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + tok.accounting_mut().set_allowance(ALICE, BOB, U256::MAX).unwrap(); + LOGIC.transfer_from(&mut tok, BOB, ALICE, BOB, U256::from(30u64), true).unwrap(); + assert_eq!(tok.accounting().allowance(ALICE, BOB).unwrap(), U256::MAX); + } + + // --- approve --- + + #[test] + fn approve_sets_allowance_and_emits() { + let mut tok = token(); + LOGIC.approve(&mut tok, ALICE, BOB, U256::from(50u64)).unwrap(); + assert_eq!(tok.accounting().allowance(ALICE, BOB).unwrap(), U256::from(50u64)); + assert_eq!(last_event_sig(&tok), IB20::Approval::SIGNATURE_HASH); + } + + // --- mint --- + + #[test] + fn mint_privileged_increases_supply_and_balance() { + let mut tok = token(); + LOGIC.mint(&mut tok, ADMIN, BOB, U256::from(100u64), true).unwrap(); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(100u64)); + assert_eq!(tok.accounting().total_supply().unwrap(), U256::from(100u64)); + } + + #[test] + fn mint_reverts_over_supply_cap() { + let mut tok = token(); + tok.accounting_mut().set_supply_cap(U256::from(50u64)).unwrap(); + let err = LOGIC.mint(&mut tok, ADMIN, BOB, U256::from(100u64), true).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::SupplyCapExceeded { + cap: U256::from(50u64), + attempted: U256::from(100u64), + }) + ); + } + + #[test] + fn mint_unprivileged_requires_mint_role() { + let mut tok = token(); + let err = LOGIC.mint(&mut tok, ALICE, BOB, U256::from(1u64), false).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Mint.id(), + }) + ); + } + + // --- burn --- + + #[test] + fn burn_requires_role_then_decreases_supply() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + let err = LOGIC.burn(&mut tok, ALICE, U256::from(1u64)).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Burn.id(), + }) + ); + grant(&mut tok, B20TokenRole::Burn.id(), ALICE); + LOGIC.burn(&mut tok, ALICE, U256::from(40u64)).unwrap(); + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(60u64)); + assert_eq!(tok.accounting().total_supply().unwrap(), U256::from(60u64)); + } + + #[test] + fn burn_blocked_destroys_from_unauthorized_account() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + tok.accounting_mut() + .set_policy_id( + B20PolicyType::TransferSender.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + LOGIC.burn_blocked(&mut tok, ADMIN, ALICE, U256::from(40u64), true).unwrap(); + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(60u64)); + assert_eq!(last_event_sig(&tok), IB20::BurnedBlocked::SIGNATURE_HASH); + } + + // --- seize --- + + /// Points `SEIZE_EXEMPT_POLICY` at the always-block policy, making every account seizable. + fn make_seizable(tok: &mut Tok) { + tok.accounting_mut() + .set_policy_id(B20PolicyType::SeizeExempt.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID) + .unwrap(); + } + + fn event_sigs(tok: &Tok) -> Vec { + tok.accounting().events.iter().map(|e| e.topics()[0]).collect() + } + + #[test] + fn seize_moves_balance_and_emits_transfer_memo_event() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + let supply = tok.accounting().total_supply().unwrap(); + + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(40u64), MEMO).unwrap(); + + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(60u64)); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(40u64)); + assert_eq!(tok.accounting().total_supply().unwrap(), supply, "seize is a transfer"); + assert_eq!( + event_sigs(&tok), + vec![ + IB20::Transfer::SIGNATURE_HASH, + IB20::Memo::SIGNATURE_HASH, + IB20::Seized::SIGNATURE_HASH, + ] + ); + } + + #[test] + fn seize_reverts_when_account_not_seizable() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + // SEIZE_EXEMPT_POLICY unset => ALWAYS_ALLOW => ALICE authorized (exempt) => not seizable. + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccountNotSeizable { account: ALICE })); + } + + #[test] + fn seize_reverts_on_zero_receiver() { + let mut tok = token(); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + let err = LOGIC + .seize_with_memo(&mut tok, ADMIN, ALICE, Address::ZERO, U256::from(1u64), MEMO) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO }) + ); + } + + #[test] + fn seize_reverts_on_zero_from() { + let mut tok = token(); + // A non-default `SeizeExempt` (here ALWAYS_BLOCK via `make_seizable`) treats the zero + // address as seizable, so without the `from != 0` guard a zero-amount seize from the zero + // address would emit a misleading `Transfer(0x0, to, 0)` that indexers read as a mint. + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + + let err = LOGIC + .seize_with_memo(&mut tok, ADMIN, Address::ZERO, BOB, U256::ZERO, MEMO) + .unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidSender { sender: Address::ZERO })); + assert!( + event_sigs(&tok).is_empty(), + "no misleading Transfer/Memo/Seized on a rejected zero-from seize" + ); + } + + #[test] + fn seize_reverts_on_self_seize() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + + let err = LOGIC + .seize_with_memo(&mut tok, ADMIN, ALICE, ALICE, U256::from(1u64), MEMO) + .unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: ALICE })); + assert_eq!( + tok.accounting().balance_of(ALICE).unwrap(), + U256::from(100u64), + "balance unchanged" + ); + assert!( + event_sigs(&tok).is_empty(), + "no misleading Transfer/Memo/Seized on a rejected self-seize" + ); + } + + #[test] + fn seize_ignores_receiver_policy_on_to() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(50u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + // A normal transfer to BOB would revert on this; seize does not consult it. + tok.accounting_mut() + .set_policy_id( + B20PolicyType::TransferReceiver.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(50u64), MEMO).unwrap(); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(50u64)); + } + + #[test] + fn seize_reverts_when_receiver_policy_forbids() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(50u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + tok.accounting_mut() + .set_policy_id( + B20PolicyType::SeizeReceiver.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::SeizeReceiver.id(), + policyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID, + }) + ); + } + + #[test] + fn seize_unset_receiver_policy_allows_any_destination() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(50u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + // SEIZE_RECEIVER_POLICY left unset => ALWAYS_ALLOW => any destination is allowed. + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(50u64), MEMO).unwrap(); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(50u64)); + } + + #[test] + fn seize_succeeds_with_configured_receiver_policy_allow() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(50u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + tok.accounting_mut() + .set_policy_id( + B20PolicyType::SeizeReceiver.id(), + PolicyRegistryStorage::ALWAYS_ALLOW_ID, + ) + .unwrap(); + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(50u64), MEMO).unwrap(); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(50u64)); + } + + #[test] + fn seize_exempt_policy_beats_receiver_policy() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + // SEIZE_EXEMPT unset => ALICE not seizable; SEIZE_RECEIVER blocks BOB. Exempt check fires first. + tok.accounting_mut() + .set_policy_id( + B20PolicyType::SeizeReceiver.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccountNotSeizable { account: ALICE })); + } + + #[test] + fn seize_receiver_policy_beats_balance() { + let mut tok = token(); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + // ALICE is seizable and has zero balance, but the receiver policy forbids BOB first. + tok.accounting_mut() + .set_policy_id( + B20PolicyType::SeizeReceiver.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::SeizeReceiver.id(), + policyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID, + }) + ); + } + + #[test] + fn seize_requires_role() { + let mut tok = token(); + make_seizable(&mut tok); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ADMIN, + neededRole: B20TokenRole::Seize.id(), + }) + ); + } + + #[test] + fn seize_reverts_when_seize_paused() { + let mut tok = token(); + make_seizable(&mut tok); + LOGIC.pause(&mut tok, ADMIN, vec![IB20::PausableFeature::SEIZE], true).unwrap(); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::SEIZE + }) + ); + } + + // --- pause --- + + #[test] + fn pause_and_unpause_toggle_feature_bit() { + let mut tok = token(); + LOGIC.pause(&mut tok, ADMIN, vec![IB20::PausableFeature::MINT], true).unwrap(); + assert!(LOGIC.is_paused(&tok, IB20::PausableFeature::MINT).unwrap()); + LOGIC.unpause(&mut tok, ADMIN, vec![IB20::PausableFeature::MINT], true).unwrap(); + assert!(!LOGIC.is_paused(&tok, IB20::PausableFeature::MINT).unwrap()); + } + + #[test] + fn paused_features_includes_seize() { + let mut tok = token(); + LOGIC.pause(&mut tok, ADMIN, vec![IB20::PausableFeature::SEIZE], true).unwrap(); + let features = LOGIC.paused_features(&tok).unwrap(); + assert_eq!(features, vec![IB20::PausableFeature::SEIZE]); + } + + // --- roles --- + + #[test] + fn grant_role_privileged_grants_and_emits() { + let mut tok = token(); + LOGIC.grant_role(&mut tok, ADMIN, B20TokenRole::Mint.id(), ALICE, true).unwrap(); + assert!(tok.accounting().has_role(B20TokenRole::Mint.id(), ALICE).unwrap()); + assert_eq!(last_event_sig(&tok), IB20::RoleGranted::SIGNATURE_HASH); + } + + #[test] + fn revoke_last_admin_is_rejected() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::DefaultAdmin.id(), ADMIN); + let err = LOGIC + .revoke_role(&mut tok, ADMIN, B20TokenRole::DefaultAdmin.id(), ADMIN, true) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); + } + + #[test] + fn grant_role_unchecked_bumps_admin_count() { + let mut tok = token(); + LOGIC + .grant_role_unchecked(&mut tok, B20TokenRole::DefaultAdmin.id(), ADMIN, TOKEN) + .unwrap(); + assert!(tok.accounting().has_role(B20TokenRole::DefaultAdmin.id(), ADMIN).unwrap()); + assert_eq!( + tok.accounting().role_member_count(B20TokenRole::DefaultAdmin.id()).unwrap(), + U256::ONE + ); + } + + // --- policy --- + + #[test] + fn update_policy_sets_new_id() { + let mut tok = token(); + tok.policy_storage_mut().create_existing_policy(7); + LOGIC.update_policy(&mut tok, ADMIN, B20PolicyType::TransferSender.id(), 7, true).unwrap(); + assert_eq!(tok.accounting().policy_id(B20PolicyType::TransferSender.id()).unwrap(), 7); + } + + #[test] + fn policy_id_rejects_unsupported_scope() { + let tok = token(); + let scope = B256::repeat_byte(0xEE); + let err = LOGIC.policy_id(&tok, scope).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::UnsupportedPolicyType { policyScope: scope }) + ); + } + + // --- permit --- + + #[test] + fn permit_sets_allowance_and_increments_nonce() { + let mut tok = token(); + let owner = anvil_owner(); + let args = signed_permit(&tok, owner, BOB, U256::from(500u64), U256::MAX); + LOGIC.permit(&mut tok, CHAIN_ID, U256::ZERO, args).unwrap(); + assert_eq!(tok.accounting().allowance(owner, BOB).unwrap(), U256::from(500u64)); + assert_eq!(tok.accounting().nonce(owner).unwrap(), U256::ONE); + } + + #[test] + fn permit_reverts_when_expired() { + let mut tok = token(); + let owner = anvil_owner(); + let args = signed_permit(&tok, owner, BOB, U256::from(1u64), U256::from(10u64)); + let err = LOGIC.permit(&mut tok, CHAIN_ID, U256::from(11u64), args).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ExpiredSignature { deadline: U256::from(10u64) }) + ); + } + + #[test] + fn permit_charges_metered_hashing_and_fixed_recovery() { + let mut tok = token(); + let owner = anvil_owner(); + let args = signed_permit(&tok, owner, BOB, U256::from(500u64), U256::MAX); + + // `signed_permit` computes the domain separator during setup, so measure deltas. + let keccak_before = tok.accounting().keccak_charges.get(); + let gas_before = tok.accounting().gas_deducted.get(); + + LOGIC.permit(&mut tok, CHAIN_ID, U256::ZERO, args).unwrap(); + + // domain separator (name, version, encoded) + signing digest (struct hash, digest) = 5. + assert_eq!(tok.accounting().keccak_charges.get() - keccak_before, 5); + // Fixed ECRECOVER-comparable recovery charge, applied exactly once. + assert_eq!(tok.accounting().gas_deducted.get() - gas_before, PermitArgs::RECOVER_GAS); + } + + #[test] + fn caught_revert_permit_loop_charges_each_iteration() { + let mut tok = token(); + let owner = anvil_owner(); + // Realistic DoS shape: a valid, recoverable signature but an unrelated `owner`, so every + // call performs the full EIP-712 hashing and secp256k1 recovery and then reverts + // `InvalidSigner` (before the nonce is bumped, so the same args replays each iteration). + let mut args = signed_permit(&tok, owner, BOB, U256::from(1u64), U256::MAX); + args.owner = Address::repeat_byte(0xcc); + + const ITERATIONS: u64 = 8; + let keccak_before = tok.accounting().keccak_charges.get(); + let gas_before = tok.accounting().gas_deducted.get(); + for _ in 0..ITERATIONS { + assert!(LOGIC.permit(&mut tok, CHAIN_ID, U256::ZERO, args.clone()).is_err()); + } + + // A caught-revert loop no longer runs for free: hashing and recovery metering scale + // linearly with the number of calls. + assert_eq!(tok.accounting().keccak_charges.get() - keccak_before, 5 * ITERATIONS); + assert_eq!( + tok.accounting().gas_deducted.get() - gas_before, + PermitArgs::RECOVER_GAS * ITERATIONS + ); + } + + // --- asset: multiplier --- + + #[test] + fn to_scaled_balance_one_to_one_multiplier() { + let tok = token(); + assert_eq!(LOGIC.to_scaled_balance(&tok, U256::from(100u64)).unwrap(), U256::from(100u64)); + } + + #[test] + fn to_scaled_balance_two_to_one_multiplier() { + let mut tok = token(); + tok.accounting_mut().set_multiplier(B20AssetStorage::WAD * U256::from(2u64)).unwrap(); + assert_eq!(LOGIC.to_scaled_balance(&tok, U256::from(50u64)).unwrap(), U256::from(100u64)); + } + + #[test] + fn scaled_balance_of_derives_from_balance() { + let mut tok = token(); + tok.accounting_mut().set_balance(ALICE, U256::from(75u64)).unwrap(); + assert_eq!(LOGIC.scaled_balance_of(&tok, ALICE).unwrap(), U256::from(75u64)); + } + + #[test] + fn to_scaled_balance_overflows_when_product_exceeds_u256_max() { + let mut tok = token(); + tok.accounting_mut().set_multiplier(U256::MAX / U256::from(2u64) + U256::ONE).unwrap(); + assert_eq!( + LOGIC.to_scaled_balance(&tok, U256::from(2u64)).unwrap_err(), + BasePrecompileError::under_overflow() + ); + } + + #[test] + fn update_multiplier_requires_operator_role() { + let mut tok = token(); + let err = + LOGIC.update_multiplier(&mut tok, ALICE, B20AssetStorage::WAD, false).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: AssetV3::OPERATOR_ROLE, + }) + ); + } + + #[test] + fn update_multiplier_rejects_zero() { + let mut tok = token(); + let err = LOGIC.update_multiplier(&mut tok, ADMIN, U256::ZERO, true).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::InvalidMultiplier {})); + } + + #[test] + fn update_multiplier_persists_and_emits() { + let mut tok = token(); + let new_multiplier = B20AssetStorage::WAD * U256::from(3u64); + LOGIC.update_multiplier(&mut tok, ADMIN, new_multiplier, true).unwrap(); + assert_eq!(tok.accounting().multiplier().unwrap(), new_multiplier); + // V2's instant setter emits the deprecated `MultiplierUpdated` (backward compat) then the + // ERC-8056 `UIMultiplierUpdated`. + let events = &tok.accounting().events; + assert_eq!( + events[events.len() - 2].topics()[0], + IB20Asset::MultiplierUpdated::SIGNATURE_HASH + ); + assert_eq!(last_event_sig(&tok), IB20Asset::UIMultiplierUpdated::SIGNATURE_HASH); + } + + // --- asset: batch mint --- + + #[test] + fn batch_mint_increases_balances() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Mint.id(), ALICE); + // The inner mints enforce the MintReceiver policy even when privileged. + LOGIC + .batch_mint( + &mut tok, + ALICE, + vec![ALICE, BOB], + vec![U256::from(100u64), U256::from(200u64)], + false, + ) + .unwrap(); + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(100u64)); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(200u64)); + assert_eq!(tok.accounting().total_supply().unwrap(), U256::from(300u64)); + } + + #[test] + fn batch_mint_requires_mint_role() { + let mut tok = token(); + let err = LOGIC + .batch_mint(&mut tok, ALICE, vec![BOB], vec![U256::from(1u64)], false) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Mint.id(), + }) + ); + } + + #[test] + fn batch_mint_rejects_empty() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Mint.id(), ALICE); + let err = LOGIC.batch_mint(&mut tok, ALICE, vec![], vec![], false).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::EmptyBatch {})); + } + + #[test] + fn batch_mint_rejects_length_mismatch() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Mint.id(), ALICE); + let err = LOGIC + .batch_mint(&mut tok, ALICE, vec![ALICE], vec![U256::ONE, U256::ONE], false) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Asset::LengthMismatch { + leftLen: U256::ONE, + rightLen: U256::from(2u64), + }) + ); + } + + /// PAUSE fires before the role check. + #[test] + fn batch_mint_paused_gets_pause_error() { + let mut tok = token(); + LOGIC.pause(&mut tok, ADMIN, vec![IB20::PausableFeature::MINT], true).unwrap(); + let err = LOGIC + .batch_mint(&mut tok, ALICE, vec![ALICE, BOB], vec![U256::ONE], false) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::MINT + }) + ); + } + + // --- asset: extra metadata --- + + #[test] + fn update_extra_metadata_roundtrip_and_clear() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Metadata.id(), ADMIN); + LOGIC + .update_extra_metadata( + &mut tok, + ADMIN, + "category".to_string(), + "real-world-asset".to_string(), + false, + ) + .unwrap(); + assert_eq!(LOGIC.extra_metadata(&tok, "category").unwrap(), "real-world-asset"); + assert_eq!(last_event_sig(&tok), IB20Asset::ExtraMetadataUpdated::SIGNATURE_HASH); + // Empty value clears the entry. + LOGIC + .update_extra_metadata(&mut tok, ADMIN, "category".to_string(), String::new(), false) + .unwrap(); + assert_eq!(LOGIC.extra_metadata(&tok, "category").unwrap(), ""); + } + + #[test] + fn update_extra_metadata_rejects_empty_key() { + let mut tok = token(); + let err = LOGIC + .update_extra_metadata(&mut tok, ADMIN, String::new(), "v".to_string(), true) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::InvalidMetadataKey {})); + } + + #[test] + fn update_extra_metadata_requires_metadata_role() { + let mut tok = token(); + let err = LOGIC + .update_extra_metadata(&mut tok, ALICE, "k".to_string(), "v".to_string(), false) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Metadata.id(), + }) + ); + } + + // --- asset: announcements --- + + #[test] + fn begin_announce_marks_id_and_emits() { + let mut tok = token(); + let id = "2026-Q1-split".to_string(); + assert!(!LOGIC.is_announcement_id_used(&tok, &id).unwrap()); + LOGIC + .begin_announce(&mut tok, ADMIN, id.clone(), "split".to_string(), String::new(), true) + .unwrap(); + assert!(LOGIC.is_announcement_id_used(&tok, &id).unwrap()); + assert_eq!(last_event_sig(&tok), IB20Asset::Announcement::SIGNATURE_HASH); + } + + #[test] + fn begin_announce_rejects_reused_id() { + let mut tok = token(); + let id = "dup".to_string(); + tok.accounting_mut().mark_announcement_id_used(&id).unwrap(); + let err = LOGIC + .begin_announce(&mut tok, ADMIN, id.clone(), String::new(), String::new(), true) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::AnnouncementIdAlreadyUsed { id })); + } + + #[test] + fn begin_announce_requires_operator_role() { + let mut tok = token(); + let err = LOGIC + .begin_announce(&mut tok, ALICE, "id".to_string(), String::new(), String::new(), false) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: AssetV3::OPERATOR_ROLE, + }) + ); + } + + #[test] + fn end_announce_emits_end_event() { + let mut tok = token(); + LOGIC.end_announce(&mut tok, "id".to_string()).unwrap(); + assert_eq!(last_event_sig(&tok), IB20Asset::EndAnnouncement::SIGNATURE_HASH); + } + + // --- reads --- + + #[test] + fn decimals_reads_storage() { + let mut tok = token(); + assert_eq!(LOGIC.decimals(&tok).unwrap(), 6); + tok.accounting_mut().decimals = 18; + assert_eq!(LOGIC.decimals(&tok).unwrap(), 18); + } + + #[test] + fn is_initialized_reflects_storage() { + let mut tok = token(); + assert!(LOGIC.is_initialized(&tok).unwrap()); + tok.accounting_mut().initialized = false; + assert!(!LOGIC.is_initialized(&tok).unwrap()); + } + + // --- ERC-8056 scheduled multiplier (V2 divergences) --- + + #[test] + fn multiplier_defaults_to_wad() { + let tok = token(); + assert_eq!(LOGIC.multiplier(&tok).unwrap(), wad()); + } + + #[test] + fn multiplier_flips_lazily_at_effective_at_boundary() { + let mut tok = token(); + let target = wad() * U256::from(3u64); + let effective_at = U256::from(100u64); + set_now(&mut tok, U256::from(10u64)); + LOGIC.update_ui_multiplier(&mut tok, ALICE, target, effective_at, true).unwrap(); + + // T-1: still the old (current) multiplier. + set_now(&mut tok, U256::from(99u64)); + assert_eq!(LOGIC.multiplier(&tok).unwrap(), wad()); + // T: flips to the pending target. + set_now(&mut tok, U256::from(100u64)); + assert_eq!(LOGIC.multiplier(&tok).unwrap(), target); + // T+1: stays flipped. + set_now(&mut tok, U256::from(101u64)); + assert_eq!(LOGIC.multiplier(&tok).unwrap(), target); + // ui_multiplier is an alias. + assert_eq!(LOGIC.ui_multiplier(&tok).unwrap(), target); + } + + #[test] + fn update_ui_multiplier_emits_event_and_records_pending() { + let mut tok = token(); + let target = wad() * U256::from(2u64); + let effective_at = U256::from(500u64); + set_now(&mut tok, U256::from(1u64)); + LOGIC.update_ui_multiplier(&mut tok, ALICE, target, effective_at, true).unwrap(); + + assert_eq!( + last_event(&tok), + IB20Asset::UIMultiplierUpdated { + oldMultiplier: wad(), + newMultiplier: target, + effectiveAtTimestamp: effective_at, + } + .encode_log_data() + ); + assert_eq!(LOGIC.new_ui_multiplier(&tok).unwrap(), target); + assert_eq!(LOGIC.effective_at(&tok).unwrap(), effective_at); + } + + #[test] + fn update_ui_multiplier_requires_operator_role() { + let mut tok = token(); + set_now(&mut tok, U256::from(1u64)); + let err = LOGIC + .update_ui_multiplier(&mut tok, ALICE, wad(), U256::from(2u64), false) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: AssetV3::OPERATOR_ROLE, + }) + ); + // Once granted, the same call succeeds. + grant(&mut tok, AssetV3::OPERATOR_ROLE, ALICE); + LOGIC.update_ui_multiplier(&mut tok, ALICE, wad(), U256::from(2u64), false).unwrap(); + } + + #[test] + fn update_ui_multiplier_rejects_zero_and_above_uint128() { + let mut tok = token(); + set_now(&mut tok, U256::from(1u64)); + let zero = LOGIC + .update_ui_multiplier(&mut tok, ALICE, U256::ZERO, U256::from(2u64), true) + .unwrap_err(); + assert_eq!(zero, BasePrecompileError::revert(IB20Asset::InvalidMultiplier {})); + + let too_big = U256::from(u128::MAX) + U256::ONE; + let over = LOGIC + .update_ui_multiplier(&mut tok, ALICE, too_big, U256::from(2u64), true) + .unwrap_err(); + assert_eq!(over, BasePrecompileError::revert(IB20Asset::InvalidMultiplier {})); + } + + #[test] + fn update_ui_multiplier_rejects_effective_at_in_past_and_too_far() { + let mut tok = token(); + let now = U256::from(100u64); + set_now(&mut tok, now); + // effectiveAt == now is not in the future. + let past = LOGIC.update_ui_multiplier(&mut tok, ALICE, wad(), now, true).unwrap_err(); + assert_eq!( + past, + BasePrecompileError::revert(IB20Asset::EffectiveAtInPast { effectiveAt: now }) + ); + + let too_far = U256::from(u64::MAX) + U256::ONE; + let far = LOGIC.update_ui_multiplier(&mut tok, ALICE, wad(), too_far, true).unwrap_err(); + assert_eq!( + far, + BasePrecompileError::revert(IB20Asset::EffectiveAtTooFar { effectiveAt: too_far }) + ); + } + + #[test] + fn update_ui_multiplier_reverts_on_live_overlap() { + let mut tok = token(); + let first_effective_at = U256::from(1_000u64); + set_now(&mut tok, U256::from(1u64)); + LOGIC + .update_ui_multiplier( + &mut tok, + ALICE, + wad() * U256::from(2u64), + first_effective_at, + true, + ) + .unwrap(); + let err = LOGIC + .update_ui_multiplier( + &mut tok, + ALICE, + wad() * U256::from(3u64), + U256::from(2_000u64), + true, + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Asset::UIMultiplierUpdateExists { + effectiveAt: first_effective_at, + }) + ); + } + + #[test] + fn update_ui_multiplier_materializes_matured_pending() { + let mut tok = token(); + let first = wad() * U256::from(2u64); + let first_effective_at = U256::from(100u64); + set_now(&mut tok, U256::from(1u64)); + LOGIC.update_ui_multiplier(&mut tok, ALICE, first, first_effective_at, true).unwrap(); + + // After maturity, schedule a second: the matured first must fold into the current slot. + let now = U256::from(150u64); + set_now(&mut tok, now); + assert_eq!(LOGIC.multiplier(&tok).unwrap(), first, "first has matured"); + let second = wad() * U256::from(3u64); + let second_effective_at = U256::from(300u64); + LOGIC.update_ui_multiplier(&mut tok, ALICE, second, second_effective_at, true).unwrap(); + + // `old` in the emitted event is the folded (matured) value, not the pre-fold WAD. + assert_eq!( + last_event(&tok), + IB20Asset::UIMultiplierUpdated { + oldMultiplier: first, + newMultiplier: second, + effectiveAtTimestamp: second_effective_at, + } + .encode_log_data() + ); + // Slot 1 (current) now holds the folded matured multiplier and is still effective. + assert_eq!(tok.accounting().multiplier().unwrap(), first); + assert_eq!(LOGIC.multiplier(&tok).unwrap(), first); + // The second flips in on maturity. + set_now(&mut tok, second_effective_at); + assert_eq!(LOGIC.multiplier(&tok).unwrap(), second); + } + + #[test] + fn cancel_clears_pending_and_emits() { + let mut tok = token(); + let target = wad() * U256::from(2u64); + let effective_at = U256::from(1_000u64); + set_now(&mut tok, U256::from(1u64)); + LOGIC.update_ui_multiplier(&mut tok, ALICE, target, effective_at, true).unwrap(); + + LOGIC.cancel_ui_multiplier_update(&mut tok, ALICE, true).unwrap(); + + assert_eq!( + last_event(&tok), + IB20Asset::UIMultiplierUpdateCancelled { + cancelledMultiplier: target, + cancelledEffectiveAt: effective_at, + } + .encode_log_data() + ); + assert_eq!(LOGIC.effective_at(&tok).unwrap(), U256::ZERO); + // No-live-pending invariant. + assert_eq!(LOGIC.new_ui_multiplier(&tok).unwrap(), LOGIC.ui_multiplier(&tok).unwrap()); + } + + #[test] + fn cancel_reverts_without_live_pending() { + let mut tok = token(); + set_now(&mut tok, U256::from(1u64)); + let none = LOGIC.cancel_ui_multiplier_update(&mut tok, ALICE, true).unwrap_err(); + assert_eq!(none, BasePrecompileError::revert(IB20Asset::UIMultiplierUpdateDoesNotExist {})); + + // A matured pending is no longer "live", so cancel still reverts. + LOGIC + .update_ui_multiplier( + &mut tok, + ALICE, + wad() * U256::from(2u64), + U256::from(100u64), + true, + ) + .unwrap(); + set_now(&mut tok, U256::from(100u64)); + let matured = LOGIC.cancel_ui_multiplier_update(&mut tok, ALICE, true).unwrap_err(); + assert_eq!( + matured, + BasePrecompileError::revert(IB20Asset::UIMultiplierUpdateDoesNotExist {}) + ); + } + + #[test] + fn update_multiplier_emits_ui_event_at_now() { + let mut tok = token(); + let now = U256::from(42u64); + let target = wad() * U256::from(5u64); + set_now(&mut tok, now); + LOGIC.update_multiplier(&mut tok, ALICE, target, true).unwrap(); + assert_eq!( + last_event(&tok), + IB20Asset::UIMultiplierUpdated { + oldMultiplier: wad(), + newMultiplier: target, + effectiveAtTimestamp: now, + } + .encode_log_data() + ); + assert_eq!(LOGIC.multiplier(&tok).unwrap(), target); + } + + #[test] + fn update_multiplier_clears_live_pending_with_cancel_event() { + let mut tok = token(); + let pending = wad() * U256::from(2u64); + let pending_effective_at = U256::from(1_000u64); + set_now(&mut tok, U256::from(1u64)); + LOGIC.update_ui_multiplier(&mut tok, ALICE, pending, pending_effective_at, true).unwrap(); + + let now = U256::from(10u64); + let instant = wad() * U256::from(5u64); + set_now(&mut tok, now); + LOGIC.update_multiplier(&mut tok, ALICE, instant, true).unwrap(); + + let events = &tok.accounting().events; + assert_eq!( + events[events.len() - 3], + IB20Asset::UIMultiplierUpdateCancelled { + cancelledMultiplier: pending, + cancelledEffectiveAt: pending_effective_at, + } + .encode_log_data() + ); + assert_eq!( + events[events.len() - 2], + IB20Asset::MultiplierUpdated { multiplier: instant }.encode_log_data() + ); + assert_eq!( + events[events.len() - 1], + IB20Asset::UIMultiplierUpdated { + oldMultiplier: wad(), + newMultiplier: instant, + effectiveAtTimestamp: now, + } + .encode_log_data() + ); + assert_eq!(LOGIC.effective_at(&tok).unwrap(), U256::ZERO); + assert_eq!(LOGIC.multiplier(&tok).unwrap(), instant); + } + + #[test] + fn update_multiplier_clears_matured_pending_without_cancel_event() { + let mut tok = token(); + let matured = wad() * U256::from(2u64); + set_now(&mut tok, U256::from(1u64)); + LOGIC.update_ui_multiplier(&mut tok, ALICE, matured, U256::from(100u64), true).unwrap(); + + let now = U256::from(150u64); // matured + let instant = wad() * U256::from(5u64); + set_now(&mut tok, now); + LOGIC.update_multiplier(&mut tok, ALICE, instant, true).unwrap(); + + // MultiplierUpdated + UIMultiplierUpdated are emitted (no cancellation for a matured + // pending, which folds into `old` silently); `old` is the matured effective value. + let events = &tok.accounting().events; + assert_eq!( + events[events.len() - 2], + IB20Asset::MultiplierUpdated { multiplier: instant }.encode_log_data() + ); + assert_eq!( + events[events.len() - 1], + IB20Asset::UIMultiplierUpdated { + oldMultiplier: matured, + newMultiplier: instant, + effectiveAtTimestamp: now, + } + .encode_log_data() + ); + let cancelled_sig = IB20Asset::UIMultiplierUpdateCancelled::SIGNATURE_HASH; + assert!( + !tok.accounting().events.iter().any(|log| log.topics()[0] == cancelled_sig), + "no cancellation event for a matured pending" + ); + assert_eq!(LOGIC.effective_at(&tok).unwrap(), U256::ZERO); + } + + #[test] + fn update_multiplier_rejects_above_uint128() { + let mut tok = token(); + set_now(&mut tok, U256::from(1u64)); + let over = LOGIC + .update_multiplier(&mut tok, ADMIN, U256::from(u128::MAX) + U256::ONE, true) + .unwrap_err(); + assert_eq!(over, BasePrecompileError::revert(IB20Asset::InvalidMultiplier {})); + } + + #[test] + fn new_ui_multiplier_matured_mirrors_ui_multiplier_and_keeps_past_effective_at() { + let mut tok = token(); + let target = wad() * U256::from(2u64); + let effective_at = U256::from(100u64); + set_now(&mut tok, U256::from(1u64)); + LOGIC.update_ui_multiplier(&mut tok, ALICE, target, effective_at, true).unwrap(); + + let now = U256::from(150u64); + set_now(&mut tok, now); + assert_eq!(LOGIC.new_ui_multiplier(&tok).unwrap(), LOGIC.ui_multiplier(&tok).unwrap()); + assert_eq!(LOGIC.new_ui_multiplier(&tok).unwrap(), target); + // A matured pending keeps its (now past) effectiveAt until a set/cancel materializes it. + assert_eq!(LOGIC.effective_at(&tok).unwrap(), effective_at); + } + + #[test] + fn scaled_reads_use_effective_multiplier() { + let mut tok = token(); + tok.accounting_mut().set_balance(ALICE, U256::from(100u64)).unwrap(); + tok.accounting_mut().set_total_supply(U256::from(100u64)).unwrap(); + let target = wad() * U256::from(2u64); + let effective_at = U256::from(100u64); + set_now(&mut tok, U256::from(1u64)); + LOGIC.update_ui_multiplier(&mut tok, ALICE, target, effective_at, true).unwrap(); + + // Before maturity: 1:1. + set_now(&mut tok, U256::from(50u64)); + assert_eq!(LOGIC.to_scaled_balance(&tok, U256::from(10u64)).unwrap(), U256::from(10u64)); + assert_eq!(LOGIC.scaled_balance_of(&tok, ALICE).unwrap(), U256::from(100u64)); + assert_eq!(LOGIC.balance_of_ui(&tok, ALICE).unwrap(), U256::from(100u64)); + assert_eq!(LOGIC.total_supply_ui(&tok).unwrap(), U256::from(100u64)); + + // After maturity: doubled. + set_now(&mut tok, U256::from(100u64)); + assert_eq!(LOGIC.to_scaled_balance(&tok, U256::from(10u64)).unwrap(), U256::from(20u64)); + assert_eq!(LOGIC.to_raw_balance(&tok, U256::from(20u64)).unwrap(), U256::from(10u64)); + assert_eq!(LOGIC.scaled_balance_of(&tok, ALICE).unwrap(), U256::from(200u64)); + assert_eq!(LOGIC.balance_of_ui(&tok, ALICE).unwrap(), U256::from(200u64)); + assert_eq!(LOGIC.total_supply_ui(&tok).unwrap(), U256::from(200u64)); + } + + #[test] + fn supports_interface_advertises_claimed_ids_only() { + // IERC165, IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances, and the + // IScaledUIAmountConversion extension (0x57854fc3), claimed by the interface review. + for id in [ + [0x01, 0xff, 0xc9, 0xa7], + [0xa6, 0x0b, 0xf1, 0x3d], + [0x4b, 0xd2, 0x76, 0x48], + [0xd8, 0x90, 0xfd, 0x71], + [0x57, 0x85, 0x4f, 0xc3], + ] { + assert!( + >::supports_interface( + &LOGIC, + FixedBytes::new(id) + ) + .unwrap() + ); + } + // An arbitrary id is not advertised. + for id in [[0xde, 0xad, 0xbe, 0xef], [0xff, 0xff, 0xff, 0xff]] { + assert!( + !>::supports_interface( + &LOGIC, + FixedBytes::new(id) + ) + .unwrap() + ); + } + } + + #[test] + fn max_ui_multiplier_getter_returns_uint128_max() { + // Pins the const definition (the U256 limbs must equal type(uint128).max, the value the + // setter guards enforce) and the getter that exposes it. + assert_eq!(AssetV3::MAX_UI_MULTIPLIER, U256::from(u128::MAX)); + let value = + >::max_ui_multiplier(&LOGIC) + .unwrap(); + assert_eq!(value, U256::from(u128::MAX)); + } + + /// Pins this version's frozen EIP-712 domain typehash to the exact type string it must hash. + /// The constant is duplicated per version so each fork's wire surface stays independently + /// frozen; without this check a typo in one copy would silently change that version's digest. + #[test] + fn domain_typehash_matches_eip712_domain_type() { + let domain_type = + b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; + assert_eq!(super::DOMAIN_TYPEHASH, keccak256(domain_type)); + assert_eq!(super::VERSION, b"1"); + } +} diff --git a/crates/common/precompiles/src/b20_asset/mod.rs b/crates/common/precompiles/src/b20_asset/mod.rs index 7a4e601419..62b3a31020 100644 --- a/crates/common/precompiles/src/b20_asset/mod.rs +++ b/crates/common/precompiles/src/b20_asset/mod.rs @@ -13,7 +13,7 @@ pub(crate) use versions::AssetCall; pub use versions::{AssetAbi, AssetAbiPair, AssetVersion, AssetVersions}; mod logic; -pub use logic::{Asset, AssetV1, AssetV2, B20AssetToken}; +pub use logic::{Asset, AssetV1, AssetV2, AssetV3, B20AssetToken}; mod precompile; pub use precompile::B20AssetPrecompile; diff --git a/crates/common/precompiles/src/b20_asset/versions.rs b/crates/common/precompiles/src/b20_asset/versions.rs index 3ef03c5e2a..66a9e40f31 100644 --- a/crates/common/precompiles/src/b20_asset/versions.rs +++ b/crates/common/precompiles/src/b20_asset/versions.rs @@ -21,8 +21,8 @@ use base_common_genesis::BaseUpgrade; use base_precompile_storage::{BasePrecompileError, Result}; use crate::{ - Asset, AssetAccounting, AssetV1, AssetV2, B20Abi, IB20, IB20Asset, IB20AssetV1, IB20AssetV2, - PolicyAccounting, + Asset, AssetAccounting, AssetV1, AssetV2, AssetV3, B20Abi, IB20, IB20Asset, IB20AssetV1, + IB20AssetV2, PolicyAccounting, }; /// An activated version of the asset B-20 precompile logic. @@ -35,6 +35,8 @@ pub enum AssetVersion { V1, /// Introduced at Cobalt. Adds the ERC-8056 scheduled-multiplier surface V2, + /// Introduced at Denim, superseding [`Self::V2`]. + V3, } impl AssetVersion { @@ -46,9 +48,11 @@ impl AssetVersion { { static V1: AssetV1 = AssetV1; static V2: AssetV2 = AssetV2; + static V3: AssetV3 = AssetV3; match self { Self::V1 => &V1, Self::V2 => &V2, + Self::V3 => &V3, } } @@ -60,7 +64,8 @@ impl AssetVersion { pub const fn abi(self) -> AssetAbiPair { match self { Self::V1 => AssetAbiPair { asset: AssetAbi::V1, common_b20: B20Abi::V1 }, - Self::V2 => AssetAbiPair { asset: AssetAbi::V2, common_b20: B20Abi::V2 }, + // V3 reuses the Cobalt wire surface until a later Denim PR adds selectors. + Self::V2 | Self::V3 => AssetAbiPair { asset: AssetAbi::V2, common_b20: B20Abi::V2 }, } } } @@ -186,13 +191,14 @@ impl AssetVersions { /// Returns the version active at `upgrade`, or `None` before the introduction /// fork (Beryl), where the asset precompile is not installed at all. /// - /// V1 is active from Beryl; V2 supersedes it from Cobalt. + /// V1 is active from Beryl; V2 supersedes it from Cobalt; V3 supersedes V2 from Denim. pub fn from_base_upgrade(upgrade: BaseUpgrade) -> Option { - // Ordered thresholds rather than per-variant arms: a fork newer than Cobalt must inherit the - // latest version (V2) until one supersedes it, and `BaseUpgrade` is `#[non_exhaustive]`, so + // Ordered thresholds rather than per-variant arms: a fork newer than Denim must inherit the + // latest version (V3) until one supersedes it, and `BaseUpgrade` is `#[non_exhaustive]`, so // an explicit-variant match would need a wildcard that would wrongly send future forks to // `None`. match upgrade { + u if u >= BaseUpgrade::Denim => Some(AssetVersion::V3), u if u >= BaseUpgrade::Cobalt => Some(AssetVersion::V2), u if u >= BaseUpgrade::Beryl => Some(AssetVersion::V1), _ => None, @@ -229,6 +235,12 @@ mod tests { assert_eq!(AssetVersions::from_base_upgrade(BaseUpgrade::Cobalt), Some(AssetVersion::V2)); } + #[test] + fn resolves_v3_from_denim() { + assert_eq!(AssetVersions::from_base_upgrade(BaseUpgrade::Denim), Some(AssetVersion::V3)); + assert_eq!(AssetVersions::from_base_upgrade(BaseUpgrade::Zenith), Some(AssetVersion::V3)); + } + /// The logic axis and both wire axes meet only here. Driven from the fork ladder so the whole /// chain (upgrade -> version -> composite surface) is pinned, not just the inner lookups. #[test] @@ -241,13 +253,20 @@ mod tests { AssetVersion::V2.abi(), AssetAbiPair { asset: AssetAbi::V2, common_b20: B20Abi::V2 } ); + assert_eq!( + AssetVersion::V3.abi(), + AssetAbiPair { asset: AssetAbi::V2, common_b20: B20Abi::V2 } + ); let beryl = AssetVersions::from_base_upgrade(BaseUpgrade::Beryl).unwrap(); let cobalt = AssetVersions::from_base_upgrade(BaseUpgrade::Cobalt).unwrap(); + let denim = AssetVersions::from_base_upgrade(BaseUpgrade::Denim).unwrap(); assert_eq!(beryl.abi().asset, AssetAbi::V1); assert_eq!(beryl.abi().common_b20, B20Abi::V1); assert_eq!(cobalt.abi().asset, AssetAbi::V2); assert_eq!(cobalt.abi().common_b20, B20Abi::V2); + assert_eq!(denim.abi().asset, AssetAbi::V2); + assert_eq!(denim.abi().common_b20, B20Abi::V2); } /// `SolInterface::NAME` lands in consensus data: the short-calldata branch of @@ -323,7 +342,7 @@ mod tests { #[test] fn asset_and_common_selectors_are_disjoint_on_each_wire() { - for wire in [AssetVersion::V1.abi(), AssetVersion::V2.abi()] { + for wire in [AssetVersion::V1.abi(), AssetVersion::V2.abi(), AssetVersion::V3.abi()] { for selector in IB20Asset::IB20AssetCalls::selectors() { if wire.asset.valid_selector(selector) { assert!( @@ -357,16 +376,18 @@ mod tests { #[test] fn decode_accepts_cobalt_asset_selector_at_v2() { let calldata = IB20Asset::uiMultiplierCall {}.abi_encode(); - assert!(matches!( - AssetVersion::V2.abi().decode(&calldata), - Ok(AssetCall::Asset(IB20Asset::IB20AssetCalls::uiMultiplier(_))) - )); + for version in [AssetVersion::V2, AssetVersion::V3] { + assert!(matches!( + version.abi().decode(&calldata), + Ok(AssetCall::Asset(IB20Asset::IB20AssetCalls::uiMultiplier(_))) + )); + } } #[test] fn decode_accepts_common_selector_at_both_versions() { let calldata = IB20::nameCall {}.abi_encode(); - for version in [AssetVersion::V1, AssetVersion::V2] { + for version in [AssetVersion::V1, AssetVersion::V2, AssetVersion::V3] { assert!(matches!( version.abi().decode(&calldata), Ok(AssetCall::Common(IB20::IB20Calls::name(_))) @@ -386,8 +407,9 @@ mod tests { .. } if selector == IB20::transferCall::SELECTOR )); - // Same bytes at V2: common V1 and V2 declare the same surface today. - assert_eq!(AssetVersion::V2.abi().decode(&calldata), Err(err)); + // Same bytes at V2/V3: common V1 and V2 declare the same surface today, and V3 reuses V2. + assert_eq!(AssetVersion::V2.abi().decode(&calldata), Err(err.clone())); + assert_eq!(AssetVersion::V3.abi().decode(&calldata), Err(err)); } #[test] @@ -416,6 +438,10 @@ mod tests { AssetVersion::V2.abi().decode(&calldata), Err(BasePrecompileError::UnknownFunctionSelector(calldata)) ); + assert_eq!( + AssetVersion::V3.abi().decode(&calldata), + Err(BasePrecompileError::UnknownFunctionSelector(calldata)) + ); } #[test] diff --git a/crates/common/precompiles/src/b20_stablecoin/logic/mod.rs b/crates/common/precompiles/src/b20_stablecoin/logic/mod.rs index 9c40b8cd1f..385267d8cf 100644 --- a/crates/common/precompiles/src/b20_stablecoin/logic/mod.rs +++ b/crates/common/precompiles/src/b20_stablecoin/logic/mod.rs @@ -2,8 +2,9 @@ //! //! [`Stablecoin`] (in [`interface`](self)) is the append-only business-logic //! interface each version implements; [`B20StablecoinToken`] is the minimal -//! storage + policy holder the logic operates on; and [`StablecoinV1`] is the -//! first frozen implementation. +//! storage + policy holder the logic operates on; [`StablecoinV1`] is the first +//! frozen implementation (activated at Beryl), [`StablecoinV2`] the second (activated +//! at Cobalt), and [`StablecoinV3`] the third (activated at Denim). use alloy_primitives::Address; @@ -18,6 +19,9 @@ pub use v1::StablecoinV1; mod v2; pub use v2::StablecoinV2; +mod v3; +pub use v3::StablecoinV3; + /// Storage + policy binding the stablecoin logic operates on. /// /// A minimal `(accounting, policy, policy_version)` holder implementing [`Token`]; diff --git a/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs b/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs new file mode 100644 index 0000000000..19c3374caf --- /dev/null +++ b/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs @@ -0,0 +1,1830 @@ +//! Version 3 of the stablecoin B-20 precompile logic, activated at Denim. + +use alloc::{ + string::{String, ToString}, + vec, + vec::Vec, +}; + +use alloy_primitives::{Address, B256, FixedBytes, U256, b256}; +use alloy_sol_types::{SolEvent, SolValue}; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{ + B20_MAX_SUPPLY_CAP, B20Guards, B20PausableFeature, B20PolicyType, B20StablecoinToken, + B20TokenRole, Eip712Domain, IB20, NonZeroAddress, PermitArgs, PolicyAccounting, Stablecoin, + StablecoinAccounting, Token, TransferPolicyIds, +}; + +/// `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")` +const DOMAIN_TYPEHASH: B256 = + b256!("8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f"); + +/// EIP-712 domain version string pinned to `"1"`. +const VERSION: &[u8] = b"1"; + +/// Third stablecoin B-20 implementation. Activated at Denim, behavior-identical to V2 (scaffold seam +/// for future changes). +#[derive(Debug, Default, Clone, Copy)] +pub struct StablecoinV3; + +impl StablecoinV3 { + const PAUSABLE_FEATURES: &[IB20::PausableFeature] = &[ + IB20::PausableFeature::TRANSFER, + IB20::PausableFeature::MINT, + IB20::PausableFeature::BURN, + IB20::PausableFeature::SEIZE, + ]; + + /// Balance-moving core of `transfer`/`transferFrom`, without the pause check. + /// + /// `from` / `to` are [`NonZeroAddress`]: callers validate zero addresses (and choose the + /// typed revert) before any policy SLOAD. `policies` carries the sender/receiver ids + /// pre-read from their shared slot by the caller; `Some` enforces both (unprivileged + /// path), `None` skips them (factory-privileged path). + fn transfer_inner( + &self, + token: &mut B20StablecoinToken, + from: NonZeroAddress, + to: NonZeroAddress, + amount: U256, + policies: Option<&TransferPolicyIds>, + ) -> Result<()> { + let from = from.get(); + let to = to.get(); + if let Some(policies) = policies { + B20Guards::ensure_authorized_by_id( + token, + B20PolicyType::TransferSender.id(), + policies.sender, + from, + )?; + B20Guards::ensure_authorized_by_id( + token, + B20PolicyType::TransferReceiver.id(), + policies.receiver, + to, + )?; + } + self.move_balance(token, from, to, amount) + } + + /// Debits `from`, credits `to`, and emits `Transfer(from, to, amount)`. + /// + /// The shared balance-move primitive: no policy, allowance, pause, or zero-address checks — + /// callers apply their own guards first. Both [`Self::transfer_inner`] and the seize path use + /// this, so seize never has to reuse the policy-bearing `transfer_inner` (nor the factory + /// privileged bypass). + fn move_balance( + &self, + token: &mut B20StablecoinToken, + from: Address, + to: Address, + amount: U256, + ) -> Result<()> { + let from_balance = token.accounting().balance_of(from)?; + if from_balance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientBalance { + sender: from, + balance: from_balance, + needed: amount, + })); + } + let new_from_balance = + from_balance.checked_sub(amount).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_balance(from, new_from_balance)?; + let to_balance = token.accounting().balance_of(to)?; + let new_to_balance = + to_balance.checked_add(amount).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_balance(to, new_to_balance)?; + token.accounting_mut().emit_event(IB20::Transfer { from, to, amount }.encode_log_data()) + } + + /// Supply-reducing core of the burn operations, without pause or role checks. + fn burn_inner( + &self, + token: &mut B20StablecoinToken, + from: Address, + amount: U256, + ) -> Result<()> { + let balance = token.accounting().balance_of(from)?; + if balance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientBalance { + sender: from, + balance, + needed: amount, + })); + } + token.accounting_mut().set_balance(from, balance - amount)?; + let supply = token.accounting().total_supply()?; + let new_supply = + supply.checked_sub(amount).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_total_supply(new_supply)?; + token + .accounting_mut() + .emit_event(IB20::Transfer { from, to: Address::ZERO, amount }.encode_log_data()) + } + + /// Revokes `role` from `account` without checking caller authorization. + fn revoke_role_unchecked( + &self, + token: &mut B20StablecoinToken, + role: B256, + account: Address, + sender: Address, + ) -> Result<()> { + if !token.accounting().has_role(role, account)? { + return Ok(()); + } + token.accounting_mut().set_role(role, account, false)?; + if role == B20TokenRole::DefaultAdmin.id() { + let current = token.accounting().role_member_count(role)?; + let next = + current.checked_sub(U256::ONE).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_role_member_count(role, next)?; + } + token + .accounting_mut() + .emit_event(IB20::RoleRevoked { role, account, sender }.encode_log_data()) + } + + /// Ensures role-admin mutations are still reachable. + fn ensure_role_admin_mutations_available( + &self, + token: &B20StablecoinToken, + caller: Address, + ) -> Result<()> { + let admin_role = B20TokenRole::DefaultAdmin.id(); + if token.accounting().role_member_count(admin_role)? == U256::ZERO { + return Err(BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: caller, + neededRole: admin_role, + })); + } + Ok(()) + } + + /// Ensures `policy_scope` names a built-in B-20 policy slot available on the V2 (Cobalt) common + /// surface, which adds the seize scopes (`SEIZE_EXEMPT_POLICY` / `SEIZE_RECEIVER_POLICY`) on top + /// of V1. + /// + /// The match is exhaustive on purpose: a policy scope added to `B20PolicyType` for a future fork + /// must not silently widen this frozen V2 surface — it should fail to compile until V2's stance + /// on it is decided explicitly. + fn ensure_supported_policy_type(policy_scope: B256) -> Result<()> { + match B20PolicyType::from_id(policy_scope) { + Some( + B20PolicyType::TransferSender + | B20PolicyType::TransferReceiver + | B20PolicyType::TransferExecutor + | B20PolicyType::MintReceiver + | B20PolicyType::SeizeExempt + | B20PolicyType::SeizeReceiver, + ) => Ok(()), + None => Err(BasePrecompileError::revert(IB20::UnsupportedPolicyType { + policyScope: policy_scope, + })), + } + } +} + +impl Stablecoin for StablecoinV3 { + fn transfer( + &self, + token: &mut B20StablecoinToken, + caller: Address, + to: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::TRANSFER)?; + let to = NonZeroAddress::new(to) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; + let from = NonZeroAddress::new(caller) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: caller }))?; + if privileged { + return self.transfer_inner(token, from, to, amount, None); + } + let policies = token.accounting().transfer_policy_ids()?; + self.transfer_inner(token, from, to, amount, Some(&policies)) + } + + fn transfer_from( + &self, + token: &mut B20StablecoinToken, + caller: Address, + from: Address, + to: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::TRANSFER)?; + // Validate before allowance / transfer-policy-id SLOADs. + let to = NonZeroAddress::new(to) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; + let from = NonZeroAddress::new(from) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: from }))?; + let allowance = token.accounting().allowance(from.get(), caller)?; + let is_infinite = allowance == U256::MAX; + if !is_infinite && allowance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientAllowance { + spender: caller, + allowance, + needed: amount, + })); + } + if privileged { + self.transfer_inner(token, from, to, amount, None)?; + } else { + // One SLOAD fetches all transfer policy ids, reused for the executor and + // sender/receiver checks. + let policies = token.accounting().transfer_policy_ids()?; + if caller != from.get() { + B20Guards::ensure_authorized_by_id( + token, + B20PolicyType::TransferExecutor.id(), + policies.executor, + caller, + )?; + } + self.transfer_inner(token, from, to, amount, Some(&policies))?; + } + if is_infinite { + return Ok(()); + } + token.accounting_mut().set_allowance(from.get(), caller, allowance - amount) + } + + fn approve( + &self, + token: &mut B20StablecoinToken, + caller: Address, + spender: Address, + amount: U256, + ) -> Result<()> { + if caller == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidApprover { approver: caller })); + } + if spender == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidSpender { spender })); + } + token.accounting_mut().set_allowance(caller, spender, amount)?; + token + .accounting_mut() + .emit_event(IB20::Approval { owner: caller, spender, amount }.encode_log_data()) + } + + fn emit_memo( + &self, + token: &mut B20StablecoinToken, + caller: Address, + memo: B256, + ) -> Result<()> { + token.accounting_mut().emit_event(IB20::Memo { caller, memo }.encode_log_data()) + } + + fn mint( + &self, + token: &mut B20StablecoinToken, + caller: Address, + to: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::MINT)?; + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Mint)?; + } + if to == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); + } + B20Guards::ensure_policy_type(token, B20PolicyType::MintReceiver, to)?; + let supply = token.accounting().total_supply()?; + let cap = token.accounting().supply_cap()?; + let new_supply = + supply.checked_add(amount).ok_or_else(BasePrecompileError::under_overflow)?; + if new_supply > cap { + return Err(BasePrecompileError::revert(IB20::SupplyCapExceeded { + cap, + attempted: new_supply, + })); + } + token.accounting_mut().set_total_supply(new_supply)?; + let to_balance = token.accounting().balance_of(to)?; + let new_balance = + to_balance.checked_add(amount).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_balance(to, new_balance)?; + token + .accounting_mut() + .emit_event(IB20::Transfer { from: Address::ZERO, to, amount }.encode_log_data()) + } + + fn burn( + &self, + token: &mut B20StablecoinToken, + caller: Address, + amount: U256, + ) -> Result<()> { + // Self-burn: `from == caller`, never factory-privileged. + B20Guards::ensure_not_paused(token, IB20::PausableFeature::BURN)?; + B20Guards::ensure_token_role(token, caller, B20TokenRole::Burn)?; + self.burn_inner(token, caller, amount) + } + + fn burn_blocked( + &self, + token: &mut B20StablecoinToken, + caller: Address, + from: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::BURN)?; + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::BurnBlocked)?; + } + B20Guards::ensure_blocked(token, from)?; + self.burn_inner(token, from, amount)?; + token + .accounting_mut() + .emit_event(IB20::BurnedBlocked { caller, from, amount }.encode_log_data()) + } + + fn seize_with_memo( + &self, + token: &mut B20StablecoinToken, + caller: Address, + from: Address, + to: Address, + amount: U256, + memo: B256, + ) -> Result<()> { + B20Guards::ensure_not_paused(token, IB20::PausableFeature::SEIZE)?; + B20Guards::ensure_token_role(token, caller, B20TokenRole::Seize)?; + // `to != 0` guards against a disguised burn; `from != 0` guards against a disguised mint + // (`Transfer(0x0, to, ...)`), matching `transfer_inner`. + if to == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); + } + if from == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidSender { sender: from })); + } + if from == to { + return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); + } + B20Guards::ensure_seizable(token, from)?; + // Gate the destination like `mint` gates `MintReceiver`: an unset scope is always-allow, so a + // treasury need not be allowlisted by default. + B20Guards::ensure_policy_type(token, B20PolicyType::SeizeReceiver, to)?; + self.move_balance(token, from, to, amount)?; + // `Memo` must immediately follow the `Transfer` (from `move_balance`), before `Seized`. + self.emit_memo(token, caller, memo)?; + token + .accounting_mut() + .emit_event(IB20::Seized { caller, from, to, amount }.encode_log_data()) + } + + fn pause( + &self, + token: &mut B20StablecoinToken, + caller: Address, + features: Vec, + privileged: bool, + ) -> Result<()> { + for feature in &features { + B20PausableFeature::ensure_one_of(*feature, Self::PAUSABLE_FEATURES)?; + } + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Pause)?; + } + if features.is_empty() { + return Err(BasePrecompileError::revert(IB20::EmptyFeatureSet {})); + } + let mut next = token.accounting().paused()?; + for feature in &features { + next |= B20PausableFeature::mask(*feature); + } + token.accounting_mut().set_paused(next)?; + token + .accounting_mut() + .emit_event(IB20::Paused { updater: caller, features }.encode_log_data()) + } + + fn unpause( + &self, + token: &mut B20StablecoinToken, + caller: Address, + features: Vec, + privileged: bool, + ) -> Result<()> { + for feature in &features { + B20PausableFeature::ensure_one_of(*feature, Self::PAUSABLE_FEATURES)?; + } + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Unpause)?; + } + if features.is_empty() { + return Err(BasePrecompileError::revert(IB20::EmptyFeatureSet {})); + } + let mut next = token.accounting().paused()?; + for feature in &features { + next &= !B20PausableFeature::mask(*feature); + } + token.accounting_mut().set_paused(next)?; + token + .accounting_mut() + .emit_event(IB20::Unpaused { updater: caller, features }.encode_log_data()) + } + + fn update_supply_cap( + &self, + token: &mut B20StablecoinToken, + caller: Address, + new_cap: U256, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::DefaultAdmin)?; + } + let supply = token.accounting().total_supply()?; + if new_cap < supply || new_cap > B20_MAX_SUPPLY_CAP { + return Err(BasePrecompileError::revert(IB20::InvalidSupplyCap { + currentSupply: supply, + proposedCap: new_cap, + })); + } + let old = token.accounting().supply_cap()?; + token.accounting_mut().set_supply_cap(new_cap)?; + token.accounting_mut().emit_event( + IB20::SupplyCapUpdated { updater: caller, oldSupplyCap: old, newSupplyCap: new_cap } + .encode_log_data(), + ) + } + + fn update_name( + &self, + token: &mut B20StablecoinToken, + caller: Address, + name: String, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Metadata)?; + } + token.accounting_mut().set_name(name.clone())?; + token + .accounting_mut() + .emit_event(IB20::NameUpdated { updater: caller, newName: name }.encode_log_data())?; + token.accounting_mut().emit_event(IB20::EIP712DomainChanged {}.encode_log_data()) + } + + fn update_symbol( + &self, + token: &mut B20StablecoinToken, + caller: Address, + symbol: String, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Metadata)?; + } + token.accounting_mut().set_symbol(symbol.clone())?; + token.accounting_mut().emit_event( + IB20::SymbolUpdated { updater: caller, newSymbol: symbol }.encode_log_data(), + ) + } + + fn update_contract_uri( + &self, + token: &mut B20StablecoinToken, + caller: Address, + uri: String, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::Metadata)?; + } + token.accounting_mut().set_contract_uri(uri)?; + token.accounting_mut().emit_event(IB20::ContractURIUpdated {}.encode_log_data()) + } + + fn grant_role( + &self, + token: &mut B20StablecoinToken, + caller: Address, + role: B256, + account: Address, + privileged: bool, + ) -> Result<()> { + if role == B20TokenRole::DefaultAdmin.id() || !privileged { + self.ensure_role_admin_mutations_available(token, caller)?; + } + if !privileged { + let admin = token.accounting().role_admin(role)?; + B20Guards::ensure_role(token, caller, admin)?; + } + self.grant_role_unchecked(token, role, account, caller) + } + + fn grant_role_unchecked( + &self, + token: &mut B20StablecoinToken, + role: B256, + account: Address, + sender: Address, + ) -> Result<()> { + if token.accounting().has_role(role, account)? { + return Ok(()); + } + token.accounting_mut().set_role(role, account, true)?; + if role == B20TokenRole::DefaultAdmin.id() { + let current = token.accounting().role_member_count(role)?; + let next = + current.checked_add(U256::ONE).ok_or_else(BasePrecompileError::under_overflow)?; + token.accounting_mut().set_role_member_count(role, next)?; + } + token + .accounting_mut() + .emit_event(IB20::RoleGranted { role, account, sender }.encode_log_data()) + } + + fn revoke_role( + &self, + token: &mut B20StablecoinToken, + caller: Address, + role: B256, + account: Address, + privileged: bool, + ) -> Result<()> { + if !privileged { + self.ensure_role_admin_mutations_available(token, caller)?; + let admin = token.accounting().role_admin(role)?; + B20Guards::ensure_role(token, caller, admin)?; + } + if role == B20TokenRole::DefaultAdmin.id() + && token.accounting().has_role(role, account)? + && token.accounting().role_member_count(role)? == U256::ONE + { + return Err(BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); + } + self.revoke_role_unchecked(token, role, account, caller) + } + + fn renounce_role( + &self, + token: &mut B20StablecoinToken, + caller: Address, + role: B256, + confirmation: Address, + ) -> Result<()> { + if confirmation != caller { + return Err(BasePrecompileError::revert(IB20::AccessControlBadConfirmation {})); + } + if role == B20TokenRole::DefaultAdmin.id() + && token.accounting().has_role(role, caller)? + && token.accounting().role_member_count(role)? == U256::ONE + { + return Err(BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); + } + self.revoke_role_unchecked(token, role, caller, caller) + } + + fn renounce_last_admin( + &self, + token: &mut B20StablecoinToken, + caller: Address, + ) -> Result<()> { + let admin_role = B20TokenRole::DefaultAdmin.id(); + B20Guards::ensure_role(token, caller, admin_role)?; + if token.accounting().role_member_count(admin_role)? != U256::ONE { + return Err(BasePrecompileError::revert(IB20::NotSoleAdmin {})); + } + self.revoke_role_unchecked(token, admin_role, caller, caller)?; + token + .accounting_mut() + .emit_event(IB20::LastAdminRenounced { previousAdmin: caller }.encode_log_data()) + } + + fn set_role_admin( + &self, + token: &mut B20StablecoinToken, + caller: Address, + role: B256, + new_admin_role: B256, + privileged: bool, + ) -> Result<()> { + let previous_admin_role = token.accounting().role_admin(role)?; + if !privileged { + self.ensure_role_admin_mutations_available(token, caller)?; + B20Guards::ensure_role(token, caller, previous_admin_role)?; + } + token.accounting_mut().set_role_admin(role, new_admin_role)?; + token.accounting_mut().emit_event( + IB20::RoleAdminChanged { + role, + previousAdminRole: previous_admin_role, + newAdminRole: new_admin_role, + } + .encode_log_data(), + ) + } + + fn update_policy( + &self, + token: &mut B20StablecoinToken, + caller: Address, + policy_scope: B256, + new_policy_id: u64, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(token, caller, B20TokenRole::DefaultAdmin)?; + } + Self::ensure_supported_policy_type(policy_scope)?; + if !token.policy().policy_exists(token.policy_storage(), new_policy_id)? { + return Err(BasePrecompileError::revert(IB20::PolicyNotFound { + policyId: new_policy_id, + })); + } + let old_policy_id = token.accounting().policy_id(policy_scope)?; + token.accounting_mut().set_policy_id(policy_scope, new_policy_id)?; + token.accounting_mut().emit_event( + IB20::PolicyUpdated { + policyScope: policy_scope, + oldPolicyId: old_policy_id, + newPolicyId: new_policy_id, + } + .encode_log_data(), + ) + } + + fn permit( + &self, + token: &mut B20StablecoinToken, + chain_id: u64, + now: U256, + args: PermitArgs, + ) -> Result<()> { + if now > args.deadline { + return Err(BasePrecompileError::revert(IB20::ExpiredSignature { + deadline: args.deadline, + })); + } + let domain_sep = self.domain_separator(token, chain_id)?; + let nonce = token.accounting().nonce(args.owner)?; + let signing_hash = args.metered_signing_hash(token.accounting(), domain_sep, nonce)?; + let recovered = args.metered_recover_signer(token.accounting(), signing_hash)?; + PermitArgs::validate_recovered_address(recovered, args.owner)?; + token.accounting_mut().increment_nonce(args.owner)?; + self.approve(token, args.owner, args.spender, args.value) + } + + fn is_paused( + &self, + token: &B20StablecoinToken, + feature: IB20::PausableFeature, + ) -> Result { + B20PausableFeature::ensure_one_of(feature, Self::PAUSABLE_FEATURES)?; + Ok((token.accounting().paused()? & B20PausableFeature::mask(feature)) != U256::ZERO) + } + + fn paused_features( + &self, + token: &B20StablecoinToken, + ) -> Result> { + let paused = token.accounting().paused()?; + let mut features = Vec::new(); + for feature in [ + IB20::PausableFeature::TRANSFER, + IB20::PausableFeature::MINT, + IB20::PausableFeature::BURN, + IB20::PausableFeature::SEIZE, + ] { + if (paused & B20PausableFeature::mask(feature)) != U256::ZERO { + features.push(feature); + } + } + Ok(features) + } + + fn policy_id(&self, token: &B20StablecoinToken, policy_scope: B256) -> Result { + Self::ensure_supported_policy_type(policy_scope)?; + token.accounting().policy_id(policy_scope) + } + + fn domain_separator(&self, token: &B20StablecoinToken, chain_id: u64) -> Result { + let name = token.accounting().name()?; + let name_hash = token.accounting().metered_keccak256(name.as_bytes())?; + let version_hash = token.accounting().metered_keccak256(VERSION)?; + let encoded = + (DOMAIN_TYPEHASH, name_hash, version_hash, U256::from(chain_id), token.token_address()) + .abi_encode(); + token.accounting().metered_keccak256(&encoded) + } + + fn eip712_domain( + &self, + token: &B20StablecoinToken, + chain_id: u64, + ) -> Result { + let name = token.accounting().name()?; + Ok(( + // bits 0+1+2+3: name + version + chainId + verifyingContract + FixedBytes::<1>::from([0x0f]), + name, + "1".to_string(), + U256::from(chain_id), + token.token_address(), + B256::ZERO, + vec![], + )) + } + + fn currency(&self, token: &B20StablecoinToken) -> Result { + token.accounting().currency() + } +} + +#[cfg(test)] +mod tests { + use alloc::{ + collections::BTreeMap, + string::{String, ToString}, + vec, + vec::Vec, + }; + + use alloy_primitives::{Address, B256, LogData, U256, keccak256}; + use alloy_sol_types::SolEvent; + use base_precompile_storage::{BasePrecompileError, Result}; + use k256::ecdsa::SigningKey; + + use crate::{ + B20_MAX_SUPPLY_CAP, B20PolicyType, B20StablecoinToken, B20TokenRole, IB20, PackedPolicy, + PermitArgs, PolicyAccounting, PolicyRegistryStorage, PolicyVersion, Stablecoin, + StablecoinAccounting, StablecoinV3, Token, TokenAccounting, TransferPolicyIds, + }; + + // --- Self-contained in-memory fakes (no dependency on `common::test_utils`, so shared test + // scaffolding can never drift this frozen version's coverage) --- + + const TOKEN: Address = Address::repeat_byte(0x22); + const ADMIN: Address = Address::repeat_byte(0xAD); + const ALICE: Address = Address::repeat_byte(0xA1); + const BOB: Address = Address::repeat_byte(0xB0); + const MEMO: B256 = B256::repeat_byte(0x77); + const CHAIN_ID: u64 = 8453; + const LOGIC: StablecoinV3 = StablecoinV3; + + // Anvil/Hardhat account 0 — well-known test key, never used in production. + const PRIVATE_KEY: [u8; 32] = + alloy_primitives::hex!("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + + /// Minimal `StablecoinAccounting` backed by in-memory maps. + #[derive(Debug)] + struct FakeAccounting { + initialized: bool, + balances: BTreeMap, + allowances: BTreeMap<(Address, Address), U256>, + total_supply: U256, + supply_cap: U256, + name: String, + symbol: String, + currency: String, + paused: U256, + nonces: BTreeMap, + contract_uri: String, + roles: BTreeMap<(B256, Address), bool>, + role_member_counts: BTreeMap, + role_admins: BTreeMap, + policy_ids: BTreeMap, + events: Vec, + /// Number of `metered_keccak256` calls (interior-mutable so `&self` metering can record). + keccak_charges: core::cell::Cell, + /// Cumulative gas passed to `deduct_gas`. + gas_deducted: core::cell::Cell, + } + + impl FakeAccounting { + fn new() -> Self { + Self { + initialized: true, + balances: BTreeMap::new(), + allowances: BTreeMap::new(), + total_supply: U256::ZERO, + supply_cap: B20_MAX_SUPPLY_CAP, + name: "USD Coin".to_string(), + symbol: "USDC".to_string(), + currency: "USD".to_string(), + paused: U256::ZERO, + nonces: BTreeMap::new(), + contract_uri: String::new(), + roles: BTreeMap::new(), + role_member_counts: BTreeMap::new(), + role_admins: BTreeMap::new(), + policy_ids: BTreeMap::new(), + events: Vec::new(), + keccak_charges: core::cell::Cell::new(0), + gas_deducted: core::cell::Cell::new(0), + } + } + } + + impl TokenAccounting for FakeAccounting { + fn token_address(&self) -> Address { + TOKEN + } + fn is_initialized(&self) -> Result { + Ok(self.initialized) + } + fn balance_of(&self, account: Address) -> Result { + Ok(self.balances.get(&account).copied().unwrap_or(U256::ZERO)) + } + fn set_balance(&mut self, account: Address, balance: U256) -> Result<()> { + self.balances.insert(account, balance); + Ok(()) + } + fn allowance(&self, owner: Address, spender: Address) -> Result { + Ok(self.allowances.get(&(owner, spender)).copied().unwrap_or(U256::ZERO)) + } + fn set_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { + self.allowances.insert((owner, spender), amount); + Ok(()) + } + fn total_supply(&self) -> Result { + Ok(self.total_supply) + } + fn set_total_supply(&mut self, supply: U256) -> Result<()> { + self.total_supply = supply; + Ok(()) + } + fn supply_cap(&self) -> Result { + Ok(self.supply_cap) + } + fn set_supply_cap(&mut self, cap: U256) -> Result<()> { + self.supply_cap = cap; + Ok(()) + } + fn name(&self) -> Result { + Ok(self.name.clone()) + } + fn set_name(&mut self, name: String) -> Result<()> { + self.name = name; + Ok(()) + } + fn symbol(&self) -> Result { + Ok(self.symbol.clone()) + } + fn set_symbol(&mut self, symbol: String) -> Result<()> { + self.symbol = symbol; + Ok(()) + } + fn decimals(&self) -> Result { + Ok(6) + } + fn paused(&self) -> Result { + Ok(self.paused) + } + fn set_paused(&mut self, vectors: U256) -> Result<()> { + self.paused = vectors; + Ok(()) + } + fn nonce(&self, owner: Address) -> Result { + Ok(self.nonces.get(&owner).copied().unwrap_or(U256::ZERO)) + } + fn increment_nonce(&mut self, owner: Address) -> Result<()> { + let n = self.nonces.entry(owner).or_insert(U256::ZERO); + *n += U256::ONE; + Ok(()) + } + fn contract_uri(&self) -> Result { + Ok(self.contract_uri.clone()) + } + fn set_contract_uri(&mut self, uri: String) -> Result<()> { + self.contract_uri = uri; + Ok(()) + } + fn has_role(&self, role: B256, account: Address) -> Result { + Ok(self.roles.get(&(role, account)).copied().unwrap_or(false)) + } + fn set_role(&mut self, role: B256, account: Address, enabled: bool) -> Result<()> { + self.roles.insert((role, account), enabled); + Ok(()) + } + fn role_member_count(&self, role: B256) -> Result { + Ok(self.role_member_counts.get(&role).copied().unwrap_or(U256::ZERO)) + } + fn set_role_member_count(&mut self, role: B256, count: U256) -> Result<()> { + self.role_member_counts.insert(role, count); + Ok(()) + } + fn role_admin(&self, role: B256) -> Result { + Ok(self.role_admins.get(&role).copied().unwrap_or(B256::ZERO)) + } + fn set_role_admin(&mut self, role: B256, admin_role: B256) -> Result<()> { + self.role_admins.insert(role, admin_role); + Ok(()) + } + fn policy_id(&self, policy_scope: B256) -> Result { + Ok(self.policy_ids.get(&policy_scope).copied().unwrap_or(0)) + } + fn set_policy_id(&mut self, policy_scope: B256, policy_id: u64) -> Result<()> { + self.policy_ids.insert(policy_scope, policy_id); + Ok(()) + } + + fn transfer_policy_ids(&self) -> Result { + TransferPolicyIds::read_individually(self) + } + fn emit_event(&mut self, log: LogData) -> Result<()> { + self.events.push(log); + Ok(()) + } + fn metered_keccak256(&self, data: &[u8]) -> Result { + self.keccak_charges.set(self.keccak_charges.get() + 1); + Ok(keccak256(data)) + } + fn deduct_gas(&self, gas: u64) -> Result<()> { + self.gas_deducted.set(self.gas_deducted.get() + gas); + Ok(()) + } + } + + impl StablecoinAccounting for FakeAccounting { + fn currency(&self) -> Result { + Ok(self.currency.clone()) + } + fn set_currency(&mut self, currency: String) -> Result<()> { + self.currency = currency; + Ok(()) + } + } + + /// Minimal [`PolicyAccounting`] backed by in-memory maps. + #[derive(Debug)] + struct FakePolicyAccounting { + caller: Address, + initialized: bool, + policies: BTreeMap, + members: BTreeMap<(u64, Address), bool>, + pending_admins: BTreeMap, + next_counter: u64, + events: Vec, + } + + impl FakePolicyAccounting { + fn new() -> Self { + Self { + caller: ADMIN, + initialized: false, + policies: BTreeMap::new(), + members: BTreeMap::new(), + pending_admins: BTreeMap::new(), + next_counter: 0, + events: Vec::new(), + } + } + + fn allow(&mut self, policy_id: u64, account: Address) { + self.create_existing_policy(policy_id); + self.members.insert((policy_id, account), true); + } + + fn create_existing_policy(&mut self, policy_id: u64) { + self.policies.insert(policy_id, PackedPolicy::new(Address::ZERO).into_u256()); + } + } + + impl PolicyAccounting for FakePolicyAccounting { + fn registry_address(&self) -> Address { + Address::repeat_byte(0x02) + } + fn caller(&self) -> Address { + self.caller + } + fn read_policy_word(&self, policy_id: u64) -> Result { + Ok(self.policies.get(&policy_id).copied().unwrap_or(U256::ZERO)) + } + fn write_policy_word(&mut self, policy_id: u64, word: U256) -> Result<()> { + self.policies.insert(policy_id, word); + Ok(()) + } + fn read_member(&self, policy_id: u64, account: Address) -> Result { + Ok(self.members.get(&(policy_id, account)).copied().unwrap_or(false)) + } + fn set_member(&mut self, policy_id: u64, account: Address) -> Result<()> { + self.members.insert((policy_id, account), true); + Ok(()) + } + fn delete_member(&mut self, policy_id: u64, account: Address) -> Result<()> { + self.members.remove(&(policy_id, account)); + Ok(()) + } + fn read_pending_admin(&self, policy_id: u64) -> Result
{ + Ok(self.pending_admins.get(&policy_id).copied().unwrap_or(Address::ZERO)) + } + fn write_pending_admin(&mut self, policy_id: u64, admin: Address) -> Result<()> { + self.pending_admins.insert(policy_id, admin); + Ok(()) + } + fn delete_pending_admin(&mut self, policy_id: u64) -> Result<()> { + self.pending_admins.remove(&policy_id); + Ok(()) + } + fn read_next_counter(&self) -> Result { + Ok(self.next_counter) + } + fn write_next_counter(&mut self, counter: u64) -> Result<()> { + self.next_counter = counter; + Ok(()) + } + fn emit_event(&mut self, log: LogData) -> Result<()> { + self.events.push(log); + Ok(()) + } + fn mark_initialized(&mut self) -> Result<()> { + self.initialized = true; + Ok(()) + } + + // This fake does not exercise composite policies. + fn read_children(&self, _policy_id: u64) -> Result> { + Ok(Vec::new()) + } + + fn write_children(&mut self, _policy_id: u64, _child_policy_ids: &[u64]) -> Result<()> { + Ok(()) + } + } + + type Tok = B20StablecoinToken; + + fn token() -> Tok { + B20StablecoinToken::with_storage_and_policy( + FakeAccounting::new(), + FakePolicyAccounting::new(), + PolicyVersion::V1, + ) + } + + /// Grants `role` to `account` and keeps the admin member-count consistent. + fn grant(tok: &mut Tok, role: B256, account: Address) { + tok.accounting_mut().set_role(role, account, true).unwrap(); + let next = tok.accounting().role_member_count(role).unwrap() + U256::ONE; + tok.accounting_mut().set_role_member_count(role, next).unwrap(); + } + + /// Credits `account` with `amount` and grows total supply to match. + fn fund(tok: &mut Tok, account: Address, amount: U256) { + let bal = tok.accounting().balance_of(account).unwrap(); + tok.accounting_mut().set_balance(account, bal + amount).unwrap(); + let supply = tok.accounting().total_supply().unwrap(); + tok.accounting_mut().set_total_supply(supply + amount).unwrap(); + } + + fn last_event_sig(tok: &Tok) -> B256 { + tok.accounting().events.last().unwrap().topics()[0] + } + + fn anvil_owner() -> Address { + let key = SigningKey::from_slice(&PRIVATE_KEY).unwrap(); + let point = key.verifying_key().to_encoded_point(false); + Address::from_slice(&keccak256(&point.as_bytes()[1..])[12..]) + } + + /// Produces a validly-signed `PermitArgs` for the token's current domain and `owner` nonce. + fn signed_permit( + tok: &Tok, + owner: Address, + spender: Address, + value: U256, + deadline: U256, + ) -> PermitArgs { + let domain_sep = LOGIC.domain_separator(tok, CHAIN_ID).unwrap(); + let nonce = tok.accounting().nonce(owner).unwrap(); + let mut args = + PermitArgs { owner, spender, value, deadline, v: 0, r: B256::ZERO, s: B256::ZERO }; + let signing_hash = args.signing_hash(domain_sep, nonce); + let key = SigningKey::from_slice(&PRIVATE_KEY).unwrap(); + let (sig, recid) = key.sign_prehash_recoverable(signing_hash.as_slice()).unwrap(); + let bytes = sig.to_bytes(); + args.r = B256::from_slice(&bytes[..32]); + args.s = B256::from_slice(&bytes[32..]); + args.v = if recid.is_y_odd() { 28 } else { 27 }; + args + } + + // --- transfer --- + + #[test] + fn transfer_moves_balance_and_emits_transfer() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + LOGIC.transfer(&mut tok, ALICE, BOB, U256::from(30u64), true).unwrap(); + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(70u64)); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(30u64)); + assert_eq!(last_event_sig(&tok), IB20::Transfer::SIGNATURE_HASH); + } + + #[test] + fn transfer_reverts_on_zero_receiver() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(10u64)); + let err = + LOGIC.transfer(&mut tok, ALICE, Address::ZERO, U256::from(1u64), true).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO }) + ); + } + + #[test] + fn transfer_reverts_on_insufficient_balance() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(10u64)); + let err = LOGIC.transfer(&mut tok, ALICE, BOB, U256::from(50u64), true).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: U256::from(10u64), + needed: U256::from(50u64), + }) + ); + } + + #[test] + fn transfer_reverts_when_paused() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(10u64)); + LOGIC.pause(&mut tok, ADMIN, vec![IB20::PausableFeature::TRANSFER], true).unwrap(); + let err = LOGIC.transfer(&mut tok, ALICE, BOB, U256::from(1u64), true).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::TRANSFER, + }) + ); + } + + #[test] + fn transfer_unprivileged_enforces_transfer_policies() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + // ALLOWLIST with no members → sender/receiver policy checks revert. + const POLICY: u64 = (1u64 << 56) | 7; + tok.accounting_mut().set_policy_id(B20PolicyType::TransferSender.id(), POLICY).unwrap(); + tok.accounting_mut().set_policy_id(B20PolicyType::TransferReceiver.id(), POLICY).unwrap(); + tok.policy_storage_mut().create_existing_policy(POLICY); + assert!(LOGIC.transfer(&mut tok, ALICE, BOB, U256::from(10u64), false).is_err()); + // Authorize both parties → transfer succeeds through the guard path. + tok.policy_storage_mut().allow(POLICY, ALICE); + tok.policy_storage_mut().allow(POLICY, BOB); + LOGIC.transfer(&mut tok, ALICE, BOB, U256::from(10u64), false).unwrap(); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(10u64)); + } + + // --- transfer_from --- + + #[test] + fn transfer_from_decrements_finite_allowance() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + tok.accounting_mut().set_allowance(ALICE, BOB, U256::from(40u64)).unwrap(); + LOGIC.transfer_from(&mut tok, BOB, ALICE, BOB, U256::from(30u64), true).unwrap(); + assert_eq!(tok.accounting().allowance(ALICE, BOB).unwrap(), U256::from(10u64)); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(30u64)); + } + + #[test] + fn transfer_from_infinite_allowance_is_not_decremented() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + tok.accounting_mut().set_allowance(ALICE, BOB, U256::MAX).unwrap(); + LOGIC.transfer_from(&mut tok, BOB, ALICE, BOB, U256::from(30u64), true).unwrap(); + assert_eq!(tok.accounting().allowance(ALICE, BOB).unwrap(), U256::MAX); + } + + #[test] + fn transfer_from_reverts_on_insufficient_allowance() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + tok.accounting_mut().set_allowance(ALICE, BOB, U256::from(5u64)).unwrap(); + let err = + LOGIC.transfer_from(&mut tok, BOB, ALICE, BOB, U256::from(30u64), true).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientAllowance { + spender: BOB, + allowance: U256::from(5u64), + needed: U256::from(30u64), + }) + ); + } + + // --- approve --- + + #[test] + fn approve_sets_allowance_and_emits() { + let mut tok = token(); + LOGIC.approve(&mut tok, ALICE, BOB, U256::from(50u64)).unwrap(); + assert_eq!(tok.accounting().allowance(ALICE, BOB).unwrap(), U256::from(50u64)); + assert_eq!(last_event_sig(&tok), IB20::Approval::SIGNATURE_HASH); + } + + #[test] + fn approve_reverts_on_zero_spender() { + let mut tok = token(); + let err = LOGIC.approve(&mut tok, ALICE, Address::ZERO, U256::from(1u64)).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InvalidSpender { spender: Address::ZERO }) + ); + } + + // --- mint --- + + #[test] + fn mint_privileged_increases_supply_and_balance() { + let mut tok = token(); + tok.policy_storage_mut().allow(0, BOB); // MintReceiver policy is enforced even when privileged + LOGIC.mint(&mut tok, ADMIN, BOB, U256::from(100u64), true).unwrap(); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(100u64)); + assert_eq!(tok.accounting().total_supply().unwrap(), U256::from(100u64)); + assert_eq!(last_event_sig(&tok), IB20::Transfer::SIGNATURE_HASH); + } + + #[test] + fn mint_reverts_over_supply_cap() { + let mut tok = token(); + tok.accounting_mut().set_supply_cap(U256::from(50u64)).unwrap(); + let err = LOGIC.mint(&mut tok, ADMIN, BOB, U256::from(100u64), true).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::SupplyCapExceeded { + cap: U256::from(50u64), + attempted: U256::from(100u64), + }) + ); + } + + #[test] + fn mint_unprivileged_requires_mint_role() { + let mut tok = token(); + let err = LOGIC.mint(&mut tok, ALICE, BOB, U256::from(1u64), false).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Mint.id(), + }) + ); + } + + // --- burn / burn_blocked --- + + #[test] + fn burn_requires_role_then_decreases_supply() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + let err = LOGIC.burn(&mut tok, ALICE, U256::from(1u64)).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Burn.id(), + }) + ); + grant(&mut tok, B20TokenRole::Burn.id(), ALICE); + LOGIC.burn(&mut tok, ALICE, U256::from(40u64)).unwrap(); + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(60u64)); + assert_eq!(tok.accounting().total_supply().unwrap(), U256::from(60u64)); + assert_eq!(last_event_sig(&tok), IB20::Transfer::SIGNATURE_HASH); + } + + #[test] + fn burn_blocked_destroys_from_unauthorized_account() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + // ALWAYS_BLOCK => ALICE is unauthorized/blocked; privileged skips the role check. + tok.accounting_mut() + .set_policy_id( + B20PolicyType::TransferSender.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + LOGIC.burn_blocked(&mut tok, ADMIN, ALICE, U256::from(40u64), true).unwrap(); + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(60u64)); + assert_eq!(last_event_sig(&tok), IB20::BurnedBlocked::SIGNATURE_HASH); + } + + #[test] + fn burn_blocked_reverts_when_account_not_blocked() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + // Default ALWAYS_ALLOW authorizes ALICE => not blocked. + let err = LOGIC.burn_blocked(&mut tok, ADMIN, ALICE, U256::from(1u64), true).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccountNotBlocked { account: ALICE })); + } + + // --- seize --- + + /// Points `SEIZE_EXEMPT_POLICY` at the always-block policy, making every account seizable. + fn make_seizable(tok: &mut Tok) { + tok.accounting_mut() + .set_policy_id(B20PolicyType::SeizeExempt.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID) + .unwrap(); + } + + fn event_sigs(tok: &Tok) -> Vec { + tok.accounting().events.iter().map(|e| e.topics()[0]).collect() + } + + #[test] + fn seize_moves_balance_and_emits_transfer_memo_event() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + let supply = tok.accounting().total_supply().unwrap(); + + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(40u64), MEMO).unwrap(); + + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(60u64)); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(40u64)); + assert_eq!(tok.accounting().total_supply().unwrap(), supply, "seize is a transfer"); + assert_eq!( + event_sigs(&tok), + vec![ + IB20::Transfer::SIGNATURE_HASH, + IB20::Memo::SIGNATURE_HASH, + IB20::Seized::SIGNATURE_HASH, + ] + ); + } + + #[test] + fn seize_reverts_when_account_not_seizable() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccountNotSeizable { account: ALICE })); + } + + #[test] + fn seize_reverts_on_zero_receiver() { + let mut tok = token(); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + let err = LOGIC + .seize_with_memo(&mut tok, ADMIN, ALICE, Address::ZERO, U256::from(1u64), MEMO) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO }) + ); + } + + #[test] + fn seize_reverts_on_zero_from() { + let mut tok = token(); + // A non-default `SeizeExempt` (here ALWAYS_BLOCK via `make_seizable`) treats the zero + // address as seizable, so without the `from != 0` guard a zero-amount seize from the zero + // address would emit a misleading `Transfer(0x0, to, 0)` that indexers read as a mint. + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + + let err = LOGIC + .seize_with_memo(&mut tok, ADMIN, Address::ZERO, BOB, U256::ZERO, MEMO) + .unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidSender { sender: Address::ZERO })); + assert!( + event_sigs(&tok).is_empty(), + "no misleading Transfer/Memo/Seized on a rejected zero-from seize" + ); + } + + #[test] + fn seize_reverts_on_self_seize() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(100u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + + let err = LOGIC + .seize_with_memo(&mut tok, ADMIN, ALICE, ALICE, U256::from(1u64), MEMO) + .unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: ALICE })); + assert_eq!( + tok.accounting().balance_of(ALICE).unwrap(), + U256::from(100u64), + "balance unchanged" + ); + assert!( + event_sigs(&tok).is_empty(), + "no misleading Transfer/Memo/Seized on a rejected self-seize" + ); + } + + #[test] + fn seize_ignores_receiver_policy_on_to() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(50u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + tok.accounting_mut() + .set_policy_id( + B20PolicyType::TransferReceiver.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(50u64), MEMO).unwrap(); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(50u64)); + } + + #[test] + fn seize_reverts_when_receiver_policy_forbids() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(50u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + tok.accounting_mut() + .set_policy_id( + B20PolicyType::SeizeReceiver.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::SeizeReceiver.id(), + policyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID, + }) + ); + } + + #[test] + fn seize_unset_receiver_policy_allows_any_destination() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(50u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + // SEIZE_RECEIVER_POLICY left unset => ALWAYS_ALLOW => any destination is allowed. + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(50u64), MEMO).unwrap(); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(50u64)); + } + + #[test] + fn seize_succeeds_with_configured_receiver_policy_allow() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(50u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + tok.accounting_mut() + .set_policy_id( + B20PolicyType::SeizeReceiver.id(), + PolicyRegistryStorage::ALWAYS_ALLOW_ID, + ) + .unwrap(); + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(50u64), MEMO).unwrap(); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(50u64)); + } + + #[test] + fn seize_exempt_policy_beats_receiver_policy() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + // SEIZE_EXEMPT unset => ALICE not seizable; SEIZE_RECEIVER blocks BOB. Exempt check fires first. + tok.accounting_mut() + .set_policy_id( + B20PolicyType::SeizeReceiver.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccountNotSeizable { account: ALICE })); + } + + #[test] + fn seize_receiver_policy_beats_balance() { + let mut tok = token(); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + // ALICE is seizable and has zero balance, but the receiver policy forbids BOB first. + tok.accounting_mut() + .set_policy_id( + B20PolicyType::SeizeReceiver.id(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ) + .unwrap(); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::SeizeReceiver.id(), + policyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID, + }) + ); + } + + #[test] + fn seize_requires_role() { + let mut tok = token(); + make_seizable(&mut tok); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ADMIN, + neededRole: B20TokenRole::Seize.id(), + }) + ); + } + + #[test] + fn seize_reverts_when_seize_paused() { + let mut tok = token(); + make_seizable(&mut tok); + LOGIC.pause(&mut tok, ADMIN, vec![IB20::PausableFeature::SEIZE], true).unwrap(); + let err = + LOGIC.seize_with_memo(&mut tok, ADMIN, ALICE, BOB, U256::from(1u64), MEMO).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::SEIZE + }) + ); + } + + // --- pause --- + #[test] + fn pause_and_unpause_toggle_feature_bit() { + let mut tok = token(); + LOGIC.pause(&mut tok, ADMIN, vec![IB20::PausableFeature::MINT], true).unwrap(); + assert!(LOGIC.is_paused(&tok, IB20::PausableFeature::MINT).unwrap()); + assert!(!LOGIC.is_paused(&tok, IB20::PausableFeature::TRANSFER).unwrap()); + LOGIC.unpause(&mut tok, ADMIN, vec![IB20::PausableFeature::MINT], true).unwrap(); + assert!(!LOGIC.is_paused(&tok, IB20::PausableFeature::MINT).unwrap()); + } + + #[test] + fn pause_reverts_on_empty_feature_set() { + let mut tok = token(); + let err = LOGIC.pause(&mut tok, ADMIN, vec![], true).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::EmptyFeatureSet {})); + } + + #[test] + fn paused_features_reports_active_set() { + let mut tok = token(); + LOGIC + .pause( + &mut tok, + ADMIN, + vec![ + IB20::PausableFeature::MINT, + IB20::PausableFeature::BURN, + IB20::PausableFeature::SEIZE, + ], + true, + ) + .unwrap(); + let features = LOGIC.paused_features(&tok).unwrap(); + assert_eq!(features.len(), 3); + assert!(features.contains(&IB20::PausableFeature::SEIZE)); + assert!(LOGIC.is_paused(&tok, IB20::PausableFeature::SEIZE).unwrap()); + } + + // --- config / metadata --- + + #[test] + fn update_supply_cap_sets_and_emits() { + let mut tok = token(); + LOGIC.update_supply_cap(&mut tok, ADMIN, U256::from(1_000u64), true).unwrap(); + assert_eq!(tok.accounting().supply_cap().unwrap(), U256::from(1_000u64)); + assert_eq!(last_event_sig(&tok), IB20::SupplyCapUpdated::SIGNATURE_HASH); + } + + #[test] + fn update_supply_cap_reverts_below_current_supply() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(500u64)); + let err = LOGIC.update_supply_cap(&mut tok, ADMIN, U256::from(100u64), true).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InvalidSupplyCap { + currentSupply: U256::from(500u64), + proposedCap: U256::from(100u64), + }) + ); + } + + #[test] + fn update_name_emits_name_updated_and_domain_changed() { + let mut tok = token(); + LOGIC.update_name(&mut tok, ADMIN, "New Name".to_string(), true).unwrap(); + assert_eq!(tok.accounting().name().unwrap(), "New Name"); + let events = &tok.accounting().events; + assert_eq!(events[events.len() - 2].topics()[0], IB20::NameUpdated::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::EIP712DomainChanged::SIGNATURE_HASH); + } + + // --- roles --- + + #[test] + fn grant_role_privileged_grants_and_emits() { + let mut tok = token(); + LOGIC.grant_role(&mut tok, ADMIN, B20TokenRole::Mint.id(), ALICE, true).unwrap(); + assert!(tok.accounting().has_role(B20TokenRole::Mint.id(), ALICE).unwrap()); + assert_eq!(last_event_sig(&tok), IB20::RoleGranted::SIGNATURE_HASH); + } + + #[test] + fn revoke_role_privileged_revokes_and_emits() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Mint.id(), ALICE); + LOGIC.revoke_role(&mut tok, ADMIN, B20TokenRole::Mint.id(), ALICE, true).unwrap(); + assert!(!tok.accounting().has_role(B20TokenRole::Mint.id(), ALICE).unwrap()); + assert_eq!(last_event_sig(&tok), IB20::RoleRevoked::SIGNATURE_HASH); + } + + #[test] + fn revoke_last_admin_is_rejected() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::DefaultAdmin.id(), ADMIN); + let err = LOGIC + .revoke_role(&mut tok, ADMIN, B20TokenRole::DefaultAdmin.id(), ADMIN, true) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); + } + + #[test] + fn grant_role_unchecked_bumps_admin_count() { + let mut tok = token(); + LOGIC + .grant_role_unchecked(&mut tok, B20TokenRole::DefaultAdmin.id(), ADMIN, TOKEN) + .unwrap(); + assert!(tok.accounting().has_role(B20TokenRole::DefaultAdmin.id(), ADMIN).unwrap()); + assert_eq!( + tok.accounting().role_member_count(B20TokenRole::DefaultAdmin.id()).unwrap(), + U256::ONE + ); + } + + // --- policy --- + + #[test] + fn update_policy_sets_new_id() { + let mut tok = token(); + tok.policy_storage_mut().create_existing_policy(7); + LOGIC.update_policy(&mut tok, ADMIN, B20PolicyType::TransferSender.id(), 7, true).unwrap(); + assert_eq!(tok.accounting().policy_id(B20PolicyType::TransferSender.id()).unwrap(), 7); + } + + #[test] + fn update_policy_reverts_when_policy_missing() { + let mut tok = token(); + let err = LOGIC + .update_policy(&mut tok, ADMIN, B20PolicyType::TransferSender.id(), 99, true) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::PolicyNotFound { policyId: 99 })); + } + + #[test] + fn policy_id_rejects_unsupported_scope() { + let tok = token(); + let scope = B256::repeat_byte(0xEE); + let err = LOGIC.policy_id(&tok, scope).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::UnsupportedPolicyType { policyScope: scope }) + ); + } + + // --- permit --- + + #[test] + fn permit_sets_allowance_and_increments_nonce() { + let mut tok = token(); + let owner = anvil_owner(); + let args = signed_permit(&tok, owner, BOB, U256::from(500u64), U256::MAX); + LOGIC.permit(&mut tok, CHAIN_ID, U256::ZERO, args).unwrap(); + assert_eq!(tok.accounting().allowance(owner, BOB).unwrap(), U256::from(500u64)); + assert_eq!(tok.accounting().nonce(owner).unwrap(), U256::ONE); + } + + #[test] + fn permit_reverts_when_expired() { + let mut tok = token(); + let owner = anvil_owner(); + let args = signed_permit(&tok, owner, BOB, U256::from(1u64), U256::from(10u64)); + let err = LOGIC.permit(&mut tok, CHAIN_ID, U256::from(11u64), args).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ExpiredSignature { deadline: U256::from(10u64) }) + ); + } + + #[test] + fn permit_replay_is_rejected() { + let mut tok = token(); + let owner = anvil_owner(); + let args = signed_permit(&tok, owner, BOB, U256::from(1u64), U256::MAX); + LOGIC.permit(&mut tok, CHAIN_ID, U256::ZERO, args.clone()).unwrap(); + // Same (v, r, s): the nonce has advanced, so recovery no longer matches `owner`. + assert!(LOGIC.permit(&mut tok, CHAIN_ID, U256::ZERO, args).is_err()); + } + + #[test] + fn permit_charges_metered_hashing_and_fixed_recovery() { + let mut tok = token(); + let owner = anvil_owner(); + let args = signed_permit(&tok, owner, BOB, U256::from(500u64), U256::MAX); + + // `signed_permit` computes the domain separator during setup, so measure deltas. + let keccak_before = tok.accounting().keccak_charges.get(); + let gas_before = tok.accounting().gas_deducted.get(); + + LOGIC.permit(&mut tok, CHAIN_ID, U256::ZERO, args).unwrap(); + + // domain separator (name, version, encoded) + signing digest (struct hash, digest) = 5. + assert_eq!(tok.accounting().keccak_charges.get() - keccak_before, 5); + // Fixed ECRECOVER-comparable recovery charge, applied exactly once. + assert_eq!(tok.accounting().gas_deducted.get() - gas_before, PermitArgs::RECOVER_GAS); + } + + #[test] + fn caught_revert_permit_loop_charges_each_iteration() { + let mut tok = token(); + let owner = anvil_owner(); + // Realistic DoS shape: a valid, recoverable signature but an unrelated `owner`, so every + // call performs the full EIP-712 hashing and secp256k1 recovery and then reverts + // `InvalidSigner` (before the nonce is bumped, so the same args replays each iteration). + let mut args = signed_permit(&tok, owner, BOB, U256::from(1u64), U256::MAX); + args.owner = Address::repeat_byte(0xcc); + + const ITERATIONS: u64 = 8; + let keccak_before = tok.accounting().keccak_charges.get(); + let gas_before = tok.accounting().gas_deducted.get(); + for _ in 0..ITERATIONS { + assert!(LOGIC.permit(&mut tok, CHAIN_ID, U256::ZERO, args.clone()).is_err()); + } + + // A caught-revert loop no longer runs for free: hashing and recovery metering scale + // linearly with the number of calls. + assert_eq!(tok.accounting().keccak_charges.get() - keccak_before, 5 * ITERATIONS); + assert_eq!( + tok.accounting().gas_deducted.get() - gas_before, + PermitArgs::RECOVER_GAS * ITERATIONS + ); + } + + // --- reads --- + + #[test] + fn currency_reads_storage() { + let tok = token(); + assert_eq!(LOGIC.currency(&tok).unwrap(), "USD"); + } + + #[test] + fn is_initialized_reflects_storage() { + let mut tok = token(); + assert!(LOGIC.is_initialized(&tok).unwrap()); + tok.accounting_mut().initialized = false; + assert!(!LOGIC.is_initialized(&tok).unwrap()); + } + + #[test] + fn domain_separator_is_deterministic_and_chain_specific() { + let tok = token(); + assert_eq!( + LOGIC.domain_separator(&tok, CHAIN_ID).unwrap(), + LOGIC.domain_separator(&tok, CHAIN_ID).unwrap() + ); + assert_ne!( + LOGIC.domain_separator(&tok, 1).unwrap(), + LOGIC.domain_separator(&tok, 2).unwrap() + ); + } + + /// Pins this version's frozen EIP-712 domain typehash to the exact type string it must hash. + /// The constant is duplicated per version so each fork's wire surface stays independently + /// frozen; without this check a typo in one copy would silently change that version's digest. + #[test] + fn domain_typehash_matches_eip712_domain_type() { + let domain_type = + b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; + assert_eq!(super::DOMAIN_TYPEHASH, keccak256(domain_type)); + assert_eq!(super::VERSION, b"1"); + } +} diff --git a/crates/common/precompiles/src/b20_stablecoin/mod.rs b/crates/common/precompiles/src/b20_stablecoin/mod.rs index 366e9336de..7ebc0ddc93 100644 --- a/crates/common/precompiles/src/b20_stablecoin/mod.rs +++ b/crates/common/precompiles/src/b20_stablecoin/mod.rs @@ -12,7 +12,7 @@ mod versions; pub use versions::{StablecoinVersion, StablecoinVersions}; mod logic; -pub use logic::{B20StablecoinToken, Stablecoin, StablecoinV1, StablecoinV2}; +pub use logic::{B20StablecoinToken, Stablecoin, StablecoinV1, StablecoinV2, StablecoinV3}; mod precompile; pub use precompile::B20StablecoinPrecompile; diff --git a/crates/common/precompiles/src/b20_stablecoin/versions.rs b/crates/common/precompiles/src/b20_stablecoin/versions.rs index 0cee6bd7ab..da2d003b8d 100644 --- a/crates/common/precompiles/src/b20_stablecoin/versions.rs +++ b/crates/common/precompiles/src/b20_stablecoin/versions.rs @@ -11,6 +11,7 @@ use base_common_genesis::BaseUpgrade; use crate::{ B20Abi, PolicyAccounting, Stablecoin, StablecoinAccounting, StablecoinV1, StablecoinV2, + StablecoinV3, }; /// An activated version of the stablecoin B-20 precompile logic. @@ -22,6 +23,8 @@ pub enum StablecoinVersion { V1, /// Introduced at Cobalt. V2, + /// Introduced at Denim, superseding [`Self::V2`]. + V3, } impl StablecoinVersion { @@ -33,9 +36,11 @@ impl StablecoinVersion { { static V1: StablecoinV1 = StablecoinV1; static V2: StablecoinV2 = StablecoinV2; + static V3: StablecoinV3 = StablecoinV3; match self { Self::V1 => &V1, Self::V2 => &V2, + Self::V3 => &V3, } } @@ -47,7 +52,8 @@ impl StablecoinVersion { pub const fn common_abi(self) -> B20Abi { match self { Self::V1 => B20Abi::V1, - Self::V2 => B20Abi::V2, + // V3 reuses the Cobalt wire surface until a later Denim PR adds selectors. + Self::V2 | Self::V3 => B20Abi::V2, } } } @@ -63,9 +69,10 @@ impl StablecoinVersions { /// Returns the version active at `upgrade`, or `None` before the introduction /// fork (Beryl), where the stablecoin precompile is not installed at all. /// - /// V1 is active from Beryl; V2 supersedes it from Cobalt. + /// V1 is active from Beryl; V2 supersedes it from Cobalt; V3 supersedes V2 from Denim. pub fn from_base_upgrade(upgrade: BaseUpgrade) -> Option { match upgrade { + u if u >= BaseUpgrade::Denim => Some(StablecoinVersion::V3), u if u >= BaseUpgrade::Cobalt => Some(StablecoinVersion::V2), u if u >= BaseUpgrade::Beryl => Some(StablecoinVersion::V1), _ => None, @@ -77,7 +84,7 @@ impl StablecoinVersions { mod tests { use base_common_genesis::BaseUpgrade; - use crate::{StablecoinVersion, StablecoinVersions}; + use crate::{B20Abi, StablecoinVersion, StablecoinVersions}; #[test] fn resolves_none_before_beryl() { @@ -99,4 +106,30 @@ mod tests { Some(StablecoinVersion::V2) ); } + + #[test] + fn resolves_v3_from_denim() { + assert_eq!( + StablecoinVersions::from_base_upgrade(BaseUpgrade::Denim), + Some(StablecoinVersion::V3) + ); + assert_eq!( + StablecoinVersions::from_base_upgrade(BaseUpgrade::Zenith), + Some(StablecoinVersion::V3) + ); + } + + #[test] + fn each_fork_resolves_to_its_wire_surface() { + assert_eq!(StablecoinVersion::V1.common_abi(), B20Abi::V1); + assert_eq!(StablecoinVersion::V2.common_abi(), B20Abi::V2); + assert_eq!(StablecoinVersion::V3.common_abi(), B20Abi::V2); + + let beryl = StablecoinVersions::from_base_upgrade(BaseUpgrade::Beryl).unwrap(); + let cobalt = StablecoinVersions::from_base_upgrade(BaseUpgrade::Cobalt).unwrap(); + let denim = StablecoinVersions::from_base_upgrade(BaseUpgrade::Denim).unwrap(); + assert_eq!(beryl.common_abi(), B20Abi::V1); + assert_eq!(cobalt.common_abi(), B20Abi::V2); + assert_eq!(denim.common_abi(), B20Abi::V2); + } } diff --git a/crates/common/precompiles/src/lib.rs b/crates/common/precompiles/src/lib.rs index 72be9f650e..109ab78448 100644 --- a/crates/common/precompiles/src/lib.rs +++ b/crates/common/precompiles/src/lib.rs @@ -61,16 +61,16 @@ pub use metrics::{ mod b20_asset; pub(crate) use b20_asset::AssetCall; pub use b20_asset::{ - Asset, AssetAbi, AssetAbiPair, AssetAccounting, AssetV1, AssetV2, AssetVersion, AssetVersions, - B20AssetExtensionStorage, B20AssetInit, B20AssetPrecompile, B20AssetStorage, B20AssetToken, - ERC165_INTERFACE_ID, ERC8056_INTERFACE_IDS, IB20Asset, IB20AssetV1, IB20AssetV2, + Asset, AssetAbi, AssetAbiPair, AssetAccounting, AssetV1, AssetV2, AssetV3, AssetVersion, + AssetVersions, B20AssetExtensionStorage, B20AssetInit, B20AssetPrecompile, B20AssetStorage, + B20AssetToken, ERC165_INTERFACE_ID, ERC8056_INTERFACE_IDS, IB20Asset, IB20AssetV1, IB20AssetV2, }; mod b20_stablecoin; pub use b20_stablecoin::{ B20StablecoinExtensionStorage, B20StablecoinInit, B20StablecoinPrecompile, B20StablecoinStorage, B20StablecoinToken, IB20Stablecoin, Stablecoin, StablecoinAccounting, - StablecoinV1, StablecoinV2, StablecoinVersion, StablecoinVersions, + StablecoinV1, StablecoinV2, StablecoinV3, StablecoinVersion, StablecoinVersions, }; mod b20_factory; diff --git a/crates/common/precompiles/tests/b20_asset_v3_golden.rs b/crates/common/precompiles/tests/b20_asset_v3_golden.rs new file mode 100644 index 0000000000..1d5d0a592e --- /dev/null +++ b/crates/common/precompiles/tests/b20_asset_v3_golden.rs @@ -0,0 +1,3811 @@ +//! Golden tests pinning Asset **V3** behavior of the B-20 precompile. +//! +//! V3 is activated at Denim as a behavior-preserving copy of V2 (a scaffold seam for future +//! Denim-era changes). Its logic and storage layout are identical to V2, so this suite pins its +//! own roots to lock V3's behavior independently: a future edit that alters V3 must re-bless these +//! roots. These goldens run production Cobalt storage features (Denim inherits them via +//! `upgrade >= Cobalt`) while the V1 suite runs Legacy, so a root matches V2's only when the op +//! does not diverge; ops that write shrinking dynamic values can legitimately diverge from V1's +//! Legacy pin. +//! +//! Every op is driven through the **version-resolver-gated** dispatch path +//! (`BaseUpgrade::Denim` -> `AssetVersion::V3`) against the real EVM-backed `B20AssetStorage` +//! over `HashMapStorageProvider` configured with `StorageFeatures::Cobalt` (the production +//! storage config at Denim), with a `FakePolicyAccounting` for deterministic allow/block +//! decisions. Each case asserts: +//! 1. exact returned ABI bytes (or the typed revert), +//! 2. resulting state (balances / supply / roles / allowances / multiplier / metadata / storage), +//! 3. emitted events, and +//! 4. a per-case keccak storage **hash** snapshot (the frozen-manifest baseline). +//! +//! ## Blessing storage hashes +//! State-root constants below are pinned. To (re)generate them after an intentional change, run: +//! `BLESS_GOLDEN=1 cargo test -p base-common-precompiles --features test-utils \ +//! --test b20_asset_v3_golden -- --nocapture` and copy the printed `GOLDEN_ROOT` values. + +use alloy_primitives::{Address, B256, Bytes, U256, b256, keccak256}; +use alloy_sol_types::{SolCall, SolError, SolEvent, SolValue}; +use base_common_genesis::BaseUpgrade; +use base_common_precompiles::{ + Asset, AssetAccounting, AssetV3, AssetVersion, AssetVersions, B20_MAX_SUPPLY_CAP, B20AssetInit, + B20AssetStorage, B20AssetToken, B20PolicyType, B20TokenRole, ERC165_INTERFACE_ID, + ERC8056_INTERFACE_IDS, FakePolicyAccounting, IB20, IB20Asset, NoopPrecompileCallObserver, + PolicyVersion, TokenAccounting, UpgradeGatedStorageFeatures, +}; +use base_precompile_storage::{BasePrecompileError, Handler, HashMapStorageProvider, StorageCtx}; + +mod common; +use common::{ + ADMIN, ALICE, BOB, CAROL, CHAIN_ID, MEMO, POLICY_ID, TOKEN, anvil_owner, bless_or_assert_gas, + bless_or_assert_root, hash_token_state, ok_true, signed_permit, u, +}; + +// --- fixtures --------------------------------------------------------------- + +const NAME: &str = "Base Asset"; +const SYMBOL: &str = "bASSET"; +const DECIMALS: u8 = 6; +const LOGIC: AssetV3 = AssetV3; + +/// A second policy id distinct from [`POLICY_ID`], used where a test needs both a +/// seize-holder and a seize-receiver scope configured simultaneously. +const POLICY_ID_2: u64 = (1u64 << 56) | 8; + +// --- pinned storage hashes (bless with BLESS_GOLDEN=1; see module docs) -------- +// +// V3 is a behavior- and layout-preserving copy of V2, so these track V2's roots — but pinned +// independently here: these goldens run `StorageFeatures::Cobalt` (inherited at Denim) while the +// V1 suite runs Legacy, so an op that triggers Cobalt dynamic tail cleanup can diverge from its +// V1 pin. Re-blessing reflects the true Denim snapshot for each. + +const ROOT_FRESH: B256 = b256!("e29cabf2f5d0e0eedebf4697b61ad93a4a24fa2d911d4004d641bb0b123fa091"); +const ROOT_TRANSFER_PRIV: B256 = + b256!("8a8c95a5a46fd1968f924479897846bdd2a1cc88c8365741c782c47b0d3ffa81"); +const ROOT_TRANSFER_UNPRIV: B256 = + b256!("63c542ccf49f3be8d67a0e5fc4d1d0f6126e81157d1b1b53b969154ddf9a4f8b"); +const ROOT_TRANSFER_WITH_MEMO: B256 = + b256!("1373d29ddaf8cf795c185c179ccd72a3be6a518eaf6e909bc660f0d9c430faa2"); +const ROOT_TRANSFER_FROM_FINITE: B256 = + b256!("826132aed4b216cb6157dfbe2eee15c5529b40c640532dc1edd39023d7bfe43e"); +const ROOT_TRANSFER_FROM_INFINITE: B256 = + b256!("c7c98558f1eefd845c4c8ae6994cfe27921f7e1f75b32a06b1042aea23eedbed"); +const ROOT_TRANSFER_FROM_WITH_MEMO: B256 = + b256!("d8a423aeeb838ccf840c3304426a44affa08f7e4c73dab6b6d0355ea9a93cb55"); +const ROOT_APPROVE: B256 = + b256!("eb54df6e31bdfe6ff3b9c898e776c7a9aa6249604c421c247014bfe38c5320d5"); +const ROOT_MINT_PRIV: B256 = + b256!("9028416ca6edfb4e75add6f4f9a93b5bf19fe6a04d5b57c5a49ded3009cf3f39"); +const ROOT_MINT_UNPRIV: B256 = + b256!("3374c9af2e12ba5ec092f1c4f1f52142110af23ac420f0eb5153bc18b2788666"); +const ROOT_MINT_WITH_MEMO: B256 = + b256!("7d1fce256019cc70fd7f68fb4f6070aca3fb313f4a31ed99aa42acacc65affe8"); +const ROOT_BURN: B256 = b256!("ade155c4138f7b71f96ace7ac3a5804c10e94c421d79331c790867ba79bf66c1"); +const ROOT_BURN_WITH_MEMO: B256 = + b256!("f2dc0e262f62cde4e299e8d2bba339f9cd2d28cc25607a327553d324db6e7596"); +const ROOT_BURN_BLOCKED: B256 = + b256!("5249765fc1a60e0cca911b0dfca10bcc7c4afd23e15b1b7dbc3d4e91bb9e4603"); +const ROOT_PAUSE: B256 = b256!("1c2af409faaa966d7e866226209ad6e34784c8f6b34771a5857f3592cdd269fa"); +const ROOT_UNPAUSE: B256 = + b256!("6487edf62f45d0209b1d93e668a6d7b7879133062927908c2fd2addd0d7c649f"); +const ROOT_UPDATE_SUPPLY_CAP: B256 = + b256!("b7da26ed4139cc7f730792550ee8ce46e0c1d9d7fe480e580d6772ea5000d09b"); +const ROOT_UPDATE_NAME: B256 = + b256!("1d4993119f19447ea2738eafadf0dd6fd4d9843ebff11af78f6140c60522f3cd"); +const ROOT_UPDATE_SYMBOL: B256 = + b256!("5948dd0afd2cfe783d54fa684a0e79f74afb28ebf3a699868f0e43a607039b9d"); +const ROOT_UPDATE_CONTRACT_URI: B256 = + b256!("b9110c98a36b52516f37b6e7b2f1ca7bf45a7a69d882bd73fbd12307d0c24aa7"); +const ROOT_GRANT_ROLE: B256 = + b256!("db7327c728187786e000a33766fc33d14f6e990b40b467388e46bf92fe1270f4"); +const ROOT_REVOKE_ROLE: B256 = + b256!("bae23d9d6012567505cb15687219c903a7f6264221ae1ce651b157f796116791"); +const ROOT_RENOUNCE_ROLE: B256 = + b256!("d51de5372be373c54b010cf92edd87a100b94142641b251e1cc0d3a38d35a50a"); +const ROOT_RENOUNCE_LAST_ADMIN: B256 = + b256!("4e3aaeac81242cb1de6434972071d768a40283dc2b428b915125db89b5a35d5d"); +const ROOT_SET_ROLE_ADMIN: B256 = + b256!("8b9c7bb4b91bea8469409833f010f0e7d599bec804863b28f8d7c643d2e8f9ee"); +const ROOT_UPDATE_POLICY: B256 = + b256!("17c384f3c7009cde9c64520ca48e735f0e74ff44a1c20f5f835252ed5316d633"); +const ROOT_PERMIT: B256 = b256!("073a87de0e60125aeb9df668b6d9462d33559bd8696360dc0fc87ad062545803"); +const ROOT_MULTIPLIER_SCALED: B256 = + b256!("686201a1ea687d57677c2b0bbf82da29be8e43b7028935651294f77cbd4e32e2"); +const ROOT_ANNOUNCE_ID_USED: B256 = + b256!("37d104dac2c6bafec2e0eb2ad0c48e463dfae4d239102367249be33c55446cdb"); +const ROOT_EXTRA_METADATA_READ: B256 = + b256!("a7235b360a5c2cf33b1706c9c37c25aff8cf029b6072335922e9bda7616c632f"); +const ROOT_BATCH_MINT: B256 = + b256!("f3c6787115aee5f808fae895bc4a6f1eb895a476e273de0e4e2e35de70bc84e8"); +const ROOT_METADATA_SET: B256 = + b256!("eb23e81c02299fc34cf9471b38d9f606e59fcf6dd6d0aff446b9f831e90eba8a"); +const ROOT_METADATA_REMOVE: B256 = + b256!("28c8bef2b1b23198ab03bef1cae027031f9e993be2630f16218f0cf8a73422eb"); +const ROOT_GRANT_DEFAULT_ADMIN: B256 = + b256!("e47421a3157f752161dd7daaa3a042639cb27e92a7d6045a0a541f014feb03ec"); +const ROOT_GRANT_IDEMPOTENT: B256 = + b256!("05e26defbbd563cd07319fe3155331476630d5ea43ff8da11ac2283c01253cb8"); + +// --- pinned storage hashes: V2-only (bless with BLESS_GOLDEN=1) -------------- +// +// Genuinely new at V2, or diverging from V1's pinned behavior (updateMultiplier's event changed; +// announce's inner updateMultiplier call therefore emits a different event too). + +const ROOT_UPDATE_MULTIPLIER_V2: B256 = + b256!("fedb235aa7953b3224856747a42ef936f814559763791f14126249a05f5f9a6b"); +const ROOT_UPDATE_MULTIPLIER_CLEARS_PENDING: B256 = + b256!("1bcd7dd7a84d36e144d3f9a4547acda2eb80f6622ed7eec9a8cd6ad3a021f0ba"); +const ROOT_UPDATE_UI_MULTIPLIER: B256 = + b256!("45900c3ea027f3f8b2943b15ce566961186a69b2b0ce8b0f4a1578cad75f6ee9"); +const ROOT_UPDATE_UI_MULTIPLIER_FOLDS_MATURED: B256 = + b256!("a0942971f4c3b38bd0d2ebd19730bda7585f517774ffaf8d7c5ba21c5cf63475"); +const ROOT_CANCEL_UI_MULTIPLIER_UPDATE: B256 = + b256!("7473a102f829e2eeea1b73c3458a4b88422d022a8faea4189ac91cb20793a81c"); +const ROOT_SEIZE: B256 = b256!("1aba53dba437cfde3f7938c1d4c02cce8c3df5b629dfa49637edaac20e052a6a"); +const ROOT_ANNOUNCE_V2: B256 = + b256!("5b3ea1fe17f441ce9e5d82f0764ce177f818fad8118cd1358fe74cbfafcde5e1"); + +// --- harness ---------------------------------------------------------------- + +/// Fresh provider with an initialized `Base Asset` at [`TOKEN`], matching the factory +/// bootstrap: the multiplier slot is left physically zero and the getter normalizes it to WAD. +fn fresh() -> HashMapStorageProvider { + let mut storage = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + StorageCtx::enter(&mut storage, |ctx| { + let mut token = B20AssetStorage::from_address(TOKEN, ctx); + token + .initialize(B20AssetInit { + name: NAME.into(), + symbol: SYMBOL.into(), + supply_cap: B20_MAX_SUPPLY_CAP, + multiplier: U256::ZERO, // factory INITIAL_MULTIPLIER: physical zero, getter returns WAD + decimals: DECIMALS, + }) + .expect("initialize asset"); + }); + storage +} + +/// Mutates raw token storage through the accounting port (test setup only). +fn seed(storage: &mut HashMapStorageProvider, f: impl FnOnce(&mut B20AssetStorage<'_>)) { + StorageCtx::enter(storage, |ctx| { + let mut token = B20AssetStorage::from_address(TOKEN, ctx); + f(&mut token); + }); +} + +/// Reads token state through the accounting port. +fn read(storage: &mut HashMapStorageProvider, f: impl FnOnce(&B20AssetStorage<'_>) -> R) -> R { + StorageCtx::enter(storage, |ctx| f(&B20AssetStorage::from_address(TOKEN, ctx))) +} + +/// Advances the provider's block timestamp, for maturity/scheduling assertions. +const fn warp(storage: &mut HashMapStorageProvider, ts: U256) { + storage.set_timestamp(ts); +} + +/// Drives one op through the resolver-gated (`Denim` -> V3) unprivileged path. +fn op( + storage: &mut HashMapStorageProvider, + caller: Address, + policy: FakePolicyAccounting, + calldata: Vec, +) -> Result { + storage.set_caller(caller); + StorageCtx::enter(storage, |ctx| { + let version = + AssetVersions::from_base_upgrade(BaseUpgrade::Denim).expect("Denim activates V3"); + B20AssetToken::with_storage_and_policy( + B20AssetStorage::from_address(TOKEN, ctx), + policy, + PolicyVersion::V3, + ) + .route(ctx, &calldata, version, false, NoopPrecompileCallObserver) + }) +} + +/// Drives one op through V3 with factory-init privilege (guards skipped). +fn op_privileged( + storage: &mut HashMapStorageProvider, + caller: Address, + policy: FakePolicyAccounting, + calldata: Vec, +) -> Result { + storage.set_caller(caller); + StorageCtx::enter(storage, |ctx| { + B20AssetToken::with_storage_and_policy( + B20AssetStorage::from_address(TOKEN, ctx), + policy, + PolicyVersion::V3, + ) + .route(ctx, &calldata, AssetVersion::V3, true, NoopPrecompileCallObserver) + }) +} + +/// Topic-0 (signature hash) of the last event emitted by the token. +fn last_topic0(storage: &HashMapStorageProvider) -> B256 { + storage.get_events(TOKEN).last().expect("an emitted event").topics()[0] +} + +/// Asserts the token's storage hash, or prints it under `BLESS_GOLDEN` for (re)pinning. +#[track_caller] +fn assert_root(label: &str, storage: HashMapStorageProvider, expected: B256) { + bless_or_assert_root(label, hash_token_state(storage, TOKEN), expected); +} + +/// Grants `role` to `who` and bumps the role member count (setup only). +fn give_role(token: &mut B20AssetStorage<'_>, role: B256, who: Address) { + token.set_role(role, who, true).unwrap(); + let next = token.role_member_count(role).unwrap() + U256::ONE; + token.set_role_member_count(role, next).unwrap(); +} + +/// Credits `who` with `amount` and grows total supply to match (setup only). +fn fund(token: &mut B20AssetStorage<'_>, who: Address, amount: U256) { + let balance = token.balance_of(who).unwrap(); + token.set_balance(who, balance + amount).unwrap(); + let supply = token.total_supply().unwrap(); + token.set_total_supply(supply + amount).unwrap(); +} + +/// The asset operator role id: `keccak256("OPERATOR_ROLE")` (frozen identically at V1 and V2). +fn operator_role() -> B256 { + keccak256("OPERATOR_ROLE") +} + +/// The V2 EIP-712 domain separator for the token at [`TOKEN`] on [`CHAIN_ID`]. +fn domain_separator(storage: &mut HashMapStorageProvider) -> B256 { + StorageCtx::enter(storage, |ctx| { + let token = B20AssetToken::with_storage_and_policy( + B20AssetStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ); + LOGIC.domain_separator(&token, CHAIN_ID).unwrap() + }) +} + +/// Configures `from` as seizable (not authorized by `SeizeExempt`) and `to` as an authorized +/// `SeizeReceiver`, using two distinct policy ids so the two scopes are independently exercised. +fn make_seizable(token: &mut B20AssetStorage<'_>) { + token.set_policy_id(B20PolicyType::SeizeExempt.id(), POLICY_ID).unwrap(); + token.set_policy_id(B20PolicyType::SeizeReceiver.id(), POLICY_ID_2).unwrap(); +} + +/// A `FakePolicyAccounting` that authorizes `who` under [`POLICY_ID_2`] (the seize-receiver +/// scope configured by [`make_seizable`]), leaving [`POLICY_ID`] (seize-holder) with no members. +fn seize_receiver_policy(who: Address) -> FakePolicyAccounting { + let mut policy = FakePolicyAccounting::new(); + policy.allow(POLICY_ID_2, who); + policy +} + +/// A `FakePolicyAccounting` authorizing `who` under the default (0) scope. +fn allow0(who: Address) -> FakePolicyAccounting { + let mut p = FakePolicyAccounting::new(); + p.allow(0, who); + p +} + +/// Asserts an unprivileged metadata/admin op reverts for a caller lacking `role`. +#[track_caller] +fn assert_unprivileged_requires_role(calldata: Vec, role: B256) { + let mut s = fresh(); + let err = op(&mut s, ALICE, FakePolicyAccounting::new(), calldata).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: role, + }) + ); +} + +// ============================================================================ +// version resolver / dispatch envelope +// ============================================================================ + +#[test] +fn resolver_resolves_v3_from_denim() { + assert_eq!(AssetVersions::from_base_upgrade(BaseUpgrade::Denim), Some(AssetVersion::V3)); +} + +#[test] +fn dispatch_rejects_nonzero_value() { + let mut s = fresh(); + let calldata = IB20::balanceOfCall { account: ALICE }.abi_encode(); + s.set_call_value(U256::ONE); + let out = StorageCtx::enter(&mut s, |ctx| { + B20AssetToken::with_storage_and_policy( + B20AssetStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ) + .dispatch_with_observer( + ctx, + &calldata, + BaseUpgrade::Denim, + NoopPrecompileCallObserver, + ) + }) + .expect("dispatch must not fatally error"); + assert!(out.is_revert()); + assert_eq!(out.bytes, Bytes::from(IB20::NonPayable {}.abi_encode())); +} + +#[test] +fn dispatch_reverts_when_uninitialized() { + let mut s = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + let calldata = IB20::balanceOfCall { account: ALICE }.abi_encode(); + let out = StorageCtx::enter(&mut s, |ctx| { + B20AssetToken::with_storage_and_policy( + B20AssetStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ) + .dispatch_with_observer( + ctx, + &calldata, + BaseUpgrade::Denim, + NoopPrecompileCallObserver, + ) + }) + .expect("dispatch must not fatally error"); + assert!(out.is_revert()); + assert!(out.bytes.is_empty()); +} + +#[test] +fn golden_dispatch_no_observer_wrapper_reverts_uninitialized() { + // Exercises the no-observer `dispatch()` wrapper + the is_initialized=false gate. + let mut s = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + s.set_caller(ALICE); + let calldata = IB20::balanceOfCall { account: ALICE }.abi_encode(); + let out = StorageCtx::enter(&mut s, |ctx| { + B20AssetToken::with_storage_and_policy( + B20AssetStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ) + .dispatch(ctx, &calldata, BaseUpgrade::Denim) + }) + .expect("dispatch must not fatally error"); + assert!(out.is_revert()); +} + +// ============================================================================ +// transfer (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_transfer_privileged() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + + let out = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(30) }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| { + assert_eq!(t.balance_of(ALICE).unwrap(), u(70)); + assert_eq!(t.balance_of(BOB).unwrap(), u(30)); + }); + assert_eq!(last_topic0(&s), IB20::Transfer::SIGNATURE_HASH); + assert_root("transfer_privileged", s, ROOT_TRANSFER_PRIV); +} + +#[test] +fn golden_transfer_unprivileged_allowed() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + t.set_policy_id(B20PolicyType::TransferReceiver.id(), POLICY_ID).unwrap(); + }); + let mut policy = FakePolicyAccounting::new(); + policy.allow(POLICY_ID, ALICE); + policy.allow(POLICY_ID, BOB); + let out = op(&mut s, ALICE, policy, IB20::transferCall { to: BOB, amount: u(10) }.abi_encode()) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| { + assert_eq!(t.balance_of(ALICE).unwrap(), u(90)); + assert_eq!(t.balance_of(BOB).unwrap(), u(10)); + }); + assert_root("transfer_unprivileged", s, ROOT_TRANSFER_UNPRIV); +} + +#[test] +fn golden_transfer_unprivileged_blocked_sender_reverts() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(10) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferSender.id(), + policyId: POLICY_ID, + }) + ); +} + +#[test] +fn golden_transfer_reverts_zero_receiver() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(10))); + let err = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: Address::ZERO, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); +} + +#[test] +fn golden_transfer_reverts_zero_sender() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + Address::ZERO, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidSender { sender: Address::ZERO })); +} + +#[test] +fn golden_transfer_reverts_insufficient_balance() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(10))); + let err = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(50) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: u(10), + needed: u(50), + }) + ); +} + +#[test] +fn golden_transfer_reverts_when_paused() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(10))); + op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::TRANSFER] }.abi_encode(), + ) + .unwrap(); + let err = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::TRANSFER + }) + ); +} + +#[test] +fn golden_transfer_with_memo_emits_transfer_then_memo() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + let out = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferWithMemoCall { to: BOB, amount: u(30), memo: MEMO }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_root("transfer_with_memo", s, ROOT_TRANSFER_WITH_MEMO); +} + +// ============================================================================ +// transferFrom (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_transfer_from_finite_allowance_decrements() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(40)).unwrap(); + }); + let out = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(30) }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| { + assert_eq!(t.allowance(ALICE, BOB).unwrap(), u(10)); + assert_eq!(t.balance_of(BOB).unwrap(), u(30)); + }); + assert_root("transfer_from_finite", s, ROOT_TRANSFER_FROM_FINITE); +} + +#[test] +fn golden_transfer_from_infinite_allowance_not_decremented() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, U256::MAX).unwrap(); + }); + let out = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(30) }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| assert_eq!(t.allowance(ALICE, BOB).unwrap(), U256::MAX)); + assert_root("transfer_from_infinite", s, ROOT_TRANSFER_FROM_INFINITE); +} + +#[test] +fn golden_transfer_from_reverts_insufficient_allowance() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(5)).unwrap(); + }); + let err = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(30) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientAllowance { + spender: BOB, + allowance: u(5), + needed: u(30), + }) + ); +} + +#[test] +fn golden_transfer_from_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(40)).unwrap(); + }); + seed(&mut s, |t| { + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: CAROL, amount: u(10) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + +#[test] +fn golden_transfer_from_with_memo() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(40)).unwrap(); + }); + let out = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromWithMemoCall { from: ALICE, to: CAROL, amount: u(30), memo: MEMO } + .abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_root("transfer_from_with_memo", s, ROOT_TRANSFER_FROM_WITH_MEMO); +} + +#[test] +fn golden_transfer_from_reverts_zero_receiver() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: Address::ZERO, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); +} + +#[test] +fn golden_transfer_from_reverts_zero_sender() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: Address::ZERO, to: CAROL, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidSender { sender: Address::ZERO })); +} + +// ============================================================================ +// approve (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_approve_sets_allowance_and_emits() { + let mut s = fresh(); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::approveCall { spender: BOB, amount: u(50) }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| assert_eq!(t.allowance(ALICE, BOB).unwrap(), u(50))); + assert_eq!(last_topic0(&s), IB20::Approval::SIGNATURE_HASH); + assert_root("approve", s, ROOT_APPROVE); +} + +#[test] +fn golden_approve_reverts_zero_spender() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::approveCall { spender: Address::ZERO, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidSpender { spender: Address::ZERO })); +} + +#[test] +fn golden_approve_reverts_zero_approver() { + let mut s = fresh(); + let err = op( + &mut s, + Address::ZERO, + FakePolicyAccounting::new(), + IB20::approveCall { spender: BOB, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidApprover { approver: Address::ZERO })); +} + +// ============================================================================ +// mint (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_mint_privileged_still_enforces_receiver_policy() { + let mut s = fresh(); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let out = op_privileged( + &mut s, + ADMIN, + policy, + IB20::mintCall { to: BOB, amount: u(100) }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.balance_of(BOB).unwrap(), u(100)); + assert_eq!(t.total_supply().unwrap(), u(100)); + }); + assert_eq!(last_topic0(&s), IB20::Transfer::SIGNATURE_HASH); + assert_root("mint_privileged", s, ROOT_MINT_PRIV); +} + +#[test] +fn golden_mint_unprivileged_requires_role_and_policy() { + let mut s = fresh(); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let err = op(&mut s, ALICE, policy, IB20::mintCall { to: BOB, amount: u(1) }.abi_encode()) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Mint.id(), + }) + ); + + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let out = + op(&mut s, ALICE, policy, IB20::mintCall { to: BOB, amount: u(75) }.abi_encode()).unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.balance_of(BOB).unwrap(), u(75))); + assert_root("mint_unprivileged", s, ROOT_MINT_UNPRIV); +} + +#[test] +fn golden_mint_reverts_over_supply_cap() { + let mut s = fresh(); + seed(&mut s, |t| t.set_supply_cap(u(50)).unwrap()); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let err = op_privileged( + &mut s, + ADMIN, + policy, + IB20::mintCall { to: BOB, amount: u(100) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::SupplyCapExceeded { cap: u(50), attempted: u(100) }) + ); +} + +#[test] +fn golden_mint_reverts_zero_receiver() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::mintCall { to: Address::ZERO, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); +} + +#[test] +fn golden_mint_with_memo() { + let mut s = fresh(); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let out = op_privileged( + &mut s, + ADMIN, + policy, + IB20::mintWithMemoCall { to: BOB, amount: u(40), memo: MEMO }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_root("mint_with_memo", s, ROOT_MINT_WITH_MEMO); +} + +// ============================================================================ +// burn / burnBlocked (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_burn_requires_role_then_reduces_supply() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnCall { amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Burn.id(), + }) + ); + + seed(&mut s, |t| give_role(t, B20TokenRole::Burn.id(), ALICE)); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnCall { amount: u(40) }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.balance_of(ALICE).unwrap(), u(60)); + assert_eq!(t.total_supply().unwrap(), u(60)); + }); + assert_eq!(last_topic0(&s), IB20::Transfer::SIGNATURE_HASH); + assert_root("burn", s, ROOT_BURN); +} + +#[test] +fn golden_burn_reverts_insufficient_balance() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(10)); + give_role(t, B20TokenRole::Burn.id(), ALICE); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnCall { amount: u(50) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: u(10), + needed: u(50), + }) + ); +} + +#[test] +fn golden_burn_with_memo() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Burn.id(), ALICE); + }); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnWithMemoCall { amount: u(40), memo: MEMO }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_root("burn_with_memo", s, ROOT_BURN_WITH_MEMO); +} + +#[test] +fn golden_burn_blocked_destroys_from_blocked_account() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + }); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::burnBlockedCall { from: ALICE, amount: u(40) }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.balance_of(ALICE).unwrap(), u(60))); + assert_eq!(last_topic0(&s), IB20::BurnedBlocked::SIGNATURE_HASH); + assert_root("burn_blocked", s, ROOT_BURN_BLOCKED); +} + +#[test] +fn golden_burn_blocked_reverts_when_not_blocked() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + }); + let mut policy = FakePolicyAccounting::new(); + policy.allow(POLICY_ID, ALICE); + let err = op_privileged( + &mut s, + ADMIN, + policy, + IB20::burnBlockedCall { from: ALICE, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccountNotBlocked { account: ALICE })); +} + +#[test] +fn golden_burn_blocked_unprivileged_requires_role() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnBlockedCall { from: BOB, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::BurnBlocked.id(), + }) + ); +} + +// ============================================================================ +// seizeWithMemo (new at V2) +// ============================================================================ + +#[test] +fn golden_seize_moves_balance_emits_transfer_memo_seized() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + let out = op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(40), memo: MEMO }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty(), "seizeWithMemo is a void admin op (no bool return)"); + read(&mut s, |t| { + assert_eq!(t.balance_of(ALICE).unwrap(), u(60)); + assert_eq!(t.balance_of(BOB).unwrap(), u(40)); + }); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 3].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_eq!( + events[events.len() - 1], + IB20::Seized { caller: ADMIN, from: ALICE, to: BOB, amount: u(40) }.encode_log_data() + ); + assert_root("seize", s, ROOT_SEIZE); +} + +#[test] +fn golden_seize_reverts_missing_role() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + make_seizable(t); + }); + let err = op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ADMIN, + neededRole: B20TokenRole::Seize.id(), + }) + ); +} + +#[test] +fn golden_seize_reverts_zero_receiver() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + let err = op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::seizeWithMemoCall { from: ALICE, to: Address::ZERO, amount: u(1), memo: MEMO } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); +} + +#[test] +fn golden_seize_reverts_zero_from() { + let mut s = fresh(); + seed(&mut s, |t| { + give_role(t, B20TokenRole::Seize.id(), ADMIN); + // A non-default `SeizeExempt` treats the zero address as seizable; the `from != 0` guard + // still rejects a zero-amount seize that would otherwise emit a mint-like Transfer(0x0,..). + make_seizable(t); + }); + let err = op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::seizeWithMemoCall { from: Address::ZERO, to: BOB, amount: u(0), memo: MEMO } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidSender { sender: Address::ZERO })); +} + +#[test] +fn golden_seize_reverts_account_not_seizable() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + // ALICE authorized under SeizeExempt => not seizable. + t.set_policy_id(B20PolicyType::SeizeExempt.id(), POLICY_ID).unwrap(); + }); + let mut policy = FakePolicyAccounting::new(); + policy.allow(POLICY_ID, ALICE); + let err = op( + &mut s, + ADMIN, + policy, + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccountNotSeizable { account: ALICE })); +} + +#[test] +fn golden_seize_reverts_insufficient_balance() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(10)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + let err = op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(50), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: u(10), + needed: u(50), + }) + ); +} + +#[test] +fn golden_seize_reverts_when_paused() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::SEIZE] }.abi_encode(), + ) + .unwrap(); + let err = op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { feature: IB20::PausableFeature::SEIZE }) + ); +} + +#[test] +fn golden_seize_enforces_receiver_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + // No FakePolicyAccounting authorization for BOB under POLICY_ID_2 (SeizeReceiver) => blocked. + let err = op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::SeizeReceiver.id(), + policyId: POLICY_ID_2, + }) + ); +} + +#[test] +fn golden_seize_privileged_still_enforces_role_and_seizable() { + // seize_with_memo never accepts a `privileged` bypass: factory-privileged dispatch still + // enforces both the role and the seizability guard. + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ADMIN, + neededRole: B20TokenRole::Seize.id(), + }) + ); +} + +#[test] +fn golden_read_seize_role_and_policy_constants() { + let mut s = fresh(); + let cases: Vec<(Vec, B256)> = vec![ + (IB20::SEIZE_ROLECall {}.abi_encode(), B20TokenRole::Seize.id()), + (IB20::SEIZE_EXEMPT_POLICYCall {}.abi_encode(), B20PolicyType::SeizeExempt.id()), + (IB20::SEIZE_RECEIVER_POLICYCall {}.abi_encode(), B20PolicyType::SeizeReceiver.id()), + ]; + for (calldata, expected) in cases { + let out = op(&mut s, ALICE, FakePolicyAccounting::new(), calldata).unwrap(); + assert_eq!(out, Bytes::from(expected.abi_encode())); + } + assert_root("read_seize_constants", s, ROOT_FRESH); +} + +// ============================================================================ +// pause / unpause (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_pause_sets_feature_bit() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + assert_eq!(last_topic0(&s), IB20::Paused::SIGNATURE_HASH); + assert_root("pause", s, ROOT_PAUSE); +} + +#[test] +fn golden_unpause_clears_feature_bit() { + let mut s = fresh(); + op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::unpauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + assert_eq!(last_topic0(&s), IB20::Unpaused::SIGNATURE_HASH); + assert_root("unpause", s, ROOT_UNPAUSE); +} + +#[test] +fn golden_unpause_reverts_empty_feature_set() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::unpauseCall { features: vec![] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::EmptyFeatureSet {})); +} + +#[test] +fn golden_unpause_unprivileged_requires_role() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::unpauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Unpause.id(), + }) + ); +} + +#[test] +fn golden_pause_reverts_empty_feature_set() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::EmptyFeatureSet {})); +} + +#[test] +fn golden_pause_unprivileged_requires_role() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Pause.id(), + }) + ); +} + +#[test] +fn golden_read_is_paused_and_paused_features_includes_seize() { + let mut s = fresh(); + op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::SEIZE] }.abi_encode(), + ) + .unwrap(); + + let paused_seize = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::isPausedCall { feature: IB20::PausableFeature::SEIZE }.abi_encode(), + ) + .unwrap(); + assert_eq!(paused_seize, Bytes::from(true.abi_encode())); + + let features = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20::pausedFeaturesCall {}.abi_encode()) + .unwrap(); + assert_eq!(features, Bytes::from(vec![IB20::PausableFeature::SEIZE].abi_encode())); +} + +// ============================================================================ +// config / metadata (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_update_supply_cap() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSupplyCapCall { newSupplyCap: u(1_000) }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.supply_cap().unwrap(), u(1_000))); + assert_eq!(last_topic0(&s), IB20::SupplyCapUpdated::SIGNATURE_HASH); + assert_root("update_supply_cap", s, ROOT_UPDATE_SUPPLY_CAP); +} + +#[test] +fn golden_update_supply_cap_reverts_below_supply() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(500))); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSupplyCapCall { newSupplyCap: u(100) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InvalidSupplyCap { + currentSupply: u(500), + proposedCap: u(100), + }) + ); +} + +#[test] +fn golden_update_name_emits_name_and_domain_changed() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateNameCall { newName: "New Name".into() }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.name().unwrap(), "New Name")); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::NameUpdated::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::EIP712DomainChanged::SIGNATURE_HASH); + assert_root("update_name", s, ROOT_UPDATE_NAME); +} + +#[test] +fn golden_update_symbol() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSymbolCall { newSymbol: "USDX".into() }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.symbol().unwrap(), "USDX")); + assert_eq!(last_topic0(&s), IB20::SymbolUpdated::SIGNATURE_HASH); + assert_root("update_symbol", s, ROOT_UPDATE_SYMBOL); +} + +#[test] +fn golden_update_contract_uri() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateContractURICall { newURI: "ipfs://x".into() }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.contract_uri().unwrap(), "ipfs://x")); + assert_eq!(last_topic0(&s), IB20::ContractURIUpdated::SIGNATURE_HASH); + assert_root("update_contract_uri", s, ROOT_UPDATE_CONTRACT_URI); +} + +#[test] +fn golden_update_supply_cap_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updateSupplyCapCall { newSupplyCap: u(1) }.abi_encode(), + B20TokenRole::DefaultAdmin.id(), + ); +} + +#[test] +fn golden_update_name_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updateNameCall { newName: "x".into() }.abi_encode(), + B20TokenRole::Metadata.id(), + ); +} + +#[test] +fn golden_update_symbol_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updateSymbolCall { newSymbol: "x".into() }.abi_encode(), + B20TokenRole::Metadata.id(), + ); +} + +#[test] +fn golden_update_contract_uri_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updateContractURICall { newURI: "x".into() }.abi_encode(), + B20TokenRole::Metadata.id(), + ); +} + +// ============================================================================ +// roles (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_grant_role() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_eq!(last_topic0(&s), IB20::RoleGranted::SIGNATURE_HASH); + assert_root("grant_role", s, ROOT_GRANT_ROLE); +} + +#[test] +fn golden_grant_role_unprivileged_no_admin_reverts() { + // No admin exists yet => the admin-availability guard reverts. + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: BOB }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); +} + +#[test] +fn golden_grant_role_unprivileged_non_admin_caller_reverts() { + // An admin exists, but ALICE is not the role's admin => the role check reverts. + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: BOB }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); +} + +#[test] +fn golden_grant_default_admin_bumps_member_count() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::DefaultAdmin.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert!(t.has_role(B20TokenRole::DefaultAdmin.id(), ALICE).unwrap()); + assert_eq!(t.role_member_count(B20TokenRole::DefaultAdmin.id()).unwrap(), u(2)); + }); + assert_eq!(last_topic0(&s), IB20::RoleGranted::SIGNATURE_HASH); + assert_root("grant_default_admin", s, ROOT_GRANT_DEFAULT_ADMIN); +} + +#[test] +fn golden_grant_role_idempotent_when_already_held() { + let mut s = fresh(); + seed(&mut s, |t| { + give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN); + give_role(t, B20TokenRole::Mint.id(), ALICE); + }); + // ALICE already holds MINT_ROLE => grant is a no-op (no event, count unchanged). + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_root("grant_idempotent", s, ROOT_GRANT_IDEMPOTENT); +} + +#[test] +fn golden_revoke_role() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(!t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_eq!(last_topic0(&s), IB20::RoleRevoked::SIGNATURE_HASH); + assert_root("revoke_role", s, ROOT_REVOKE_ROLE); +} + +#[test] +fn golden_revoke_role_unprivileged_non_admin_caller_reverts() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: BOB, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); +} + +#[test] +fn golden_revoke_role_noop_when_not_held() { + let mut s = fresh(); + // ALICE does not hold MINT_ROLE => revoke is a no-op; state stays at fresh. + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(!t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_root("revoke_noop", s, ROOT_FRESH); +} + +#[test] +fn golden_revoke_last_admin_rejected() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::DefaultAdmin.id(), account: ADMIN }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); +} + +#[test] +fn golden_renounce_role() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::renounceRoleCall { role: B20TokenRole::Mint.id(), callerConfirmation: ALICE } + .abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(!t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_eq!(last_topic0(&s), IB20::RoleRevoked::SIGNATURE_HASH); + assert_root("renounce_role", s, ROOT_RENOUNCE_ROLE); +} + +#[test] +fn golden_renounce_role_bad_confirmation() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::renounceRoleCall { role: B20TokenRole::Mint.id(), callerConfirmation: BOB } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccessControlBadConfirmation {})); +} + +#[test] +fn golden_renounce_role_reverts_last_admin() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::renounceRoleCall { role: B20TokenRole::DefaultAdmin.id(), callerConfirmation: ADMIN } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); +} + +#[test] +fn golden_renounce_last_admin() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let out = + op(&mut s, ADMIN, FakePolicyAccounting::new(), IB20::renounceLastAdminCall {}.abi_encode()) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert!(!t.has_role(B20TokenRole::DefaultAdmin.id(), ADMIN).unwrap()); + assert_eq!(t.role_member_count(B20TokenRole::DefaultAdmin.id()).unwrap(), U256::ZERO); + }); + assert_eq!(last_topic0(&s), IB20::LastAdminRenounced::SIGNATURE_HASH); + assert_root("renounce_last_admin", s, ROOT_RENOUNCE_LAST_ADMIN); +} + +#[test] +fn golden_renounce_last_admin_reverts_when_not_sole() { + let mut s = fresh(); + seed(&mut s, |t| { + give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN); + give_role(t, B20TokenRole::DefaultAdmin.id(), BOB); + }); + let err = + op(&mut s, ADMIN, FakePolicyAccounting::new(), IB20::renounceLastAdminCall {}.abi_encode()) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::NotSoleAdmin {})); +} + +#[test] +fn golden_set_role_admin() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::setRoleAdminCall { + role: B20TokenRole::Mint.id(), + newAdminRole: B20TokenRole::Metadata.id(), + } + .abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.role_admin(B20TokenRole::Mint.id()).unwrap(), B20TokenRole::Metadata.id()) + }); + assert_eq!(last_topic0(&s), IB20::RoleAdminChanged::SIGNATURE_HASH); + assert_root("set_role_admin", s, ROOT_SET_ROLE_ADMIN); +} + +#[test] +fn golden_set_role_admin_unprivileged_non_admin_caller_reverts() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::setRoleAdminCall { + role: B20TokenRole::Mint.id(), + newAdminRole: B20TokenRole::Metadata.id(), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: BOB, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); +} + +// ============================================================================ +// policy (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_update_policy() { + let mut s = fresh(); + let mut policy = FakePolicyAccounting::new(); + policy.create_existing_policy(7); + let out = op_privileged( + &mut s, + ADMIN, + policy, + IB20::updatePolicyCall { policyScope: B20PolicyType::TransferSender.id(), newPolicyId: 7 } + .abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.policy_id(B20PolicyType::TransferSender.id()).unwrap(), 7)); + assert_eq!(last_topic0(&s), IB20::PolicyUpdated::SIGNATURE_HASH); + assert_root("update_policy", s, ROOT_UPDATE_POLICY); +} + +#[test] +fn golden_update_policy_reverts_missing_policy() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updatePolicyCall { policyScope: B20PolicyType::TransferSender.id(), newPolicyId: 99 } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::PolicyNotFound { policyId: 99 })); +} + +#[test] +fn golden_update_policy_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updatePolicyCall { policyScope: B20PolicyType::TransferSender.id(), newPolicyId: 1 } + .abi_encode(), + B20TokenRole::DefaultAdmin.id(), + ); +} + +// ============================================================================ +// permit (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_permit_sets_allowance_and_increments_nonce() { + let mut s = fresh(); + let owner = anvil_owner(); + let domain = domain_separator(&mut s); + let call = signed_permit(domain, U256::ZERO, owner, BOB, u(500), U256::MAX); + s.set_timestamp(U256::ZERO); + let out = op(&mut s, owner, FakePolicyAccounting::new(), call.abi_encode()).unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.allowance(owner, BOB).unwrap(), u(500)); + assert_eq!(t.nonce(owner).unwrap(), U256::ONE); + }); + assert_eq!(last_topic0(&s), IB20::Approval::SIGNATURE_HASH); + assert_root("permit", s, ROOT_PERMIT); +} + +#[test] +fn golden_permit_reverts_when_expired() { + let mut s = fresh(); + let owner = anvil_owner(); + let domain = domain_separator(&mut s); + let call = signed_permit(domain, U256::ZERO, owner, BOB, u(1), u(10)); + s.set_timestamp(u(11)); + let err = op(&mut s, owner, FakePolicyAccounting::new(), call.abi_encode()).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::ExpiredSignature { deadline: u(10) })); +} + +/// V2 `permit` charges the fixed ECRECOVER-equivalent recovery cost (`PermitArgs::RECOVER_GAS`, +/// 3000) on the real provider. The `.route(...)` path bills no calldata gas — that lives in +/// `dispatch_with_observer` — so the observed `gas_deducted()` is exactly the recovery charge. The +/// V1 golden pins this at `0`; that metered/unmetered contrast is the point of this pair. +#[test] +fn golden_permit_charges_recovery_gas() { + let mut s = fresh(); + let owner = anvil_owner(); + let calldata = + signed_permit(domain_separator(&mut fresh()), U256::ZERO, owner, BOB, u(500), U256::MAX) + .abi_encode(); + s.set_caller(owner); + s.set_timestamp(U256::ZERO); + StorageCtx::enter(&mut s, |ctx| { + B20AssetToken::with_storage_and_policy( + B20AssetStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ) + .route(ctx, &calldata, AssetVersion::V3, true, NoopPrecompileCallObserver) + }) + .expect("permit must succeed"); + assert_eq!(s.gas_deducted(), 3000, "V2 permit must charge the fixed ECRECOVER-equivalent cost"); +} + +// ============================================================================ +// computed + direct + constant reads (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_read_policy_id_and_unsupported_scope() { + let mut s = fresh(); + let ok = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::policyIdCall { policyScope: B20PolicyType::TransferSender.id() }.abi_encode(), + ) + .unwrap(); + assert_eq!(ok, Bytes::from(0u64.abi_encode())); + + let bad_scope = B256::repeat_byte(0xEE); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::policyIdCall { policyScope: bad_scope }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::UnsupportedPolicyType { policyScope: bad_scope }) + ); +} + +#[test] +fn golden_read_domain_separator() { + let mut s = fresh(); + let expected = domain_separator(&mut s); + let out = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20::DOMAIN_SEPARATORCall {}.abi_encode()) + .unwrap(); + assert_eq!(out, Bytes::from(expected.abi_encode())); + assert_root("read_domain_separator", s, ROOT_FRESH); +} + +#[test] +fn golden_read_eip712_domain() { + let mut s = fresh(); + let out = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20::eip712DomainCall {}.abi_encode()) + .unwrap(); + let decoded = IB20::eip712DomainCall::abi_decode_returns(&out).unwrap(); + assert_eq!(decoded.name, NAME); + assert_eq!(decoded.version, "1"); + assert_eq!(decoded.chainId, U256::from(CHAIN_ID)); + assert_eq!(decoded.verifyingContract, TOKEN); + assert_eq!(decoded.fields, alloy_primitives::FixedBytes::<1>::from([0x0f])); +} + +#[test] +fn golden_read_metadata_and_supply() { + let mut s = fresh(); + let cases: Vec<(Vec, Bytes)> = vec![ + (IB20::nameCall {}.abi_encode(), Bytes::from(NAME.abi_encode())), + (IB20::symbolCall {}.abi_encode(), Bytes::from(SYMBOL.abi_encode())), + (IB20::decimalsCall {}.abi_encode(), Bytes::from(u(6).abi_encode())), + (IB20::totalSupplyCall {}.abi_encode(), Bytes::from(U256::ZERO.abi_encode())), + (IB20::supplyCapCall {}.abi_encode(), Bytes::from(B20_MAX_SUPPLY_CAP.abi_encode())), + (IB20::contractURICall {}.abi_encode(), Bytes::from(String::new().abi_encode())), + (IB20::balanceOfCall { account: ALICE }.abi_encode(), Bytes::from(U256::ZERO.abi_encode())), + ( + IB20::allowanceCall { owner: ALICE, spender: BOB }.abi_encode(), + Bytes::from(U256::ZERO.abi_encode()), + ), + (IB20::noncesCall { owner: ALICE }.abi_encode(), Bytes::from(U256::ZERO.abi_encode())), + ( + IB20::hasRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + Bytes::from(false.abi_encode()), + ), + ( + IB20::getRoleAdminCall { role: B20TokenRole::Mint.id() }.abi_encode(), + Bytes::from(B256::ZERO.abi_encode()), + ), + ]; + for (calldata, expected) in cases { + let out = op(&mut s, ALICE, FakePolicyAccounting::new(), calldata).unwrap(); + assert_eq!(out, expected); + } + assert_root("read_metadata", s, ROOT_FRESH); +} + +#[test] +fn golden_read_role_and_policy_constants() { + let mut s = fresh(); + let cases: Vec<(Vec, B256)> = vec![ + (IB20::DEFAULT_ADMIN_ROLECall {}.abi_encode(), B20TokenRole::DefaultAdmin.id()), + (IB20::MINT_ROLECall {}.abi_encode(), B20TokenRole::Mint.id()), + (IB20::BURN_ROLECall {}.abi_encode(), B20TokenRole::Burn.id()), + (IB20::BURN_BLOCKED_ROLECall {}.abi_encode(), B20TokenRole::BurnBlocked.id()), + (IB20::PAUSE_ROLECall {}.abi_encode(), B20TokenRole::Pause.id()), + (IB20::UNPAUSE_ROLECall {}.abi_encode(), B20TokenRole::Unpause.id()), + (IB20::METADATA_ROLECall {}.abi_encode(), B20TokenRole::Metadata.id()), + (IB20::TRANSFER_SENDER_POLICYCall {}.abi_encode(), B20PolicyType::TransferSender.id()), + (IB20::TRANSFER_RECEIVER_POLICYCall {}.abi_encode(), B20PolicyType::TransferReceiver.id()), + (IB20::TRANSFER_EXECUTOR_POLICYCall {}.abi_encode(), B20PolicyType::TransferExecutor.id()), + (IB20::MINT_RECEIVER_POLICYCall {}.abi_encode(), B20PolicyType::MintReceiver.id()), + ]; + for (calldata, expected) in cases { + let out = op(&mut s, ALICE, FakePolicyAccounting::new(), calldata).unwrap(); + assert_eq!(out, Bytes::from(expected.abi_encode())); + } + assert_root("read_constants", s, ROOT_FRESH); +} + +#[test] +fn golden_read_operator_role_and_wad() { + let mut s = fresh(); + let role = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::OPERATOR_ROLECall {}.abi_encode(), + ) + .unwrap(); + assert_eq!(role, Bytes::from(operator_role().abi_encode())); + let wad = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::WAD_PRECISIONCall {}.abi_encode(), + ) + .unwrap(); + assert_eq!(wad, Bytes::from(B20AssetStorage::WAD.abi_encode())); + assert_root("read_operator_wad", s, ROOT_FRESH); +} + +#[test] +fn golden_read_multiplier_and_scaled_balances() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_multiplier(B20AssetStorage::WAD * u(2)).unwrap(); + }); + let m = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20Asset::multiplierCall {}.abi_encode()) + .unwrap(); + assert_eq!(m, Bytes::from((B20AssetStorage::WAD * u(2)).abi_encode())); + + let scaled = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::toScaledBalanceCall { rawBalance: u(100) }.abi_encode(), + ) + .unwrap(); + assert_eq!(scaled, Bytes::from(u(200).abi_encode())); + + let raw = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::toRawBalanceCall { scaledBalance: u(200) }.abi_encode(), + ) + .unwrap(); + assert_eq!(raw, Bytes::from(u(100).abi_encode())); + + let sbo = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::scaledBalanceOfCall { account: ALICE }.abi_encode(), + ) + .unwrap(); + assert_eq!(sbo, Bytes::from(u(200).abi_encode())); + + assert_root("read_multiplier_scaled", s, ROOT_MULTIPLIER_SCALED); +} + +#[test] +fn golden_read_is_announcement_id_used() { + let mut s = fresh(); + let unused = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::isAnnouncementIdUsedCall { id: "evt-1".into() }.abi_encode(), + ) + .unwrap(); + assert_eq!(unused, Bytes::from(false.abi_encode())); + + seed(&mut s, |t| t.mark_announcement_id_used("evt-1").unwrap()); + let used = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::isAnnouncementIdUsedCall { id: "evt-1".into() }.abi_encode(), + ) + .unwrap(); + assert_eq!(used, Bytes::from(true.abi_encode())); + assert_root("read_announcement_id_used", s, ROOT_ANNOUNCE_ID_USED); +} + +#[test] +fn golden_read_extra_metadata() { + let mut s = fresh(); + let empty = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::extraMetadataCall { key: "category".into() }.abi_encode(), + ) + .unwrap(); + assert_eq!(empty, Bytes::from(String::new().abi_encode())); + + seed(&mut s, |t| t.set_extra_metadata_value("category", "commodity".into()).unwrap()); + let set = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::extraMetadataCall { key: "category".into() }.abi_encode(), + ) + .unwrap(); + assert_eq!(set, Bytes::from("commodity".abi_encode())); + assert_root("read_extra_metadata", s, ROOT_EXTRA_METADATA_READ); +} + +// ============================================================================ +// batchMint / updateExtraMetadata (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_batch_mint() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + policy.allow(0, CAROL); + let out = op( + &mut s, + ALICE, + policy, + IB20Asset::batchMintCall { recipients: vec![BOB, CAROL], amounts: vec![u(30), u(70)] } + .abi_encode(), + ) + .unwrap(); + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.balance_of(BOB).unwrap(), u(30)); + assert_eq!(t.balance_of(CAROL).unwrap(), u(70)); + assert_eq!(t.total_supply().unwrap(), u(100)); + }); + assert_root("batch_mint", s, ROOT_BATCH_MINT); +} + +#[test] +fn golden_batch_mint_reverts_length_mismatch() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::batchMintCall { recipients: vec![BOB, CAROL], amounts: vec![u(30)] } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Asset::LengthMismatch { leftLen: u(2), rightLen: u(1) }) + ); +} + +#[test] +fn golden_batch_mint_reverts_empty() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::batchMintCall { recipients: vec![], amounts: vec![] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::EmptyBatch {})); +} + +#[test] +fn golden_batch_mint_reverts_when_paused() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap(); + let err = op( + &mut s, + ALICE, + allow0(BOB), + IB20Asset::batchMintCall { recipients: vec![BOB], amounts: vec![u(1)] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { feature: IB20::PausableFeature::MINT }) + ); +} + +#[test] +fn golden_batch_mint_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20Asset::batchMintCall { recipients: vec![BOB], amounts: vec![u(1)] }.abi_encode(), + B20TokenRole::Mint.id(), + ); +} + +#[test] +fn golden_update_extra_metadata_set() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Metadata.id(), ALICE)); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateExtraMetadataCall { key: "category".into(), value: "commodity".into() } + .abi_encode(), + ) + .unwrap(); + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.extra_metadata("category").unwrap(), "commodity")); + assert_eq!( + *s.get_events(TOKEN).last().unwrap(), + IB20Asset::ExtraMetadataUpdated { key: "category".into(), value: "commodity".into() } + .encode_log_data() + ); + assert_root("update_extra_metadata_set", s, ROOT_METADATA_SET); +} + +#[test] +fn golden_update_extra_metadata_remove() { + let mut s = fresh(); + seed(&mut s, |t| { + give_role(t, B20TokenRole::Metadata.id(), ALICE); + t.set_extra_metadata_value("category", "commodity".into()).unwrap(); + }); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateExtraMetadataCall { key: "category".into(), value: String::new() } + .abi_encode(), + ) + .unwrap(); + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.extra_metadata("category").unwrap(), "")); + assert_root("update_extra_metadata_remove", s, ROOT_METADATA_REMOVE); +} + +#[test] +fn golden_update_extra_metadata_reverts_empty_key() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Metadata.id(), ALICE)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateExtraMetadataCall { key: String::new(), value: "x".into() }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::InvalidMetadataKey {})); +} + +#[test] +fn golden_update_extra_metadata_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20Asset::updateExtraMetadataCall { key: "category".into(), value: "x".into() } + .abi_encode(), + B20TokenRole::Metadata.id(), + ); +} + +// ============================================================================ +// updateMultiplier (V2 semantics: emits UIMultiplierUpdated; clears a live pending) +// ============================================================================ + +// Guards `fresh()` against drifting back to a physical-WAD prestate: a factory-bootstrapped +// token leaves the multiplier slot physically zero, and the getter normalizes it to WAD. +#[test] +fn golden_fresh_multiplier_slot_is_physical_zero() { + let mut s = fresh(); + read(&mut s, |t| { + assert_eq!(t.asset.multiplier.read().unwrap(), U256::ZERO); // raw slot + assert_eq!(t.multiplier().unwrap(), B20AssetStorage::WAD); // normalized getter + }); +} + +#[test] +fn golden_update_multiplier_emits_ui_multiplier_updated() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, U256::ZERO); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateMultiplierCall { newMultiplier: B20AssetStorage::WAD * u(2) }.abi_encode(), + ) + .unwrap(); + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.multiplier().unwrap(), B20AssetStorage::WAD * u(2))); + assert_eq!( + *s.get_events(TOKEN).last().unwrap(), + IB20Asset::UIMultiplierUpdated { + oldMultiplier: B20AssetStorage::WAD, + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAtTimestamp: U256::ZERO, + } + .encode_log_data() + ); + assert_root("update_multiplier_v2", s, ROOT_UPDATE_MULTIPLIER_V2); +} + +#[test] +fn golden_update_multiplier_reverts_zero() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateMultiplierCall { newMultiplier: U256::ZERO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::InvalidMultiplier {})); +} + +#[test] +fn golden_update_multiplier_unprivileged_requires_role() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateMultiplierCall { newMultiplier: B20AssetStorage::WAD }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: operator_role(), + }) + ); +} + +#[test] +fn golden_update_multiplier_clears_live_pending_and_emits_cancellation() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(3), + effectiveAt: u(1_000), + } + .abi_encode(), + ) + .unwrap(); + + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateMultiplierCall { newMultiplier: B20AssetStorage::WAD * u(5) }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.multiplier().unwrap(), B20AssetStorage::WAD * u(5)); + assert_eq!(t.pending_multiplier().unwrap(), 0); + assert_eq!(t.pending_effective_at().unwrap(), 0); + }); + let events = s.get_events(TOKEN); + assert_eq!( + events[events.len() - 3], + IB20Asset::UIMultiplierUpdateCancelled { + cancelledMultiplier: B20AssetStorage::WAD * u(3), + cancelledEffectiveAt: u(1_000), + } + .encode_log_data() + ); + assert_eq!( + events[events.len() - 2], + IB20Asset::MultiplierUpdated { multiplier: B20AssetStorage::WAD * u(5) }.encode_log_data() + ); + assert_eq!( + events[events.len() - 1], + IB20Asset::UIMultiplierUpdated { + oldMultiplier: B20AssetStorage::WAD, + newMultiplier: B20AssetStorage::WAD * u(5), + effectiveAtTimestamp: u(1), + } + .encode_log_data() + ); + assert_root("update_multiplier_clears_pending", s, ROOT_UPDATE_MULTIPLIER_CLEARS_PENDING); +} + +// ============================================================================ +// updateUIMultiplier / cancelUIMultiplierUpdate / lazy multiplier reads (new at V2) +// ============================================================================ + +#[test] +fn golden_update_ui_multiplier_schedules_pending() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + let target = B20AssetStorage::WAD * u(3); + let effective_at = u(1_000); + + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { newMultiplier: target, effectiveAt: effective_at } + .abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.multiplier().unwrap(), B20AssetStorage::WAD); + assert_eq!(t.pending_multiplier().unwrap(), target.to::()); + assert_eq!(t.pending_effective_at().unwrap(), effective_at.to::()); + }); + assert_eq!( + *s.get_events(TOKEN).last().unwrap(), + IB20Asset::UIMultiplierUpdated { + oldMultiplier: B20AssetStorage::WAD, + newMultiplier: target, + effectiveAtTimestamp: effective_at, + } + .encode_log_data() + ); + assert_root("update_ui_multiplier", s, ROOT_UPDATE_UI_MULTIPLIER); +} + +#[test] +fn golden_multiplier_lazily_flips_at_maturity() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + let target = B20AssetStorage::WAD * u(3); + let effective_at = u(1_000); + op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { newMultiplier: target, effectiveAt: effective_at } + .abi_encode(), + ) + .unwrap(); + + warp(&mut s, u(999)); + let before = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20Asset::multiplierCall {}.abi_encode()) + .unwrap(); + assert_eq!(before, Bytes::from(B20AssetStorage::WAD.abi_encode())); + + warp(&mut s, effective_at); + let after = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20Asset::multiplierCall {}.abi_encode()) + .unwrap(); + assert_eq!(after, Bytes::from(target.abi_encode())); + + let ui_alias = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20Asset::uiMultiplierCall {}.abi_encode()) + .unwrap(); + assert_eq!(ui_alias, after); +} + +#[test] +fn golden_new_ui_multiplier_and_effective_at_reads() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + + let no_pending = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::newUIMultiplierCall {}.abi_encode(), + ) + .unwrap(); + assert_eq!(no_pending, Bytes::from(B20AssetStorage::WAD.abi_encode())); + let no_effective_at = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20Asset::effectiveAtCall {}.abi_encode()) + .unwrap(); + assert_eq!(no_effective_at, Bytes::from(U256::ZERO.abi_encode())); + + let target = B20AssetStorage::WAD * u(3); + let effective_at = u(1_000); + op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { newMultiplier: target, effectiveAt: effective_at } + .abi_encode(), + ) + .unwrap(); + + let pending = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::newUIMultiplierCall {}.abi_encode(), + ) + .unwrap(); + assert_eq!(pending, Bytes::from(target.abi_encode())); + let effective_at_read = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20Asset::effectiveAtCall {}.abi_encode()) + .unwrap(); + assert_eq!(effective_at_read, Bytes::from(effective_at.abi_encode())); + + warp(&mut s, effective_at); + let matured = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::newUIMultiplierCall {}.abi_encode(), + ) + .unwrap(); + assert_eq!(matured, Bytes::from(target.abi_encode())); +} + +#[test] +fn golden_balance_of_ui_and_total_supply_ui_scale_by_effective_multiplier() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, operator_role(), ADMIN); + }); + warp(&mut s, u(1)); + op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAt: u(1_000), + } + .abi_encode(), + ) + .unwrap(); + + let pre_maturity = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::balanceOfUICall { account: ALICE }.abi_encode(), + ) + .unwrap(); + assert_eq!(pre_maturity, Bytes::from(u(100).abi_encode())); + + warp(&mut s, u(1_000)); + let post_maturity = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::balanceOfUICall { account: ALICE }.abi_encode(), + ) + .unwrap(); + assert_eq!(post_maturity, Bytes::from(u(200).abi_encode())); + + let supply_ui = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::totalSupplyUICall {}.abi_encode(), + ) + .unwrap(); + assert_eq!(supply_ui, Bytes::from(u(200).abi_encode())); +} + +#[test] +fn golden_update_ui_multiplier_reverts_invalid_multiplier() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { newMultiplier: U256::ZERO, effectiveAt: u(1_000) } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::InvalidMultiplier {})); +} + +#[test] +fn golden_update_ui_multiplier_reverts_effective_at_in_past() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1_000)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAt: u(999), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Asset::EffectiveAtInPast { effectiveAt: u(999) }) + ); +} + +#[test] +fn golden_update_ui_multiplier_reverts_effective_at_too_far() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + let too_far = U256::from(u64::MAX) + U256::ONE; + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAt: too_far, + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Asset::EffectiveAtTooFar { effectiveAt: too_far }) + ); +} + +#[test] +fn golden_update_ui_multiplier_reverts_schedule_overlap() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAt: u(1_000), + } + .abi_encode(), + ) + .unwrap(); + + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(4), + effectiveAt: u(2_000), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Asset::UIMultiplierUpdateExists { effectiveAt: u(1_000) }) + ); +} + +#[test] +fn golden_update_ui_multiplier_folds_matured_pending_before_rescheduling() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAt: u(1_000), + } + .abi_encode(), + ) + .unwrap(); + + // Warp past maturity, then schedule a second update: the matured 2x must fold into + // `multiplier` before the new 4x schedule is written. + warp(&mut s, u(1_500)); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(4), + effectiveAt: u(2_000), + } + .abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.multiplier().unwrap(), B20AssetStorage::WAD * u(2)); + assert_eq!(t.pending_multiplier().unwrap(), (B20AssetStorage::WAD * u(4)).to::()); + assert_eq!(t.pending_effective_at().unwrap(), 2_000); + }); + assert_eq!( + *s.get_events(TOKEN).last().unwrap(), + IB20Asset::UIMultiplierUpdated { + oldMultiplier: B20AssetStorage::WAD * u(2), + newMultiplier: B20AssetStorage::WAD * u(4), + effectiveAtTimestamp: u(2_000), + } + .encode_log_data() + ); + assert_root("update_ui_multiplier_folds_matured", s, ROOT_UPDATE_UI_MULTIPLIER_FOLDS_MATURED); +} + +#[test] +fn golden_update_ui_multiplier_unprivileged_requires_role() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAt: u(1_000), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: operator_role(), + }) + ); +} + +#[test] +fn golden_cancel_ui_multiplier_update() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAt: u(1_000), + } + .abi_encode(), + ) + .unwrap(); + + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::cancelUIMultiplierUpdateCall {}.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.multiplier().unwrap(), B20AssetStorage::WAD); + assert_eq!(t.pending_multiplier().unwrap(), 0); + assert_eq!(t.pending_effective_at().unwrap(), 0); + }); + assert_eq!( + *s.get_events(TOKEN).last().unwrap(), + IB20Asset::UIMultiplierUpdateCancelled { + cancelledMultiplier: B20AssetStorage::WAD * u(2), + cancelledEffectiveAt: u(1_000), + } + .encode_log_data() + ); + assert_root("cancel_ui_multiplier_update", s, ROOT_CANCEL_UI_MULTIPLIER_UPDATE); +} + +#[test] +fn golden_cancel_ui_multiplier_update_reverts_when_none() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::cancelUIMultiplierUpdateCall {}.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::UIMultiplierUpdateDoesNotExist {})); +} + +#[test] +fn golden_cancel_ui_multiplier_update_reverts_when_already_matured() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + warp(&mut s, u(1)); + op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAt: u(1_000), + } + .abi_encode(), + ) + .unwrap(); + + warp(&mut s, u(1_000)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::cancelUIMultiplierUpdateCall {}.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::UIMultiplierUpdateDoesNotExist {})); +} + +#[test] +fn golden_cancel_ui_multiplier_update_unprivileged_requires_role() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::cancelUIMultiplierUpdateCall {}.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: operator_role(), + }) + ); +} + +// ============================================================================ +// supportsInterface (ERC-165 / ERC-8056, new at V2) +// ============================================================================ + +#[test] +fn golden_supports_interface_true_for_erc165_and_erc8056_ids() { + let mut s = fresh(); + for interface_id in + core::iter::once(ERC165_INTERFACE_ID).chain(ERC8056_INTERFACE_IDS.iter().copied()) + { + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::supportsInterfaceCall { interfaceId: interface_id }.abi_encode(), + ) + .unwrap(); + assert_eq!(out, Bytes::from(true.abi_encode()), "interface {interface_id:?} must be true"); + } +} + +#[test] +fn golden_supports_interface_false_for_unrelated_id() { + let mut s = fresh(); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::supportsInterfaceCall { + interfaceId: alloy_primitives::FixedBytes::new([0xde, 0xad, 0xbe, 0xef]), + } + .abi_encode(), + ) + .unwrap(); + assert_eq!(out, Bytes::from(false.abi_encode())); +} + +#[test] +fn golden_supports_interface_true_for_conversion_extension_id() { + // The ERC-8056 Conversion extension id (0x57854fc3) is advertised after the interface review. + let mut s = fresh(); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::supportsInterfaceCall { + interfaceId: alloy_primitives::FixedBytes::new([0x57, 0x85, 0x4f, 0xc3]), + } + .abi_encode(), + ) + .unwrap(); + assert_eq!(out, Bytes::from(true.abi_encode())); +} + +// ============================================================================ +// Interface-review additions (BOP-495): toUIAmount / fromUIAmount aliases route +// to the same logic as their legacy counterparts; MAX_UI_MULTIPLIER exposes the +// setter bound. +// ============================================================================ + +#[test] +fn golden_to_ui_amount_and_from_ui_amount_alias_conversions() { + // The ERC-8056 Conversion aliases behave exactly like toScaledBalance / toRawBalance. + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_multiplier(B20AssetStorage::WAD * u(2)).unwrap(); + }); + let ui = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::toUIAmountCall { rawAmount: u(100) }.abi_encode(), + ) + .unwrap(); + assert_eq!(ui, Bytes::from(u(200).abi_encode())); + + let raw = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::fromUIAmountCall { uiAmount: u(200) }.abi_encode(), + ) + .unwrap(); + assert_eq!(raw, Bytes::from(u(100).abi_encode())); +} + +#[test] +fn golden_max_ui_multiplier_returns_uint128_max() { + let mut s = fresh(); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::MAX_UI_MULTIPLIERCall {}.abi_encode(), + ) + .unwrap(); + assert_eq!(out, Bytes::from(U256::from(u128::MAX).abi_encode())); +} + +// ============================================================================ +// announce (V2 semantics: inner updateMultiplier now emits UIMultiplierUpdated) +// ============================================================================ + +#[test] +fn golden_announce_emits_and_runs_internal_calls() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + // The inner `updateMultiplier` call's `UIMultiplierUpdated` event carries `now`, so the + // timestamp must be pinned for the root hash below to be deterministic. + warp(&mut s, U256::ZERO); + let inner = + IB20Asset::updateMultiplierCall { newMultiplier: B20AssetStorage::WAD * u(2) }.abi_encode(); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::announceCall { + internalCalls: vec![Bytes::from(inner)], + id: "2026-split".into(), + description: "2:1 split".into(), + uri: "ipfs://split".into(), + } + .abi_encode(), + ) + .unwrap(); + assert!(out.is_empty()); + read(&mut s, |t| { + assert!(t.is_announcement_id_used("2026-split").unwrap()); + assert_eq!(t.multiplier().unwrap(), B20AssetStorage::WAD * u(2)); + }); + let events = s.get_events(TOKEN); + // Announcement, then the inner updateMultiplier's MultiplierUpdated + UIMultiplierUpdated, + // then EndAnnouncement. + assert_eq!( + events[events.len() - 4], + IB20Asset::Announcement { + caller: ALICE, + id: "2026-split".into(), + description: "2:1 split".into(), + uri: "ipfs://split".into(), + } + .encode_log_data() + ); + assert_eq!(events[events.len() - 3].topics()[0], IB20Asset::MultiplierUpdated::SIGNATURE_HASH); + assert_eq!( + events[events.len() - 2].topics()[0], + IB20Asset::UIMultiplierUpdated::SIGNATURE_HASH + ); + assert_eq!( + *events.last().unwrap(), + IB20Asset::EndAnnouncement { id: "2026-split".into() }.encode_log_data() + ); + assert_root("announce_v2", s, ROOT_ANNOUNCE_V2); +} + +#[test] +fn golden_announce_reverts_id_already_used() { + let mut s = fresh(); + seed(&mut s, |t| { + give_role(t, operator_role(), ALICE); + t.mark_announcement_id_used("dup").unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::announceCall { + internalCalls: vec![], + id: "dup".into(), + description: String::new(), + uri: String::new(), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Asset::AnnouncementIdAlreadyUsed { id: "dup".into() }) + ); +} + +/// Cantina #16 follow-up: the same malformed `announce` (invalid UTF-8 in `id`) short-circuits at +/// V3/Denim (inherited from V2/Cobalt) with a cheap, calldata-size-independent rejection instead +/// of paying the owned decoder's O(aliases · `tail_bytes`) diagnostic construction. Contrast with +/// `golden_announce_malformed_id_stays_on_owned_diagnostic_at_v1` in the V1 suite, which pins the +/// opposite: the frozen version must never take this path. +#[test] +fn golden_announce_malformed_id_short_circuits_at_v3() { + let mut s = fresh(); + let marker = "malformed-id-marker"; + let mut calldata = IB20Asset::announceCall { + internalCalls: vec![], + id: marker.into(), + description: String::new(), + uri: String::new(), + } + .abi_encode(); + let at = calldata.windows(marker.len()).position(|w| w == marker.as_bytes()).unwrap(); + calldata[at..at + marker.len()].fill(0xff); + + let err = op(&mut s, ALICE, FakePolicyAccounting::new(), calldata).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::AbiDecodeFailed { + selector: IB20Asset::announceCall::SELECTOR, + error: "announce: malformed bytes[] payload".to_string(), + }, + "V2 must reject with the bounded, calldata-size-independent error", + ); +} + +#[test] +fn golden_announce_reverts_nested_announce() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + let nested = Bytes::from( + IB20Asset::announceCall { + internalCalls: vec![], + id: "inner".into(), + description: String::new(), + uri: String::new(), + } + .abi_encode(), + ); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::announceCall { + internalCalls: vec![nested], + id: "outer".into(), + description: String::new(), + uri: String::new(), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::AnnouncementInProgress {})); +} + +#[test] +fn golden_announce_reverts_internal_call_malformed() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + let malformed = Bytes::from(vec![0x01u8, 0x02]); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::announceCall { + internalCalls: vec![malformed.clone()], + id: "x".into(), + description: String::new(), + uri: String::new(), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Asset::InternalCallMalformed { call: malformed }) + ); +} + +#[test] +fn golden_announce_reverts_internal_call_failed() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, operator_role(), ALICE)); + // A valid selector whose business logic reverts (zero multiplier) => wrapped. + let inner = + Bytes::from(IB20Asset::updateMultiplierCall { newMultiplier: U256::ZERO }.abi_encode()); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::announceCall { + internalCalls: vec![inner.clone()], + id: "x".into(), + description: String::new(), + uri: String::new(), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Asset::InternalCallFailed { call: inner })); +} + +#[test] +fn golden_announce_unprivileged_requires_role() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Asset::announceCall { + internalCalls: vec![], + id: "x".into(), + description: String::new(), + uri: String::new(), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: operator_role(), + }) + ); +} + +// ============================================================================ +// gas: storage-access footprint per op +// ============================================================================ +// +// `gas_deducted` is 0 under the test gas schedule, so we pin the deterministic, +// schedule-independent signal instead: the SLOAD / SSTORE / KECCAK256 op counts a +// call performs. These are the storage-access footprint that drives real gas, so a +// change here (e.g. an extra SLOAD in V2) is caught even when bytes/state/events match. + +/// Runs `calldata` privileged after `setup`, returning `(sload, sstore, keccak256)` counts. +fn gas( + setup: impl FnOnce(&mut B20AssetStorage<'_>), + caller: Address, + policy: FakePolicyAccounting, + calldata: Vec, +) -> (u64, u64, u64) { + let mut s = fresh(); + seed(&mut s, setup); + s.set_caller(caller); + // Pinned so multiplier-scheduling calldata (`effectiveAt` etc.) is never accidentally + // "in the past" relative to the provider's real wall-clock default timestamp. + warp(&mut s, U256::ZERO); + s.reset_counters(); + StorageCtx::enter(&mut s, |ctx| { + B20AssetToken::with_storage_and_policy( + B20AssetStorage::from_address(TOKEN, ctx), + policy, + PolicyVersion::V3, + ) + .route(ctx, &calldata, AssetVersion::V3, true, NoopPrecompileCallObserver) + }) + .expect("gas-footprint op must succeed"); + (s.counter_sload(), s.counter_sstore(), s.counter_keccak256()) +} + +/// Unprivileged reject-path footprint: `(sload, sstore, keccak256)` after `calldata` reverts. +/// +/// Locks that a zero-address `transfer` rejects before the transfer-policy-id SLOAD +/// (Cantina #13 / BOP-600). Privileged success-path `gas()` cannot catch that. +fn gas_unprivileged_revert( + setup: impl FnOnce(&mut B20AssetStorage<'_>), + caller: Address, + policy: FakePolicyAccounting, + calldata: Vec, +) -> (u64, u64, u64) { + let mut s = fresh(); + seed(&mut s, setup); + s.set_caller(caller); + warp(&mut s, U256::ZERO); + s.reset_counters(); + let err = StorageCtx::enter(&mut s, |ctx| { + let version = + AssetVersions::from_base_upgrade(BaseUpgrade::Denim).expect("Denim activates V3"); + B20AssetToken::with_storage_and_policy( + B20AssetStorage::from_address(TOKEN, ctx), + policy, + PolicyVersion::V3, + ) + .route(ctx, &calldata, version, false, NoopPrecompileCallObserver) + }) + .expect_err("reject-path gas golden must revert"); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); + (s.counter_sload(), s.counter_sstore(), s.counter_keccak256()) +} + +#[test] +fn golden_transfer_unprivileged_zero_receiver_storage_access() { + let actual = gas_unprivileged_revert( + |t| fund(t, ALICE, u(10)), + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: Address::ZERO, amount: u(1) }.abi_encode(), + ); + // Pause SLOAD only — no transfer_policy_ids SLOAD before InvalidReceiver. + bless_or_assert_gas( + &[("transfer_unprivileged_zero_receiver", actual)], + &[("transfer_unprivileged_zero_receiver", (1, 0, 0))], + ); +} + +#[test] +fn golden_gas_footprints() { + let actual: Vec<(&str, (u64, u64, u64))> = vec![ + ( + "transfer", + gas( + |t| fund(t, ALICE, u(100)), + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(30) }.abi_encode(), + ), + ), + ( + "transfer_from", + gas( + |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(40)).unwrap(); + }, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(30) }.abi_encode(), + ), + ), + ( + "approve", + gas( + |_t| {}, + ALICE, + FakePolicyAccounting::new(), + IB20::approveCall { spender: BOB, amount: u(50) }.abi_encode(), + ), + ), + ( + "mint", + gas( + |_t| {}, + ADMIN, + allow0(BOB), + IB20::mintCall { to: BOB, amount: u(100) }.abi_encode(), + ), + ), + ( + "burn", + gas( + |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Burn.id(), ALICE); + }, + ALICE, + FakePolicyAccounting::new(), + IB20::burnCall { amount: u(40) }.abi_encode(), + ), + ), + ( + "burn_blocked", + gas( + |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + }, + ADMIN, + FakePolicyAccounting::new(), + IB20::burnBlockedCall { from: ALICE, amount: u(40) }.abi_encode(), + ), + ), + ( + "seize", + gas( + |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(40), memo: MEMO } + .abi_encode(), + ), + ), + ( + "pause", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ), + ), + ( + "unpause", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::unpauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ), + ), + ( + "update_supply_cap", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSupplyCapCall { newSupplyCap: u(1_000) }.abi_encode(), + ), + ), + ( + "update_name", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateNameCall { newName: "New Name".into() }.abi_encode(), + ), + ), + ( + "update_symbol", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSymbolCall { newSymbol: "USDX".into() }.abi_encode(), + ), + ), + ( + "update_contract_uri", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateContractURICall { newURI: "ipfs://x".into() }.abi_encode(), + ), + ), + ( + "grant_role", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ), + ), + ( + "revoke_role", + gas( + |t| give_role(t, B20TokenRole::Mint.id(), ALICE), + ADMIN, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ), + ), + ( + "set_role_admin", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::setRoleAdminCall { + role: B20TokenRole::Mint.id(), + newAdminRole: B20TokenRole::Metadata.id(), + } + .abi_encode(), + ), + ), + ( + "update_policy", + gas( + |_t| {}, + ADMIN, + { + let mut p = FakePolicyAccounting::new(); + p.create_existing_policy(7); + p + }, + IB20::updatePolicyCall { + policyScope: B20PolicyType::TransferSender.id(), + newPolicyId: 7, + } + .abi_encode(), + ), + ), + ( + "update_multiplier", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20Asset::updateMultiplierCall { newMultiplier: B20AssetStorage::WAD * u(2) } + .abi_encode(), + ), + ), + ( + "update_ui_multiplier", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20Asset::updateUIMultiplierCall { + newMultiplier: B20AssetStorage::WAD * u(2), + effectiveAt: u(1_000), + } + .abi_encode(), + ), + ), + ( + "cancel_ui_multiplier_update", + gas( + |t| { + t.set_pending_and_effective_at( + (B20AssetStorage::WAD * u(2)).to::(), + 1_000, + ) + .unwrap(); + }, + ADMIN, + FakePolicyAccounting::new(), + IB20Asset::cancelUIMultiplierUpdateCall {}.abi_encode(), + ), + ), + ( + "batch_mint", + gas( + |_t| {}, + ADMIN, + { + let mut p = FakePolicyAccounting::new(); + p.allow(0, BOB); + p.allow(0, CAROL); + p + }, + IB20Asset::batchMintCall { + recipients: vec![BOB, CAROL], + amounts: vec![u(30), u(70)], + } + .abi_encode(), + ), + ), + ( + "announce", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20Asset::announceCall { + internalCalls: vec![], + id: "gas".into(), + description: String::new(), + uri: String::new(), + } + .abi_encode(), + ), + ), + ( + "update_extra_metadata", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20Asset::updateExtraMetadataCall { + key: "category".into(), + value: "commodity".into(), + } + .abi_encode(), + ), + ), + ( + "permit", + gas( + |_t| {}, + anvil_owner(), + FakePolicyAccounting::new(), + signed_permit( + domain_separator(&mut fresh()), + U256::ZERO, + anvil_owner(), + BOB, + u(500), + U256::MAX, + ) + .abi_encode(), + ), + ), + ]; + + let expected: &[(&str, (u64, u64, u64))] = &[ + ("transfer", (3, 2, 0)), + ("transfer_from", (4, 3, 0)), + ("approve", (0, 1, 0)), + ("mint", (5, 2, 0)), + ("burn", (4, 2, 0)), + ("burn_blocked", (4, 2, 0)), + ("seize", (6, 2, 0)), + ("pause", (1, 1, 0)), + ("unpause", (1, 1, 0)), + ("update_supply_cap", (2, 1, 0)), + ("update_name", (1, 1, 0)), + ("update_symbol", (1, 1, 0)), + ("update_contract_uri", (1, 1, 0)), + ("grant_role", (1, 1, 0)), + ("revoke_role", (1, 1, 0)), + ("set_role_admin", (1, 1, 0)), + ("update_policy", (2, 1, 0)), + ("update_multiplier", (4, 1, 0)), + ("update_ui_multiplier", (3, 1, 0)), + ("cancel_ui_multiplier_update", (3, 1, 0)), + ("batch_mint", (11, 4, 0)), + ("announce", (1, 1, 0)), + ("update_extra_metadata", (1, 1, 0)), + ("permit", (3, 2, 5)), + ]; + + bless_or_assert_gas(&actual, expected); +} + +// ============================================================================ +// meta: op coverage checklist +// ============================================================================ + +/// Compile-time coverage checklist — never called; it exists only for its two +/// exhaustive `match`es (no `_` arm), each arm naming the golden `#[test]` fn(s) that +/// pin the op via [`covered`]. +/// +/// This gives two compile-time guarantees: +/// * add an op to the ABI (a new `IB20Calls` / `IB20AssetCalls` variant) → the +/// wildcard-free match fails to build until an arm (and thus a golden) is added; +/// * rename or remove a golden `#[test]` fn → the `covered(&[...])` reference fails +/// to build. +/// +/// Unlike V1's frozen checklist, V2 is the live version: new selectors are expected to +/// land here over time, each pulling in a new golden test. +#[allow(dead_code)] +fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Asset::IB20AssetCalls) { + use IB20::IB20Calls as C; + use IB20Asset::IB20AssetCalls as SC; + + // No-op: forces each arm to name real golden `#[test]` fns by path. + fn covered(_goldens: &[fn()]) {} + + match call { + // ERC-20 core + C::transfer(_) => covered(&[ + golden_transfer_privileged, + golden_transfer_unprivileged_allowed, + golden_transfer_unprivileged_blocked_sender_reverts, + golden_transfer_reverts_zero_receiver, + golden_transfer_unprivileged_zero_receiver_storage_access, + golden_transfer_reverts_insufficient_balance, + golden_transfer_reverts_when_paused, + golden_transfer_reverts_zero_sender, + ]), + C::transferFrom(_) => covered(&[ + golden_transfer_from_finite_allowance_decrements, + golden_transfer_from_infinite_allowance_not_decremented, + golden_transfer_from_reverts_insufficient_allowance, + golden_transfer_from_unprivileged_enforces_executor_policy, + golden_transfer_from_reverts_zero_receiver, + golden_transfer_from_reverts_zero_sender, + ]), + C::approve(_) => covered(&[ + golden_approve_sets_allowance_and_emits, + golden_approve_reverts_zero_spender, + golden_approve_reverts_zero_approver, + ]), + C::transferWithMemo(_) => covered(&[golden_transfer_with_memo_emits_transfer_then_memo]), + C::transferFromWithMemo(_) => covered(&[golden_transfer_from_with_memo]), + + // mint / burn + C::mint(_) => covered(&[ + golden_mint_privileged_still_enforces_receiver_policy, + golden_mint_unprivileged_requires_role_and_policy, + golden_mint_reverts_over_supply_cap, + golden_mint_reverts_zero_receiver, + ]), + C::mintWithMemo(_) => covered(&[golden_mint_with_memo]), + C::burn(_) => covered(&[ + golden_burn_requires_role_then_reduces_supply, + golden_burn_reverts_insufficient_balance, + ]), + C::burnWithMemo(_) => covered(&[golden_burn_with_memo]), + C::burnBlocked(_) => covered(&[ + golden_burn_blocked_destroys_from_blocked_account, + golden_burn_blocked_reverts_when_not_blocked, + golden_burn_blocked_unprivileged_requires_role, + ]), + + // seize (new at V2) + C::seizeWithMemo(_) => covered(&[ + golden_seize_moves_balance_emits_transfer_memo_seized, + golden_seize_reverts_missing_role, + golden_seize_reverts_zero_receiver, + golden_seize_reverts_account_not_seizable, + golden_seize_reverts_insufficient_balance, + golden_seize_reverts_when_paused, + golden_seize_enforces_receiver_policy, + golden_seize_privileged_still_enforces_role_and_seizable, + ]), + C::SEIZE_ROLE(_) | C::SEIZE_EXEMPT_POLICY(_) | C::SEIZE_RECEIVER_POLICY(_) => { + covered(&[golden_read_seize_role_and_policy_constants]) + } + + // pause / config / roles / policy / permit + C::pause(_) => covered(&[ + golden_pause_sets_feature_bit, + golden_pause_reverts_empty_feature_set, + golden_pause_unprivileged_requires_role, + ]), + C::unpause(_) => covered(&[ + golden_unpause_clears_feature_bit, + golden_unpause_reverts_empty_feature_set, + golden_unpause_unprivileged_requires_role, + ]), + C::updateSupplyCap(_) => covered(&[ + golden_update_supply_cap, + golden_update_supply_cap_reverts_below_supply, + golden_update_supply_cap_unprivileged_requires_role, + ]), + C::updateName(_) => covered(&[ + golden_update_name_emits_name_and_domain_changed, + golden_update_name_unprivileged_requires_role, + ]), + C::updateSymbol(_) => { + covered(&[golden_update_symbol, golden_update_symbol_unprivileged_requires_role]) + } + C::updateContractURI(_) => covered(&[ + golden_update_contract_uri, + golden_update_contract_uri_unprivileged_requires_role, + ]), + C::grantRole(_) => covered(&[ + golden_grant_role, + golden_grant_role_unprivileged_no_admin_reverts, + golden_grant_role_unprivileged_non_admin_caller_reverts, + golden_grant_default_admin_bumps_member_count, + golden_grant_role_idempotent_when_already_held, + ]), + C::revokeRole(_) => covered(&[ + golden_revoke_role, + golden_revoke_last_admin_rejected, + golden_revoke_role_unprivileged_non_admin_caller_reverts, + golden_revoke_role_noop_when_not_held, + ]), + C::renounceRole(_) => covered(&[ + golden_renounce_role, + golden_renounce_role_bad_confirmation, + golden_renounce_role_reverts_last_admin, + ]), + C::renounceLastAdmin(_) => { + covered(&[golden_renounce_last_admin, golden_renounce_last_admin_reverts_when_not_sole]) + } + C::setRoleAdmin(_) => covered(&[ + golden_set_role_admin, + golden_set_role_admin_unprivileged_non_admin_caller_reverts, + ]), + C::updatePolicy(_) => covered(&[ + golden_update_policy, + golden_update_policy_reverts_missing_policy, + golden_update_policy_unprivileged_requires_role, + ]), + C::permit(_) => covered(&[ + golden_permit_sets_allowance_and_increments_nonce, + golden_permit_reverts_when_expired, + ]), + + // computed reads + C::isPaused(_) | C::pausedFeatures(_) => { + covered(&[golden_read_is_paused_and_paused_features_includes_seize]) + } + C::policyId(_) => covered(&[golden_read_policy_id_and_unsupported_scope]), + C::DOMAIN_SEPARATOR(_) => covered(&[golden_read_domain_separator]), + C::eip712Domain(_) => covered(&[golden_read_eip712_domain]), + + // direct reads + C::name(_) + | C::symbol(_) + | C::decimals(_) + | C::totalSupply(_) + | C::balanceOf(_) + | C::allowance(_) + | C::supplyCap(_) + | C::nonces(_) + | C::contractURI(_) + | C::hasRole(_) + | C::getRoleAdmin(_) => covered(&[golden_read_metadata_and_supply]), + + // role / policy-id constants + C::DEFAULT_ADMIN_ROLE(_) + | C::MINT_ROLE(_) + | C::BURN_ROLE(_) + | C::BURN_BLOCKED_ROLE(_) + | C::PAUSE_ROLE(_) + | C::UNPAUSE_ROLE(_) + | C::METADATA_ROLE(_) + | C::TRANSFER_SENDER_POLICY(_) + | C::TRANSFER_RECEIVER_POLICY(_) + | C::TRANSFER_EXECUTOR_POLICY(_) + | C::MINT_RECEIVER_POLICY(_) => covered(&[golden_read_role_and_policy_constants]), + } + + match ext { + // asset-specific reads + SC::OPERATOR_ROLE(_) | SC::WAD_PRECISION(_) => { + covered(&[golden_read_operator_role_and_wad]) + } + SC::multiplier(_) + | SC::toScaledBalance(_) + | SC::toRawBalance(_) + | SC::scaledBalanceOf(_) => covered(&[golden_read_multiplier_and_scaled_balances]), + // ERC-8056 Conversion extension aliases of toScaledBalance / toRawBalance. + SC::toUIAmount(_) | SC::fromUIAmount(_) => { + covered(&[golden_to_ui_amount_and_from_ui_amount_alias_conversions]) + } + SC::MAX_UI_MULTIPLIER(_) => covered(&[golden_max_ui_multiplier_returns_uint128_max]), + SC::isAnnouncementIdUsed(_) => covered(&[golden_read_is_announcement_id_used]), + SC::extraMetadata(_) => covered(&[golden_read_extra_metadata]), + + // asset-specific mutations + SC::updateMultiplier(_) => covered(&[ + golden_update_multiplier_emits_ui_multiplier_updated, + golden_update_multiplier_reverts_zero, + golden_update_multiplier_unprivileged_requires_role, + golden_update_multiplier_clears_live_pending_and_emits_cancellation, + ]), + SC::batchMint(_) => covered(&[ + golden_batch_mint, + golden_batch_mint_reverts_length_mismatch, + golden_batch_mint_reverts_empty, + golden_batch_mint_reverts_when_paused, + golden_batch_mint_unprivileged_requires_role, + ]), + SC::updateExtraMetadata(_) => covered(&[ + golden_update_extra_metadata_set, + golden_update_extra_metadata_remove, + golden_update_extra_metadata_reverts_empty_key, + golden_update_extra_metadata_unprivileged_requires_role, + ]), + SC::announce(_) => covered(&[ + golden_announce_emits_and_runs_internal_calls, + golden_announce_reverts_id_already_used, + golden_announce_reverts_internal_call_malformed, + golden_announce_reverts_nested_announce, + golden_announce_reverts_internal_call_failed, + golden_announce_unprivileged_requires_role, + ]), + + // ERC-8056 scheduled-multiplier surface: introduced at V2 (Cobalt). + SC::uiMultiplier(_) => covered(&[golden_multiplier_lazily_flips_at_maturity]), + SC::newUIMultiplier(_) | SC::effectiveAt(_) => { + covered(&[golden_new_ui_multiplier_and_effective_at_reads]) + } + SC::balanceOfUI(_) | SC::totalSupplyUI(_) => { + covered(&[golden_balance_of_ui_and_total_supply_ui_scale_by_effective_multiplier]) + } + SC::updateUIMultiplier(_) => covered(&[ + golden_update_ui_multiplier_schedules_pending, + golden_update_ui_multiplier_reverts_invalid_multiplier, + golden_update_ui_multiplier_reverts_effective_at_in_past, + golden_update_ui_multiplier_reverts_effective_at_too_far, + golden_update_ui_multiplier_reverts_schedule_overlap, + golden_update_ui_multiplier_folds_matured_pending_before_rescheduling, + golden_update_ui_multiplier_unprivileged_requires_role, + ]), + SC::cancelUIMultiplierUpdate(_) => covered(&[ + golden_cancel_ui_multiplier_update, + golden_cancel_ui_multiplier_update_reverts_when_none, + golden_cancel_ui_multiplier_update_reverts_when_already_matured, + golden_cancel_ui_multiplier_update_unprivileged_requires_role, + ]), + SC::supportsInterface(_) => covered(&[ + golden_supports_interface_true_for_erc165_and_erc8056_ids, + golden_supports_interface_false_for_unrelated_id, + golden_supports_interface_true_for_conversion_extension_id, + ]), + } +} diff --git a/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs b/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs new file mode 100644 index 0000000000..861cb09d16 --- /dev/null +++ b/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs @@ -0,0 +1,2601 @@ +//! Golden tests pinning Stablecoin **V3** behavior of the B-20 precompile. +//! +//! V3 is activated at Denim as a behavior-preserving copy of V2 (a scaffold seam for future +//! Denim-era changes). Its logic and storage layout are identical to V2, so this suite pins its +//! own roots to lock V3's behavior independently: a future edit that alters V3 must re-bless these +//! roots. These goldens run production Cobalt storage features (Denim inherits them via +//! `upgrade >= Cobalt`) while the V1 suite runs Legacy, so a root matches V2's only when the op +//! does not diverge; ops that write shrinking dynamic values can legitimately diverge from V1's +//! Legacy pin. +//! +//! Every op is driven through the **version-resolver-gated** dispatch path +//! (`BaseUpgrade::Denim` -> `StablecoinVersion::V3`) against the real EVM-backed +//! `B20StablecoinStorage` over `HashMapStorageProvider` configured with `StorageFeatures::Cobalt` +//! (the production storage config at Denim), with a `FakePolicyAccounting` for deterministic +//! allow/block decisions. Each case asserts: +//! 1. exact returned ABI bytes (or the typed revert), +//! 2. resulting state (balances / supply / roles / allowances / storage), +//! 3. emitted events, and +//! 4. a per-case keccak storage **hash** snapshot (the frozen-manifest baseline). +//! +//! ## Blessing storage hashes +//! State-root constants below are pinned. To (re)generate them after an intentional change, run: +//! `BLESS_GOLDEN=1 cargo test -p base-common-precompiles --features test-utils \ +//! --test b20_stablecoin_v3_golden -- --nocapture` and copy the printed `GOLDEN_ROOT` values. + +use alloy_primitives::{Address, B256, Bytes, U256, b256}; +use alloy_sol_types::{SolCall, SolError, SolEvent, SolValue}; +use base_common_genesis::BaseUpgrade; +use base_common_precompiles::{ + B20_MAX_SUPPLY_CAP, B20PolicyType, B20StablecoinInit, B20StablecoinStorage, B20StablecoinToken, + B20TokenRole, FakePolicyAccounting, IB20, IB20Stablecoin, NoopPrecompileCallObserver, + PolicyVersion, Stablecoin, StablecoinV3, StablecoinVersion, StablecoinVersions, + TokenAccounting, UpgradeGatedStorageFeatures, +}; +use base_precompile_storage::{BasePrecompileError, HashMapStorageProvider, StorageCtx}; + +mod common; +use common::{ + ADMIN, ALICE, BOB, CAROL, CHAIN_ID, MEMO, POLICY_ID, TOKEN, anvil_owner, bless_or_assert_gas, + bless_or_assert_root, hash_token_state, ok_true, signed_permit, u, +}; + +// --- fixtures --------------------------------------------------------------- + +const NAME: &str = "Base Dollar"; +const SYMBOL: &str = "bUSD"; +const CURRENCY: &str = "USD"; +const LOGIC: StablecoinV3 = StablecoinV3; + +/// A second policy id distinct from [`POLICY_ID`], used where a test needs both a +/// seize-holder and a seize-receiver scope configured simultaneously. +const POLICY_ID_2: u64 = (1u64 << 56) | 8; + +// --- pinned storage hashes (bless with BLESS_GOLDEN=1; see module docs) -------- +// +// V3 is a behavior- and layout-preserving copy of V2, so these track V2's roots — but pinned +// independently here: these goldens run `StorageFeatures::Cobalt` (inherited at Denim) while the +// V1 suite runs Legacy, so an op that triggers Cobalt dynamic tail cleanup (name/symbol/contract-URI) +// can diverge from its V1 pin. Re-blessing reflects the true Denim snapshot for each. + +const ROOT_FRESH: B256 = b256!("bf580eac849e894561daeeecac9e412373430ad50b65434c0dca29025b00fdd7"); +const ROOT_TRANSFER_PRIV: B256 = + b256!("0ee1464bf7b842fec4fca6802f1fb3397087a99171c3da757d8aafa27322eda1"); +const ROOT_TRANSFER_UNPRIV: B256 = + b256!("94535be95a5043e812a723a89d80632b6455091c93049601afc5ead6b3237a65"); +const ROOT_TRANSFER_WITH_MEMO: B256 = + b256!("9424567f766f42badffca4e54588a67091a583400c9bdb881bc74d9df81f6428"); +const ROOT_TRANSFER_FROM_FINITE: B256 = + b256!("2924377d5e2d504195dfd71a19563ba6a390af8fe58e2a1950aa8baed03703c5"); +const ROOT_TRANSFER_FROM_INFINITE: B256 = + b256!("2ad40ca237386a1e6fb2ca314b7eba660d58b8f97724c385c42c2eb9676713a7"); +const ROOT_TRANSFER_FROM_WITH_MEMO: B256 = + b256!("96bcdc5001debd10e63f42a4dfe0430eb05ac44ac80390aa4b3e42f2e364577c"); +const ROOT_APPROVE: B256 = + b256!("c53db8b6c550d1dd55943584f5e0beaaac8553368beeb3c56243498d6fb1cbe7"); +const ROOT_MINT_PRIV: B256 = + b256!("e3ff114e985b47774ef4291604546e2ebd1f0d208b3e58dd054232767760ffaf"); +const ROOT_MINT_UNPRIV: B256 = + b256!("e6764fa78f04eb3cc3f64658600a11353e6d8a7822f6219fb5f7817eb4349c28"); +const ROOT_MINT_WITH_MEMO: B256 = + b256!("b573c425885d631f1dc025c72190dca8af773bd08ea08a1201f500efbad07dc9"); +const ROOT_BURN: B256 = b256!("dbafa7df680e7c0f9314a08d133d92467c39e90d3000f020f6a0887f69dceaaa"); +const ROOT_BURN_WITH_MEMO: B256 = + b256!("8e498187967c8c4a9b44731b97cff8371df4fe878b21d87c79d53663dff9a574"); +const ROOT_BURN_BLOCKED: B256 = + b256!("8c0903882ce06f44f39f712095e04fe4043dee99ff9a0ea7dd4d29f59bdb92dc"); +const ROOT_PAUSE: B256 = b256!("bd750dfb76762b748ead6ec237eac189bda7ff7d2ef3e315b9c65003c5d6ed6d"); +const ROOT_UNPAUSE: B256 = + b256!("46c5e87f7aa51dab8813d94294dfc2586f4d6ec6444f3c3180e4023297f95074"); +const ROOT_UPDATE_SUPPLY_CAP: B256 = + b256!("db5b9601b07844e2a2b0f65c77b923e61b4760ae12193fd18157743587190797"); +const ROOT_UPDATE_NAME: B256 = + b256!("b03cee01b5a69496527aab9a87371b6e32048271d1a37d9f4a85275decbf4f00"); +const ROOT_UPDATE_SYMBOL: B256 = + b256!("b13fcaa2a3805825974cb5ccf27a7c78ac28794c3797ebbff1f0ce75bb36755a"); +const ROOT_UPDATE_CONTRACT_URI: B256 = + b256!("dcb77fabcbb0ef4c32ccb66e79f8d9b6ff6c12215acba7fb790d6a40add7bf03"); +const ROOT_GRANT_ROLE: B256 = + b256!("4fef96b846b5c7ea29959887578d2df0b55cfe9609f4e84c8f0d126bf40e3489"); +const ROOT_REVOKE_ROLE: B256 = + b256!("b60badc66c4ae9e16a4e3d9cd2e44af737b2d48035533c8162afbf4aa4965b80"); +const ROOT_RENOUNCE_ROLE: B256 = + b256!("b5e95421f5739bda693e190082faa6f91fd880356efea89fcf72793eb70714fd"); +const ROOT_RENOUNCE_LAST_ADMIN: B256 = + b256!("9ebc86ddc23454c7fa99cc88d8e1d1c546d11aa3740906178e3258d488c561dc"); +const ROOT_SET_ROLE_ADMIN: B256 = + b256!("e3da1c91ceb11ba3b0384052609b85c946ba9cd2832ab857ff154dad0130b1fc"); +const ROOT_UPDATE_POLICY: B256 = + b256!("e5bbb9b1b1bda4eb1fb180508c911c51f45162554bd36bfac773ee45ad32ba02"); +const ROOT_PERMIT: B256 = b256!("24182d9464353e2ab9200272dd2ec1efe319e0bf29998e4d65e04a75b66ffd34"); +const ROOT_GRANT_DEFAULT_ADMIN: B256 = + b256!("0b14e2c61be5bd28090aeaa21c1dcade5f8ce590daf9ed548ae41ad0f35cc8aa"); +const ROOT_GRANT_IDEMPOTENT: B256 = + b256!("0899e7aa92d9ada391e535276cc089dc8f3a231511284ec623e22d00e05b4b3c"); + +// --- pinned storage hashes: V2-only (bless with BLESS_GOLDEN=1) -------------- +// +// `seizeWithMemo` is genuinely new at V2 (V1's default trait body rejects it), so it gets its +// own fresh pin. + +const ROOT_SEIZE: B256 = b256!("9314f58649ea21b77a277a7ac8a0cd455286315bd0454fd7ec1a03ab306354d1"); + +// --- harness ---------------------------------------------------------------- + +/// Fresh provider with an initialized `Base Dollar` stablecoin at [`TOKEN`]. +fn fresh() -> HashMapStorageProvider { + let mut storage = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + StorageCtx::enter(&mut storage, |ctx| { + let mut token = B20StablecoinStorage::from_address(TOKEN, ctx); + token + .initialize(B20StablecoinInit { + name: NAME.into(), + symbol: SYMBOL.into(), + supply_cap: B20_MAX_SUPPLY_CAP, + currency: CURRENCY.into(), + }) + .expect("initialize stablecoin"); + }); + storage +} + +/// Mutates raw token storage through the accounting port (test setup only). +fn seed(storage: &mut HashMapStorageProvider, f: impl FnOnce(&mut B20StablecoinStorage<'_>)) { + StorageCtx::enter(storage, |ctx| { + let mut token = B20StablecoinStorage::from_address(TOKEN, ctx); + f(&mut token); + }); +} + +/// Reads token state through the accounting port. +fn read( + storage: &mut HashMapStorageProvider, + f: impl FnOnce(&B20StablecoinStorage<'_>) -> R, +) -> R { + StorageCtx::enter(storage, |ctx| f(&B20StablecoinStorage::from_address(TOKEN, ctx))) +} + +/// Drives one op through the resolver-gated (`Denim` -> V3) unprivileged path. +fn op( + storage: &mut HashMapStorageProvider, + caller: Address, + policy: FakePolicyAccounting, + calldata: Vec, +) -> Result { + storage.set_caller(caller); + StorageCtx::enter(storage, |ctx| { + let version = + StablecoinVersions::from_base_upgrade(BaseUpgrade::Denim).expect("Denim activates V3"); + B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(TOKEN, ctx), + policy, + PolicyVersion::V3, + ) + .route(ctx, &calldata, version, false, NoopPrecompileCallObserver) + }) +} + +/// Drives one op through V3 with factory-init privilege (guards skipped). +fn op_privileged( + storage: &mut HashMapStorageProvider, + caller: Address, + policy: FakePolicyAccounting, + calldata: Vec, +) -> Result { + storage.set_caller(caller); + StorageCtx::enter(storage, |ctx| { + B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(TOKEN, ctx), + policy, + PolicyVersion::V3, + ) + .route(ctx, &calldata, StablecoinVersion::V3, true, NoopPrecompileCallObserver) + }) +} + +/// Topic-0 (signature hash) of the last event emitted by the token. +fn last_topic0(storage: &HashMapStorageProvider) -> B256 { + storage.get_events(TOKEN).last().expect("an emitted event").topics()[0] +} + +/// Asserts the token's storage hash, or prints it under `BLESS_GOLDEN` for (re)pinning. +#[track_caller] +fn assert_root(label: &str, storage: HashMapStorageProvider, expected: B256) { + bless_or_assert_root(label, hash_token_state(storage, TOKEN), expected); +} + +/// Grants `role` to `who` and bumps the role member count (setup only). +fn give_role(token: &mut B20StablecoinStorage<'_>, role: B256, who: Address) { + token.set_role(role, who, true).unwrap(); + let next = token.role_member_count(role).unwrap() + U256::ONE; + token.set_role_member_count(role, next).unwrap(); +} + +/// Credits `who` with `amount` and grows total supply to match (setup only). +fn fund(token: &mut B20StablecoinStorage<'_>, who: Address, amount: U256) { + let balance = token.balance_of(who).unwrap(); + token.set_balance(who, balance + amount).unwrap(); + let supply = token.total_supply().unwrap(); + token.set_total_supply(supply + amount).unwrap(); +} + +/// The V2 EIP-712 domain separator for the token at [`TOKEN`] on [`CHAIN_ID`]. +fn domain_separator(storage: &mut HashMapStorageProvider) -> B256 { + StorageCtx::enter(storage, |ctx| { + let token = B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ); + LOGIC.domain_separator(&token, CHAIN_ID).unwrap() + }) +} + +/// Configures `from` as seizable (not authorized by `SeizeExempt`) and `to` as an authorized +/// `SeizeReceiver`, using two distinct policy ids so the two scopes are independently exercised. +fn make_seizable(token: &mut B20StablecoinStorage<'_>) { + token.set_policy_id(B20PolicyType::SeizeExempt.id(), POLICY_ID).unwrap(); + token.set_policy_id(B20PolicyType::SeizeReceiver.id(), POLICY_ID_2).unwrap(); +} + +/// A `FakePolicyAccounting` that authorizes `who` under [`POLICY_ID_2`] (the seize-receiver +/// scope configured by [`make_seizable`]), leaving [`POLICY_ID`] (seize-holder) with no members. +fn seize_receiver_policy(who: Address) -> FakePolicyAccounting { + let mut policy = FakePolicyAccounting::new(); + policy.allow(POLICY_ID_2, who); + policy +} + +/// Asserts an unprivileged metadata/admin op reverts for a caller lacking `role`. +#[track_caller] +fn assert_unprivileged_requires_role(calldata: Vec, role: B256) { + let mut s = fresh(); + let err = op(&mut s, ALICE, FakePolicyAccounting::new(), calldata).unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: role, + }) + ); +} + +/// A `FakePolicyAccounting` authorizing `who` under the default (0) scope. +fn allow0(who: Address) -> FakePolicyAccounting { + let mut p = FakePolicyAccounting::new(); + p.allow(0, who); + p +} + +// ============================================================================ +// version resolver / dispatch envelope +// ============================================================================ + +#[test] +fn resolver_activates_v3_from_denim() { + assert_eq!( + StablecoinVersions::from_base_upgrade(BaseUpgrade::Denim), + Some(StablecoinVersion::V3) + ); +} + +#[test] +fn dispatch_rejects_nonzero_value() { + let mut s = fresh(); + let calldata = IB20::balanceOfCall { account: ALICE }.abi_encode(); + s.set_call_value(U256::ONE); + let out = StorageCtx::enter(&mut s, |ctx| { + B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ) + .dispatch_with_observer( + ctx, + &calldata, + BaseUpgrade::Denim, + NoopPrecompileCallObserver, + ) + }) + .expect("dispatch must not fatally error"); + assert!(out.is_revert()); + assert_eq!(out.bytes, Bytes::from(IB20::NonPayable {}.abi_encode())); +} + +#[test] +fn dispatch_reverts_when_uninitialized() { + let mut s = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + let calldata = IB20::balanceOfCall { account: ALICE }.abi_encode(); + let out = StorageCtx::enter(&mut s, |ctx| { + B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ) + .dispatch_with_observer( + ctx, + &calldata, + BaseUpgrade::Denim, + NoopPrecompileCallObserver, + ) + }) + .expect("dispatch must not fatally error"); + assert!(out.is_revert()); + assert!(out.bytes.is_empty()); +} + +#[test] +fn golden_dispatch_no_observer_wrapper_reverts_uninitialized() { + // Exercises the no-observer `dispatch()` wrapper + the is_initialized=false gate. + let mut s = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + s.set_caller(ALICE); + let calldata = IB20::balanceOfCall { account: ALICE }.abi_encode(); + let out = StorageCtx::enter(&mut s, |ctx| { + B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ) + .dispatch(ctx, &calldata, BaseUpgrade::Denim) + }) + .expect("dispatch must not fatally error"); + assert!(out.is_revert()); +} + +// ============================================================================ +// transfer (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_transfer_privileged() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + + let out = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(30) }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| { + assert_eq!(t.balance_of(ALICE).unwrap(), u(70)); + assert_eq!(t.balance_of(BOB).unwrap(), u(30)); + }); + assert_eq!(last_topic0(&s), IB20::Transfer::SIGNATURE_HASH); + assert_root("transfer_privileged", s, ROOT_TRANSFER_PRIV); +} + +#[test] +fn golden_transfer_unprivileged_allowed() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + t.set_policy_id(B20PolicyType::TransferReceiver.id(), POLICY_ID).unwrap(); + }); + let mut policy = FakePolicyAccounting::new(); + policy.allow(POLICY_ID, ALICE); + policy.allow(POLICY_ID, BOB); + let out = op(&mut s, ALICE, policy, IB20::transferCall { to: BOB, amount: u(10) }.abi_encode()) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| { + assert_eq!(t.balance_of(ALICE).unwrap(), u(90)); + assert_eq!(t.balance_of(BOB).unwrap(), u(10)); + }); + assert_root("transfer_unprivileged", s, ROOT_TRANSFER_UNPRIV); +} + +#[test] +fn golden_transfer_unprivileged_blocked_sender_reverts() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(10) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferSender.id(), + policyId: POLICY_ID, + }) + ); +} + +#[test] +fn golden_transfer_reverts_zero_receiver() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(10))); + let err = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: Address::ZERO, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); +} + +#[test] +fn golden_transfer_reverts_zero_sender() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + Address::ZERO, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidSender { sender: Address::ZERO })); +} + +#[test] +fn golden_transfer_reverts_insufficient_balance() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(10))); + let err = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(50) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: u(10), + needed: u(50), + }) + ); +} + +#[test] +fn golden_transfer_reverts_when_paused() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(10))); + op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::TRANSFER] }.abi_encode(), + ) + .unwrap(); + let err = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::TRANSFER + }) + ); +} + +#[test] +fn golden_transfer_with_memo_emits_transfer_then_memo() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + let out = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferWithMemoCall { to: BOB, amount: u(30), memo: MEMO }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_root("transfer_with_memo", s, ROOT_TRANSFER_WITH_MEMO); +} + +// ============================================================================ +// transferFrom (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_transfer_from_finite_allowance_decrements() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(40)).unwrap(); + }); + let out = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(30) }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| { + assert_eq!(t.allowance(ALICE, BOB).unwrap(), u(10)); + assert_eq!(t.balance_of(BOB).unwrap(), u(30)); + }); + assert_root("transfer_from_finite", s, ROOT_TRANSFER_FROM_FINITE); +} + +#[test] +fn golden_transfer_from_infinite_allowance_not_decremented() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, U256::MAX).unwrap(); + }); + let out = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(30) }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| assert_eq!(t.allowance(ALICE, BOB).unwrap(), U256::MAX)); + assert_root("transfer_from_infinite", s, ROOT_TRANSFER_FROM_INFINITE); +} + +#[test] +fn golden_transfer_from_reverts_insufficient_allowance() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(5)).unwrap(); + }); + let err = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(30) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientAllowance { + spender: BOB, + allowance: u(5), + needed: u(30), + }) + ); +} + +#[test] +fn golden_transfer_from_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(40)).unwrap(); + }); + seed(&mut s, |t| { + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: CAROL, amount: u(10) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + +#[test] +fn golden_transfer_from_with_memo() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(40)).unwrap(); + }); + let out = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromWithMemoCall { from: ALICE, to: CAROL, amount: u(30), memo: MEMO } + .abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_root("transfer_from_with_memo", s, ROOT_TRANSFER_FROM_WITH_MEMO); +} + +#[test] +fn golden_transfer_from_reverts_zero_receiver() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: Address::ZERO, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); +} + +#[test] +fn golden_transfer_from_reverts_zero_sender() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: Address::ZERO, to: CAROL, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidSender { sender: Address::ZERO })); +} + +// ============================================================================ +// approve (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_approve_sets_allowance_and_emits() { + let mut s = fresh(); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::approveCall { spender: BOB, amount: u(50) }.abi_encode(), + ) + .unwrap(); + + assert_eq!(out, ok_true()); + read(&mut s, |t| assert_eq!(t.allowance(ALICE, BOB).unwrap(), u(50))); + assert_eq!(last_topic0(&s), IB20::Approval::SIGNATURE_HASH); + assert_root("approve", s, ROOT_APPROVE); +} + +#[test] +fn golden_approve_reverts_zero_spender() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::approveCall { spender: Address::ZERO, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidSpender { spender: Address::ZERO })); +} + +#[test] +fn golden_approve_reverts_zero_approver() { + let mut s = fresh(); + let err = op( + &mut s, + Address::ZERO, + FakePolicyAccounting::new(), + IB20::approveCall { spender: BOB, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidApprover { approver: Address::ZERO })); +} + +// ============================================================================ +// mint (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_mint_privileged_still_enforces_receiver_policy() { + let mut s = fresh(); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let out = op_privileged( + &mut s, + ADMIN, + policy, + IB20::mintCall { to: BOB, amount: u(100) }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.balance_of(BOB).unwrap(), u(100)); + assert_eq!(t.total_supply().unwrap(), u(100)); + }); + assert_eq!(last_topic0(&s), IB20::Transfer::SIGNATURE_HASH); + assert_root("mint_privileged", s, ROOT_MINT_PRIV); +} + +#[test] +fn golden_mint_unprivileged_requires_role_and_policy() { + let mut s = fresh(); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let err = op(&mut s, ALICE, policy, IB20::mintCall { to: BOB, amount: u(1) }.abi_encode()) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Mint.id(), + }) + ); + + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let out = + op(&mut s, ALICE, policy, IB20::mintCall { to: BOB, amount: u(75) }.abi_encode()).unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.balance_of(BOB).unwrap(), u(75))); + assert_root("mint_unprivileged", s, ROOT_MINT_UNPRIV); +} + +#[test] +fn golden_mint_reverts_over_supply_cap() { + let mut s = fresh(); + seed(&mut s, |t| t.set_supply_cap(u(50)).unwrap()); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let err = op_privileged( + &mut s, + ADMIN, + policy, + IB20::mintCall { to: BOB, amount: u(100) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::SupplyCapExceeded { cap: u(50), attempted: u(100) }) + ); +} + +#[test] +fn golden_mint_reverts_zero_receiver() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::mintCall { to: Address::ZERO, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); +} + +#[test] +fn golden_mint_with_memo() { + let mut s = fresh(); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let out = op_privileged( + &mut s, + ADMIN, + policy, + IB20::mintWithMemoCall { to: BOB, amount: u(40), memo: MEMO }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_root("mint_with_memo", s, ROOT_MINT_WITH_MEMO); +} + +// ============================================================================ +// burn / burnBlocked (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_burn_requires_role_then_reduces_supply() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnCall { amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Burn.id(), + }) + ); + + seed(&mut s, |t| give_role(t, B20TokenRole::Burn.id(), ALICE)); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnCall { amount: u(40) }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.balance_of(ALICE).unwrap(), u(60)); + assert_eq!(t.total_supply().unwrap(), u(60)); + }); + assert_eq!(last_topic0(&s), IB20::Transfer::SIGNATURE_HASH); + assert_root("burn", s, ROOT_BURN); +} + +#[test] +fn golden_burn_reverts_insufficient_balance() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(10)); + give_role(t, B20TokenRole::Burn.id(), ALICE); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnCall { amount: u(50) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: u(10), + needed: u(50), + }) + ); +} + +#[test] +fn golden_burn_with_memo() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Burn.id(), ALICE); + }); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnWithMemoCall { amount: u(40), memo: MEMO }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_root("burn_with_memo", s, ROOT_BURN_WITH_MEMO); +} + +#[test] +fn golden_burn_blocked_destroys_from_blocked_account() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + }); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::burnBlockedCall { from: ALICE, amount: u(40) }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.balance_of(ALICE).unwrap(), u(60))); + assert_eq!(last_topic0(&s), IB20::BurnedBlocked::SIGNATURE_HASH); + assert_root("burn_blocked", s, ROOT_BURN_BLOCKED); +} + +#[test] +fn golden_burn_blocked_reverts_when_not_blocked() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + }); + let mut policy = FakePolicyAccounting::new(); + policy.allow(POLICY_ID, ALICE); + let err = op_privileged( + &mut s, + ADMIN, + policy, + IB20::burnBlockedCall { from: ALICE, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccountNotBlocked { account: ALICE })); +} + +#[test] +fn golden_burn_blocked_unprivileged_requires_role() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::burnBlockedCall { from: BOB, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::BurnBlocked.id(), + }) + ); +} + +// ============================================================================ +// seizeWithMemo (new at V2 — V1's default trait body rejects it) +// ============================================================================ + +#[test] +fn golden_seize_moves_balance_emits_transfer_memo_seized() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + let out = op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(40), memo: MEMO }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty(), "seizeWithMemo is a void admin op (no bool return)"); + read(&mut s, |t| { + assert_eq!(t.balance_of(ALICE).unwrap(), u(60)); + assert_eq!(t.balance_of(BOB).unwrap(), u(40)); + assert_eq!(t.total_supply().unwrap(), u(100), "seize is a transfer, not a burn"); + }); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 3].topics()[0], IB20::Transfer::SIGNATURE_HASH); + assert_eq!(events[events.len() - 2].topics()[0], IB20::Memo::SIGNATURE_HASH); + assert_eq!( + events[events.len() - 1], + IB20::Seized { caller: ADMIN, from: ALICE, to: BOB, amount: u(40) }.encode_log_data() + ); + assert_root("seize", s, ROOT_SEIZE); +} + +#[test] +fn golden_seize_reverts_missing_role() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + make_seizable(t); + }); + let err = op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ADMIN, + neededRole: B20TokenRole::Seize.id(), + }) + ); +} + +#[test] +fn golden_seize_reverts_zero_receiver() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + let err = op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::seizeWithMemoCall { from: ALICE, to: Address::ZERO, amount: u(1), memo: MEMO } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); +} + +#[test] +fn golden_seize_reverts_account_not_seizable() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + // ALICE authorized under SeizeExempt => not seizable. + t.set_policy_id(B20PolicyType::SeizeExempt.id(), POLICY_ID).unwrap(); + }); + let mut policy = FakePolicyAccounting::new(); + policy.allow(POLICY_ID, ALICE); + let err = op( + &mut s, + ADMIN, + policy, + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccountNotSeizable { account: ALICE })); +} + +#[test] +fn golden_seize_reverts_insufficient_balance() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(10)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + let err = op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(50), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: u(10), + needed: u(50), + }) + ); +} + +#[test] +fn golden_seize_reverts_when_paused() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::SEIZE] }.abi_encode(), + ) + .unwrap(); + let err = op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { feature: IB20::PausableFeature::SEIZE }) + ); +} + +#[test] +fn golden_seize_enforces_receiver_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + // No FakePolicyAccounting authorization for BOB under POLICY_ID_2 (SeizeReceiver) => blocked. + let err = op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::SeizeReceiver.id(), + policyId: POLICY_ID_2, + }) + ); +} + +#[test] +fn golden_seize_privileged_still_enforces_role_and_seizable() { + // seize_with_memo never accepts a `privileged` bypass: factory-privileged dispatch still + // enforces both the role and the seizability guard. + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(100))); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(1), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ADMIN, + neededRole: B20TokenRole::Seize.id(), + }) + ); +} + +#[test] +fn golden_read_seize_role_and_policy_constants() { + let mut s = fresh(); + let cases: Vec<(Vec, B256)> = vec![ + (IB20::SEIZE_ROLECall {}.abi_encode(), B20TokenRole::Seize.id()), + (IB20::SEIZE_EXEMPT_POLICYCall {}.abi_encode(), B20PolicyType::SeizeExempt.id()), + (IB20::SEIZE_RECEIVER_POLICYCall {}.abi_encode(), B20PolicyType::SeizeReceiver.id()), + ]; + for (calldata, expected) in cases { + let out = op(&mut s, ALICE, FakePolicyAccounting::new(), calldata).unwrap(); + assert_eq!(out, Bytes::from(expected.abi_encode())); + } + assert_root("read_seize_constants", s, ROOT_FRESH); +} + +// ============================================================================ +// pause / unpause (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_pause_sets_feature_bit() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + assert_eq!(last_topic0(&s), IB20::Paused::SIGNATURE_HASH); + assert_root("pause", s, ROOT_PAUSE); +} + +#[test] +fn golden_unpause_clears_feature_bit() { + let mut s = fresh(); + op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::unpauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + assert_eq!(last_topic0(&s), IB20::Unpaused::SIGNATURE_HASH); + assert_root("unpause", s, ROOT_UNPAUSE); +} + +#[test] +fn golden_pause_reverts_empty_feature_set() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::EmptyFeatureSet {})); +} + +#[test] +fn golden_pause_unprivileged_requires_role() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Pause.id(), + }) + ); +} + +#[test] +fn golden_unpause_reverts_empty_feature_set() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::unpauseCall { features: vec![] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::EmptyFeatureSet {})); +} + +#[test] +fn golden_unpause_unprivileged_requires_role() { + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::unpauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Unpause.id(), + }) + ); +} + +#[test] +fn golden_read_is_paused_and_paused_features_includes_seize() { + let mut s = fresh(); + op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::SEIZE] }.abi_encode(), + ) + .unwrap(); + + let paused_seize = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::isPausedCall { feature: IB20::PausableFeature::SEIZE }.abi_encode(), + ) + .unwrap(); + assert_eq!(paused_seize, Bytes::from(true.abi_encode())); + + let features = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20::pausedFeaturesCall {}.abi_encode()) + .unwrap(); + assert_eq!(features, Bytes::from(vec![IB20::PausableFeature::SEIZE].abi_encode())); +} + +// ============================================================================ +// config / metadata (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_update_supply_cap() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSupplyCapCall { newSupplyCap: u(1_000) }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.supply_cap().unwrap(), u(1_000))); + assert_eq!(last_topic0(&s), IB20::SupplyCapUpdated::SIGNATURE_HASH); + assert_root("update_supply_cap", s, ROOT_UPDATE_SUPPLY_CAP); +} + +#[test] +fn golden_update_supply_cap_reverts_below_supply() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(500))); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSupplyCapCall { newSupplyCap: u(100) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::InvalidSupplyCap { + currentSupply: u(500), + proposedCap: u(100), + }) + ); +} + +#[test] +fn golden_update_name_emits_name_and_domain_changed() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateNameCall { newName: "New Name".into() }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.name().unwrap(), "New Name")); + let events = s.get_events(TOKEN); + assert_eq!(events[events.len() - 2].topics()[0], IB20::NameUpdated::SIGNATURE_HASH); + assert_eq!(events[events.len() - 1].topics()[0], IB20::EIP712DomainChanged::SIGNATURE_HASH); + assert_root("update_name", s, ROOT_UPDATE_NAME); +} + +#[test] +fn golden_update_symbol() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSymbolCall { newSymbol: "USDX".into() }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.symbol().unwrap(), "USDX")); + assert_eq!(last_topic0(&s), IB20::SymbolUpdated::SIGNATURE_HASH); + assert_root("update_symbol", s, ROOT_UPDATE_SYMBOL); +} + +#[test] +fn golden_update_contract_uri() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateContractURICall { newURI: "ipfs://x".into() }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.contract_uri().unwrap(), "ipfs://x")); + assert_eq!(last_topic0(&s), IB20::ContractURIUpdated::SIGNATURE_HASH); + assert_root("update_contract_uri", s, ROOT_UPDATE_CONTRACT_URI); +} + +#[test] +fn golden_update_supply_cap_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updateSupplyCapCall { newSupplyCap: u(1) }.abi_encode(), + B20TokenRole::DefaultAdmin.id(), + ); +} + +#[test] +fn golden_update_name_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updateNameCall { newName: "x".into() }.abi_encode(), + B20TokenRole::Metadata.id(), + ); +} + +#[test] +fn golden_update_symbol_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updateSymbolCall { newSymbol: "x".into() }.abi_encode(), + B20TokenRole::Metadata.id(), + ); +} + +#[test] +fn golden_update_contract_uri_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updateContractURICall { newURI: "x".into() }.abi_encode(), + B20TokenRole::Metadata.id(), + ); +} + +// ============================================================================ +// roles (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_grant_role() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_eq!(last_topic0(&s), IB20::RoleGranted::SIGNATURE_HASH); + assert_root("grant_role", s, ROOT_GRANT_ROLE); +} + +#[test] +fn golden_grant_role_unprivileged_no_admin_reverts() { + // No admin exists yet => the admin-availability guard reverts. + let mut s = fresh(); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: BOB }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); +} + +#[test] +fn golden_grant_role_unprivileged_non_admin_caller_reverts() { + // An admin exists, but ALICE is not the role's admin => the role check reverts. + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: BOB }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); +} + +#[test] +fn golden_grant_default_admin_bumps_member_count() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::DefaultAdmin.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert!(t.has_role(B20TokenRole::DefaultAdmin.id(), ALICE).unwrap()); + assert_eq!(t.role_member_count(B20TokenRole::DefaultAdmin.id()).unwrap(), u(2)); + }); + assert_eq!(last_topic0(&s), IB20::RoleGranted::SIGNATURE_HASH); + assert_root("grant_default_admin", s, ROOT_GRANT_DEFAULT_ADMIN); +} + +#[test] +fn golden_grant_role_idempotent_when_already_held() { + let mut s = fresh(); + seed(&mut s, |t| { + give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN); + give_role(t, B20TokenRole::Mint.id(), ALICE); + }); + // ALICE already holds MINT_ROLE => grant is a no-op (no event, count unchanged). + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_root("grant_idempotent", s, ROOT_GRANT_IDEMPOTENT); +} + +#[test] +fn golden_revoke_role() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(!t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_eq!(last_topic0(&s), IB20::RoleRevoked::SIGNATURE_HASH); + assert_root("revoke_role", s, ROOT_REVOKE_ROLE); +} + +#[test] +fn golden_revoke_role_unprivileged_non_admin_caller_reverts() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: BOB, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); +} + +#[test] +fn golden_revoke_role_noop_when_not_held() { + let mut s = fresh(); + // ALICE does not hold MINT_ROLE => revoke is a no-op; state stays at fresh. + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(!t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_root("revoke_noop", s, ROOT_FRESH); +} + +#[test] +fn golden_revoke_last_admin_rejected() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::DefaultAdmin.id(), account: ADMIN }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); +} + +#[test] +fn golden_renounce_role() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::renounceRoleCall { role: B20TokenRole::Mint.id(), callerConfirmation: ALICE } + .abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert!(!t.has_role(B20TokenRole::Mint.id(), ALICE).unwrap())); + assert_eq!(last_topic0(&s), IB20::RoleRevoked::SIGNATURE_HASH); + assert_root("renounce_role", s, ROOT_RENOUNCE_ROLE); +} + +#[test] +fn golden_renounce_role_bad_confirmation() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::renounceRoleCall { role: B20TokenRole::Mint.id(), callerConfirmation: BOB } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::AccessControlBadConfirmation {})); +} + +#[test] +fn golden_renounce_role_reverts_last_admin() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::renounceRoleCall { role: B20TokenRole::DefaultAdmin.id(), callerConfirmation: ADMIN } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); +} + +#[test] +fn golden_renounce_last_admin() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let out = + op(&mut s, ADMIN, FakePolicyAccounting::new(), IB20::renounceLastAdminCall {}.abi_encode()) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert!(!t.has_role(B20TokenRole::DefaultAdmin.id(), ADMIN).unwrap()); + assert_eq!(t.role_member_count(B20TokenRole::DefaultAdmin.id()).unwrap(), U256::ZERO); + }); + assert_eq!(last_topic0(&s), IB20::LastAdminRenounced::SIGNATURE_HASH); + assert_root("renounce_last_admin", s, ROOT_RENOUNCE_LAST_ADMIN); +} + +#[test] +fn golden_renounce_last_admin_reverts_when_not_sole() { + let mut s = fresh(); + seed(&mut s, |t| { + give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN); + give_role(t, B20TokenRole::DefaultAdmin.id(), BOB); + }); + let err = + op(&mut s, ADMIN, FakePolicyAccounting::new(), IB20::renounceLastAdminCall {}.abi_encode()) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::NotSoleAdmin {})); +} + +#[test] +fn golden_set_role_admin() { + let mut s = fresh(); + let out = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::setRoleAdminCall { + role: B20TokenRole::Mint.id(), + newAdminRole: B20TokenRole::Metadata.id(), + } + .abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.role_admin(B20TokenRole::Mint.id()).unwrap(), B20TokenRole::Metadata.id()) + }); + assert_eq!(last_topic0(&s), IB20::RoleAdminChanged::SIGNATURE_HASH); + assert_root("set_role_admin", s, ROOT_SET_ROLE_ADMIN); +} + +#[test] +fn golden_set_role_admin_unprivileged_non_admin_caller_reverts() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN)); + let err = op( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::setRoleAdminCall { + role: B20TokenRole::Mint.id(), + newAdminRole: B20TokenRole::Metadata.id(), + } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: BOB, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); +} + +// ============================================================================ +// policy (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_update_policy() { + let mut s = fresh(); + let mut policy = FakePolicyAccounting::new(); + policy.create_existing_policy(7); + let out = op_privileged( + &mut s, + ADMIN, + policy, + IB20::updatePolicyCall { policyScope: B20PolicyType::TransferSender.id(), newPolicyId: 7 } + .abi_encode(), + ) + .unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| assert_eq!(t.policy_id(B20PolicyType::TransferSender.id()).unwrap(), 7)); + assert_eq!(last_topic0(&s), IB20::PolicyUpdated::SIGNATURE_HASH); + assert_root("update_policy", s, ROOT_UPDATE_POLICY); +} + +#[test] +fn golden_update_policy_reverts_missing_policy() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::updatePolicyCall { policyScope: B20PolicyType::TransferSender.id(), newPolicyId: 99 } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::PolicyNotFound { policyId: 99 })); +} + +#[test] +fn golden_update_policy_unprivileged_requires_role() { + assert_unprivileged_requires_role( + IB20::updatePolicyCall { policyScope: B20PolicyType::TransferSender.id(), newPolicyId: 1 } + .abi_encode(), + B20TokenRole::DefaultAdmin.id(), + ); +} + +// ============================================================================ +// permit (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_permit_sets_allowance_and_increments_nonce() { + let mut s = fresh(); + let owner = anvil_owner(); + let domain = domain_separator(&mut s); + let call = signed_permit(domain, U256::ZERO, owner, BOB, u(500), U256::MAX); + s.set_timestamp(U256::ZERO); + let out = op(&mut s, owner, FakePolicyAccounting::new(), call.abi_encode()).unwrap(); + + assert!(out.is_empty()); + read(&mut s, |t| { + assert_eq!(t.allowance(owner, BOB).unwrap(), u(500)); + assert_eq!(t.nonce(owner).unwrap(), U256::ONE); + }); + assert_eq!(last_topic0(&s), IB20::Approval::SIGNATURE_HASH); + assert_root("permit", s, ROOT_PERMIT); +} + +#[test] +fn golden_permit_reverts_when_expired() { + let mut s = fresh(); + let owner = anvil_owner(); + let domain = domain_separator(&mut s); + let call = signed_permit(domain, U256::ZERO, owner, BOB, u(1), u(10)); + s.set_timestamp(u(11)); + let err = op(&mut s, owner, FakePolicyAccounting::new(), call.abi_encode()).unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::ExpiredSignature { deadline: u(10) })); +} + +/// V2 `permit` charges the fixed ECRECOVER-equivalent recovery cost (`PermitArgs::RECOVER_GAS`, +/// 3000) on the real provider. The `.route(...)` path bills no calldata gas — that lives in +/// `dispatch_with_observer` — so the observed `gas_deducted()` is exactly the recovery charge. The +/// V1 golden pins this at `0`; that metered/unmetered contrast is the point of this pair. +#[test] +fn golden_permit_charges_recovery_gas() { + let mut s = fresh(); + let owner = anvil_owner(); + let calldata = + signed_permit(domain_separator(&mut fresh()), U256::ZERO, owner, BOB, u(500), U256::MAX) + .abi_encode(); + s.set_caller(owner); + StorageCtx::enter(&mut s, |ctx| { + B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(TOKEN, ctx), + FakePolicyAccounting::new(), + PolicyVersion::V3, + ) + .route(ctx, &calldata, StablecoinVersion::V3, true, NoopPrecompileCallObserver) + }) + .expect("permit must succeed"); + assert_eq!(s.gas_deducted(), 3000, "V2 permit must charge the fixed ECRECOVER-equivalent cost"); +} + +// ============================================================================ +// computed + direct + constant reads (behavior-preserving: V1 roots reused) +// ============================================================================ + +#[test] +fn golden_read_currency() { + let mut s = fresh(); + let out = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20Stablecoin::currencyCall {}.abi_encode(), + ) + .unwrap(); + assert_eq!(out, Bytes::from(CURRENCY.abi_encode())); + assert_root("read_currency", s, ROOT_FRESH); +} + +#[test] +fn golden_read_policy_id_and_unsupported_scope() { + let mut s = fresh(); + let ok = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::policyIdCall { policyScope: B20PolicyType::TransferSender.id() }.abi_encode(), + ) + .unwrap(); + assert_eq!(ok, Bytes::from(0u64.abi_encode())); + + let bad_scope = B256::repeat_byte(0xEE); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::policyIdCall { policyScope: bad_scope }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::UnsupportedPolicyType { policyScope: bad_scope }) + ); +} + +#[test] +fn golden_read_domain_separator() { + let mut s = fresh(); + let expected = domain_separator(&mut s); + let out = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20::DOMAIN_SEPARATORCall {}.abi_encode()) + .unwrap(); + assert_eq!(out, Bytes::from(expected.abi_encode())); + assert_root("read_domain_separator", s, ROOT_FRESH); +} + +#[test] +fn golden_read_eip712_domain() { + let mut s = fresh(); + let out = + op(&mut s, ALICE, FakePolicyAccounting::new(), IB20::eip712DomainCall {}.abi_encode()) + .unwrap(); + let decoded = IB20::eip712DomainCall::abi_decode_returns(&out).unwrap(); + assert_eq!(decoded.name, NAME); + assert_eq!(decoded.version, "1"); + assert_eq!(decoded.chainId, U256::from(CHAIN_ID)); + assert_eq!(decoded.verifyingContract, TOKEN); + assert_eq!(decoded.fields, alloy_primitives::FixedBytes::<1>::from([0x0f])); +} + +#[test] +fn golden_read_metadata_and_supply() { + let mut s = fresh(); + let cases: Vec<(Vec, Bytes)> = vec![ + (IB20::nameCall {}.abi_encode(), Bytes::from(NAME.abi_encode())), + (IB20::symbolCall {}.abi_encode(), Bytes::from(SYMBOL.abi_encode())), + (IB20::decimalsCall {}.abi_encode(), Bytes::from(u(6).abi_encode())), + (IB20::totalSupplyCall {}.abi_encode(), Bytes::from(U256::ZERO.abi_encode())), + (IB20::supplyCapCall {}.abi_encode(), Bytes::from(B20_MAX_SUPPLY_CAP.abi_encode())), + (IB20::contractURICall {}.abi_encode(), Bytes::from(String::new().abi_encode())), + (IB20::balanceOfCall { account: ALICE }.abi_encode(), Bytes::from(U256::ZERO.abi_encode())), + ( + IB20::allowanceCall { owner: ALICE, spender: BOB }.abi_encode(), + Bytes::from(U256::ZERO.abi_encode()), + ), + (IB20::noncesCall { owner: ALICE }.abi_encode(), Bytes::from(U256::ZERO.abi_encode())), + ( + IB20::hasRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + Bytes::from(false.abi_encode()), + ), + ( + IB20::getRoleAdminCall { role: B20TokenRole::Mint.id() }.abi_encode(), + Bytes::from(B256::ZERO.abi_encode()), + ), + ]; + for (calldata, expected) in cases { + let out = op(&mut s, ALICE, FakePolicyAccounting::new(), calldata).unwrap(); + assert_eq!(out, expected); + } + assert_root("read_metadata", s, ROOT_FRESH); +} + +#[test] +fn golden_read_role_and_policy_constants() { + let mut s = fresh(); + let cases: Vec<(Vec, B256)> = vec![ + (IB20::DEFAULT_ADMIN_ROLECall {}.abi_encode(), B20TokenRole::DefaultAdmin.id()), + (IB20::MINT_ROLECall {}.abi_encode(), B20TokenRole::Mint.id()), + (IB20::BURN_ROLECall {}.abi_encode(), B20TokenRole::Burn.id()), + (IB20::BURN_BLOCKED_ROLECall {}.abi_encode(), B20TokenRole::BurnBlocked.id()), + (IB20::PAUSE_ROLECall {}.abi_encode(), B20TokenRole::Pause.id()), + (IB20::UNPAUSE_ROLECall {}.abi_encode(), B20TokenRole::Unpause.id()), + (IB20::METADATA_ROLECall {}.abi_encode(), B20TokenRole::Metadata.id()), + (IB20::TRANSFER_SENDER_POLICYCall {}.abi_encode(), B20PolicyType::TransferSender.id()), + (IB20::TRANSFER_RECEIVER_POLICYCall {}.abi_encode(), B20PolicyType::TransferReceiver.id()), + (IB20::TRANSFER_EXECUTOR_POLICYCall {}.abi_encode(), B20PolicyType::TransferExecutor.id()), + (IB20::MINT_RECEIVER_POLICYCall {}.abi_encode(), B20PolicyType::MintReceiver.id()), + ]; + for (calldata, expected) in cases { + let out = op(&mut s, ALICE, FakePolicyAccounting::new(), calldata).unwrap(); + assert_eq!(out, Bytes::from(expected.abi_encode())); + } + assert_root("read_constants", s, ROOT_FRESH); +} + +// ============================================================================ +// gas: storage-access footprint per op +// ============================================================================ +// +// `gas_deducted` is 0 under the test gas schedule, so we pin the deterministic, +// schedule-independent signal instead: the SLOAD / SSTORE / KECCAK256 op counts a +// call performs. These are the storage-access footprint that drives real gas, so a +// change here (e.g. an extra SLOAD in V2) is caught even when bytes/state/events match. + +/// Runs `calldata` privileged after `setup`, returning `(sload, sstore, keccak256)` counts. +fn gas( + setup: impl FnOnce(&mut B20StablecoinStorage<'_>), + caller: Address, + policy: FakePolicyAccounting, + calldata: Vec, +) -> (u64, u64, u64) { + let mut s = fresh(); + seed(&mut s, setup); + s.set_caller(caller); + s.reset_counters(); + StorageCtx::enter(&mut s, |ctx| { + B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(TOKEN, ctx), + policy, + PolicyVersion::V3, + ) + .route(ctx, &calldata, StablecoinVersion::V3, true, NoopPrecompileCallObserver) + }) + .expect("gas-footprint op must succeed"); + (s.counter_sload(), s.counter_sstore(), s.counter_keccak256()) +} + +/// Unprivileged reject-path footprint: `(sload, sstore, keccak256)` after `calldata` reverts. +/// +/// Locks that a zero-address `transfer` rejects before the transfer-policy-id SLOAD +/// (Cantina #13 / BOP-600). Privileged success-path `gas()` cannot catch that. +fn gas_unprivileged_revert( + setup: impl FnOnce(&mut B20StablecoinStorage<'_>), + caller: Address, + policy: FakePolicyAccounting, + calldata: Vec, +) -> (u64, u64, u64) { + let mut s = fresh(); + seed(&mut s, setup); + s.set_caller(caller); + s.reset_counters(); + let err = StorageCtx::enter(&mut s, |ctx| { + let version = + StablecoinVersions::from_base_upgrade(BaseUpgrade::Denim).expect("Denim activates V3"); + B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(TOKEN, ctx), + policy, + PolicyVersion::V3, + ) + .route(ctx, &calldata, version, false, NoopPrecompileCallObserver) + }) + .expect_err("reject-path gas golden must revert"); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); + (s.counter_sload(), s.counter_sstore(), s.counter_keccak256()) +} + +#[test] +fn golden_transfer_unprivileged_zero_receiver_storage_access() { + let actual = gas_unprivileged_revert( + |t| fund(t, ALICE, u(10)), + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: Address::ZERO, amount: u(1) }.abi_encode(), + ); + // Pause SLOAD only — no transfer_policy_ids SLOAD before InvalidReceiver. + bless_or_assert_gas( + &[("transfer_unprivileged_zero_receiver", actual)], + &[("transfer_unprivileged_zero_receiver", (1, 0, 0))], + ); +} + +#[test] +fn golden_gas_footprints() { + let actual: Vec<(&str, (u64, u64, u64))> = vec![ + ( + "transfer", + gas( + |t| fund(t, ALICE, u(100)), + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(30) }.abi_encode(), + ), + ), + ( + "transfer_from", + gas( + |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(40)).unwrap(); + }, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(30) }.abi_encode(), + ), + ), + ( + "approve", + gas( + |_t| {}, + ALICE, + FakePolicyAccounting::new(), + IB20::approveCall { spender: BOB, amount: u(50) }.abi_encode(), + ), + ), + ( + "mint", + gas( + |_t| {}, + ADMIN, + allow0(BOB), + IB20::mintCall { to: BOB, amount: u(100) }.abi_encode(), + ), + ), + ( + "burn", + gas( + |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Burn.id(), ALICE); + }, + ALICE, + FakePolicyAccounting::new(), + IB20::burnCall { amount: u(40) }.abi_encode(), + ), + ), + ( + "burn_blocked", + gas( + |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferSender.id(), POLICY_ID).unwrap(); + }, + ADMIN, + FakePolicyAccounting::new(), + IB20::burnBlockedCall { from: ALICE, amount: u(40) }.abi_encode(), + ), + ), + ( + "seize", + gas( + |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(40), memo: MEMO } + .abi_encode(), + ), + ), + ( + "pause", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::pauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ), + ), + ( + "unpause", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::unpauseCall { features: vec![IB20::PausableFeature::MINT] }.abi_encode(), + ), + ), + ( + "update_supply_cap", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSupplyCapCall { newSupplyCap: u(1_000) }.abi_encode(), + ), + ), + ( + "update_name", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateNameCall { newName: "New Name".into() }.abi_encode(), + ), + ), + ( + "update_symbol", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateSymbolCall { newSymbol: "USDX".into() }.abi_encode(), + ), + ), + ( + "update_contract_uri", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::updateContractURICall { newURI: "ipfs://x".into() }.abi_encode(), + ), + ), + ( + "grant_role", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ), + ), + ( + "revoke_role", + gas( + |t| give_role(t, B20TokenRole::Mint.id(), ALICE), + ADMIN, + FakePolicyAccounting::new(), + IB20::revokeRoleCall { role: B20TokenRole::Mint.id(), account: ALICE }.abi_encode(), + ), + ), + ( + "set_role_admin", + gas( + |_t| {}, + ADMIN, + FakePolicyAccounting::new(), + IB20::setRoleAdminCall { + role: B20TokenRole::Mint.id(), + newAdminRole: B20TokenRole::Metadata.id(), + } + .abi_encode(), + ), + ), + ( + "update_policy", + gas( + |_t| {}, + ADMIN, + { + let mut p = FakePolicyAccounting::new(); + p.create_existing_policy(7); + p + }, + IB20::updatePolicyCall { + policyScope: B20PolicyType::TransferSender.id(), + newPolicyId: 7, + } + .abi_encode(), + ), + ), + ( + "transfer_with_memo", + gas( + |t| fund(t, ALICE, u(100)), + ALICE, + FakePolicyAccounting::new(), + IB20::transferWithMemoCall { to: BOB, amount: u(30), memo: MEMO }.abi_encode(), + ), + ), + ( + "transfer_from_with_memo", + gas( + |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, BOB, u(40)).unwrap(); + }, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromWithMemoCall { from: ALICE, to: BOB, amount: u(30), memo: MEMO } + .abi_encode(), + ), + ), + ( + "mint_with_memo", + gas( + |_t| {}, + ADMIN, + allow0(BOB), + IB20::mintWithMemoCall { to: BOB, amount: u(100), memo: MEMO }.abi_encode(), + ), + ), + ( + "burn_with_memo", + gas( + |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Burn.id(), ALICE); + }, + ALICE, + FakePolicyAccounting::new(), + IB20::burnWithMemoCall { amount: u(40), memo: MEMO }.abi_encode(), + ), + ), + ( + "renounce_role", + gas( + |t| give_role(t, B20TokenRole::Mint.id(), ALICE), + ALICE, + FakePolicyAccounting::new(), + IB20::renounceRoleCall { role: B20TokenRole::Mint.id(), callerConfirmation: ALICE } + .abi_encode(), + ), + ), + ( + "renounce_last_admin", + gas( + |t| give_role(t, B20TokenRole::DefaultAdmin.id(), ADMIN), + ADMIN, + FakePolicyAccounting::new(), + IB20::renounceLastAdminCall {}.abi_encode(), + ), + ), + ( + "permit", + gas( + |_t| {}, + anvil_owner(), + FakePolicyAccounting::new(), + signed_permit( + domain_separator(&mut fresh()), + U256::ZERO, + anvil_owner(), + BOB, + u(500), + U256::MAX, + ) + .abi_encode(), + ), + ), + ]; + + let expected: &[(&str, (u64, u64, u64))] = &[ + ("transfer", (3, 2, 0)), + ("transfer_from", (4, 3, 0)), + ("approve", (0, 1, 0)), + ("mint", (5, 2, 0)), + ("burn", (4, 2, 0)), + ("burn_blocked", (4, 2, 0)), + ("seize", (6, 2, 0)), + ("pause", (1, 1, 0)), + ("unpause", (1, 1, 0)), + ("update_supply_cap", (2, 1, 0)), + ("update_name", (1, 1, 0)), + ("update_symbol", (1, 1, 0)), + ("update_contract_uri", (1, 1, 0)), + ("grant_role", (1, 1, 0)), + ("revoke_role", (1, 1, 0)), + ("set_role_admin", (1, 1, 0)), + ("update_policy", (2, 1, 0)), + ("transfer_with_memo", (3, 2, 0)), + ("transfer_from_with_memo", (4, 3, 0)), + ("mint_with_memo", (5, 2, 0)), + ("burn_with_memo", (4, 2, 0)), + ("renounce_role", (1, 1, 0)), + ("renounce_last_admin", (4, 2, 0)), + ("permit", (3, 2, 5)), + ]; + + bless_or_assert_gas(&actual, expected); +} + +/// `emit_memo` only pushes a `Memo` event — no SLOAD/SSTORE/KECCAK256 — so every `*WithMemo` op's +/// storage-access footprint must exactly match its base op's. Checked as a live relationship +/// (not just two pinned tuples that happen to agree): a future change that makes `emit_memo` +/// storage-touching would fail this even if both `golden_gas_footprints` pins were updated +/// consistently but incorrectly. +#[test] +fn golden_gas_footprint_memo_variants_add_no_storage_cost() { + let transfer = gas( + |t| fund(t, ALICE, u(100)), + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(30) }.abi_encode(), + ); + let transfer_with_memo = gas( + |t| fund(t, ALICE, u(100)), + ALICE, + FakePolicyAccounting::new(), + IB20::transferWithMemoCall { to: BOB, amount: u(30), memo: MEMO }.abi_encode(), + ); + assert_eq!(transfer, transfer_with_memo, "transferWithMemo must cost exactly transfer"); + + let mint_setup = |_t: &mut B20StablecoinStorage<'_>| {}; + let mint = gas( + mint_setup, + ADMIN, + allow0(BOB), + IB20::mintCall { to: BOB, amount: u(100) }.abi_encode(), + ); + let mint_with_memo = gas( + mint_setup, + ADMIN, + allow0(BOB), + IB20::mintWithMemoCall { to: BOB, amount: u(100), memo: MEMO }.abi_encode(), + ); + assert_eq!(mint, mint_with_memo, "mintWithMemo must cost exactly mint"); + + let burn_setup = |t: &mut B20StablecoinStorage<'_>| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Burn.id(), ALICE); + }; + let burn = gas( + burn_setup, + ALICE, + FakePolicyAccounting::new(), + IB20::burnCall { amount: u(40) }.abi_encode(), + ); + let burn_with_memo = gas( + burn_setup, + ALICE, + FakePolicyAccounting::new(), + IB20::burnWithMemoCall { amount: u(40), memo: MEMO }.abi_encode(), + ); + assert_eq!(burn, burn_with_memo, "burnWithMemo must cost exactly burn"); +} + +/// `seize_with_memo` shares `move_balance` with `transfer` — both write exactly the two balance +/// slots and nothing else — so their SSTORE counts must match even though `seize` performs +/// strictly more SLOADs (role + seizable + receiver-policy guards on top of the balance reads). +/// A future change that made seize write anything beyond the balance move (e.g. an extra +/// per-account counter) would silently escape `golden_gas_footprints`' independent pins but +/// fail this relationship. +#[test] +fn golden_gas_footprint_seize_writes_no_more_than_transfer() { + let transfer = gas( + |t| fund(t, ALICE, u(100)), + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(30) }.abi_encode(), + ); + let seize = gas( + |t| { + fund(t, ALICE, u(100)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: ALICE, to: BOB, amount: u(30), memo: MEMO }.abi_encode(), + ); + assert_eq!(seize.1, transfer.1, "seize and transfer must write the same number of slots"); + assert!(seize.0 >= transfer.0, "seize must read at least as much as transfer (extra guards)"); +} + +// ============================================================================ +// meta: op coverage checklist +// ============================================================================ + +/// Compile-time coverage checklist — never called; it exists only for its two +/// exhaustive `match`es (no `_` arm), each arm naming the golden `#[test]` fn(s) that +/// pin the op via [`covered`]. +/// +/// This gives two compile-time guarantees: +/// * add an op to the ABI (a new `IB20Calls` / `IB20StablecoinCalls` variant) → the +/// wildcard-free match fails to build until an arm (and thus a golden) is added; +/// * rename or remove a golden `#[test]` fn → the `covered(&[...])` reference fails +/// to build. +/// +/// Unlike V1's frozen checklist, V2 is the live version: new selectors are expected to +/// land here over time, each pulling in a new golden test. +#[allow(dead_code)] +fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Stablecoin::IB20StablecoinCalls) { + use IB20::IB20Calls as C; + use IB20Stablecoin::IB20StablecoinCalls as SC; + + // No-op: forces each arm to name real golden `#[test]` fns by path. + fn covered(_goldens: &[fn()]) {} + + match call { + // ERC-20 core + C::transfer(_) => covered(&[ + golden_transfer_privileged, + golden_transfer_unprivileged_allowed, + golden_transfer_unprivileged_blocked_sender_reverts, + golden_transfer_reverts_zero_receiver, + golden_transfer_unprivileged_zero_receiver_storage_access, + golden_transfer_reverts_insufficient_balance, + golden_transfer_reverts_when_paused, + golden_transfer_reverts_zero_sender, + ]), + C::transferFrom(_) => covered(&[ + golden_transfer_from_finite_allowance_decrements, + golden_transfer_from_infinite_allowance_not_decremented, + golden_transfer_from_reverts_insufficient_allowance, + golden_transfer_from_unprivileged_enforces_executor_policy, + golden_transfer_from_reverts_zero_receiver, + golden_transfer_from_reverts_zero_sender, + ]), + C::approve(_) => covered(&[ + golden_approve_sets_allowance_and_emits, + golden_approve_reverts_zero_spender, + golden_approve_reverts_zero_approver, + ]), + C::transferWithMemo(_) => covered(&[golden_transfer_with_memo_emits_transfer_then_memo]), + C::transferFromWithMemo(_) => covered(&[golden_transfer_from_with_memo]), + + // mint / burn + C::mint(_) => covered(&[ + golden_mint_privileged_still_enforces_receiver_policy, + golden_mint_unprivileged_requires_role_and_policy, + golden_mint_reverts_over_supply_cap, + golden_mint_reverts_zero_receiver, + ]), + C::mintWithMemo(_) => covered(&[golden_mint_with_memo]), + C::burn(_) => covered(&[ + golden_burn_requires_role_then_reduces_supply, + golden_burn_reverts_insufficient_balance, + ]), + C::burnWithMemo(_) => covered(&[golden_burn_with_memo]), + C::burnBlocked(_) => covered(&[ + golden_burn_blocked_destroys_from_blocked_account, + golden_burn_blocked_reverts_when_not_blocked, + golden_burn_blocked_unprivileged_requires_role, + ]), + + // seize (new at V2) + C::seizeWithMemo(_) => covered(&[ + golden_seize_moves_balance_emits_transfer_memo_seized, + golden_seize_reverts_missing_role, + golden_seize_reverts_zero_receiver, + golden_seize_reverts_account_not_seizable, + golden_seize_reverts_insufficient_balance, + golden_seize_reverts_when_paused, + golden_seize_enforces_receiver_policy, + golden_seize_privileged_still_enforces_role_and_seizable, + ]), + C::SEIZE_ROLE(_) | C::SEIZE_EXEMPT_POLICY(_) | C::SEIZE_RECEIVER_POLICY(_) => { + covered(&[golden_read_seize_role_and_policy_constants]) + } + + // pause / config / roles / policy / permit + C::pause(_) => covered(&[ + golden_pause_sets_feature_bit, + golden_pause_reverts_empty_feature_set, + golden_pause_unprivileged_requires_role, + ]), + C::unpause(_) => covered(&[ + golden_unpause_clears_feature_bit, + golden_unpause_reverts_empty_feature_set, + golden_unpause_unprivileged_requires_role, + ]), + C::updateSupplyCap(_) => covered(&[ + golden_update_supply_cap, + golden_update_supply_cap_reverts_below_supply, + golden_update_supply_cap_unprivileged_requires_role, + ]), + C::updateName(_) => covered(&[ + golden_update_name_emits_name_and_domain_changed, + golden_update_name_unprivileged_requires_role, + ]), + C::updateSymbol(_) => { + covered(&[golden_update_symbol, golden_update_symbol_unprivileged_requires_role]) + } + C::updateContractURI(_) => covered(&[ + golden_update_contract_uri, + golden_update_contract_uri_unprivileged_requires_role, + ]), + C::grantRole(_) => covered(&[ + golden_grant_role, + golden_grant_role_unprivileged_no_admin_reverts, + golden_grant_role_unprivileged_non_admin_caller_reverts, + golden_grant_default_admin_bumps_member_count, + golden_grant_role_idempotent_when_already_held, + ]), + C::revokeRole(_) => covered(&[ + golden_revoke_role, + golden_revoke_last_admin_rejected, + golden_revoke_role_unprivileged_non_admin_caller_reverts, + golden_revoke_role_noop_when_not_held, + ]), + C::renounceRole(_) => covered(&[ + golden_renounce_role, + golden_renounce_role_bad_confirmation, + golden_renounce_role_reverts_last_admin, + ]), + C::renounceLastAdmin(_) => { + covered(&[golden_renounce_last_admin, golden_renounce_last_admin_reverts_when_not_sole]) + } + C::setRoleAdmin(_) => covered(&[ + golden_set_role_admin, + golden_set_role_admin_unprivileged_non_admin_caller_reverts, + ]), + C::updatePolicy(_) => covered(&[ + golden_update_policy, + golden_update_policy_reverts_missing_policy, + golden_update_policy_unprivileged_requires_role, + ]), + C::permit(_) => covered(&[ + golden_permit_sets_allowance_and_increments_nonce, + golden_permit_reverts_when_expired, + ]), + + // computed reads + C::isPaused(_) | C::pausedFeatures(_) => { + covered(&[golden_read_is_paused_and_paused_features_includes_seize]) + } + C::policyId(_) => covered(&[golden_read_policy_id_and_unsupported_scope]), + C::DOMAIN_SEPARATOR(_) => covered(&[golden_read_domain_separator]), + C::eip712Domain(_) => covered(&[golden_read_eip712_domain]), + + // direct reads + C::name(_) + | C::symbol(_) + | C::decimals(_) + | C::totalSupply(_) + | C::balanceOf(_) + | C::allowance(_) + | C::supplyCap(_) + | C::nonces(_) + | C::contractURI(_) + | C::hasRole(_) + | C::getRoleAdmin(_) => covered(&[golden_read_metadata_and_supply]), + + // role / policy-id constants + C::DEFAULT_ADMIN_ROLE(_) + | C::MINT_ROLE(_) + | C::BURN_ROLE(_) + | C::BURN_BLOCKED_ROLE(_) + | C::PAUSE_ROLE(_) + | C::UNPAUSE_ROLE(_) + | C::METADATA_ROLE(_) + | C::TRANSFER_SENDER_POLICY(_) + | C::TRANSFER_RECEIVER_POLICY(_) + | C::TRANSFER_EXECUTOR_POLICY(_) + | C::MINT_RECEIVER_POLICY(_) => covered(&[golden_read_role_and_policy_constants]), + } + + match ext { + SC::currency(_) => covered(&[golden_read_currency]), + } +} From 877e0cb4ed0c76c580de2a206b830adc2a6c2ef7 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Wed, 16 Sep 2026 21:14:26 +0000 Subject: [PATCH 019/299] fix(proof): gate the interval ABI on the AggregateVerifier version (#5062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(proof): gate the interval ABI on the AggregateVerifier version `intervalsForStartingBlock` landed in AggregateVerifier 0.3.0, which also removed `BLOCK_INTERVAL()` / `INTERMEDIATE_BLOCK_INTERVAL()` in favour of `SLOW_*` and `FAST_*` pairs. The two ABIs are disjoint, so exactly one call shape is valid for any given address. The client picked between them by treating an empty revert as a missing selector. A node returning empty data is indistinguishable from that at the call layer, so a transient failure on a 0.3.0 verifier fell through to getters that no longer exist and surfaced as a missing `BLOCK_INTERVAL()`, burying the real error. Read `version()` and decide from it instead. The check runs only when the primary call fails, so a 0.3.0 verifier costs no extra eth_call; a pre-0.3.0 one costs one. Co-Authored-By: Claude * fix(proof): move the interval ABI gate boundary to 0.2.0 The break landed in contracts#431, which bumped AggregateVerifier to 0.2.0; contracts#438 bumped to 0.3.0 without touching the ABI. Gating on 0.3.0 would classify a 0.2.x verifier as legacy and call BLOCK_INTERVAL(), which 0.2.0 already removed. Verified the rest of the surface: of the 31 methods these bindings declare, the only difference between 0.1.0 and 0.3.0 is intervalsForStartingBlock (added) and BLOCK_INTERVAL / INTERMEDIATE_BLOCK_INTERVAL (removed). Every other method is present in both with an identical signature. Co-Authored-By: Claude * fix(proof): align version() mutability and keep both errors on a double failure Address review on #5062: - The sol! binding declared version() as view while the doc said pure. On-chain it is pure (AggregateVerifier.sol:1166), so the binding was the wrong one; correct it rather than the doc, and restate why a CWIA clone still reports the governing implementation's version. - If version() also fails, the `?` discarded the original intervalsForStartingBlock error. Report both instead, since a double-transient is exactly the case this path exists to distinguish from a real ABI mismatch. Co-Authored-By: Claude * chore(proof): trim the version-gate comments Cut duplicated and self-dating prose: the "nothing else differs between 0.1.0 and 0.3.0" paragraph was a review artifact, the CWIA explanation was already on the trait, and the fallback arm restated itself twice. Also drop an unreachable unwrap_or fallback. No behaviour change. Co-Authored-By: Claude * fix(proof): keep the missing-method error shape when version() also fails Wrapping the double-failure in ContractError::validation broke basectl: both call sites match `Err(error) if error.is_missing_method()` to treat a non-verifier address as simply having no intervals, and is_missing_method() only matches ContractError::Call. Two basectl-cli tests failed as a result. Return the original intervalsForStartingBlock error instead, which both keeps the caller contract and satisfies the original review point about discarding it, and log the version() failure so the second error is not lost. Co-Authored-By: Claude * fix(proof): require exactly three numeric components in the version gate Only the first two components were parsed, so a malformed string whose prefix happened to parse below the boundary — 0.1, 0.1.x, 0.1.0.1 — was classified as legacy and sent to getters that may not exist, contradicting the documented behaviour of failing onto the new path when the version is unreadable. The patch component is now required and must be numeric, and trailing components are rejected. It is still not compared; presence is what proves the string is a real MAJOR.MINOR.PATCH release rather than something unrecognised. Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../proof/contracts/src/aggregate_verifier.rs | 116 +++++++++++++++++- 1 file changed, 111 insertions(+), 5 deletions(-) diff --git a/crates/proof/contracts/src/aggregate_verifier.rs b/crates/proof/contracts/src/aggregate_verifier.rs index 5e7d0c5e47..c00ee87e8f 100644 --- a/crates/proof/contracts/src/aggregate_verifier.rs +++ b/crates/proof/contracts/src/aggregate_verifier.rs @@ -11,6 +11,7 @@ use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_provider::RootProvider; use alloy_sol_types::{SolCall, SolError, sol}; use async_trait::async_trait; +use tracing::warn; use crate::{ ContractError, @@ -18,6 +19,41 @@ use crate::{ dispute_game_factory::DisputeGameFactoryClient, }; +/// The first `AggregateVerifier` version that exposes `intervalsForStartingBlock`. +/// +/// Compared as `(major, minor)`; the patch level is not part of the ABI contract. +const FORK_AWARE_INTERVALS_VERSION: (u64, u64) = (0, 2); + +/// Returns whether an `AggregateVerifier` reporting `version` speaks the fork-aware +/// interval ABI. +/// +/// The two ABIs are disjoint, not additive: 0.2.0 added `intervalsForStartingBlock` and +/// *removed* `BLOCK_INTERVAL()` / `INTERMEDIATE_BLOCK_INTERVAL()`, which it split into +/// `SLOW_*` and `FAST_*` pairs. Exactly one of the two call shapes is valid for any given +/// address, and the version is what decides which. +/// +/// Anything that is not exactly three numeric components is treated as fork-aware. Every +/// deployed verifier reports `MAJOR.MINOR.PATCH`, so an unreadable one means these bindings +/// are behind the chain; failing on the new path produces a better error than quietly +/// calling getters that no longer exist. The patch component is required but not compared — +/// a string like `0.1.x` is malformed, not a 0.1 release, and must not buy its way onto the +/// legacy path by having two parseable components in front. +fn supports_fork_aware_intervals(version: &str) -> bool { + let core = version.split(['-', '+']).next().unwrap_or_default(); + let mut parts = core.split('.'); + let (Some(major), Some(minor), Some(patch), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return true; + }; + let (Ok(major), Ok(minor), Ok(_)) = + (major.trim().parse::(), minor.trim().parse::(), patch.trim().parse::()) + else { + return true; + }; + (major, minor) >= FORK_AWARE_INTERVALS_VERSION +} + /// Resolves the `(block_interval, intermediate_block_interval)` pair that applies to a /// game of `game_type` whose range starts at `starting_block`. /// @@ -30,8 +66,8 @@ use crate::{ /// proxy — is queried because callers resolve intervals for games that do not exist yet /// (the anchor's successor, the proposer's next proposal). For an existing game, call /// `read_intervals_for_starting_block` on its proxy instead so the pair it was created -/// with is used even after an implementation upgrade; legacy proxies without -/// `intervalsForStartingBlock` fall back to their fixed interval getters. +/// with is used even after an implementation upgrade; proxies older than +/// `AggregateVerifier` 0.2.0 fall back to their fixed interval getters. pub async fn resolve_intervals( factory_client: &dyn DisputeGameFactoryClient, verifier_client: &dyn AggregateVerifierClient, @@ -85,7 +121,11 @@ sol! { /// Returns the parent game's address. function parentAddress() external pure returns (address); + /// Returns the contract's semantic version, e.g. `"0.3.0"`. + function version() external pure returns (string memory); + /// Returns the block interval between proposals (immutable on the implementation). + /// Removed in `AggregateVerifier` 0.2.0 in favour of `intervalsForStartingBlock`. function BLOCK_INTERVAL() external view returns (uint256); /// Returns the intermediate block interval for intermediate output root checkpoints. @@ -288,9 +328,10 @@ pub trait AggregateVerifierClient: Send + Sync { /// that does not exist yet) or an existing game's proxy. A game proxy is a CWIA /// clone, not an upgradeable proxy: it delegates to the implementation baked into /// its bytecode at creation, so reading through it returns the pair the game was - /// created with regardless of any later `setImplementation`. Verifiers deployed - /// before `intervalsForStartingBlock` existed fall back to their fixed interval - /// getters. + /// created with regardless of any later `setImplementation`. Verifiers older than + /// 0.2.0 fall back to `BLOCK_INTERVAL()` / `INTERMEDIATE_BLOCK_INTERVAL()`; the two + /// ABIs are disjoint, so which one applies is decided by reading `version()`, not by + /// treating an empty revert as a missing selector. async fn read_intervals_for_starting_block( &self, verifier_address: Address, @@ -403,6 +444,17 @@ impl AggregateVerifierContractClient { pub const fn new(provider: RootProvider) -> Self { Self { provider } } + + /// Reads `version()` from a verifier and reports which interval ABI that address speaks. + /// + /// Works through a game proxy too: `version()` is `pure`, so a CWIA clone reports the + /// string of the implementation that governs it, not one of its own. + async fn is_fork_aware(&self, verifier_address: Address) -> Result { + let contract = + IAggregateVerifier::IAggregateVerifierInstance::new(verifier_address, &self.provider); + let version: String = contract_call!(contract.version().call(), "version failed")?; + Ok(supports_fork_aware_intervals(&version)) + } } #[async_trait] @@ -532,6 +584,31 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { ) { Ok(result) => result, Err(error) if error.is_missing_method() => { + // Empty data and a missing selector look identical here, so confirm with + // `version()` before using the pre-0.2.0 getters, which 0.2.0 removed. + match self.is_fork_aware(verifier_address).await { + Ok(true) => { + return Err(ContractError::validation(format!( + "intervalsForStartingBlock is unavailable on {verifier_address}, but \ + it reports version {}.{}.x or later, which must expose it: {error}", + FORK_AWARE_INTERVALS_VERSION.0, FORK_AWARE_INTERVALS_VERSION.1 + ))); + } + Ok(false) => {} + Err(version_error) => { + // Undetermined ABI. Return the original error rather than a new + // one: callers key on `is_missing_method()` to treat a non-verifier + // address as simply having no intervals, and wrapping would break + // that. Log the second failure so it is not lost. + warn!( + %verifier_address, + %version_error, + "version() failed while disambiguating intervalsForStartingBlock" + ); + return Err(error); + } + } + let (block_interval, intermediate_block_interval) = futures::try_join!( self.read_block_interval(verifier_address), self.read_intermediate_block_interval(verifier_address), @@ -809,6 +886,35 @@ mod tests { use super::*; + #[test] + fn test_supports_fork_aware_intervals_gates_on_0_2_0() { + assert!(!supports_fork_aware_intervals("0.1.0")); + assert!(!supports_fork_aware_intervals("0.1.99")); + + // 0.2.0 is the ABI break (contracts#431): only intervalsForStartingBlock exists. + // 0.3.0 (contracts#438) was a release semver bump with no ABI change. + assert!(supports_fork_aware_intervals("0.2.0")); + assert!(supports_fork_aware_intervals("0.2.99")); + assert!(supports_fork_aware_intervals("0.3.0")); + assert!(supports_fork_aware_intervals("0.10.0")); + assert!(supports_fork_aware_intervals("1.0.0")); + + assert!(supports_fork_aware_intervals("0.2.0-beta.1")); + assert!(!supports_fork_aware_intervals("0.1.0-rc.1")); + assert!(supports_fork_aware_intervals("0.3.0+deadbeef")); + + assert!(supports_fork_aware_intervals("")); + assert!(supports_fork_aware_intervals("unversioned")); + assert!(supports_fork_aware_intervals("3")); + + // Malformed strings whose first two components happen to parse below the boundary + // must not reach the legacy getters. + assert!(supports_fork_aware_intervals("0.1")); + assert!(supports_fork_aware_intervals("0.1.x")); + assert!(supports_fork_aware_intervals("0.1.0.1")); + assert!(supports_fork_aware_intervals("0.1.")); + } + #[test] fn test_encode_nullify_calldata_has_selector() { let calldata = encode_nullify_calldata( From 9463811d3a933164c4be4e51e90c8d58a3e44e3e Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Wed, 16 Sep 2026 22:15:40 +0000 Subject: [PATCH 020/299] fix(genesis): avoid zero block time schedule panics (#5083) --- crates/common/genesis/src/rollup.rs | 34 +++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/common/genesis/src/rollup.rs b/crates/common/genesis/src/rollup.rs index 0bee95ae7c..61dd67095c 100644 --- a/crates/common/genesis/src/rollup.rs +++ b/crates/common/genesis/src/rollup.rs @@ -365,28 +365,30 @@ impl RollupConfig { /// Returns the L2 block offset from genesis at which Denim activates. /// - /// If Denim is not configured, returns [`None`]. + /// If Denim is not configured or the block time is zero, returns [`None`]. pub fn denim_activation_block_number(&self) -> Option { let denim_timestamp = self.upgrade_activation_timestamp(BaseUpgrade::Denim)?; + let block_time = self.block_time; - if self.block_time == 0 { - panic!("rollup config: block time cannot be 0"); + if block_time == 0 { + return None; } - Some(denim_timestamp.saturating_sub(self.genesis.l2_time).div_ceil(self.block_time)) + Some(denim_timestamp.saturating_sub(self.genesis.l2_time).div_ceil(block_time)) } /// Returns the L2 block number at which the genesis-only Zenith testing gate activates. /// - /// If Zenith is not configured, returns [`None`]. + /// If Zenith is not configured or the block time is zero, returns [`None`]. pub fn zenith_activation_block_number(&self) -> Option { let zenith_timestamp = self.upgrade_activation_timestamp(BaseUpgrade::Zenith)?; + let block_time = self.block_time; - if self.block_time == 0 { - panic!("rollup config: block time cannot be 0"); + if block_time == 0 { + return None; } - Some(zenith_timestamp.saturating_sub(self.genesis.l2_time).div_ceil(self.block_time)) + Some(zenith_timestamp.saturating_sub(self.genesis.l2_time).div_ceil(block_time)) } /// Returns the deterministic timestamp of an L2 block in milliseconds. @@ -1205,9 +1207,11 @@ mod tests { } #[test] - #[should_panic(expected = "rollup config: block time cannot be 0")] - fn denim_activation_block_number_rejects_zero_block_time() { - rollup_config_with_denim(100, 0, Some(101)).denim_activation_block_number(); + fn denim_activation_block_number_returns_none_for_zero_block_time() { + let cfg = rollup_config_with_denim(100, 0, Some(101)); + + assert_eq!(cfg.denim_activation_block_number(), None); + assert_eq!(cfg.l2_block_timestamp_millis(1), 100_000); } #[test] @@ -1277,9 +1281,11 @@ mod tests { } #[test] - #[should_panic(expected = "rollup config: block time cannot be 0")] - fn zenith_activation_block_number_rejects_zero_block_time() { - rollup_config_with_zenith(100, 0, Some(101)).zenith_activation_block_number(); + fn zenith_activation_block_number_returns_none_for_zero_block_time() { + assert_eq!( + rollup_config_with_zenith(100, 0, Some(101)).zenith_activation_block_number(), + None + ); } #[test] From 1007225a5bd2625574057b5ce6dfbed9a1b85dab Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 01:21:42 +0000 Subject: [PATCH 021/299] fix(engine): drop invalid external payloads (#5084) --- .../src/task_queue/tasks/insert/task.rs | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/crates/consensus/engine/src/task_queue/tasks/insert/task.rs b/crates/consensus/engine/src/task_queue/tasks/insert/task.rs index d9456b91bb..60471cd6d8 100644 --- a/crates/consensus/engine/src/task_queue/tasks/insert/task.rs +++ b/crates/consensus/engine/src/task_queue/tasks/insert/task.rs @@ -84,6 +84,11 @@ pub struct InsertTask { payload_safety: InsertPayloadSafety, /// Whether the payload must extend the current unsafe chain. payload_policy: InsertPayloadPolicy, + /// Whether an EL-invalid payload should be consumed instead of retried in place. + /// + /// This is enabled for externally received unsafe payloads. Locally built payloads use + /// [`Self::new`] and retain their error so sealing can perform its fallback handling. + drop_invalid_payload: bool, /// Optional response channel used by callers that need insertion acknowledgement. result_tx: Option>, } @@ -102,6 +107,7 @@ impl InsertTask { envelope, payload_safety, payload_policy: InsertPayloadPolicy::ExtendingOnly, + drop_invalid_payload: false, result_tx: None, } } @@ -112,7 +118,15 @@ impl InsertTask { rollup_config: Arc, envelope: BaseExecutionPayloadEnvelope, ) -> Self { - Self::new(client, rollup_config, envelope, InsertPayloadSafety::Unsafe) + Self { + client, + rollup_config, + envelope, + payload_safety: InsertPayloadSafety::Unsafe, + payload_policy: InsertPayloadPolicy::ExtendingOnly, + drop_invalid_payload: true, + result_tx: None, + } } /// Creates a new task to insert an unsafe payload and send insertion acknowledgement. @@ -128,6 +142,7 @@ impl InsertTask { envelope, payload_safety: InsertPayloadSafety::Unsafe, payload_policy: InsertPayloadPolicy::ExtendingOnly, + drop_invalid_payload: true, result_tx: Some(result_tx), } } @@ -144,6 +159,7 @@ impl InsertTask { envelope, payload_safety: InsertPayloadSafety::Unsafe, payload_policy: InsertPayloadPolicy::Authoritative, + drop_invalid_payload: false, result_tx: None, } } @@ -374,6 +390,22 @@ impl EngineTaskExt for InsertTask { if self.result_tx.is_some() { self.send_channel_result(result).await; Ok(()) + } else if let Err(InsertTaskError::UnexpectedPayloadStatus(PayloadStatusEnum::Invalid { + ref validation_error, + })) = result + && self.drop_invalid_payload + { + // EL-invalid externally received payloads are permanent failures. They must be + // consumed so the queue (and follow loop) can progress to the next payload instead + // of retrying this same payload forever. Callers that requested an acknowledgement + // still receive the temporary insert error through `result_tx` above. + warn!( + target: "engine", + error = %validation_error, + payload_safety = self.payload_safety.as_label(), + "Dropping externally received payload rejected by execution layer" + ); + Ok(()) } else { result.map(|_| ()) } @@ -382,7 +414,7 @@ impl EngineTaskExt for InsertTask { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{sync::Arc, time::Duration}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{Address, B256, Bloom, FixedBytes, U256}; @@ -393,10 +425,12 @@ mod tests { use base_protocol::{ BaseTimeScheduleError, BaseTimeUpdateTx, BlockInfo, L1BlockInfoBedrock, L2BlockInfo, }; + use tokio::{sync::watch, time::timeout}; use super::{InsertPayloadPolicy, InsertPayloadSafety, InsertTask}; use crate::{ - Engine, EngineTaskError, EngineTaskErrorSeverity, EngineTaskExt, InsertTaskError, + Engine, EngineTask, EngineTaskError, EngineTaskErrorSeverity, EngineTaskExt, + InsertTaskError, test_utils::{TestEngineStateBuilder, test_engine_client_builder}, }; @@ -651,6 +685,49 @@ mod tests { assert_eq!(state.sync_state.unsafe_head(), current_unsafe); } + #[tokio::test] + async fn invalid_new_payload_is_dropped_and_drain_continues() { + let invalid_client = Arc::new( + test_engine_client_builder() + .with_new_payload_v2_response(PayloadStatus { + status: PayloadStatusEnum::Invalid { + validation_error: "invalid transaction".into(), + }, + latest_valid_hash: None, + }) + .build(), + ); + let valid_client = test_client(); + let config = Arc::new(base_common_genesis::RollupConfig::default()); + let initial_state = TestEngineStateBuilder::new().build(); + let (state_tx, _) = watch::channel(initial_state); + let (queue_tx, queue_rx) = watch::channel(0usize); + let mut engine = Engine::new(initial_state, state_tx, queue_tx); + + for client in [invalid_client, Arc::clone(&valid_client)] { + engine.enqueue(EngineTask::Insert(Box::new(InsertTask::unsafe_payload( + client, + Arc::clone(&config), + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: None, + execution_payload: bedrock_payload(1), + }, + )))); + } + + timeout(Duration::from_millis(100), engine.drain()) + .await + .expect("an invalid payload must not retry in place") + .expect("invalid payload must be dropped so the following payload can be inserted"); + + assert_eq!(*queue_rx.borrow(), 0, "all queued payloads should be consumed"); + assert_eq!(engine.state().sync_state.unsafe_head().block_info.number, 1); + assert!( + valid_client.last_new_payload_v2().await.is_some(), + "the payload after the invalid one should be inserted" + ); + } + #[tokio::test] async fn denim_schedule_mismatch_is_rejected_before_new_payload() { let client = test_client(); From a3761294ddcc21e51a1abe73d4d1f7859e1269e2 Mon Sep 17 00:00:00 2001 From: William Law Date: Thu, 17 Sep 2026 02:39:41 +0000 Subject: [PATCH 022/299] feat(simulation): carry MeterBundleResponse on pooled tx (2/n) (#4599) * feat(txpool): carry MeterBundleResponse on pooled transactions Attach optional in-process meter_bundle output to BasePooledTransaction so later PRs can insert/forward simmed txs without a side cache. Co-authored-by: Cursor * fix(txpool): count opcode-gas heap in pooled tx size MeterBundleResponse nests Vec with String names, so pool InMemorySize was missing that heap when opcode metering is on. Co-authored-by: Cursor * fix(txpool): backtick meter_bundle in docs for clippy Co-authored-by: Cursor * fix(txpool): wrap pooled metering in Arc for cheap clones Co-authored-by: Cursor --------- Co-authored-by: Cursor --- Cargo.lock | 1 + crates/common/bundles/src/meter.rs | 55 +++++++++ crates/execution/txpool/Cargo.toml | 1 + crates/execution/txpool/src/transaction.rs | 124 +++++++++++++++++++++ 4 files changed, 181 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a6b3d70a74..84c4b824c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4675,6 +4675,7 @@ dependencies = [ "alloy-signer-local 2.4.1", "alloy-sol-types", "async-trait", + "base-bundles", "base-common-chains", "base-common-consensus", "base-common-evm", diff --git a/crates/common/bundles/src/meter.rs b/crates/common/bundles/src/meter.rs index 295fe25a88..c979dfdcb3 100644 --- a/crates/common/bundles/src/meter.rs +++ b/crates/common/bundles/src/meter.rs @@ -72,6 +72,19 @@ pub struct MeterBundleResponse { pub total_execution_time_us: u128, } +impl MeterBundleResponse { + /// Heap bytes owned beyond `size_of::()`: the results buffer, each + /// `opcode_gas` buffer, and opcode name strings. + pub fn heap_size(&self) -> usize { + let mut size = core::mem::size_of_val(self.results.as_slice()); + for result in &self.results { + size += core::mem::size_of_val(result.opcode_gas.as_slice()); + size += result.opcode_gas.iter().map(|gas| gas.opcode.len()).sum::(); + } + size + } +} + #[cfg(test)] mod tests { use alloy_primitives::address; @@ -203,4 +216,46 @@ mod tests { assert_eq!(deserialized.state_block_number, 12345); assert_eq!(deserialized.total_gas_used, 21000); } + + #[test] + fn heap_size_counts_results_opcode_gas_and_names() { + let empty = MeterBundleResponse::default(); + assert_eq!(empty.heap_size(), 0); + + let response = MeterBundleResponse { + results: vec![TransactionResult { + coinbase_diff: U256::ZERO, + eth_sent_to_coinbase: U256::ZERO, + from_address: Address::ZERO, + gas_fees: U256::ZERO, + gas_price: U256::ZERO, + gas_used: 21_000, + to_address: None, + tx_hash: B256::default(), + value: U256::ZERO, + execution_time_us: 1, + opcode_gas: vec![ + OpcodeGas { + contract_address: Address::ZERO, + opcode: "SSTORE".to_string(), + count: 1, + gas_used: 20_000, + }, + OpcodeGas { + contract_address: Address::ZERO, + opcode: "SLOAD".to_string(), + count: 2, + gas_used: 2_100, + }, + ], + }], + ..MeterBundleResponse::default() + }; + + let results_size = core::mem::size_of_val(response.results.as_slice()); + let opcode_gas_size = core::mem::size_of_val(response.results[0].opcode_gas.as_slice()); + let opcode_names_size = "SSTORE".len() + "SLOAD".len(); + + assert_eq!(response.heap_size(), results_size + opcode_gas_size + opcode_names_size); + } } diff --git a/crates/execution/txpool/Cargo.toml b/crates/execution/txpool/Cargo.toml index 56203af6c3..146e3423e9 100644 --- a/crates/execution/txpool/Cargo.toml +++ b/crates/execution/txpool/Cargo.toml @@ -28,6 +28,7 @@ reth-storage-api.workspace = true reth-transaction-pool.workspace = true reth-primitives-traits.workspace = true +base-bundles.workspace = true base-common-flz.workspace = true base-common-chains.workspace = true base-common-evm.workspace = true diff --git a/crates/execution/txpool/src/transaction.rs b/crates/execution/txpool/src/transaction.rs index 07bff6d171..1d4bee6762 100644 --- a/crates/execution/txpool/src/transaction.rs +++ b/crates/execution/txpool/src/transaction.rs @@ -14,6 +14,7 @@ use alloy_eips::{ eip7702::SignedAuthorization, }; use alloy_primitives::{Address, B256, Bytes, TxHash, TxKind, U256}; +use base_bundles::MeterBundleResponse; use base_common_consensus::{BaseTransactionSigned, Eip8130Constants, Eip8130Signed}; use c_kzg::KzgSettings; use reth_primitives_traits::{InMemorySize, SignedTransaction}; @@ -71,6 +72,13 @@ pub struct BasePooledTransaction< /// EIP-8130 validation. Unset for other transaction types; see /// [`crate::WatchManifest`]. watch_manifest: OnceLock, + /// In-process `meter_bundle` result, attached after sim and before pool insert. + /// + /// Behind [`Arc`] so [`Clone`] (payload-building `ParkableBestPayloadTransactions`) + /// stays a pointer bump once later PRs populate this. `None` on + /// sequencer/builder inserts and on mempool txs while inline simulation is + /// off. The later consumer only forwards `Some`. + metering: Option>, } impl BasePooledTransaction { @@ -97,9 +105,22 @@ impl BasePooledTransaction { watch_set: OnceLock::new(), limit_class: OnceLock::new(), watch_manifest: OnceLock::new(), + metering: None, } } + /// Attaches an in-process `meter_bundle` result to this transaction. + #[must_use] + pub fn with_metering(mut self, metering: MeterBundleResponse) -> Self { + self.metering = Some(Arc::new(metering)); + self + } + + /// Returns the attached `meter_bundle` result, if any. + pub fn metering(&self) -> Option<&MeterBundleResponse> { + self.metering.as_deref() + } + /// Sets the validity predicates required for this transaction's inclusion. /// /// Predicates are stored in canonical evaluation order (timing before @@ -228,6 +249,9 @@ impl InMemorySize for BasePooledTransaction() + metering.heap_size() + }); self.inner.size() + core::mem::size_of::() + core::mem::size_of::>() @@ -237,6 +261,8 @@ impl InMemorySize for BasePooledTransaction>() + manifest_slots_size + validity_predicates_size + + core::mem::size_of::>>() + + metering_size } } @@ -511,6 +537,7 @@ mod tests { use alloy_primitives::{Address, Bytes, TxKind, U256}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; + use base_bundles::{MeterBundleResponse, OpcodeGas, TransactionResult}; use base_common_chains::ChainConfig; use base_common_consensus::{ BasePooledTransaction as ConsensusPooledTransaction, BasePrimitives, BaseTransactionSigned, @@ -530,6 +557,28 @@ mod tests { ValidityOperator, ValidityPredicate, WatchManifest, WatchSet, }; + fn meter_response(results: usize) -> MeterBundleResponse { + MeterBundleResponse { + results: (0..results) + .map(|_| TransactionResult { + coinbase_diff: U256::ZERO, + eth_sent_to_coinbase: U256::ZERO, + from_address: Address::ZERO, + gas_fees: U256::ZERO, + gas_price: U256::ZERO, + gas_used: 21_000, + to_address: None, + tx_hash: Default::default(), + value: U256::ZERO, + execution_time_us: 1, + opcode_gas: Vec::new(), + }) + .collect(), + total_gas_used: 21_000 * results as u64, + ..MeterBundleResponse::default() + } + } + fn signer() -> PrivateKeySigner { PrivateKeySigner::random() } @@ -705,6 +754,81 @@ mod tests { assert_eq!(transaction.size(), size_without_slots + slots_size); } + #[test] + fn metering_defaults_to_none() { + let transaction = eip8130_pooled(U256::ZERO); + + assert!(transaction.metering().is_none()); + } + + #[test] + fn retains_metering() { + let metering = meter_response(1); + let transaction = eip8130_pooled(U256::ZERO).with_metering(metering.clone()); + + assert_eq!(transaction.metering(), Some(&metering)); + } + + #[test] + fn clone_shares_metering_arc() { + let transaction = eip8130_pooled(U256::ZERO).with_metering(meter_response(1)); + let cloned = transaction.clone(); + + assert!( + core::ptr::eq( + transaction.metering().expect("original should retain metering"), + cloned.metering().expect("clone should retain metering"), + ), + "payload-building clones should share the metering Arc, not deep-copy it" + ); + } + + #[test] + fn in_memory_size_includes_metering_results() { + let transaction = eip8130_pooled(U256::ZERO); + let size_without_metering = transaction.size(); + let metering = meter_response(2); + let results_size = core::mem::size_of_val(metering.results.as_slice()); + + let transaction = transaction.with_metering(metering); + + assert_eq!( + transaction.size(), + size_without_metering + core::mem::size_of::() + results_size, + "attaching metering should add the Arc-allocated response plus the results slice" + ); + assert!(transaction.metering().is_some(), "metering should stay attached"); + } + + #[test] + fn in_memory_size_includes_opcode_gas_heap() { + let transaction = eip8130_pooled(U256::ZERO); + let size_without_metering = transaction.size(); + let opcode = OpcodeGas { + contract_address: Address::ZERO, + opcode: "SSTORE".to_string(), + count: 1, + gas_used: 20_000, + }; + let mut metering = meter_response(1); + metering.results[0].opcode_gas = vec![opcode]; + let results_size = core::mem::size_of_val(metering.results.as_slice()); + let opcode_gas_size = core::mem::size_of_val(metering.results[0].opcode_gas.as_slice()); + let opcode_name_size = "SSTORE".len(); + + let transaction = transaction.with_metering(metering); + + assert_eq!( + transaction.size(), + size_without_metering + + core::mem::size_of::() + + results_size + + opcode_gas_size + + opcode_name_size, + "pool size should include Arc-allocated response, opcode_gas entries, and opcode name bytes" + ); + } + #[test] fn retains_validity_predicates() { let predicate = ValidityPredicate::Balance { From bb7ba218ab2f84cc989217a310513cf5cbd147e2 Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Thu, 17 Sep 2026 13:12:01 +0000 Subject: [PATCH 023/299] fix(snapshots): select Base snapshot sources instead of 404s and reth defaults (#5093) The snapshot downloader could not reach any Base snapshot. Advertised sources pointed at `https://chain.base.org/`, which 404s, and the unified `base` binary never initialized Base's download defaults at all, so `base snapshot download` queried reth's public snapshot API instead. - Derive every download default from one snapshot root so the browse URL and discovery API cannot drift apart - Advertise the per-chain buckets the discovery API actually serves manifests from - Call `init_snapshots!()` from the `base` binary, matching base-node-reth and base-builder - Add `Snapshots::download_defaults()` so the config is testable without the process-wide `OnceLock` - Correct a stale doc comment citing the removed chain-specific base URL Co-authored-by: OpenCode --- Cargo.lock | 1 + bin/base/Cargo.toml | 1 + bin/base/src/main.rs | 2 + crates/execution/cli/src/commands/download.rs | 6 +-- crates/utilities/reth-cli/src/snapshots.rs | 47 +++++++++++++------ 5 files changed, 39 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84c4b824c6..9f28dbb161 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2919,6 +2919,7 @@ dependencies = [ "base-execution-evm", "base-node-core", "base-node-runner", + "base-reth-cli", "base-shadow-indexer", "base-txpool-rpc", "base-upgrade-signal", diff --git a/bin/base/Cargo.toml b/bin/base/Cargo.toml index 915b5d08b2..8c4d847f5f 100644 --- a/bin/base/Cargo.toml +++ b/bin/base/Cargo.toml @@ -17,6 +17,7 @@ workspace = true [dependencies] # workspace +base-reth-cli.workspace = true base-cli-utils.workspace = true base-node-core.workspace = true base-txpool-rpc.workspace = true diff --git a/bin/base/src/main.rs b/bin/base/src/main.rs index cf426586d5..d079b1e36f 100644 --- a/bin/base/src/main.rs +++ b/bin/base/src/main.rs @@ -16,5 +16,7 @@ static ALLOC: reth_cli_util::allocator::Allocator = reth_cli_util::allocator::ne static MALLOC_CONF: &[u8] = b"prof:true,prof_active:true,lg_prof_sample:19\0"; fn main() { + base_reth_cli::init_snapshots!(); + base_cli_utils::run_cli_main!(cli::BaseCli); } diff --git a/crates/execution/cli/src/commands/download.rs b/crates/execution/cli/src/commands/download.rs index 4c5a4aee58..57cfb331bf 100644 --- a/crates/execution/cli/src/commands/download.rs +++ b/crates/execution/cli/src/commands/download.rs @@ -125,9 +125,9 @@ fn json_u64(value: Option<&serde_json::Value>) -> Option { /// Discovers the latest modular snapshot manifest URL for `chain_id`. /// /// Reth's download pipeline queries the snapshot API (`metadataUrl`) rather than -/// concatenating `{default_base_url}/{chain_id}/manifest.json`. `default_base_url` -/// is already chain-specific (`https://chain.base.org/8453`), so appending another -/// chain ID produced 404s such as `https://chain.base.org/8453/763360/manifest.json`. +/// concatenating `{default_base_url}/{chain_id}/manifest.json`. Each chain publishes +/// manifests under its own bucket (`https://zeronet-v2-snapshots.base.org/...`), so +/// deriving a URL from the snapshot root instead produces 404s. async fn discover_latest_manifest_url(api_url: &str, chain_id: u64) -> Result { info!( target: "reth::cli", diff --git a/crates/utilities/reth-cli/src/snapshots.rs b/crates/utilities/reth-cli/src/snapshots.rs index 89ec90c657..4d4400debd 100644 --- a/crates/utilities/reth-cli/src/snapshots.rs +++ b/crates/utilities/reth-cli/src/snapshots.rs @@ -6,8 +6,9 @@ use reth_cli_commands::download::DownloadDefaults; use reth_node_core::args::DefaultPruningValues; use reth_prune_types::PruneMode; -pub(crate) const DEFAULT_DOWNLOAD_URL: &str = "https://chain.base.org/8453"; -const SNAPSHOT_API_URL: &str = "https://chain.base.org/api/snapshots"; +/// Must stay a bare host: reth derives `{root}/api/snapshots` from it and appends +/// its own path segments, so a chain path here produces 404 manifest URLs. +const SNAPSHOT_SOURCE_URL: &str = "https://chain.base.org"; const FULL_HISTORY_DISTANCE: u64 = 1_339_200; /// Reth snapshot and pruning-default initialization for Base execution layer binaries. @@ -15,6 +16,17 @@ const FULL_HISTORY_DISTANCE: u64 = 1_339_200; pub struct Snapshots; impl Snapshots { + /// Snapshot sources advertised in `--help`, derived from one snapshot root. + pub fn download_defaults() -> DownloadDefaults { + DownloadDefaults::default().with_snapshot_source_url(SNAPSHOT_SOURCE_URL).with_snapshots( + vec![ + Cow::Borrowed("https://mainnet-v2-snapshots.base.org (mainnet)"), + Cow::Borrowed("https://sepolia-v2-snapshots.base.org (sepolia)"), + Cow::Borrowed("https://zeronet-v2-snapshots.base.org (zeronet)"), + ], + ) + } + /// Initializes Reth's global snapshot download URLs and pruning defaults. /// /// This sets up the snapshot sources and makes the full preset retain approximately one month @@ -24,19 +36,7 @@ impl Snapshots { /// /// Panics if the download URLs or pruning defaults were already initialized. pub fn init_snapshots() { - let download_defaults = DownloadDefaults { - available_snapshots: vec![ - Cow::Owned(format!("{DEFAULT_DOWNLOAD_URL} (mainnet)")), - Cow::Borrowed("https://chain.base.org/84532 (sepolia)"), - Cow::Borrowed("https://chain.base.org/763360 (zeronet)"), - ], - default_base_url: Cow::Borrowed(DEFAULT_DOWNLOAD_URL), - default_chain_aware_base_url: None, - snapshot_api_url: Cow::Borrowed(SNAPSHOT_API_URL), - long_help: None, - }; - - download_defaults.try_init().expect("failed to initialize download URLs"); + Self::download_defaults().try_init().expect("failed to initialize download URLs"); let mut pruning_defaults = DefaultPruningValues::default(); pruning_defaults.full_prune_modes.bodies_history = @@ -69,6 +69,23 @@ macro_rules! init_snapshots { mod tests { use super::*; + #[test] + fn snapshot_root_carries_no_chain_path() { + let defaults = Snapshots::download_defaults(); + let host = defaults.default_base_url.trim_start_matches("https://"); + + assert!( + !host.contains('/'), + "default base URL {} must be the snapshot root; a chain path makes reth \ + build 404 manifest URLs", + defaults.default_base_url + ); + assert_eq!( + defaults.snapshot_api_url, + format!("{}/api/snapshots", defaults.default_base_url) + ); + } + #[test] fn full_preset_retains_one_month_of_history() { Snapshots::init_snapshots(); From 8478f7f169594e0c5d3455255f55cc3c1ac327c2 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 17 Sep 2026 13:33:23 +0000 Subject: [PATCH 024/299] feat(precompiles): enforce transfer executor policy at Denim (#5080) * feat(precompiles): enforce transfer executor policy at Denim Co-authored-by: Codex * chore: nit * refactor(precompiles): group AssetV3 transfer operands into TokenTransfer Avoid a long positional transfer_inner argument list while keeping the privileged early-return path. Co-authored-by: Cursor * refactor(precompiles): group StablecoinV3 transfer operands into TokenTransfer Mirror the AssetV3 transfer_inner argument object so both Denim variants share the same call shape. Co-authored-by: Cursor * chore: rustfmt TokenTransfer privileged call sites Co-authored-by: Cursor --------- Co-authored-by: Codex Co-authored-by: Cursor --- .../precompiles/src/b20_asset/logic/v3.rs | 90 +++++++------ .../src/b20_stablecoin/logic/v3.rs | 82 +++++++----- .../precompiles/tests/b20_asset_v3_golden.rs | 125 ++++++++++++++++-- .../tests/b20_stablecoin_v3_golden.rs | 125 ++++++++++++++++-- 4 files changed, 327 insertions(+), 95 deletions(-) diff --git a/crates/common/precompiles/src/b20_asset/logic/v3.rs b/crates/common/precompiles/src/b20_asset/logic/v3.rs index 293d53d0e5..b867ef0ffb 100644 --- a/crates/common/precompiles/src/b20_asset/logic/v3.rs +++ b/crates/common/precompiles/src/b20_asset/logic/v3.rs @@ -1,8 +1,8 @@ //! Version 3 of the asset B-20 precompile logic, activated at Denim. //! -//! V3 is a behavior-identical copy of V2 (a scaffold seam for future Denim-era changes). Every -//! method carries V2's verbatim body; V3 does not call into V2. Storage is append-only, so a token -//! created under V1 or V2 upgrades in place with no migration. +//! V3 is a self-contained copy of V2 that enforces `TRANSFER_EXECUTOR_POLICY` on every transfer +//! path. Every other method carries V2's verbatim body; V3 does not call into V2. Storage is +//! append-only, so a token created under V1 or V2 upgrades in place with no migration. use alloc::{ string::{String, ToString}, @@ -27,11 +27,25 @@ const DOMAIN_TYPEHASH: B256 = /// EIP-712 domain version string pinned to `"1"`. const VERSION: &[u8] = b"1"; -/// Third asset B-20 implementation. Activated at Denim, behavior-identical to V2 (scaffold seam -/// for future changes). +/// Third asset B-20 implementation. Activated at Denim; applies the transfer-executor policy to +/// every transfer path on top of the frozen V2 surface. #[derive(Debug, Default, Clone, Copy)] pub struct AssetV3; +/// One token movement: `caller` sends `amount` from `from` to `to`. +/// +/// `from` / `to` are [`NonZeroAddress`]: callers validate zero addresses (and choose the typed +/// revert) before any policy SLOAD. `policies` carries all three transfer policy ids pre-read from +/// their shared slot; `Some` enforces them (unprivileged path), `None` skips them (factory-privileged +/// path). +struct TokenTransfer<'a> { + caller: Address, + from: NonZeroAddress, + to: NonZeroAddress, + amount: U256, + policies: Option<&'a TransferPolicyIds>, +} + impl AssetV3 { const PAUSABLE_FEATURES: &[IB20::PausableFeature] = &[ IB20::PausableFeature::TRANSFER, @@ -54,28 +68,30 @@ impl AssetV3 { pub const MAX_UI_MULTIPLIER: U256 = U256::from_limbs([u64::MAX, u64::MAX, 0, 0]); /// Balance-moving core of `transfer`/`transferFrom`, without the pause check. - /// - /// `from` / `to` are [`NonZeroAddress`]: callers validate zero addresses (and choose the - /// typed revert) before any policy SLOAD. `policies` carries the sender/receiver ids - /// pre-read from their shared slot by the caller; `Some` enforces both (unprivileged - /// path), `None` skips them (factory-privileged path). fn transfer_inner( &self, token: &mut B20AssetToken, - from: NonZeroAddress, - to: NonZeroAddress, - amount: U256, - policies: Option<&TransferPolicyIds>, + transfer: TokenTransfer<'_>, ) -> Result<()> { - let from = from.get(); - let to = to.get(); - if let Some(policies) = policies { + let from = transfer.from.get(); + let to = transfer.to.get(); + if let Some(policies) = transfer.policies { B20Guards::ensure_authorized_by_id( token, - B20PolicyType::TransferSender.id(), - policies.sender, - from, + B20PolicyType::TransferExecutor.id(), + policies.executor, + transfer.caller, )?; + let should_check_sender_policy = + transfer.caller != from || policies.executor != policies.sender; + if should_check_sender_policy { + B20Guards::ensure_authorized_by_id( + token, + B20PolicyType::TransferSender.id(), + policies.sender, + from, + )?; + } B20Guards::ensure_authorized_by_id( token, B20PolicyType::TransferReceiver.id(), @@ -83,7 +99,7 @@ impl AssetV3 { to, )?; } - self.move_balance(token, from, to, amount) + self.move_balance(token, from, to, transfer.amount) } /// Debits `from`, credits `to`, and emits `Transfer(from, to, amount)`. @@ -181,12 +197,12 @@ impl AssetV3 { Ok(()) } - /// Ensures `policy_scope` names a built-in B-20 policy slot available on the V2 (Cobalt) common - /// surface, which adds the seize scopes (`SEIZE_EXEMPT_POLICY` / `SEIZE_RECEIVER_POLICY`) on top + /// Ensures `policy_scope` names a built-in B-20 policy slot available on the V3 (Denim) common + /// surface, which retains the Cobalt seize scopes (`SEIZE_EXEMPT_POLICY` / `SEIZE_RECEIVER_POLICY`) on top /// of V1. /// /// The match is exhaustive on purpose: a policy scope added to `B20PolicyType` for a future fork - /// must not silently widen this frozen V2 surface — it should fail to compile until V2's stance + /// must not silently widen this frozen V3 surface — it should fail to compile until V3's stance /// on it is decided explicitly. fn ensure_supported_policy_type(policy_scope: B256) -> Result<()> { match B20PolicyType::from_id(policy_scope) { @@ -244,10 +260,14 @@ impl Asset for AssetV3 { let from = NonZeroAddress::new(caller) .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: caller }))?; if privileged { - return self.transfer_inner(token, from, to, amount, None); + return self + .transfer_inner(token, TokenTransfer { caller, from, to, amount, policies: None }); } let policies = token.accounting().transfer_policy_ids()?; - self.transfer_inner(token, from, to, amount, Some(&policies)) + self.transfer_inner( + token, + TokenTransfer { caller, from, to, amount, policies: Some(&policies) }, + ) } fn transfer_from( @@ -275,20 +295,14 @@ impl Asset for AssetV3 { })); } if privileged { - self.transfer_inner(token, from, to, amount, None)?; + self.transfer_inner(token, TokenTransfer { caller, from, to, amount, policies: None })?; } else { - // One SLOAD fetches all transfer policy ids, reused for the executor and - // sender/receiver checks. + // One SLOAD fetches all transfer policy ids for the shared transfer checks. let policies = token.accounting().transfer_policy_ids()?; - if caller != from.get() { - B20Guards::ensure_authorized_by_id( - token, - B20PolicyType::TransferExecutor.id(), - policies.executor, - caller, - )?; - } - self.transfer_inner(token, from, to, amount, Some(&policies))?; + self.transfer_inner( + token, + TokenTransfer { caller, from, to, amount, policies: Some(&policies) }, + )?; } if is_infinite { return Ok(()); diff --git a/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs b/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs index 19c3374caf..d43d6bdb74 100644 --- a/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs +++ b/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs @@ -23,11 +23,25 @@ const DOMAIN_TYPEHASH: B256 = /// EIP-712 domain version string pinned to `"1"`. const VERSION: &[u8] = b"1"; -/// Third stablecoin B-20 implementation. Activated at Denim, behavior-identical to V2 (scaffold seam -/// for future changes). +/// Third stablecoin B-20 implementation. Activated at Denim; applies the transfer-executor policy +/// to every transfer path on top of the frozen V2 surface. #[derive(Debug, Default, Clone, Copy)] pub struct StablecoinV3; +/// One token movement: `caller` sends `amount` from `from` to `to`. +/// +/// `from` / `to` are [`NonZeroAddress`]: callers validate zero addresses (and choose the typed +/// revert) before any policy SLOAD. `policies` carries all three transfer policy ids pre-read from +/// their shared slot; `Some` enforces them (unprivileged path), `None` skips them (factory-privileged +/// path). +struct TokenTransfer<'a> { + caller: Address, + from: NonZeroAddress, + to: NonZeroAddress, + amount: U256, + policies: Option<&'a TransferPolicyIds>, +} + impl StablecoinV3 { const PAUSABLE_FEATURES: &[IB20::PausableFeature] = &[ IB20::PausableFeature::TRANSFER, @@ -37,28 +51,28 @@ impl StablecoinV3 { ]; /// Balance-moving core of `transfer`/`transferFrom`, without the pause check. - /// - /// `from` / `to` are [`NonZeroAddress`]: callers validate zero addresses (and choose the - /// typed revert) before any policy SLOAD. `policies` carries the sender/receiver ids - /// pre-read from their shared slot by the caller; `Some` enforces both (unprivileged - /// path), `None` skips them (factory-privileged path). fn transfer_inner( &self, token: &mut B20StablecoinToken, - from: NonZeroAddress, - to: NonZeroAddress, - amount: U256, - policies: Option<&TransferPolicyIds>, + transfer: TokenTransfer<'_>, ) -> Result<()> { - let from = from.get(); - let to = to.get(); - if let Some(policies) = policies { + let from = transfer.from.get(); + let to = transfer.to.get(); + if let Some(policies) = transfer.policies { B20Guards::ensure_authorized_by_id( token, - B20PolicyType::TransferSender.id(), - policies.sender, - from, + B20PolicyType::TransferExecutor.id(), + policies.executor, + transfer.caller, )?; + if transfer.caller != from || policies.executor != policies.sender { + B20Guards::ensure_authorized_by_id( + token, + B20PolicyType::TransferSender.id(), + policies.sender, + from, + )?; + } B20Guards::ensure_authorized_by_id( token, B20PolicyType::TransferReceiver.id(), @@ -66,7 +80,7 @@ impl StablecoinV3 { to, )?; } - self.move_balance(token, from, to, amount) + self.move_balance(token, from, to, transfer.amount) } /// Debits `from`, credits `to`, and emits `Transfer(from, to, amount)`. @@ -164,12 +178,12 @@ impl StablecoinV3 { Ok(()) } - /// Ensures `policy_scope` names a built-in B-20 policy slot available on the V2 (Cobalt) common - /// surface, which adds the seize scopes (`SEIZE_EXEMPT_POLICY` / `SEIZE_RECEIVER_POLICY`) on top + /// Ensures `policy_scope` names a built-in B-20 policy slot available on the V3 (Denim) common + /// surface, which retains the Cobalt seize scopes (`SEIZE_EXEMPT_POLICY` / `SEIZE_RECEIVER_POLICY`) on top /// of V1. /// /// The match is exhaustive on purpose: a policy scope added to `B20PolicyType` for a future fork - /// must not silently widen this frozen V2 surface — it should fail to compile until V2's stance + /// must not silently widen this frozen V3 surface — it should fail to compile until V3's stance /// on it is decided explicitly. fn ensure_supported_policy_type(policy_scope: B256) -> Result<()> { match B20PolicyType::from_id(policy_scope) { @@ -203,10 +217,14 @@ impl Stablecoin for Stableco let from = NonZeroAddress::new(caller) .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: caller }))?; if privileged { - return self.transfer_inner(token, from, to, amount, None); + return self + .transfer_inner(token, TokenTransfer { caller, from, to, amount, policies: None }); } let policies = token.accounting().transfer_policy_ids()?; - self.transfer_inner(token, from, to, amount, Some(&policies)) + self.transfer_inner( + token, + TokenTransfer { caller, from, to, amount, policies: Some(&policies) }, + ) } fn transfer_from( @@ -234,20 +252,14 @@ impl Stablecoin for Stableco })); } if privileged { - self.transfer_inner(token, from, to, amount, None)?; + self.transfer_inner(token, TokenTransfer { caller, from, to, amount, policies: None })?; } else { - // One SLOAD fetches all transfer policy ids, reused for the executor and - // sender/receiver checks. + // One SLOAD fetches all transfer policy ids for the shared transfer checks. let policies = token.accounting().transfer_policy_ids()?; - if caller != from.get() { - B20Guards::ensure_authorized_by_id( - token, - B20PolicyType::TransferExecutor.id(), - policies.executor, - caller, - )?; - } - self.transfer_inner(token, from, to, amount, Some(&policies))?; + self.transfer_inner( + token, + TokenTransfer { caller, from, to, amount, policies: Some(&policies) }, + )?; } if is_infinite { return Ok(()); diff --git a/crates/common/precompiles/tests/b20_asset_v3_golden.rs b/crates/common/precompiles/tests/b20_asset_v3_golden.rs index 1d5d0a592e..88e64bd6df 100644 --- a/crates/common/precompiles/tests/b20_asset_v3_golden.rs +++ b/crates/common/precompiles/tests/b20_asset_v3_golden.rs @@ -1,12 +1,12 @@ //! Golden tests pinning Asset **V3** behavior of the B-20 precompile. //! -//! V3 is activated at Denim as a behavior-preserving copy of V2 (a scaffold seam for future -//! Denim-era changes). Its logic and storage layout are identical to V2, so this suite pins its -//! own roots to lock V3's behavior independently: a future edit that alters V3 must re-bless these -//! roots. These goldens run production Cobalt storage features (Denim inherits them via -//! `upgrade >= Cobalt`) while the V1 suite runs Legacy, so a root matches V2's only when the op -//! does not diverge; ops that write shrinking dynamic values can legitimately diverge from V1's -//! Legacy pin. +//! V3 activates at Denim and enforces `TRANSFER_EXECUTOR_POLICY` on every transfer path. +//! The rest of the logic and the storage layout carry V2 forward, so this suite pins its own roots +//! to lock V3's behavior independently: a future edit that alters V3 must re-bless these roots. +//! These goldens run production Cobalt storage features (Denim inherits them via `upgrade >= +//! Cobalt`) while the V1 suite runs Legacy, so a root matches V2's only when the operation does +//! not diverge; ops that write shrinking dynamic values can legitimately diverge from V1's Legacy +//! pin. //! //! Every op is driven through the **version-resolver-gated** dispatch path //! (`BaseUpgrade::Denim` -> `AssetVersion::V3`) against the real EVM-backed `B20AssetStorage` @@ -382,7 +382,7 @@ fn golden_dispatch_no_observer_wrapper_reverts_uninitialized() { } // ============================================================================ -// transfer (behavior-preserving: V1 roots reused) +// transfer // ============================================================================ #[test] @@ -452,6 +452,29 @@ fn golden_transfer_unprivileged_blocked_sender_reverts() { ); } +#[test] +fn golden_transfer_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(10) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + #[test] fn golden_transfer_reverts_zero_receiver() { let mut s = fresh(); @@ -545,8 +568,31 @@ fn golden_transfer_with_memo_emits_transfer_then_memo() { assert_root("transfer_with_memo", s, ROOT_TRANSFER_WITH_MEMO); } +#[test] +fn golden_transfer_with_memo_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferWithMemoCall { to: BOB, amount: u(10), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + // ============================================================================ -// transferFrom (behavior-preserving: V1 roots reused) +// transferFrom // ============================================================================ #[test] @@ -642,6 +688,30 @@ fn golden_transfer_from_unprivileged_enforces_executor_policy() { ); } +#[test] +fn golden_transfer_from_self_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, ALICE, u(10)).unwrap(); + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(10) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + #[test] fn golden_transfer_from_with_memo() { let mut s = fresh(); @@ -665,6 +735,31 @@ fn golden_transfer_from_with_memo() { assert_root("transfer_from_with_memo", s, ROOT_TRANSFER_FROM_WITH_MEMO); } +#[test] +fn golden_transfer_from_with_memo_self_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, ALICE, u(10)).unwrap(); + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferFromWithMemoCall { from: ALICE, to: BOB, amount: u(10), memo: MEMO } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + #[test] fn golden_transfer_from_reverts_zero_receiver() { let mut s = fresh(); @@ -3580,6 +3675,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Asset::IB20AssetCall golden_transfer_privileged, golden_transfer_unprivileged_allowed, golden_transfer_unprivileged_blocked_sender_reverts, + golden_transfer_unprivileged_enforces_executor_policy, golden_transfer_reverts_zero_receiver, golden_transfer_unprivileged_zero_receiver_storage_access, golden_transfer_reverts_insufficient_balance, @@ -3591,6 +3687,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Asset::IB20AssetCall golden_transfer_from_infinite_allowance_not_decremented, golden_transfer_from_reverts_insufficient_allowance, golden_transfer_from_unprivileged_enforces_executor_policy, + golden_transfer_from_self_unprivileged_enforces_executor_policy, golden_transfer_from_reverts_zero_receiver, golden_transfer_from_reverts_zero_sender, ]), @@ -3599,8 +3696,14 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Asset::IB20AssetCall golden_approve_reverts_zero_spender, golden_approve_reverts_zero_approver, ]), - C::transferWithMemo(_) => covered(&[golden_transfer_with_memo_emits_transfer_then_memo]), - C::transferFromWithMemo(_) => covered(&[golden_transfer_from_with_memo]), + C::transferWithMemo(_) => covered(&[ + golden_transfer_with_memo_emits_transfer_then_memo, + golden_transfer_with_memo_unprivileged_enforces_executor_policy, + ]), + C::transferFromWithMemo(_) => covered(&[ + golden_transfer_from_with_memo, + golden_transfer_from_with_memo_self_unprivileged_enforces_executor_policy, + ]), // mint / burn C::mint(_) => covered(&[ diff --git a/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs b/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs index 861cb09d16..fcee612d8b 100644 --- a/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs +++ b/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs @@ -1,12 +1,12 @@ //! Golden tests pinning Stablecoin **V3** behavior of the B-20 precompile. //! -//! V3 is activated at Denim as a behavior-preserving copy of V2 (a scaffold seam for future -//! Denim-era changes). Its logic and storage layout are identical to V2, so this suite pins its -//! own roots to lock V3's behavior independently: a future edit that alters V3 must re-bless these -//! roots. These goldens run production Cobalt storage features (Denim inherits them via -//! `upgrade >= Cobalt`) while the V1 suite runs Legacy, so a root matches V2's only when the op -//! does not diverge; ops that write shrinking dynamic values can legitimately diverge from V1's -//! Legacy pin. +//! V3 activates at Denim and enforces `TRANSFER_EXECUTOR_POLICY` on every transfer path. +//! The rest of the logic and the storage layout carry V2 forward, so this suite pins its own roots +//! to lock V3's behavior independently: a future edit that alters V3 must re-bless these roots. +//! These goldens run production Cobalt storage features (Denim inherits them via `upgrade >= +//! Cobalt`) while the V1 suite runs Legacy, so a root matches V2's only when the operation does +//! not diverge; ops that write shrinking dynamic values can legitimately diverge from V1's Legacy +//! pin. //! //! Every op is driven through the **version-resolver-gated** dispatch path //! (`BaseUpgrade::Denim` -> `StablecoinVersion::V3`) against the real EVM-backed @@ -352,7 +352,7 @@ fn golden_dispatch_no_observer_wrapper_reverts_uninitialized() { } // ============================================================================ -// transfer (behavior-preserving: V1 roots reused) +// transfer // ============================================================================ #[test] @@ -422,6 +422,29 @@ fn golden_transfer_unprivileged_blocked_sender_reverts() { ); } +#[test] +fn golden_transfer_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: BOB, amount: u(10) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + #[test] fn golden_transfer_reverts_zero_receiver() { let mut s = fresh(); @@ -515,8 +538,31 @@ fn golden_transfer_with_memo_emits_transfer_then_memo() { assert_root("transfer_with_memo", s, ROOT_TRANSFER_WITH_MEMO); } +#[test] +fn golden_transfer_with_memo_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferWithMemoCall { to: BOB, amount: u(10), memo: MEMO }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + // ============================================================================ -// transferFrom (behavior-preserving: V1 roots reused) +// transferFrom // ============================================================================ #[test] @@ -612,6 +658,30 @@ fn golden_transfer_from_unprivileged_enforces_executor_policy() { ); } +#[test] +fn golden_transfer_from_self_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, ALICE, u(10)).unwrap(); + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: ALICE, to: BOB, amount: u(10) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + #[test] fn golden_transfer_from_with_memo() { let mut s = fresh(); @@ -635,6 +705,31 @@ fn golden_transfer_from_with_memo() { assert_root("transfer_from_with_memo", s, ROOT_TRANSFER_FROM_WITH_MEMO); } +#[test] +fn golden_transfer_from_with_memo_self_unprivileged_enforces_executor_policy() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, ALICE, u(100)); + t.set_allowance(ALICE, ALICE, u(10)).unwrap(); + t.set_policy_id(B20PolicyType::TransferExecutor.id(), POLICY_ID).unwrap(); + }); + let err = op( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferFromWithMemoCall { from: ALICE, to: BOB, amount: u(10), memo: MEMO } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: POLICY_ID, + }) + ); +} + #[test] fn golden_transfer_from_reverts_zero_receiver() { let mut s = fresh(); @@ -2442,6 +2537,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Stablecoin::IB20Stab golden_transfer_privileged, golden_transfer_unprivileged_allowed, golden_transfer_unprivileged_blocked_sender_reverts, + golden_transfer_unprivileged_enforces_executor_policy, golden_transfer_reverts_zero_receiver, golden_transfer_unprivileged_zero_receiver_storage_access, golden_transfer_reverts_insufficient_balance, @@ -2453,6 +2549,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Stablecoin::IB20Stab golden_transfer_from_infinite_allowance_not_decremented, golden_transfer_from_reverts_insufficient_allowance, golden_transfer_from_unprivileged_enforces_executor_policy, + golden_transfer_from_self_unprivileged_enforces_executor_policy, golden_transfer_from_reverts_zero_receiver, golden_transfer_from_reverts_zero_sender, ]), @@ -2461,8 +2558,14 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Stablecoin::IB20Stab golden_approve_reverts_zero_spender, golden_approve_reverts_zero_approver, ]), - C::transferWithMemo(_) => covered(&[golden_transfer_with_memo_emits_transfer_then_memo]), - C::transferFromWithMemo(_) => covered(&[golden_transfer_from_with_memo]), + C::transferWithMemo(_) => covered(&[ + golden_transfer_with_memo_emits_transfer_then_memo, + golden_transfer_with_memo_unprivileged_enforces_executor_policy, + ]), + C::transferFromWithMemo(_) => covered(&[ + golden_transfer_from_with_memo, + golden_transfer_from_with_memo_self_unprivileged_enforces_executor_policy, + ]), // mint / burn C::mint(_) => covered(&[ From 9b50489852c1f8c2077094ccf7b4e6aadfc907bc Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Thu, 17 Sep 2026 13:42:25 +0000 Subject: [PATCH 025/299] fix(proof): select interval ABI before calling verifier (#5086) Co-authored-by: Codex --- crates/infra/basectl/src/rpc/games.rs | 7 +- .../proof/contracts/src/aggregate_verifier.rs | 80 ++++++------------- 2 files changed, 30 insertions(+), 57 deletions(-) diff --git a/crates/infra/basectl/src/rpc/games.rs b/crates/infra/basectl/src/rpc/games.rs index db2cd6aa36..5b4e5d60c7 100644 --- a/crates/infra/basectl/src/rpc/games.rs +++ b/crates/infra/basectl/src/rpc/games.rs @@ -583,8 +583,8 @@ mod tests { push_abi(&asserter, &(1_u32, 1_u64, unsupported_game)); push_abi(&asserter, &unsupported_impl); asserter.push_success(&Bytes::new()); - asserter.push_success(&Bytes::new()); push_abi(&asserter, &aggregate_impl); + push_abi(&asserter, &"0.3.0".to_string()); push_abi(&asserter, &(U256::from(100), U256::from(10))); let skipped = client @@ -657,7 +657,9 @@ mod tests { push_game_details_reads(&asserter); push_abi(&asserter, &(game, 0_u64)); - push_abi(&asserter, &(U256::from(1000), U256::from(100))); + push_abi(&asserter, &"0.2.0".to_string()); + push_abi(&asserter, &U256::from(1000)); + push_abi(&asserter, &U256::from(100)); let details = client.game_details(game).await.unwrap(); @@ -679,7 +681,6 @@ mod tests { push_game_details_reads(&asserter); push_abi(&asserter, &(game, 0_u64)); asserter.push_success(&Bytes::new()); - asserter.push_success(&Bytes::new()); let details = client.game_details(game).await.unwrap(); diff --git a/crates/proof/contracts/src/aggregate_verifier.rs b/crates/proof/contracts/src/aggregate_verifier.rs index c00ee87e8f..29e80377be 100644 --- a/crates/proof/contracts/src/aggregate_verifier.rs +++ b/crates/proof/contracts/src/aggregate_verifier.rs @@ -11,7 +11,6 @@ use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_provider::RootProvider; use alloy_sol_types::{SolCall, SolError, sol}; use async_trait::async_trait; -use tracing::warn; use crate::{ ContractError, @@ -22,12 +21,12 @@ use crate::{ /// The first `AggregateVerifier` version that exposes `intervalsForStartingBlock`. /// /// Compared as `(major, minor)`; the patch level is not part of the ABI contract. -const FORK_AWARE_INTERVALS_VERSION: (u64, u64) = (0, 2); +const FORK_AWARE_INTERVALS_VERSION: (u64, u64) = (0, 3); /// Returns whether an `AggregateVerifier` reporting `version` speaks the fork-aware /// interval ABI. /// -/// The two ABIs are disjoint, not additive: 0.2.0 added `intervalsForStartingBlock` and +/// The two ABIs are disjoint, not additive: 0.3.0 added `intervalsForStartingBlock` and /// *removed* `BLOCK_INTERVAL()` / `INTERMEDIATE_BLOCK_INTERVAL()`, which it split into /// `SLOW_*` and `FAST_*` pairs. Exactly one of the two call shapes is valid for any given /// address, and the version is what decides which. @@ -67,7 +66,7 @@ fn supports_fork_aware_intervals(version: &str) -> bool { /// (the anchor's successor, the proposer's next proposal). For an existing game, call /// `read_intervals_for_starting_block` on its proxy instead so the pair it was created /// with is used even after an implementation upgrade; proxies older than -/// `AggregateVerifier` 0.2.0 fall back to their fixed interval getters. +/// `AggregateVerifier` 0.3.0 fall back to their fixed interval getters. pub async fn resolve_intervals( factory_client: &dyn DisputeGameFactoryClient, verifier_client: &dyn AggregateVerifierClient, @@ -125,7 +124,7 @@ sol! { function version() external pure returns (string memory); /// Returns the block interval between proposals (immutable on the implementation). - /// Removed in `AggregateVerifier` 0.2.0 in favour of `intervalsForStartingBlock`. + /// Removed in `AggregateVerifier` 0.3.0 in favour of `intervalsForStartingBlock`. function BLOCK_INTERVAL() external view returns (uint256); /// Returns the intermediate block interval for intermediate output root checkpoints. @@ -329,7 +328,7 @@ pub trait AggregateVerifierClient: Send + Sync { /// clone, not an upgradeable proxy: it delegates to the implementation baked into /// its bytecode at creation, so reading through it returns the pair the game was /// created with regardless of any later `setImplementation`. Verifiers older than - /// 0.2.0 fall back to `BLOCK_INTERVAL()` / `INTERMEDIATE_BLOCK_INTERVAL()`; the two + /// 0.3.0 fall back to `BLOCK_INTERVAL()` / `INTERMEDIATE_BLOCK_INTERVAL()`; the two /// ABIs are disjoint, so which one applies is decided by reading `version()`, not by /// treating an empty revert as a missing selector. async fn read_intervals_for_starting_block( @@ -578,50 +577,23 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { ) -> Result<(u64, u64), ContractError> { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(verifier_address, &self.provider); - let result = match contract_call!( + if !self.is_fork_aware(verifier_address).await? { + let (block_interval, intermediate_block_interval) = futures::try_join!( + self.read_block_interval(verifier_address), + self.read_intermediate_block_interval(verifier_address), + )?; + if !block_interval.is_multiple_of(intermediate_block_interval) { + return Err(ContractError::validation(format!( + "BLOCK_INTERVAL ({block_interval}) is not divisible by INTERMEDIATE_BLOCK_INTERVAL ({intermediate_block_interval})" + ))); + } + return Ok((block_interval, intermediate_block_interval)); + } + + let result = contract_call!( contract.intervalsForStartingBlock(U256::from(starting_block)).call(), "intervalsForStartingBlock failed" - ) { - Ok(result) => result, - Err(error) if error.is_missing_method() => { - // Empty data and a missing selector look identical here, so confirm with - // `version()` before using the pre-0.2.0 getters, which 0.2.0 removed. - match self.is_fork_aware(verifier_address).await { - Ok(true) => { - return Err(ContractError::validation(format!( - "intervalsForStartingBlock is unavailable on {verifier_address}, but \ - it reports version {}.{}.x or later, which must expose it: {error}", - FORK_AWARE_INTERVALS_VERSION.0, FORK_AWARE_INTERVALS_VERSION.1 - ))); - } - Ok(false) => {} - Err(version_error) => { - // Undetermined ABI. Return the original error rather than a new - // one: callers key on `is_missing_method()` to treat a non-verifier - // address as simply having no intervals, and wrapping would break - // that. Log the second failure so it is not lost. - warn!( - %verifier_address, - %version_error, - "version() failed while disambiguating intervalsForStartingBlock" - ); - return Err(error); - } - } - - let (block_interval, intermediate_block_interval) = futures::try_join!( - self.read_block_interval(verifier_address), - self.read_intermediate_block_interval(verifier_address), - )?; - if !block_interval.is_multiple_of(intermediate_block_interval) { - return Err(ContractError::validation(format!( - "BLOCK_INTERVAL ({block_interval}) is not divisible by INTERMEDIATE_BLOCK_INTERVAL ({intermediate_block_interval})" - ))); - } - return Ok((block_interval, intermediate_block_interval)); - } - Err(error) => return Err(error), - }; + )?; let block_interval: u64 = result ._0 @@ -887,19 +859,19 @@ mod tests { use super::*; #[test] - fn test_supports_fork_aware_intervals_gates_on_0_2_0() { + fn test_supports_fork_aware_intervals_gates_on_0_3_0() { assert!(!supports_fork_aware_intervals("0.1.0")); assert!(!supports_fork_aware_intervals("0.1.99")); - // 0.2.0 is the ABI break (contracts#431): only intervalsForStartingBlock exists. - // 0.3.0 (contracts#438) was a release semver bump with no ABI change. - assert!(supports_fork_aware_intervals("0.2.0")); - assert!(supports_fork_aware_intervals("0.2.99")); + assert!(!supports_fork_aware_intervals("0.2.0")); + assert!(!supports_fork_aware_intervals("0.2.99")); + + // 0.3.0 is the ABI break (contracts#431 and contracts#438). assert!(supports_fork_aware_intervals("0.3.0")); assert!(supports_fork_aware_intervals("0.10.0")); assert!(supports_fork_aware_intervals("1.0.0")); - assert!(supports_fork_aware_intervals("0.2.0-beta.1")); + assert!(!supports_fork_aware_intervals("0.2.0-beta.1")); assert!(!supports_fork_aware_intervals("0.1.0-rc.1")); assert!(supports_fork_aware_intervals("0.3.0+deadbeef")); From eda899b42102e981b7726f2d0e7df4a61ce6beeb Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 17 Sep 2026 13:57:36 +0000 Subject: [PATCH 026/299] feat(precompiles): add V3 inverted policy IDs (#5070) * feat(precompiles): add inverted policy IDs to V3 Co-authored-by: Codex * test(precompiles): make invertedPolicyId golden assertions self-documenting Name revert vs success outputs and spell the contract in assertion messages so failures show whether V1/V2 rejected the selector or V3 returned the inverted bit. Co-authored-by: Cursor * refactor(precompiles): address invertedPolicyId review nits - extract is_policy_inverted named boolean in is_authorized - drop self-explanatory fail-closed comment Co-Authored-By: Claude * style(precompiles): rustfmt invertedPolicyId golden calldata assignments Co-Authored-By: Claude * refactor(precompiles): rename trait inverted_policy_id to compute_inverted_policy_id Disambiguate the version-gated trait method from the inherent pure helper of the same name on PolicyRegistryV3, which shared a name and signature and created an ambiguous Self:: vs self. call-site footgun. The inherent const fn keeps the domain name (matching the ABI selector and the peer base_policy_id helper); the trait method dispatch invokes is now compute_inverted_policy_id. Co-Authored-By: Claude * Update crates/common/precompiles/src/policy/abi/v3.rs Co-authored-by: Stephan Cilliers --------- Co-authored-by: Codex Co-authored-by: Cursor Co-authored-by: Claude Co-authored-by: Stephan Cilliers --- crates/common/precompiles/src/lib.rs | 7 +- .../common/precompiles/src/policy/abi/mod.rs | 39 +++- .../common/precompiles/src/policy/abi/v3.rs | 141 +++++++++++++++ .../common/precompiles/src/policy/dispatch.rs | 8 +- .../precompiles/src/policy/logic/interface.rs | 5 + .../common/precompiles/src/policy/logic/v3.rs | 167 +++++++++++++++++- crates/common/precompiles/src/policy/mod.rs | 2 +- .../common/precompiles/src/policy/versions.rs | 37 ++-- .../precompiles/tests/b20_policy_v1_golden.rs | 121 ++++++++----- .../precompiles/tests/b20_policy_v2_golden.rs | 21 +++ .../precompiles/tests/b20_policy_v3_golden.rs | 49 +++-- 11 files changed, 509 insertions(+), 88 deletions(-) create mode 100644 crates/common/precompiles/src/policy/abi/v3.rs diff --git a/crates/common/precompiles/src/lib.rs b/crates/common/precompiles/src/lib.rs index 109ab78448..b94cd7021e 100644 --- a/crates/common/precompiles/src/lib.rs +++ b/crates/common/precompiles/src/lib.rs @@ -81,9 +81,10 @@ pub use b20_factory::{ mod policy; pub use policy::{ - IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, PackedPolicy, PolicyAbi, - PolicyAccounting, PolicyRegistryLogic, PolicyRegistryPrecompile, PolicyRegistryStorage, - PolicyRegistryV1, PolicyRegistryV2, PolicyRegistryV3, PolicyVersion, PolicyVersions, + IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, IPolicyRegistryV3, PackedPolicy, + PolicyAbi, PolicyAccounting, PolicyRegistryLogic, PolicyRegistryPrecompile, + PolicyRegistryStorage, PolicyRegistryV1, PolicyRegistryV2, PolicyRegistryV3, PolicyVersion, + PolicyVersions, }; mod tx_context; diff --git a/crates/common/precompiles/src/policy/abi/mod.rs b/crates/common/precompiles/src/policy/abi/mod.rs index d11c72d98d..a6a54ad419 100644 --- a/crates/common/precompiles/src/policy/abi/mod.rs +++ b/crates/common/precompiles/src/policy/abi/mod.rs @@ -9,7 +9,10 @@ mod v1; pub use v1::IPolicyRegistry as IPolicyRegistryV1; mod v2; -pub use v2::{IPolicyRegistry, IPolicyRegistry as IPolicyRegistryV2}; +pub use v2::IPolicyRegistry as IPolicyRegistryV2; + +mod v3; +pub use v3::{IPolicyRegistry, IPolicyRegistry as IPolicyRegistryV3}; impl IPolicyRegistry::IPolicyRegistryCalls { /// Returns the stable metric label for this decoded policy-registry call. @@ -31,6 +34,7 @@ impl IPolicyRegistry::IPolicyRegistryCalls { Self::policyAdmin(_) => "policy.policyAdmin", Self::pendingPolicyAdmin(_) => "policy.pendingPolicyAdmin", Self::compositePolicyChildIds(_) => "policy.compositePolicyChildIds", + Self::invertedPolicyId(_) => "policy.invertedPolicyId", } } } @@ -47,7 +51,7 @@ mod tests { use alloy_primitives::{Address, B256, b256}; use alloy_sol_types::{SolEnum, SolError, SolEvent, SolInterface}; - use super::{IPolicyRegistry, IPolicyRegistryV1}; + use super::{IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, IPolicyRegistryV3}; use crate::AbiFingerprint; /// Absolute wire fingerprint for Beryl's surface. Catches both-sides drift that relative @@ -55,11 +59,15 @@ mod tests { const V1_ABI_FINGERPRINT: B256 = b256!("1ae189209c8c4875de2caa707322ea74f0d1f3e74a1104ecee6884e8984415da"); - /// Absolute wire fingerprint for Cobalt's (canonical) surface. + /// Absolute wire fingerprint for Cobalt's surface. const V2_ABI_FINGERPRINT: B256 = b256!("da3137a81688286fb3af7f0f09a6369ae7c1197c08844a29dbde13f8c036394d"); - /// These two surfaces pass no enum ordinals to [`AbiFingerprint`], so the pinned constants + /// Absolute wire fingerprint for Denim's (canonical) surface. + const V3_ABI_FINGERPRINT: B256 = + b256!("bb97c5e5d9be7ba918aa74de1d7fa405fbc8fd7f77084f1d40217920ee17690e"); + + /// These three surfaces pass no enum ordinals to [`AbiFingerprint`], so the pinned constants /// above keep the values they were blessed with. `PolicyType` ordinals *are* load-bearing — /// the discriminant rides the top byte of every policy ID via `PolicyRegistryV1::make_id` — /// and `shared_policy_type_discriminants_agree_across_surfaces` below only catches a reorder @@ -77,10 +85,20 @@ mod tests { fn v2_abi_fingerprint() -> B256 { AbiFingerprint::compute( - IPolicyRegistry::IPolicyRegistryCalls::selectors(), - IPolicyRegistry::IPolicyRegistryEvents::SELECTORS.iter().copied().map(B256::new), - IPolicyRegistry::IPolicyRegistryErrors::selectors(), - IPolicyRegistry::PolicyType::COUNT, + IPolicyRegistryV2::IPolicyRegistryCalls::selectors(), + IPolicyRegistryV2::IPolicyRegistryEvents::SELECTORS.iter().copied().map(B256::new), + IPolicyRegistryV2::IPolicyRegistryErrors::selectors(), + IPolicyRegistryV2::PolicyType::COUNT, + [], + ) + } + + fn v3_abi_fingerprint() -> B256 { + AbiFingerprint::compute( + IPolicyRegistryV3::IPolicyRegistryCalls::selectors(), + IPolicyRegistryV3::IPolicyRegistryEvents::SELECTORS.iter().copied().map(B256::new), + IPolicyRegistryV3::IPolicyRegistryErrors::selectors(), + IPolicyRegistryV3::PolicyType::COUNT, [], ) } @@ -95,6 +113,11 @@ mod tests { assert_eq!(v2_abi_fingerprint(), V2_ABI_FINGERPRINT); } + #[test] + fn v3_abi_fingerprint_is_pinned() { + assert_eq!(v3_abi_fingerprint(), V3_ABI_FINGERPRINT); + } + #[test] fn every_policy_type_discriminant_decodes() { for discriminant in 0..IPolicyRegistry::PolicyType::COUNT { diff --git a/crates/common/precompiles/src/policy/abi/v3.rs b/crates/common/precompiles/src/policy/abi/v3.rs new file mode 100644 index 0000000000..2e7e24bd8b --- /dev/null +++ b/crates/common/precompiles/src/policy/abi/v3.rs @@ -0,0 +1,141 @@ +//! The `PolicyRegistry` wire surface frozen at Denim, which added inverted policy IDs. Also the +//! canonical live surface, re-exported unqualified by [`super`]. +//! A new wire surface goes in a new `abi/vN.rs`; see [`super`]. + +use alloy_sol_types::sol; + +sol! { + #[derive(Debug, PartialEq, Eq)] + interface IPolicyRegistry { + enum PolicyType { + /// Rejects only accounts explicitly added to the blocklist. + /// An empty blocklist authorizes everyone. + BLOCKLIST, + /// Authorizes only accounts explicitly added to the allowlist. + /// An empty allowlist rejects everyone. + ALLOWLIST, + /// Introduced in V2 (Cobalt). Composite gate: authorized if any child policy authorizes. + UNION, + /// Introduced in V2 (Cobalt). Composite gate: authorized only if every child policy authorizes. + INTERSECT + } + + /// ETH was attached to a call targeting a nonpayable policy registry selector. + error NonPayable(); + + error Unauthorized(); + error PolicyNotFound(); + error IncompatiblePolicyType(); + error ZeroAddress(); + error BatchSizeTooLarge(uint256 maxBatchSize); + error NoPendingAdmin(); + + /// Introduced in V2 (Cobalt). A composite policy was created or updated with a child-policy + /// count outside the permitted range. A composite must reference between + /// `MIN_COMPOSITE_CHILD_POLICIES` and `MAX_COMPOSITE_CHILD_POLICIES` simple policies, inclusive. + error ChildPoliciesOutsideOfRange(); + /// Introduced in V2 (Cobalt). A composite child must be an existing ALLOWLIST or BLOCKLIST + /// policy — never a built-in sentinel or another composite. + error InvalidChildPolicy(uint64 childPolicyId); + + event PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType); + event PolicyAdminStaged(uint64 indexed policyId, address indexed currentAdmin, address indexed pendingAdmin); + event PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin); + event AllowlistUpdated(uint64 indexed policyId, address indexed updater, bool allowed, address[] accounts); + event BlocklistUpdated(uint64 indexed policyId, address indexed updater, bool blocked, address[] accounts); + /// Introduced in V2 (Cobalt). A composite policy's child set was set or replaced in full. + /// Emitted on composite creation and on every subsequent update; carries the complete + /// post-update set. + event CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds); + + function createPolicy(address admin, PolicyType policyType) external returns (uint64); + function createPolicyWithAccounts(address admin, PolicyType policyType, address[] calldata accounts) external returns (uint64); + /// Introduced in V2 (Cobalt). Creates a composite policy combining existing simple policies + /// under a UNION or INTERSECT gate. Children must be simple policies; the child count must + /// be in `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]`. + function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds) external returns (uint64); + /// Introduced in V2 (Cobalt). Replaces a composite policy's child set in full, re-validated + /// exactly as at creation. The gate is fixed in the ID and cannot change. + function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) external; + /// Pass address(0) as newAdmin to clear a previously staged transfer without nominating a replacement. + function stageUpdateAdmin(uint64 policyId, address newAdmin) external; + function finalizeUpdateAdmin(uint64 policyId) external; + function renounceAdmin(uint64 policyId) external; + function updateAllowlist(uint64 policyId, bool allowed, address[] calldata accounts) external; + function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; + function isAuthorized(uint64 policyId, address account) external view returns (bool); + /// Introduced in V2 (Cobalt). Minimum number of child policies a composite must + /// reference, inclusive. Never reverts. + function MIN_COMPOSITE_CHILD_POLICIES() external view returns (uint256); + /// Introduced in V2 (Cobalt). Maximum number of child policies a composite may + /// reference, inclusive. Never reverts. + function MAX_COMPOSITE_CHILD_POLICIES() external view returns (uint256); + function policyExists(uint64 policyId) external view returns (bool); + function policyAdmin(uint64 policyId) external view returns (address); + function pendingPolicyAdmin(uint64 policyId) external view returns (address); + /// Introduced in V2 (Cobalt). Read function for composite policy child IDs. + function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory); + /// Introduced in V3 (Denim). Flips bit 63 of a policy ID without reading state. + function invertedPolicyId(uint64 policyId) external pure returns (uint64); + } +} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use alloy_sol_types::{SolCall, SolEnum, SolInterface}; + + use super::IPolicyRegistry; + + /// See [`super`] — the interface name reaches consensus data via `AbiDecodeFailed`. + #[test] + fn interface_name_is_frozen() { + assert_eq!(IPolicyRegistry::IPolicyRegistryCalls::NAME, "IPolicyRegistryCalls"); + } + + #[test] + fn policy_type_carries_the_composite_gates() { + assert_eq!(IPolicyRegistry::PolicyType::COUNT, 4); + assert_eq!( + IPolicyRegistry::PolicyType::try_from(2u8), + Ok(IPolicyRegistry::PolicyType::UNION) + ); + assert_eq!( + IPolicyRegistry::PolicyType::try_from(3u8), + Ok(IPolicyRegistry::PolicyType::INTERSECT) + ); + } + + /// The exact selector set dialable at Denim. + #[test] + fn selector_set_is_frozen() { + let mut selectors: Vec<[u8; 4]> = + IPolicyRegistry::IPolicyRegistryCalls::selectors().collect(); + selectors.sort_unstable(); + + let mut expected: Vec<[u8; 4]> = alloc::vec![ + IPolicyRegistry::createPolicyCall::SELECTOR, + IPolicyRegistry::createPolicyWithAccountsCall::SELECTOR, + IPolicyRegistry::createCompositePolicyCall::SELECTOR, + IPolicyRegistry::updateCompositeCall::SELECTOR, + IPolicyRegistry::stageUpdateAdminCall::SELECTOR, + IPolicyRegistry::finalizeUpdateAdminCall::SELECTOR, + IPolicyRegistry::renounceAdminCall::SELECTOR, + IPolicyRegistry::updateAllowlistCall::SELECTOR, + IPolicyRegistry::updateBlocklistCall::SELECTOR, + IPolicyRegistry::isAuthorizedCall::SELECTOR, + IPolicyRegistry::MIN_COMPOSITE_CHILD_POLICIESCall::SELECTOR, + IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall::SELECTOR, + IPolicyRegistry::policyExistsCall::SELECTOR, + IPolicyRegistry::policyAdminCall::SELECTOR, + IPolicyRegistry::pendingPolicyAdminCall::SELECTOR, + IPolicyRegistry::compositePolicyChildIdsCall::SELECTOR, + IPolicyRegistry::invertedPolicyIdCall::SELECTOR, + ]; + expected.sort_unstable(); + + assert_eq!(selectors.len(), 17); + assert_eq!(selectors, expected); + } +} diff --git a/crates/common/precompiles/src/policy/dispatch.rs b/crates/common/precompiles/src/policy/dispatch.rs index ebc49bb315..fec4cad571 100644 --- a/crates/common/precompiles/src/policy/dispatch.rs +++ b/crates/common/precompiles/src/policy/dispatch.rs @@ -68,7 +68,8 @@ impl PolicyRegistryStorage<'_> { || sel == IPolicyRegistry::policyExistsCall::SELECTOR || sel == IPolicyRegistry::policyAdminCall::SELECTOR || sel == IPolicyRegistry::pendingPolicyAdminCall::SELECTOR - || sel == IPolicyRegistry::compositePolicyChildIdsCall::SELECTOR) => + || sel == IPolicyRegistry::compositePolicyChildIdsCall::SELECTOR + || sel == IPolicyRegistry::invertedPolicyIdCall::SELECTOR) => { self.route(calldata, version, &observer) } @@ -197,6 +198,11 @@ impl PolicyRegistryStorage<'_> { Ok(IPolicyRegistry::compositePolicyChildIdsCall::abi_encode_returns(&children) .into()) } + // Introduced in V3 (Denim). + C::invertedPolicyId(call) => { + let policy_id = logic.compute_inverted_policy_id(call.policyId)?; + Ok(IPolicyRegistry::invertedPolicyIdCall::abi_encode_returns(&policy_id).into()) + } } } } diff --git a/crates/common/precompiles/src/policy/logic/interface.rs b/crates/common/precompiles/src/policy/logic/interface.rs index 8ec4caf5f7..0eabfa429d 100644 --- a/crates/common/precompiles/src/policy/logic/interface.rs +++ b/crates/common/precompiles/src/policy/logic/interface.rs @@ -97,4 +97,9 @@ pub trait PolicyRegistryLogic { fn composite_policy_child_ids(&self, _storage: &S, _policy_id: u64) -> Result> { reject_frozen_selector!() } + + /// (V3) Toggles the invert flag on `policy_id` without reading state. + fn compute_inverted_policy_id(&self, _policy_id: u64) -> Result { + reject_frozen_selector!() + } } diff --git a/crates/common/precompiles/src/policy/logic/v3.rs b/crates/common/precompiles/src/policy/logic/v3.rs index abf300bd81..49aa8598eb 100644 --- a/crates/common/precompiles/src/policy/logic/v3.rs +++ b/crates/common/precompiles/src/policy/logic/v3.rs @@ -11,7 +11,7 @@ use crate::{ PolicyRegistryLogic, }; -/// Third `PolicyRegistry` implementation. Activated at Denim, behavior-identical to V2 (scaffold seam for future changes). +/// Third `PolicyRegistry` implementation. Activated at Denim, adding inverted policy IDs. #[derive(Debug, Default, Clone, Copy)] pub struct PolicyRegistryV3; @@ -34,6 +34,10 @@ impl PolicyRegistryV3 { /// Mask covering the low 56 bits of a policy ID (the counter space). pub const COUNTER_MASK: u64 = (1u64 << 56) - 1; + /// Bit 63 of a policy ID. When set, authorization evaluates the base policy and negates + /// the result without creating or storing a second policy record. + pub const INVERTED_POLICY_BIT: u64 = 1u64 << 63; + /// Maximum number of accounts per membership batch (`createPolicyWithAccounts`, /// `updateAllowlist`, `updateBlocklist`). pub const MAX_ACCOUNTS_PER_BATCH: usize = 64; @@ -61,6 +65,11 @@ impl PolicyRegistryV3 { (policy_type as u64) << Self::POLICY_ID_TYPE_SHIFT | (counter & Self::COUNTER_MASK) } + /// Returns `policy_id` with its invert flag toggled. + pub const fn inverted_policy_id(policy_id: u64) -> u64 { + policy_id ^ Self::INVERTED_POLICY_BIT + } + /// Reads a custom (non-built-in) policy word, reverting `PolicyNotFound` if absent. fn require_existing_policy( &self, @@ -116,6 +125,11 @@ impl PolicyRegistryV3 { policy_id == Self::ALWAYS_ALLOW_ID || policy_id == Self::ALWAYS_BLOCK_ID } + /// Removes the invert flag so a read can resolve the stored base policy. + const fn base_policy_id(policy_id: u64) -> u64 { + policy_id & !Self::INVERTED_POLICY_BIT + } + /// Reverts `ChildPoliciesOutsideOfRange` when the child count is outside `[2, 4]`. fn require_child_policy_in_range(child_policy_ids: &[u64]) -> Result<()> { let count = child_policy_ids.len(); @@ -136,11 +150,12 @@ impl PolicyRegistryV3 { child_policy_ids: &[u64], ) -> Result<()> { for &child in child_policy_ids { - // Reverts PolicyNotFound when the child does not exist. - self.require_existing_policy(storage, child)?; + // Reverts PolicyNotFound when the child's base policy does not exist. + self.require_existing_policy(storage, Self::base_policy_id(child))?; } for &child in child_policy_ids { - if Self::is_builtin(child) || Self::is_composite(child) { + let base = Self::base_policy_id(child); + if Self::is_builtin(base) || Self::is_composite(base) { return Err(BasePrecompileError::revert(IPolicyRegistry::InvalidChildPolicy { childPolicyId: child, })); @@ -516,12 +531,21 @@ impl PolicyRegistryLogic for PolicyRegistryV3 { fn is_authorized(&self, storage: &S, policy_id: u64, account: Address) -> Result { // Built-in short-circuits precede any storage read: ALWAYS_ALLOW_ID = 0 is the EVM // default for any uninitialized policy field, so this must work before init has run. + // Inverted built-ins have a different ID and therefore fall through to the invert branch. if policy_id == Self::ALWAYS_ALLOW_ID { return Ok(true); } if policy_id == Self::ALWAYS_BLOCK_ID { return Ok(false); } + let is_policy_inverted = policy_id & Self::INVERTED_POLICY_BIT != 0; + if is_policy_inverted { + let base = Self::base_policy_id(policy_id); + if !self.policy_exists(storage, base)? { + return Ok(false); + } + return self.is_authorized(storage, base, account).map(|authorized| !authorized); + } // Malformed IDs (type byte > INTERSECT) are treated as unauthorized rather than reverting. if !Self::is_well_formed(policy_id) { return Ok(false); @@ -536,6 +560,7 @@ impl PolicyRegistryLogic for PolicyRegistryV3 { } fn policy_exists(&self, storage: &S, policy_id: u64) -> Result { + let policy_id = Self::base_policy_id(policy_id); if policy_id == Self::ALWAYS_ALLOW_ID || policy_id == Self::ALWAYS_BLOCK_ID { return Ok(true); } @@ -547,6 +572,7 @@ impl PolicyRegistryLogic for PolicyRegistryV3 { } fn get_policy_admin(&self, storage: &S, policy_id: u64) -> Result
{ + let policy_id = Self::base_policy_id(policy_id); if !Self::is_well_formed(policy_id) { return Ok(Address::ZERO); } @@ -558,6 +584,7 @@ impl PolicyRegistryLogic for PolicyRegistryV3 { } fn pending_policy_admin(&self, storage: &S, policy_id: u64) -> Result
{ + let policy_id = Self::base_policy_id(policy_id); if !Self::is_well_formed(policy_id) { return Ok(Address::ZERO); } @@ -568,6 +595,7 @@ impl PolicyRegistryLogic for PolicyRegistryV3 { } fn composite_policy_child_ids(&self, storage: &S, policy_id: u64) -> Result> { + let policy_id = Self::base_policy_id(policy_id); if !Self::is_well_formed(policy_id) { return Ok(Vec::new()); } @@ -576,6 +604,10 @@ impl PolicyRegistryLogic for PolicyRegistryV3 { } storage.read_children(policy_id) } + + fn compute_inverted_policy_id(&self, policy_id: u64) -> Result { + Ok(Self::inverted_policy_id(policy_id)) + } } #[cfg(test)] @@ -1598,4 +1630,131 @@ mod tests { }) ); } + + // --- inverted policy IDs (V3 / Denim) --- + + #[test] + fn inverted_policy_id_toggles_bit_and_round_trips() { + let policy_id = PolicyRegistryV3::make_id(PolicyType::ALLOWLIST as u8, 42); + let inverted = PolicyRegistryV3::inverted_policy_id(policy_id); + assert_eq!(inverted, policy_id ^ PolicyRegistryV3::INVERTED_POLICY_BIT); + assert_eq!(PolicyRegistryV3::inverted_policy_id(inverted), policy_id); + } + + #[test] + fn inverted_unknown_policy_fails_closed() { + let rt = initialized(); + let unknown_blocklist = PolicyRegistryV3::make_id(PolicyType::BLOCKLIST as u8, 999); + assert!(is_authorized(&rt, unknown_blocklist, ALICE)); + assert!(!is_authorized( + &rt, + unknown_blocklist | PolicyRegistryV3::INVERTED_POLICY_BIT, + ALICE, + )); + } + + #[test] + fn inverted_policies_negate_simple_composite_and_builtin_authorization() { + let mut rt = initialized(); + let allowlist = create_allowlist(&mut rt); + LOGIC.update_allowlist(&mut rt, allowlist, true, vec![ALICE]).unwrap(); + let inverted_allowlist = allowlist | PolicyRegistryV3::INVERTED_POLICY_BIT; + assert!(!is_authorized(&rt, inverted_allowlist, ALICE)); + assert!(is_authorized(&rt, inverted_allowlist, BOB)); + + let other = create_allowlist(&mut rt); + let composite = create_union(&mut rt, vec![allowlist, other]); + assert!(is_authorized(&rt, composite, ALICE)); + assert!(!is_authorized(&rt, composite | PolicyRegistryV3::INVERTED_POLICY_BIT, ALICE,)); + assert!(!is_authorized(&rt, composite, BOB)); + assert!(is_authorized(&rt, composite | PolicyRegistryV3::INVERTED_POLICY_BIT, BOB,)); + + assert!(!is_authorized(&rt, PolicyRegistryV3::INVERTED_POLICY_BIT, ALICE)); + assert!(is_authorized( + &rt, + PolicyRegistryV3::ALWAYS_BLOCK_ID | PolicyRegistryV3::INVERTED_POLICY_BIT, + ALICE, + )); + } + + #[test] + fn inverted_simple_children_are_valid_and_remain_verbatim() { + let mut rt = initialized(); + let allowlist = create_allowlist(&mut rt); + let exclusion = create_allowlist(&mut rt); + let inverted_exclusion = exclusion | PolicyRegistryV3::INVERTED_POLICY_BIT; + LOGIC.update_allowlist(&mut rt, allowlist, true, vec![ALICE]).unwrap(); + let composite = create_intersect(&mut rt, vec![allowlist, inverted_exclusion]); + + assert_eq!( + LOGIC.composite_policy_child_ids(&rt, composite).unwrap(), + vec![allowlist, inverted_exclusion], + ); + assert_eq!( + LOGIC + .composite_policy_child_ids(&rt, composite | PolicyRegistryV3::INVERTED_POLICY_BIT,) + .unwrap(), + vec![allowlist, inverted_exclusion], + ); + assert!(is_authorized(&rt, composite, ALICE)); + + LOGIC.update_allowlist(&mut rt, exclusion, true, vec![ALICE]).unwrap(); + assert!(!is_authorized(&rt, composite, ALICE)); + } + + #[test] + fn update_composite_accepts_an_inverted_simple_child() { + let mut rt = initialized(); + let first = create_allowlist(&mut rt); + let second = create_allowlist(&mut rt); + let replacement = create_allowlist(&mut rt); + let composite = create_union(&mut rt, vec![first, second]); + let inverted_replacement = replacement | PolicyRegistryV3::INVERTED_POLICY_BIT; + + LOGIC.update_composite(&mut rt, composite, vec![first, inverted_replacement]).unwrap(); + assert_eq!( + LOGIC.composite_policy_child_ids(&rt, composite).unwrap(), + vec![first, inverted_replacement], + ); + } + + #[test] + fn inverted_composite_child_is_rejected_with_original_id() { + let mut rt = initialized(); + let first = create_allowlist(&mut rt); + let second = create_allowlist(&mut rt); + let composite = create_union(&mut rt, vec![first, second]); + let inverted_composite = composite | PolicyRegistryV3::INVERTED_POLICY_BIT; + let third = create_allowlist(&mut rt); + + let err = LOGIC + .create_composite_policy( + &mut rt, + ADMIN, + PolicyType::INTERSECT, + vec![third, inverted_composite], + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::InvalidChildPolicy { + childPolicyId: inverted_composite, + }) + ); + } + + #[test] + fn inverted_getters_resolve_the_base_policy() { + let mut rt = initialized(); + let policy_id = create_allowlist(&mut rt); + let inverted = policy_id | PolicyRegistryV3::INVERTED_POLICY_BIT; + LOGIC.stage_update_admin(&mut rt, policy_id, NEW_ADMIN).unwrap(); + + assert_eq!( + LOGIC.policy_exists(&rt, inverted).unwrap(), + LOGIC.policy_exists(&rt, policy_id).unwrap() + ); + assert_eq!(LOGIC.get_policy_admin(&rt, inverted).unwrap(), ADMIN); + assert_eq!(LOGIC.pending_policy_admin(&rt, inverted).unwrap(), NEW_ADMIN); + } } diff --git a/crates/common/precompiles/src/policy/mod.rs b/crates/common/precompiles/src/policy/mod.rs index 881edc8db2..a079930aed 100644 --- a/crates/common/precompiles/src/policy/mod.rs +++ b/crates/common/precompiles/src/policy/mod.rs @@ -1,7 +1,7 @@ //! `PolicyRegistry` native precompile — global singleton transfer-policy registry for B-20 tokens. mod abi; -pub use abi::{IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2}; +pub use abi::{IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, IPolicyRegistryV3}; mod accounting; pub use accounting::PolicyAccounting; diff --git a/crates/common/precompiles/src/policy/versions.rs b/crates/common/precompiles/src/policy/versions.rs index 67568700e2..6f0697cc8f 100644 --- a/crates/common/precompiles/src/policy/versions.rs +++ b/crates/common/precompiles/src/policy/versions.rs @@ -14,8 +14,8 @@ use base_common_genesis::BaseUpgrade; use base_precompile_storage::{BasePrecompileError, Result}; use crate::{ - IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, PolicyAccounting, PolicyRegistryLogic, - PolicyRegistryV1, PolicyRegistryV2, PolicyRegistryV3, + IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, IPolicyRegistryV3, PolicyAccounting, + PolicyRegistryLogic, PolicyRegistryV1, PolicyRegistryV2, PolicyRegistryV3, }; /// An activated version of the `PolicyRegistry` precompile logic. @@ -50,8 +50,8 @@ impl PolicyVersion { pub const fn abi(self) -> PolicyAbi { match self { Self::V1 => PolicyAbi::V1, - // V3 reuses the Cobalt wire surface until a later Denim PR adds selectors. - Self::V2 | Self::V3 => PolicyAbi::V2, + Self::V2 => PolicyAbi::V2, + Self::V3 => PolicyAbi::V3, } } } @@ -63,6 +63,8 @@ pub enum PolicyAbi { V1, /// Wire surface activated at Cobalt. V2, + /// Wire surface activated at Denim. + V3, } impl PolicyAbi { @@ -71,6 +73,7 @@ impl PolicyAbi { match self { Self::V1 => IPolicyRegistryV1::IPolicyRegistryCalls::valid_selector(selector), Self::V2 => IPolicyRegistryV2::IPolicyRegistryCalls::valid_selector(selector), + Self::V3 => IPolicyRegistryV3::IPolicyRegistryCalls::valid_selector(selector), } } @@ -84,6 +87,9 @@ impl PolicyAbi { Self::V2 => { IPolicyRegistryV2::IPolicyRegistryCalls::abi_decode_validate(calldata).map(|_| ()) } + Self::V3 => { + IPolicyRegistryV3::IPolicyRegistryCalls::abi_decode_validate(calldata).map(|_| ()) + } } .map_err(|error| BasePrecompileError::AbiDecodeFailed { selector, @@ -105,7 +111,7 @@ impl PolicyAbi { } match self { Self::V1 => self.abi_decode_validate(calldata, selector)?, - Self::V2 => {} + Self::V2 | Self::V3 => {} } IPolicyRegistry::IPolicyRegistryCalls::abi_decode_validate(calldata).map_err(|error| { @@ -141,8 +147,8 @@ mod tests { use base_common_genesis::BaseUpgrade; use crate::{ - IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, PolicyAbi, PolicyVersion, - PolicyVersions, + IPolicyRegistry, IPolicyRegistryV1, IPolicyRegistryV2, IPolicyRegistryV3, PolicyAbi, + PolicyVersion, PolicyVersions, }; #[test] @@ -172,19 +178,19 @@ mod tests { fn each_fork_resolves_to_its_wire_surface() { assert_eq!(PolicyVersion::V1.abi(), PolicyAbi::V1); assert_eq!(PolicyVersion::V2.abi(), PolicyAbi::V2); - assert_eq!(PolicyVersion::V3.abi(), PolicyAbi::V2); + assert_eq!(PolicyVersion::V3.abi(), PolicyAbi::V3); let beryl = PolicyVersions::from_base_upgrade(BaseUpgrade::Beryl).unwrap(); let cobalt = PolicyVersions::from_base_upgrade(BaseUpgrade::Cobalt).unwrap(); let denim = PolicyVersions::from_base_upgrade(BaseUpgrade::Denim).unwrap(); assert_eq!(beryl.abi(), PolicyAbi::V1); assert_eq!(cobalt.abi(), PolicyAbi::V2); - assert_eq!(denim.abi(), PolicyAbi::V2); + assert_eq!(denim.abi(), PolicyAbi::V3); } /// The dispatcher re-decodes against the canonical surface after a frozen surface accepts, so - /// every frozen selector must exist on canonical. The difference is exactly the three - /// composite selectors Cobalt introduced. + /// every frozen selector must exist on the latest canonical surface. Cobalt adds the five + /// composite selectors to V1; Denim adds `invertedPolicyId` to V2. #[test] fn v1_selectors_are_a_subset_of_v2() { let v1: Vec<[u8; 4]> = IPolicyRegistryV1::IPolicyRegistryCalls::selectors().collect(); @@ -206,6 +212,14 @@ mod tests { assert!(added.contains(&IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall::SELECTOR)); } + #[test] + fn v3_adds_only_inverted_policy_id() { + let added: Vec<[u8; 4]> = IPolicyRegistryV3::IPolicyRegistryCalls::selectors() + .filter(|selector| !PolicyAbi::V2.valid_selector(*selector)) + .collect(); + assert_eq!(added, vec![IPolicyRegistry::invertedPolicyIdCall::SELECTOR]); + } + /// `abi_decode_validate` short-circuits on `len < MIN_DATA_LENGTH + 4` before looking at the /// selector. Before Cobalt's `MIN_COMPOSITE_CHILD_POLICIES`/`MAX_COMPOSITE_CHILD_POLICIES` /// getters (the first zero-argument calls on either surface), equal minimums across surfaces @@ -224,6 +238,7 @@ mod tests { fn surface_interface_names_are_frozen() { assert_eq!(IPolicyRegistryV1::IPolicyRegistryCalls::NAME, "IPolicyRegistryCalls"); assert_eq!(IPolicyRegistryV2::IPolicyRegistryCalls::NAME, "IPolicyRegistryCalls"); + assert_eq!(IPolicyRegistryV3::IPolicyRegistryCalls::NAME, "IPolicyRegistryCalls"); } /// The composite selectors were not dialable at Beryl, so the V1 surface must not know them. diff --git a/crates/common/precompiles/tests/b20_policy_v1_golden.rs b/crates/common/precompiles/tests/b20_policy_v1_golden.rs index c40ad42e8c..8ae8d5f57c 100644 --- a/crates/common/precompiles/tests/b20_policy_v1_golden.rs +++ b/crates/common/precompiles/tests/b20_policy_v1_golden.rs @@ -350,49 +350,74 @@ fn golden_create_composite_selector_unknown_in_v1() { // Composite policies are a V2 feature. The ABI is shared, but V1 predates these selectors, // so it must keep reverting with UnknownFunctionSelector (raw 4-byte selector) — the old // behavior — rather than routing them. - let mut s = fresh(); - let (rev, bytes) = call_policy( - &mut s, - ADMIN, - IPolicyRegistry::createCompositePolicyCall { - admin: ADMIN, - policyType: PolicyType::UNION, - childPolicyIds: vec![BLOCKLIST_ID, ALLOWLIST_ID], - } - .abi_encode(), + let mut storage = fresh(); + let calldata = IPolicyRegistry::createCompositePolicyCall { + admin: ADMIN, + policyType: PolicyType::UNION, + childPolicyIds: vec![BLOCKLIST_ID, ALLOWLIST_ID], + } + .abi_encode(); + + let (reverted, revert_data) = call_policy(&mut storage, ADMIN, calldata); + + assert!(reverted, "createCompositePolicy is a V2 selector and must revert on V1"); + assert_eq!( + revert_data, + Bytes::from(IPolicyRegistry::createCompositePolicyCall::SELECTOR.as_ref()), + "unknown-selector revert data is the 4-byte selector, not a typed error", ); - assert!(rev); - assert_eq!(bytes, Bytes::from(IPolicyRegistry::createCompositePolicyCall::SELECTOR.as_ref())); } #[test] fn golden_update_composite_selector_unknown_in_v1() { - let mut s = fresh(); - let (rev, bytes) = call_policy( - &mut s, - ADMIN, - IPolicyRegistry::updateCompositeCall { - policyId: BLOCKLIST_ID, - childPolicyIds: vec![BLOCKLIST_ID, ALLOWLIST_ID], - } - .abi_encode(), + let mut storage = fresh(); + let calldata = IPolicyRegistry::updateCompositeCall { + policyId: BLOCKLIST_ID, + childPolicyIds: vec![BLOCKLIST_ID, ALLOWLIST_ID], + } + .abi_encode(); + + let (reverted, revert_data) = call_policy(&mut storage, ADMIN, calldata); + + assert!(reverted, "updateComposite is a V2 selector and must revert on V1"); + assert_eq!( + revert_data, + Bytes::from(IPolicyRegistry::updateCompositeCall::SELECTOR.as_ref()), + "unknown-selector revert data is the 4-byte selector, not a typed error", ); - assert!(rev); - assert_eq!(bytes, Bytes::from(IPolicyRegistry::updateCompositeCall::SELECTOR.as_ref())); } #[test] fn golden_composite_child_ids_selector_unknown_in_v1() { // Views bypass the activation gate but NOT the wire gate: the selector is absent from the // frozen V1 surface, so Beryl must reject it as unknown rather than answering the read. - let mut s = fresh(); - let (rev, bytes) = call_policy( - &mut s, - ADMIN, - IPolicyRegistry::compositePolicyChildIdsCall { policyId: BLOCKLIST_ID }.abi_encode(), + let mut storage = fresh(); + let calldata = + IPolicyRegistry::compositePolicyChildIdsCall { policyId: BLOCKLIST_ID }.abi_encode(); + + let (reverted, revert_data) = call_policy(&mut storage, ADMIN, calldata); + + assert!(reverted, "compositePolicyChildIds is a V2 selector and must revert on V1"); + assert_eq!( + revert_data, + Bytes::from(IPolicyRegistry::compositePolicyChildIdsCall::SELECTOR.as_ref()), + "unknown-selector revert data is the 4-byte selector, not a typed error", + ); +} + +#[test] +fn golden_inverted_policy_id_selector_unknown_in_v1() { + let mut storage = fresh(); + let calldata = IPolicyRegistry::invertedPolicyIdCall { policyId: ALLOWLIST_ID }.abi_encode(); + + let (reverted, revert_data) = call_policy(&mut storage, ADMIN, calldata); + + assert!(reverted, "invertedPolicyId is a V3 selector and must revert on V1"); + assert_eq!( + revert_data, + Bytes::from(IPolicyRegistry::invertedPolicyIdCall::SELECTOR.as_ref()), + "unknown-selector revert data is the 4-byte selector, not a typed error", ); - assert!(rev); - assert_eq!(bytes, Bytes::from(IPolicyRegistry::compositePolicyChildIdsCall::SELECTOR.as_ref())); } #[test] @@ -400,27 +425,27 @@ fn golden_min_max_composite_child_policies_selector_unknown_in_v1() { // MIN_COMPOSITE_CHILD_POLICIES/MAX_COMPOSITE_CHILD_POLICIES are V2-only getters — composite // policies do not exist at Beryl, so their selectors must stay unknown, same as the other // composite selectors above. - let mut s = fresh(); - let (min_rev, min_bytes) = call_policy( - &mut s, - ADMIN, - IPolicyRegistry::MIN_COMPOSITE_CHILD_POLICIESCall {}.abi_encode(), - ); - assert!(min_rev); + let mut storage = fresh(); + let min_calldata = IPolicyRegistry::MIN_COMPOSITE_CHILD_POLICIESCall {}.abi_encode(); + + let (min_reverted, min_revert_data) = call_policy(&mut storage, ADMIN, min_calldata); + + assert!(min_reverted, "MIN_COMPOSITE_CHILD_POLICIES is a V2 selector and must revert on V1"); assert_eq!( - min_bytes, - Bytes::from(IPolicyRegistry::MIN_COMPOSITE_CHILD_POLICIESCall::SELECTOR.as_ref()) + min_revert_data, + Bytes::from(IPolicyRegistry::MIN_COMPOSITE_CHILD_POLICIESCall::SELECTOR.as_ref()), + "unknown-selector revert data is the 4-byte selector, not a typed error", ); - let (max_rev, max_bytes) = call_policy( - &mut s, - ADMIN, - IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall {}.abi_encode(), - ); - assert!(max_rev); + let max_calldata = IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall {}.abi_encode(); + + let (max_reverted, max_revert_data) = call_policy(&mut storage, ADMIN, max_calldata); + + assert!(max_reverted, "MAX_COMPOSITE_CHILD_POLICIES is a V2 selector and must revert on V1"); assert_eq!( - max_bytes, - Bytes::from(IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall::SELECTOR.as_ref()) + max_revert_data, + Bytes::from(IPolicyRegistry::MAX_COMPOSITE_CHILD_POLICIESCall::SELECTOR.as_ref()), + "unknown-selector revert data is the 4-byte selector, not a typed error", ); } @@ -1062,5 +1087,7 @@ fn v1_op_coverage_checklist(call: IPolicyRegistry::IPolicyRegistryCalls) { C::MIN_COMPOSITE_CHILD_POLICIES(_) | C::MAX_COMPOSITE_CHILD_POLICIES(_) => { covered(&[golden_min_max_composite_child_policies_selector_unknown_in_v1]) } + // V3 ABI; unknown to V1. + C::invertedPolicyId(_) => covered(&[golden_inverted_policy_id_selector_unknown_in_v1]), } } diff --git a/crates/common/precompiles/tests/b20_policy_v2_golden.rs b/crates/common/precompiles/tests/b20_policy_v2_golden.rs index 8809615250..f11c12bb13 100644 --- a/crates/common/precompiles/tests/b20_policy_v2_golden.rs +++ b/crates/common/precompiles/tests/b20_policy_v2_golden.rs @@ -271,6 +271,25 @@ fn golden_reads_for_nonexistent_and_builtins() { ); } +// ============================================================================ +// invertedPolicyId (V3 ABI; unknown to V2) +// ============================================================================ + +#[test] +fn golden_inverted_policy_id_selector_unknown_in_v2() { + let mut storage = fresh(); + let calldata = IPolicyRegistry::invertedPolicyIdCall { policyId: ALLOWLIST_ID }.abi_encode(); + + let (reverted, revert_data) = call_policy(&mut storage, ADMIN, calldata); + + assert!(reverted, "invertedPolicyId is a V3 selector and must revert on V2"); + assert_eq!( + revert_data, + Bytes::from(IPolicyRegistry::invertedPolicyIdCall::SELECTOR.as_ref()), + "unknown-selector revert data is the 4-byte selector, not a typed error", + ); +} + // ============================================================================ // createPolicy // ============================================================================ @@ -1224,5 +1243,7 @@ fn v2_op_coverage_checklist(call: IPolicyRegistry::IPolicyRegistryCalls) { golden_min_max_composite_child_policies, golden_min_max_composite_child_policies_read_before_activation, ]), + // V3 ABI; unknown to V2. + C::invertedPolicyId(_) => covered(&[golden_inverted_policy_id_selector_unknown_in_v2]), } } diff --git a/crates/common/precompiles/tests/b20_policy_v3_golden.rs b/crates/common/precompiles/tests/b20_policy_v3_golden.rs index 3d1784ace6..351af235d7 100644 --- a/crates/common/precompiles/tests/b20_policy_v3_golden.rs +++ b/crates/common/precompiles/tests/b20_policy_v3_golden.rs @@ -1,12 +1,10 @@ //! Golden tests pinning Policy Registry **V3** behavior of the B-20 precompile. //! -//! V3 is activated at Denim as a behavior-preserving copy of V2 (a scaffold seam for future -//! Denim-era changes). Its logic and storage layout are identical to V2, so this suite pins its -//! own roots to lock V3's behavior independently: a future edit that alters V3 must re-bless these -//! roots. These goldens run production Cobalt storage features (Denim inherits them via -//! `upgrade >= Cobalt`) while the V1 suite runs Legacy, so a root matches V2's only when the op -//! does not diverge; ops that write shrinking dynamic values can legitimately diverge from V1's -//! Legacy pin. +//! V3 is activated at Denim and extends V2 with query-time inverted policy IDs. Its storage +//! layout remains identical to V2: inversion is represented only by bit 63 of the policy ID. These +//! goldens pin Denim behavior independently. They run production Cobalt storage features (Denim +//! inherits them via `upgrade >= Cobalt`) while the V1 suite runs Legacy, so an operation that +//! writes shrinking dynamic values can legitimately diverge from V1's Legacy pin. //! //! Every op (policy creation, admin lifecycle, allow/block membership, and evaluation reads) is //! driven through the real `PolicyRegistryStorage` entry (version-resolver-gated `dispatch`, @@ -34,7 +32,8 @@ use alloy_sol_types::{SolCall, SolError, SolEvent}; use base_common_genesis::BaseUpgrade; use base_common_precompiles::{ ActivationAdminConfig, ActivationFeature, ActivationRegistryStorage, IPolicyRegistry, - PolicyRegistryStorage, PolicyVersion, PolicyVersions, UpgradeGatedStorageFeatures, + PolicyRegistryStorage, PolicyRegistryV3, PolicyVersion, PolicyVersions, + UpgradeGatedStorageFeatures, }; use base_precompile_storage::{HashMapStorageProvider, StorageCtx}; @@ -55,10 +54,8 @@ const ALLOWLIST_ID: u64 = (1u64 << 56) | 2; // --- pinned storage hashes (bless with BLESS_GOLDEN=1; see module docs) -------- // -// V3 is a behavior- and layout-preserving copy of V2, so these track V2's roots — but pinned -// independently here: these goldens run `StorageFeatures::Cobalt` (inherited at Denim) while the -// V1 suite runs Legacy, so an op that triggers Cobalt dynamic tail cleanup can diverge from its -// V1 pin. Re-blessing reflects the true Denim snapshot for each. +// V3 preserves V2 storage layout. These roots remain independently pinned because they execute +// at Denim under `StorageFeatures::Cobalt`, while the V1 suite runs Legacy. const ROOT_CREATE_BLOCKLIST: B256 = b256!("5ff0dab60b6daec34cbc6135f09097ddbbe31c6f662d4cdd9c6c4c7b5a589556"); @@ -272,6 +269,31 @@ fn golden_reads_for_nonexistent_and_builtins() { ); } +// ============================================================================ +// invertedPolicyId (V3 / Denim) +// ============================================================================ + +#[test] +fn golden_inverted_policy_id_reads_before_activation() { + let mut storage = HashMapStorageProvider::new_with_storage_features( + CHAIN_ID, + UpgradeGatedStorageFeatures::from_upgrade(BaseUpgrade::Denim), + ); + let policy_id = ALLOWLIST_ID; + let calldata = IPolicyRegistry::invertedPolicyIdCall { policyId: policy_id }.abi_encode(); + + let (reverted, return_data) = call_policy(&mut storage, OUTSIDER, calldata); + + assert!(!reverted, "invertedPolicyId is a view and must bypass activation"); + assert_eq!( + return_data, + Bytes::from(IPolicyRegistry::invertedPolicyIdCall::abi_encode_returns( + &(policy_id | PolicyRegistryV3::INVERTED_POLICY_BIT) + )), + "invertedPolicyId returns the id with the inverted-policy bit set", + ); +} + // ============================================================================ // createPolicy // ============================================================================ @@ -1153,7 +1175,7 @@ fn golden_gas_footprints() { /// Compile-time coverage checklist — never called; its exhaustive `match` (no `_` arm) names the /// golden `#[test]` fn(s) pinning each op. Adding an ABI op fails the build until a golden is added. #[allow(dead_code)] -fn v2_op_coverage_checklist(call: IPolicyRegistry::IPolicyRegistryCalls) { +fn v3_op_coverage_checklist(call: IPolicyRegistry::IPolicyRegistryCalls) { use IPolicyRegistry::IPolicyRegistryCalls as C; fn covered(_goldens: &[fn()]) {} @@ -1225,5 +1247,6 @@ fn v2_op_coverage_checklist(call: IPolicyRegistry::IPolicyRegistryCalls) { golden_min_max_composite_child_policies, golden_min_max_composite_child_policies_read_before_activation, ]), + C::invertedPolicyId(_) => covered(&[golden_inverted_policy_id_reads_before_activation]), } } From 73688631565be3d163094d9746d1f5355d7049d5 Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Thu, 17 Sep 2026 13:59:45 +0000 Subject: [PATCH 027/299] feat(chains): accept Base network selectors in the execution chain parser (#5094) `base --chain sepolia` works for integrated node commands but the execution chain parser only accepted the namespaced names, so `base snapshot download --chain sepolia` and the reth maintenance subcommands rejected the selector every operator doc uses. #5026 added that normalization to the unified binary but never reached the shared parser. - Add `ChainConfig::by_any_name`, resolving both the Base network selectors and the namespaced names - Route `BaseChainSpec::parse_chain` through it so every execution entry point accepts the same inputs - List the selectors first in `SUPPORTED_NAMES`, making `--chain` default to `mainnet` rather than `base` (same chain) - Name the selectors as constants and label the advertised snapshot sources with them Co-authored-by: OpenCode --- Cargo.lock | 1 + crates/common/chains/src/config.rs | 57 +++++++++++++++++----- crates/execution/chainspec/src/spec.rs | 2 +- crates/utilities/reth-cli/Cargo.toml | 1 + crates/utilities/reth-cli/src/snapshots.rs | 38 +++++++++++++-- 5 files changed, 83 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f28dbb161..43c2eff39c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6016,6 +6016,7 @@ name = "base-reth-cli" version = "0.0.0" dependencies = [ "anyhow", + "base-common-chains", "blake3", "humantime", "rayon", diff --git a/crates/common/chains/src/config.rs b/crates/common/chains/src/config.rs index 653e863bb9..822f783c06 100644 --- a/crates/common/chains/src/config.rs +++ b/crates/common/chains/src/config.rs @@ -171,13 +171,22 @@ impl ChainConfig { pub const ZERONET_NAME: &'static str = "base-zeronet"; /// CLI chain name for the local Base devnet. pub const DEVNET_NAME: &'static str = "dev"; - /// All chain names accepted by Base chain parsers. + /// Base-centric operator selector for Base Mainnet. + pub const MAINNET_SELECTOR: &'static str = "mainnet"; + /// Base-centric operator selector for Base Sepolia. + pub const SEPOLIA_SELECTOR: &'static str = "sepolia"; + /// Base-centric operator selector for Base Zeronet. + pub const ZERONET_SELECTOR: &'static str = "zeronet"; + /// All chain names accepted by Base chain parsers, canonical selectors first. pub const SUPPORTED_NAMES: &'static [&'static str] = &[ + Self::MAINNET_SELECTOR, + Self::SEPOLIA_SELECTOR, + Self::ZERONET_SELECTOR, + Self::DEVNET_NAME, Self::MAINNET_NAME, Self::SEPOLIA_ALIAS, Self::SEPOLIA_NAME, Self::ZERONET_NAME, - Self::DEVNET_NAME, ]; /// Base Mainnet chain configuration. @@ -243,14 +252,23 @@ impl ChainConfig { /// while `by_name("mainnet")` returns `None`. pub fn from_base_chain(name: &str) -> Option<&'static Self> { match name { - "mainnet" => Some(Self::mainnet()), - "sepolia" => Some(Self::sepolia()), - "zeronet" => Some(Self::zeronet()), - "dev" => Some(Self::devnet()), + Self::MAINNET_SELECTOR => Some(Self::mainnet()), + Self::SEPOLIA_SELECTOR => Some(Self::sepolia()), + Self::ZERONET_SELECTOR => Some(Self::zeronet()), + Self::DEVNET_NAME => Some(Self::devnet()), _ => None, } } + /// Resolves a chain from either naming surface: the Base-centric selectors matched + /// by [`from_base_chain`](Self::from_base_chain) or the namespaced names matched by + /// [`by_name`](Self::by_name). + /// + /// Chain parsers should use this so every Base entry point accepts the same inputs. + pub fn by_any_name(name: &str) -> Option<&'static Self> { + Self::from_base_chain(name).or_else(|| Self::by_name(name)) + } + /// Returns the Base-centric operator selector (`mainnet`, `sepolia`, /// `zeronet`, `dev`) for this chain — the inverse of /// [`from_base_chain`](Self::from_base_chain). @@ -262,10 +280,10 @@ impl ChainConfig { /// Returns `None` for chains that have no built-in selector. pub const fn base_chain_selector(&self) -> Option<&'static str> { match self.chain_id { - 8453 => Some("mainnet"), - 84532 => Some("sepolia"), - 763360 => Some("zeronet"), - 84538453 => Some("dev"), + 8453 => Some(Self::MAINNET_SELECTOR), + 84532 => Some(Self::SEPOLIA_SELECTOR), + 763360 => Some(Self::ZERONET_SELECTOR), + 84538453 => Some(Self::DEVNET_NAME), _ => None, } } @@ -750,7 +768,7 @@ mod tests { #[test] fn supported_chain_names_resolve() { for name in ChainConfig::SUPPORTED_NAMES { - assert!(ChainConfig::by_name(name).is_some(), "{name} should resolve"); + assert!(ChainConfig::by_any_name(name).is_some(), "{name} should resolve"); } assert_eq!(ChainConfig::by_name(ChainConfig::SEPOLIA_ALIAS), Some(ChainConfig::sepolia())); assert_eq!( @@ -782,6 +800,23 @@ mod tests { assert_eq!(ChainConfig::from_base_chain(ChainConfig::MAINNET_NAME), None); } + #[test] + fn by_any_name_accepts_both_naming_surfaces() { + for (selector, namespaced) in [ + (ChainConfig::MAINNET_SELECTOR, ChainConfig::MAINNET_NAME), + (ChainConfig::SEPOLIA_SELECTOR, ChainConfig::SEPOLIA_NAME), + (ChainConfig::ZERONET_SELECTOR, ChainConfig::ZERONET_NAME), + ] { + assert_eq!( + ChainConfig::by_any_name(selector), + ChainConfig::by_any_name(namespaced), + "{selector} and {namespaced} must resolve to the same chain" + ); + } + + assert_eq!(ChainConfig::by_any_name("not-a-chain"), None); + } + #[test] fn base_chain_selector_is_inverse_of_from_base_chain() { // Every selector round-trips through `from_base_chain`. diff --git a/crates/execution/chainspec/src/spec.rs b/crates/execution/chainspec/src/spec.rs index 881be0b89b..3608b638ed 100644 --- a/crates/execution/chainspec/src/spec.rs +++ b/crates/execution/chainspec/src/spec.rs @@ -303,7 +303,7 @@ impl BaseChainSpec { /// Parses a chain name into an [`BaseChainSpec`], if recognized. pub fn parse_chain(s: &str) -> Option> { - let cfg = ChainConfig::by_name(s)?; + let cfg = ChainConfig::by_any_name(s)?; Some(Arc::new( Self::try_from(cfg).expect("recognized Base chain config must build a valid chainspec"), )) diff --git a/crates/utilities/reth-cli/Cargo.toml b/crates/utilities/reth-cli/Cargo.toml index 3c724c745e..b94619100f 100644 --- a/crates/utilities/reth-cli/Cargo.toml +++ b/crates/utilities/reth-cli/Cargo.toml @@ -23,6 +23,7 @@ serde_json.workspace = true reth-node-core.workspace = true reth-prune-types.workspace = true reth-cli-commands.workspace = true +base-common-chains.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/utilities/reth-cli/src/snapshots.rs b/crates/utilities/reth-cli/src/snapshots.rs index 4d4400debd..b3d6152485 100644 --- a/crates/utilities/reth-cli/src/snapshots.rs +++ b/crates/utilities/reth-cli/src/snapshots.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; +use base_common_chains::ChainConfig; use reth_cli_commands::download::DownloadDefaults; use reth_node_core::args::DefaultPruningValues; use reth_prune_types::PruneMode; @@ -9,6 +10,9 @@ use reth_prune_types::PruneMode; /// Must stay a bare host: reth derives `{root}/api/snapshots` from it and appends /// its own path segments, so a chain path here produces 404 manifest URLs. const SNAPSHOT_SOURCE_URL: &str = "https://chain.base.org"; +const MAINNET_SNAPSHOT_URL: &str = "https://mainnet-v2-snapshots.base.org"; +const SEPOLIA_SNAPSHOT_URL: &str = "https://sepolia-v2-snapshots.base.org"; +const ZERONET_SNAPSHOT_URL: &str = "https://zeronet-v2-snapshots.base.org"; const FULL_HISTORY_DISTANCE: u64 = 1_339_200; /// Reth snapshot and pruning-default initialization for Base execution layer binaries. @@ -16,13 +20,23 @@ const FULL_HISTORY_DISTANCE: u64 = 1_339_200; pub struct Snapshots; impl Snapshots { - /// Snapshot sources advertised in `--help`, derived from one snapshot root. + /// Snapshot sources advertised in `--help`, each labelled with the `--chain` name + /// that selects it. pub fn download_defaults() -> DownloadDefaults { DownloadDefaults::default().with_snapshot_source_url(SNAPSHOT_SOURCE_URL).with_snapshots( vec![ - Cow::Borrowed("https://mainnet-v2-snapshots.base.org (mainnet)"), - Cow::Borrowed("https://sepolia-v2-snapshots.base.org (sepolia)"), - Cow::Borrowed("https://zeronet-v2-snapshots.base.org (zeronet)"), + Cow::Owned(format!( + "{MAINNET_SNAPSHOT_URL} (--chain {})", + ChainConfig::MAINNET_SELECTOR + )), + Cow::Owned(format!( + "{SEPOLIA_SNAPSHOT_URL} (--chain {})", + ChainConfig::SEPOLIA_SELECTOR + )), + Cow::Owned(format!( + "{ZERONET_SNAPSHOT_URL} (--chain {})", + ChainConfig::ZERONET_SELECTOR + )), ], ) } @@ -69,6 +83,22 @@ macro_rules! init_snapshots { mod tests { use super::*; + #[test] + fn advertised_snapshot_sources_name_chains_the_parser_accepts() { + for source in &Snapshots::download_defaults().available_snapshots { + let (url, chain) = source + .split_once(" (--chain ") + .unwrap_or_else(|| panic!("snapshot source {source} is missing a --chain label")); + let chain = chain.trim_end_matches(')'); + + assert!(url.starts_with("https://"), "snapshot source {url} must be an https URL"); + assert!( + ChainConfig::by_any_name(chain).is_some(), + "advertised --chain {chain} is rejected by the Base chain parser" + ); + } + } + #[test] fn snapshot_root_carries_no_chain_path() { let defaults = Snapshots::download_defaults(); From 7ff6573edaf7f980013abe38eed32392cbc5c9d0 Mon Sep 17 00:00:00 2001 From: refcell Date: Thu, 17 Sep 2026 14:09:53 +0000 Subject: [PATCH 028/299] feat(upgrades): expose configured upgrade signal mode (#5104) Amp-Thread-ID: https://ampcode.com/threads/T-01a0af9e-9e1e-7553-a9f5-c074a7f58bec Co-authored-by: Amp --- Cargo.lock | 1 + bin/base/src/commands/command.rs | 5 +- crates/consensus/service/src/service/node.rs | 6 ++ crates/execution/cli/src/standard_node.rs | 13 +++- crates/utilities/upgrade-signal/Cargo.toml | 1 + crates/utilities/upgrade-signal/README.md | 12 ++++ .../utilities/upgrade-signal/src/metrics.rs | 66 ++++++++++++++++++- 7 files changed, 99 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43c2eff39c..2e5d168acc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6607,6 +6607,7 @@ dependencies = [ "futures", "httpmock", "metrics", + "metrics-util", "reqwest 0.13.4", "rstest", "serde", diff --git a/bin/base/src/commands/command.rs b/bin/base/src/commands/command.rs index 77064a0209..55e8d37878 100644 --- a/bin/base/src/commands/command.rs +++ b/bin/base/src/commands/command.rs @@ -244,8 +244,9 @@ mod tests { "reth_base_node_upgrades{{upgrade=\"{upgrade}\"}} {time}" ) }) - }) - { + }) && body.lines().any(|line| { + line == "reth_base_upgrade_signal_mode_info{layer=\"el\",mode=\"disabled\"} 1" + }) { break; } tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/crates/consensus/service/src/service/node.rs b/crates/consensus/service/src/service/node.rs index db9a6cc0a3..c53d145860 100644 --- a/crates/consensus/service/src/service/node.rs +++ b/crates/consensus/service/src/service/node.rs @@ -22,6 +22,7 @@ use base_consensus_providers::{ use base_consensus_rpc::{BaseRpc, RpcBuilder}; use base_consensus_safedb::{DisabledSafeDB, SafeDB, SafeDBReader, SafeHeadListener}; use base_protocol::L2BlockInfo; +use base_upgrade_signal::{UpgradeSignalMetricLayer, UpgradeSignalMetrics}; use tokio::sync::{mpsc, watch}; use tokio_util::sync::CancellationToken; @@ -407,6 +408,11 @@ impl RollupNode { DerivationActor: NodeActor, { + UpgradeSignalMetrics::record_mode( + UpgradeSignalMetricLayer::Consensus, + self.upgrade_signal_config.as_ref().map(|config| config.config.mode), + ); + // Build the safe head DB pair. Both actors share the same underlying DB via Arc. // // In delegate mode the local derivation actor is replaced by a `DelegateDerivationActor` diff --git a/crates/execution/cli/src/standard_node.rs b/crates/execution/cli/src/standard_node.rs index 470773081c..79b6461d26 100644 --- a/crates/execution/cli/src/standard_node.rs +++ b/crates/execution/cli/src/standard_node.rs @@ -36,7 +36,9 @@ use base_txpool_rpc::{ TxPoolRpcExtension, }; use base_txpool_tracing::{TxPoolExtension, TxpoolConfig}; -use base_upgrade_signal::UpgradeSignalStartupMode; +use base_upgrade_signal::{ + UpgradeSignalMetricLayer, UpgradeSignalMetrics, UpgradeSignalStartupMode, +}; use tracing::warn; use url::Url; @@ -619,7 +621,14 @@ impl StandardBaseRethNode { runner: &mut BaseNodeRunner, rollup_args: &RollupArgs, ) -> eyre::Result<()> { - let Some(config) = Self::upgrade_signal_config(rollup_args)? else { + let config = Self::upgrade_signal_config(rollup_args)?; + let mode = config.as_ref().map(|config| config.signal_config.mode); + // Unified nodes may install their recorder during launch. Include disabled nodes too. + runner.add_started_callback(move || { + UpgradeSignalMetrics::record_mode(UpgradeSignalMetricLayer::Execution, mode); + Ok(()) + }); + let Some(config) = config else { return Ok(()); }; diff --git a/crates/utilities/upgrade-signal/Cargo.toml b/crates/utilities/upgrade-signal/Cargo.toml index 14ffc5f8bd..a3261a59df 100644 --- a/crates/utilities/upgrade-signal/Cargo.toml +++ b/crates/utilities/upgrade-signal/Cargo.toml @@ -43,6 +43,7 @@ serde = { workspace = true, features = ["derive", "std"] } [dev-dependencies] rstest.workspace = true httpmock.workspace = true +metrics-util = { workspace = true, features = ["debugging"] } tokio = { workspace = true, features = ["macros", "rt", "time"] } [features] diff --git a/crates/utilities/upgrade-signal/README.md b/crates/utilities/upgrade-signal/README.md index 0443827200..ec15c1c0ef 100644 --- a/crates/utilities/upgrade-signal/README.md +++ b/crates/utilities/upgrade-signal/README.md @@ -28,6 +28,18 @@ Execution-side readers also need `--upgrade-signal.l1-rpc` or from their consensus `--l1-eth-rpc` by default so execution and consensus read the same L1 source unless an explicit override is supplied. +## Mode Metric + +Execution nodes and consensus rollup nodes expose `base_upgrade_signal_mode_info` with value `1` +for their configured mode, labeled by `layer` (`el` or `cl`) and `mode` (`metrics-only`, +`startup-apply`, `runtime-admin`, or `disabled` when no contract is configured). The execution +recorder prefixes the Prometheus name with `reth_`, including CL metrics in unified nodes. + +The gauge is recorded at node startup after recorder installation and remains available on every +scrape, independently of L1 polling or apply outcomes. It describes configuration, not upgrade +readiness. Count recent reporters separately by layer to avoid double-counting unified nodes; +missing telemetry from older or unreachable nodes must not be interpreted as `disabled`. + ## Runtime Behavior All modes with a configured contract start a live observer. The observer polls L1 on an interval diff --git a/crates/utilities/upgrade-signal/src/metrics.rs b/crates/utilities/upgrade-signal/src/metrics.rs index 8735a62f8d..88f5f5a519 100644 --- a/crates/utilities/upgrade-signal/src/metrics.rs +++ b/crates/utilities/upgrade-signal/src/metrics.rs @@ -3,7 +3,7 @@ use alloy_primitives::U256; use base_common_genesis::BaseUpgrade; -use crate::{UpgradeSignal, UpgradeSignalSchedule}; +use crate::{UpgradeSignal, UpgradeSignalMode, UpgradeSignalSchedule}; /// Upgrade signal metric layer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,6 +26,10 @@ impl UpgradeSignalMetricLayer { base_metrics::define_metrics! { base.upgrade_signal, struct = UpgradeSignalMetrics, + #[describe("1 for the configured upgrade signal mode; disabled when no contract is configured")] + #[label(layer)] + #[label(mode)] + mode_info: gauge, #[describe("Configured activation timestamp read from L1")] #[label(layer)] #[label(upgrade)] @@ -64,6 +68,21 @@ base_metrics::define_metrics! { } impl UpgradeSignalMetrics { + /// Records the startup mode after the metrics recorder is installed, without reading L1. + /// + /// `None` means no upgrade signal contract is configured. Only the selected mode is emitted; + /// it remains exposed on subsequent scrapes for the lifetime of the process. + pub fn record_mode(layer: UpgradeSignalMetricLayer, mode: Option) { + Self::init(); + let mode = match mode { + Some(UpgradeSignalMode::MetricsOnly) => "metrics-only", + Some(UpgradeSignalMode::StartupApply) => "startup-apply", + Some(UpgradeSignalMode::RuntimeAdmin) => "runtime-admin", + None => "disabled", + }; + Self::mode_info(layer.label(), mode).set(1.0); + } + /// Records all metrics derived from a successfully read schedule. pub fn record_schedule(layer: UpgradeSignalMetricLayer, schedule: &UpgradeSignalSchedule) { Self::init(); @@ -185,8 +204,53 @@ impl UpgradeSignalMetrics { #[cfg(test)] mod tests { + #[cfg(feature = "metrics")] + use metrics_util::{ + MetricKind, + debugging::{DebugValue, DebuggingRecorder}, + }; + #[cfg(feature = "metrics")] + use rstest::rstest; + use super::*; + #[cfg(feature = "metrics")] + #[rstest] + #[case(None, "disabled")] + #[case(Some(UpgradeSignalMode::MetricsOnly), "metrics-only")] + #[case(Some(UpgradeSignalMode::StartupApply), "startup-apply")] + #[case(Some(UpgradeSignalMode::RuntimeAdmin), "runtime-admin")] + fn records_only_selected_mode( + #[case] mode: Option, + #[case] expected_mode: &str, + #[values(UpgradeSignalMetricLayer::Execution, UpgradeSignalMetricLayer::Consensus)] + layer: UpgradeSignalMetricLayer, + ) { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + metrics::with_local_recorder(&recorder, || { + UpgradeSignalMetrics::record_mode(layer, mode); + UpgradeSignalMetrics::record_mode(layer, mode); + }); + + let snapshot = snapshotter.snapshot().into_vec(); + let entries: Vec<_> = snapshot + .iter() + .filter(|(key, _, _, _)| key.key().name() == "base.upgrade_signal.mode_info") + .collect(); + assert_eq!(entries.len(), 1); + let (key, _, _, value) = entries[0]; + assert_eq!(key.kind(), MetricKind::Gauge); + assert_eq!(*value, DebugValue::Gauge(1.0.into())); + let mut labels: Vec<_> = key.key().labels().map(|l| (l.key(), l.value())).collect(); + labels.sort_unstable(); + let expected_layer = match layer { + UpgradeSignalMetricLayer::Execution => "el", + UpgradeSignalMetricLayer::Consensus => "cl", + }; + assert_eq!(labels, [("layer", expected_layer), ("mode", expected_mode)]); + } + #[test] fn converts_packed_semver_protocol_version_to_metric_value() { let version = crate::UpgradeSignalDefaults::packed_protocol_version(1, 1, 0); From 9f04f5fd8cbfefa9cc6b330ec1f174bc86dddae6 Mon Sep 17 00:00:00 2001 From: Haardik Date: Thu, 17 Sep 2026 14:11:50 +0000 Subject: [PATCH 029/299] ci: publish release artifacts independently (#5107) * ci: publish release artifacts independently Amp-Thread-ID: https://ampcode.com/threads/T-01a0afa6-19e5-7188-83d1-34b5bf596ba6 Co-authored-by: Amp * ci: validate binary release artifacts Amp-Thread-ID: https://ampcode.com/threads/T-01a0afa6-19e5-7188-83d1-34b5bf596ba6 Co-authored-by: Amp --------- Co-authored-by: Amp --- .github/workflows/build-release.yml | 84 +++++++++++++---------------- 1 file changed, 37 insertions(+), 47 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index c175c85bd5..0a14945dd5 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -153,6 +153,30 @@ jobs: if-no-files-found: error retention-days: 1 + - name: Create or reuse GitHub Release + if: inputs.is_final + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ inputs.tag }}" + if ! gh release view "$TAG" > /dev/null 2>&1; then + gh release create "$TAG" --title "$TAG" --notes "" --draft || gh release view "$TAG" > /dev/null + fi + + - name: Upload binary to GitHub Release + if: inputs.is_final + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ inputs.tag }}" + shopt -s nullglob + files=("${{ matrix.binary }}-${TAG}-${{ matrix.target.triple }}.tar.gz"*) + if [[ ${#files[@]} -eq 0 ]]; then + echo "::error::No binary artifacts found for ${{ matrix.binary }}-${TAG}-${{ matrix.target.triple }}" + exit 1 + fi + gh release upload --clobber "$TAG" "${files[@]}" + # Build multi-arch Docker images build-and-push: strategy: @@ -236,14 +260,13 @@ jobs: if-no-files-found: error retention-days: 1 - # Check which release artifacts exist after partial matrix failures - find-release-artifacts: - needs: [build-and-push, build-binaries] + # Check which Docker artifacts exist after partial matrix failures + find-docker-artifacts: + needs: [build-and-push] if: ${{ !cancelled() }} runs-on: ubuntu-latest outputs: has_digests: ${{ steps.artifacts.outputs.has_digests }} - has_binaries: ${{ steps.artifacts.outputs.has_binaries }} steps: - name: Harden the runner (Audit all outbound calls) @@ -251,7 +274,7 @@ jobs: with: egress-policy: audit - - name: Find release artifacts + - name: Find Docker artifacts id: artifacts env: GH_TOKEN: ${{ github.token }} @@ -263,22 +286,19 @@ jobs: artifact_names=$(gh api "repos/$GH_REPO/actions/runs/$GH_RUN_ID/artifacts" --paginate --jq '.artifacts[].name') has_digests=false - has_binaries=false while IFS= read -r name; do - case "$name" in - digests-*) has_digests=true ;; - binaries-*) has_binaries=true ;; - esac + if [[ "$name" == digests-* ]]; then + has_digests=true + fi done <<< "$artifact_names" echo "has_digests=$has_digests" >> "$GITHUB_OUTPUT" - echo "has_binaries=$has_binaries" >> "$GITHUB_OUTPUT" # Merge Docker manifests and publish publish: - needs: [find-release-artifacts] - if: ${{ !cancelled() && (needs.find-release-artifacts.outputs.has_digests == 'true' || (inputs.is_final && needs.find-release-artifacts.outputs.has_binaries == 'true')) }} + needs: [find-docker-artifacts] + if: ${{ !cancelled() && needs.find-docker-artifacts.outputs.has_digests == 'true' }} runs-on: ubuntu-latest steps: @@ -295,7 +315,6 @@ jobs: fetch-tags: true - name: Download digests - if: needs.find-release-artifacts.outputs.has_digests == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }}/digests-base @@ -303,7 +322,6 @@ jobs: merge-multiple: true - name: Login to GHCR - if: needs.find-release-artifacts.outputs.has_digests == 'true' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io @@ -311,11 +329,9 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - if: needs.find-release-artifacts.outputs.has_digests == 'true' uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Create manifest lists and push - if: needs.find-release-artifacts.outputs.has_digests == 'true' id: create-manifest run: | set -euo pipefail @@ -369,40 +385,14 @@ jobs: fi echo "pushed=true" >> "$GITHUB_OUTPUT" - - name: Create GitHub Release - if: inputs.is_final && (steps.create-manifest.outputs.pushed == 'true' || needs.find-release-artifacts.outputs.has_binaries == 'true') + - name: Create or reuse GitHub Release + if: inputs.is_final && steps.create-manifest.outputs.pushed == 'true' env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ inputs.tag }}" - if gh release view "$TAG" > /dev/null 2>&1; then - echo "GitHub Release $TAG already exists, skipping creation" - else - gh release create "$TAG" --title "$TAG" --notes "" --draft - fi - - - name: Download binary artifacts - if: inputs.is_final && needs.find-release-artifacts.outputs.has_binaries == 'true' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: ${{ runner.temp }}/binaries - pattern: binaries-* - merge-multiple: true - - - name: Upload binaries to GitHub Release - if: inputs.is_final && needs.find-release-artifacts.outputs.has_binaries == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - TAG="${{ inputs.tag }}" - BINARY_DIR="${{ runner.temp }}/binaries" - mkdir -p "$BINARY_DIR" - shopt -s nullglob - files=("$BINARY_DIR"/*.tar.gz "$BINARY_DIR"/*.tar.gz.sha256 "$BINARY_DIR"/*.tar.gz.asc) - if [[ ${#files[@]} -eq 0 ]]; then - echo "::warning::No binary artifacts found — all binary builds may have failed, skipping upload" - else - gh release upload --clobber "$TAG" "${files[@]}" + if ! gh release view "$TAG" > /dev/null 2>&1; then + gh release create "$TAG" --title "$TAG" --notes "" --draft || gh release view "$TAG" > /dev/null fi - name: Summary From 4a45ea51d6ee2ac661f185a67d1e9c16eec45c40 Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Thu, 17 Sep 2026 14:28:40 +0000 Subject: [PATCH 030/299] refactor(chains): resolve chain names through the shared normalizer (#5095) * refactor(chains): resolve chain names through the shared normalizer Two call sites hand-rolled the same chain-name resolution the shared normalizer now provides, so any future change to what `--chain` accepts had to be made in three places. - Replace the inlined `from_base_chain().or_else(by_name)` chain in the `base` binary's `--chain` parsing with `ChainConfig::by_any_name` - Do the same in the system tests' snapshot chain resolution Co-authored-by: OpenCode * docs(chains): point base_chain_selector at the shared normalizer The example on `base_chain_selector` still recommended hand-rolling `from_base_chain(x).or_else(|| by_name(x))`, the exact chain this stack consolidated into `ChainConfig::by_any_name`. Co-authored-by: OpenCode --------- Co-authored-by: OpenCode --- bin/base/src/config.rs | 3 +-- crates/common/chains/src/config.rs | 2 +- etc/systems/src/system_config.rs | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/bin/base/src/config.rs b/bin/base/src/config.rs index 6b98c6e9d2..6a9675749c 100644 --- a/bin/base/src/config.rs +++ b/bin/base/src/config.rs @@ -47,8 +47,7 @@ impl FromStr for ChainArg { // normalizing either to the canonical selector so the rest of the // pipeline only ever sees a selector. Anything unrecognized is treated as // a path to a TOML chain config file. - Ok(ChainConfig::from_base_chain(&selector) - .or_else(|| ChainConfig::by_name(&selector)) + Ok(ChainConfig::by_any_name(&selector) .and_then(ChainConfig::base_chain_selector) .map_or_else( || Self::File(PathBuf::from(value)), diff --git a/crates/common/chains/src/config.rs b/crates/common/chains/src/config.rs index 822f783c06..927fd8a65c 100644 --- a/crates/common/chains/src/config.rs +++ b/crates/common/chains/src/config.rs @@ -275,7 +275,7 @@ impl ChainConfig { /// /// Use this to normalize any recognized chain input back to the canonical /// selector understood by the `base` binary's `--chain` surface, for example - /// `from_base_chain(x).or_else(|| by_name(x)).and_then(Self::base_chain_selector)`. + /// `by_any_name(x).and_then(Self::base_chain_selector)`. /// /// Returns `None` for chains that have no built-in selector. pub const fn base_chain_selector(&self) -> Option<&'static str> { diff --git a/etc/systems/src/system_config.rs b/etc/systems/src/system_config.rs index 25b1a6fb50..d6749213b4 100644 --- a/etc/systems/src/system_config.rs +++ b/etc/systems/src/system_config.rs @@ -105,9 +105,7 @@ pub struct ResolvedSnapshotChain { impl SnapshotChainConfig { /// Resolves a built-in chain name or a Base genesis JSON plus its rollup configuration. pub fn resolve(&self) -> Result { - if let Some(config) = - ChainConfig::from_base_chain(&self.chain).or_else(|| ChainConfig::by_name(&self.chain)) - { + if let Some(config) = ChainConfig::by_any_name(&self.chain) { return Ok(ResolvedSnapshotChain { chain_spec: Arc::new(BaseChainSpec::try_from(config)?), rollup_config: Arc::new(config.rollup_config()), From f276fd2c01e7ee30f8e266733efd52b23fd81426 Mon Sep 17 00:00:00 2001 From: refcell Date: Thu, 17 Sep 2026 15:03:39 +0000 Subject: [PATCH 031/299] fix(gossip): Retire Obsolete Block Gossip Topics (#5077) * fix(gossip): retire obsolete block gossip topics * feat(gossip): add topic retirement metrics and end-to-end tests Co-authored-by: Codex * fix(gossip): avoid redundant inbound retirement checks Co-authored-by: Codex --------- Co-authored-by: Codex --- Cargo.lock | 2 + actions/harness/Cargo.toml | 3 + actions/harness/tests/unsafe_gossip.rs | 73 ++- crates/consensus/gossip/Cargo.toml | 2 + crates/consensus/gossip/README.md | 26 + crates/consensus/gossip/src/behaviour.rs | 20 +- crates/consensus/gossip/src/block_validity.rs | 3 +- crates/consensus/gossip/src/driver.rs | 466 +++++++++++++++++- crates/consensus/gossip/src/handler.rs | 247 +++++++++- crates/consensus/gossip/src/lib.rs | 2 + crates/consensus/gossip/src/metrics/mod.rs | 21 + etc/systems/tests/gossip_topic_retirement.rs | 88 ++++ 12 files changed, 920 insertions(+), 33 deletions(-) create mode 100644 etc/systems/tests/gossip_topic_retirement.rs diff --git a/Cargo.lock b/Cargo.lock index 2e5d168acc..70db8ca4f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2984,6 +2984,7 @@ dependencies = [ "base-test-utils", "base-tx-manager", "eyre", + "libp2p", "miniz_oxide 0.9.1", "reth-basic-payload-builder", "reth-db", @@ -4002,6 +4003,7 @@ dependencies = [ "libp2p-stream", "lru 0.16.4", "metrics", + "metrics-exporter-prometheus", "multihash", "rand 0.9.5", "serde", diff --git a/actions/harness/Cargo.toml b/actions/harness/Cargo.toml index 70a10cae40..1312de761d 100644 --- a/actions/harness/Cargo.toml +++ b/actions/harness/Cargo.toml @@ -83,6 +83,9 @@ serde_json.workspace = true miniz_oxide.workspace = true [dev-dependencies] +# networking +libp2p = { workspace = true, features = ["gossipsub"] } + # alloy alloy-sol-types = { workspace = true, features = ["std"] } diff --git a/actions/harness/tests/unsafe_gossip.rs b/actions/harness/tests/unsafe_gossip.rs index 6bc02f03af..35647b7163 100644 --- a/actions/harness/tests/unsafe_gossip.rs +++ b/actions/harness/tests/unsafe_gossip.rs @@ -1,10 +1,81 @@ //! Action tests for L2 unsafe-head gossip simulation. +use std::time::SystemTime; + +use alloy_signer_local::PrivateKeySigner; use base_action_harness::{ ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, - TestRollupConfigBuilder, + TestGossipTransport, TestRollupConfigBuilder, }; use base_batcher_encoder::{DaType, EncoderConfig}; +use base_consensus_gossip::{BlockHandler, Handler}; +use libp2p::gossipsub::{Message, MessageAcceptance}; +use tokio::sync::watch; + +/// Unlike the channel-only tests below, route signed sequencer payloads through +/// the production codec and handler before delivering them to the verifier. +#[tokio::test] +async fn retired_topics_do_not_advance_unsafe_head_but_v4_does() { + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + let mut config = + TestRollupConfigBuilder::base_mainnet(&BatcherConfig::default()).through_isthmus().build(); + // Keep all three generated blocks inside the real gossip timestamp window. + config.genesis.l2_time = now - 10; + let h = ActionTestHarness::new( + L1MinerConfig { genesis_timestamp: now - 10, ..Default::default() }, + config, + ); + let chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); + let key = PrivateKeySigner::random(); + let mut seq = h.create_l2_sequencer(chain.clone()); + let mut sequencer_output = h.create_signing_p2p(&mut seq, key.clone()); + let (verifier_input, transport) = TestGossipTransport::channel(); + let mut node = h.create_test_rollup_node(&seq, chain, transport); + node.initialize().await; + let (_signer, receiver) = watch::channel(key.address()); + let mut handler = BlockHandler::new(h.rollup_config.clone(), receiver); + + for number in 1..=3 { + let block = seq.build_next_block_with_single_transaction().await; + seq.broadcast_unsafe_block(&block); + let envelope = sequencer_output.try_next_unsafe_block().expect("signed sequencer block"); + + // Simulate queued traffic on each retired topic. Even malformed bytes + // must be ignored before decoding, never reaching the verifier. + for topic in [ + handler.blocks_v1_topic.hash(), + handler.blocks_v2_topic.hash(), + handler.blocks_v3_topic.hash(), + ] { + let (status, payload) = handler.handle(Message { + source: None, + sequence_number: None, + topic, + data: vec![0xff], + }); + if let Some(payload) = payload { + verifier_input.send(payload); + } + assert!(matches!(status, MessageAcceptance::Ignore)); + } + node.run_until_idle().await; + assert_eq!(node.l2_unsafe_number(), number - 1); + + let topic = handler.blocks_v4_topic.clone(); + let data = handler.encode(topic.clone(), envelope).unwrap(); + let (status, payload) = handler.handle(Message { + source: None, + sequence_number: None, + topic: topic.hash(), + data, + }); + assert!(matches!(status, MessageAcceptance::Accept)); + verifier_input.send(payload.expect("validated V4 payload")); + node.run_until_idle().await; + assert_eq!(node.l2_unsafe_number(), number); + assert_eq!(node.l2_safe_number(), 0, "no batches were submitted"); + } +} /// Simulates the P2P gossip pattern: /// diff --git a/crates/consensus/gossip/Cargo.toml b/crates/consensus/gossip/Cargo.toml index af865176f4..4fadca4f94 100644 --- a/crates/consensus/gossip/Cargo.toml +++ b/crates/consensus/gossip/Cargo.toml @@ -55,9 +55,11 @@ metrics = { workspace = true, optional = true } [dev-dependencies] tempfile.workspace = true multihash.workspace = true +metrics-exporter-prometheus.workspace = true alloy-rlp = { workspace = true, features = ["std"] } serde_json = { workspace = true, features = ["std"] } rand = { workspace = true, features = ["thread_rng"] } +tokio = { workspace = true, features = ["test-util"] } alloy-chains = { workspace = true, features = ["std"] } arbitrary = { workspace = true, features = ["derive"] } alloy-primitives = { workspace = true, features = ["arbitrary"] } diff --git a/crates/consensus/gossip/README.md b/crates/consensus/gossip/README.md index b322344c41..35520db4aa 100644 --- a/crates/consensus/gossip/README.md +++ b/crates/consensus/gossip/README.md @@ -7,6 +7,32 @@ that subscribes to unsafe block topics and propagates validated payloads. `Block validates incoming blocks against the rollup config, `ConnectionGater` enforces IP-based rate limits and peer bans, and Prometheus metrics are recorded via `Metrics`. +Block topics retire according to the configured fork schedule once every pre-fork block is +outside the 60-second gossip age window. Startup skips already-retired topics, and the driver +checks for retirement every five seconds while running, including when idle. Inbound decoding +and outbound encoding enforce the cutoff immediately; future topics remain joined ahead of +activation. Retired subscriptions are not automatically restored after a clock rollback. + +With the `metrics` feature, topic health is exported under `base_node_block_topic_*`: + +| Metric suffix | Meaning | +| --- | --- | +| `subscribed` | Actual local subscription, 0 or 1, including topics skipped at startup | +| `peers` | Connected peers advertising the topic, even if it is locally retired | +| `mesh_peers` | Peers in the local topic mesh; distinct from total connections | +| `retirements_total` | Successful runtime unsubscriptions; startup skips are not counted | +| `blocked_total` | Inbound/outbound messages blocked by the topic guards, not all wire traffic | + +Labels are bounded to `version=v1|v2|v3|v4` and, for blocked messages, +`direction=inbound|outbound`. State gauges are sampled at startup and every five seconds; +retirement immediately refreshes them. Unknown topics do not create label values. Monitor +V4 mesh peers alongside connected peers during retirement: leaving an old mesh does not +directly disconnect its peers, but an old-topic-only connection may subsequently become idle. + +Coverage includes a real-TCP mixed-subscription peer test in this crate, production-handler +delivery into an action-test verifier (`unsafe_gossip`), and a Docker-backed system test +(`gossip_topic_retirement`) that checks peer-topic RPCs and unsafe sync with batching stopped. + ## Key Components - [`GossipDriver`]: Main driver managing the libp2p swarm and event handling diff --git a/crates/consensus/gossip/src/behaviour.rs b/crates/consensus/gossip/src/behaviour.rs index 2499508ef2..7cba0c8c0d 100644 --- a/crates/consensus/gossip/src/behaviour.rs +++ b/crates/consensus/gossip/src/behaviour.rs @@ -124,7 +124,7 @@ impl From for Event { mod tests { use alloy_chains::Chain; use alloy_primitives::Address; - use base_common_genesis::RollupConfig; + use base_common_genesis::{RollupConfig, UpgradeConfig}; use libp2p::gossipsub::{IdentTopic, TopicHash}; use super::*; @@ -147,6 +147,24 @@ mod tests { let _ = Behaviour::new(key.public(), cfg, &handlers).unwrap(); } + #[test] + fn startup_after_isthmus_only_subscribes_to_v4() { + let key = libp2p::identity::Keypair::generate_secp256k1(); + let (_, recv) = tokio::sync::watch::channel(Address::ZERO); + let handler = BlockHandler::new( + RollupConfig { + l2_chain_id: Chain::base_mainnet(), + upgrades: UpgradeConfig { isthmus_time: Some(0), ..Default::default() }, + ..Default::default() + }, + recv, + ); + let expected = handler.blocks_v4_topic.hash(); + let behaviour = + Behaviour::new(key.public(), config::default_config(), &[Box::new(handler)]).unwrap(); + assert_eq!(behaviour.gossipsub.topics().cloned().collect::>(), [expected]); + } + #[test] fn test_behaviour_with_handlers() { let key = libp2p::identity::Keypair::generate_secp256k1(); diff --git a/crates/consensus/gossip/src/block_validity.rs b/crates/consensus/gossip/src/block_validity.rs index 5e19061a86..b8eee81bb4 100644 --- a/crates/consensus/gossip/src/block_validity.rs +++ b/crates/consensus/gossip/src/block_validity.rs @@ -175,7 +175,8 @@ impl BlockHandler { // The timestamp is at most 5 seconds in the future. let is_future = envelope.payload.timestamp() > current_timestamp + 5; // The timestamp is at most 60 seconds in the past. - let is_past = envelope.payload.timestamp() < current_timestamp - 60; + let is_past = + envelope.payload.timestamp() < current_timestamp.saturating_sub(Self::MAX_BLOCK_AGE); // CHECK: The timestamp is not too far in the future or past. if is_future || is_past { diff --git a/crates/consensus/gossip/src/driver.rs b/crates/consensus/gossip/src/driver.rs index cb3db756a8..eee6ee1677 100644 --- a/crates/consensus/gossip/src/driver.rs +++ b/crates/consensus/gossip/src/driver.rs @@ -4,7 +4,7 @@ use std::{ collections::{HashMap, HashSet}, num::NonZeroUsize, sync::Arc, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime}, }; use alloy_primitives::{Address, hex}; @@ -25,11 +25,16 @@ use libp2p::{ use libp2p_identity::Keypair; use libp2p_stream::IncomingStreams; use lru::LruCache; -use tokio::sync::Mutex; +#[cfg(test)] +pub use tests::GossipPair; +use tokio::{ + sync::Mutex, + time::{Instant as TokioInstant, sleep_until}, +}; use crate::{ Behaviour, BlockHandler, ConnectionGate, ConnectionGater, ConnectionLimitsConfig, Event, - GossipDriverBuilder, Handler, Metrics, PublishError, + GossipDriverBuilder, Handler, HandlerEncodeError, Metrics, PublishError, }; /// Configuration applied when constructing a [`GossipDriver`]. @@ -80,12 +85,19 @@ pub struct GossipDriver { pub connection_limits_config: ConnectionLimitsConfig, /// Tracks ping times for peers. pub ping: Arc>>, + /// Next check for obsolete block topics. Kept across polls so busy traffic + /// or cancellation of `next` cannot indefinitely postpone retirement. + pub next_topic_retirement: TokioInstant, } impl GossipDriver where G: ConnectionGate, { + /// How often to reconcile subscriptions with the configured fork schedule. + /// The handler enforces retirement before decoding even between checks. + pub const TOPIC_RETIREMENT_INTERVAL: Duration = Duration::from_secs(5); + /// Returns the [`GossipDriverBuilder`] that can be used to construct the [`GossipDriver`]. pub const fn builder( rollup_config: RollupConfig, @@ -106,7 +118,7 @@ where gate: G, config: GossipDriverConfig, ) -> Self { - Self { + let driver = Self { swarm, addr, handler, @@ -118,7 +130,11 @@ where connection_gate: gate, connection_limits_config: config.connection_limits_config, ping: Arc::new(Mutex::new(Default::default())), - } + next_topic_retirement: TokioInstant::now(), + }; + #[cfg(feature = "metrics")] + driver.record_block_topic_metrics(); + driver } /// Publishes an unsafe block to gossip. @@ -143,6 +159,16 @@ where }; let topic = selector(&self.handler); let topic_hash = topic.hash(); + // GossipSub can publish to unsubscribed topics. Do not let a retired + // topic re-enter the publishing path, including after a clock rollback. + // The handler's clock check additionally enforces the cutoff before + // the next periodic sweep removes the subscription. + if !self.swarm.behaviour().gossipsub.topics().any(|topic| *topic == topic_hash) { + if let Some(version) = self.handler.topic_version(&topic_hash) { + Metrics::block_topic_blocked_total(version, "outbound").increment(1); + } + return Err(HandlerEncodeError::UnknownTopic(topic_hash).into()); + } let data = self.handler.encode(topic, payload)?; let id = self.swarm.behaviour_mut().gossipsub.publish(topic_hash, data)?; Metrics::unsafe_block_published().increment(1.0); @@ -236,9 +262,69 @@ where self.swarm.behaviour_mut() } - /// Attempts to select the next event from the Swarm. + /// Attempts to select the next event from the swarm, retiring obsolete block + /// topics periodically even while the network is idle. pub async fn next(&mut self) -> Option> { - self.swarm.next().await + loop { + if TokioInstant::now() >= self.next_topic_retirement { + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.retire_block_topics(timestamp); + self.next_topic_retirement = TokioInstant::now() + Self::TOPIC_RETIREMENT_INTERVAL; + } + + tokio::select! { + event = self.swarm.next() => return event, + _ = sleep_until(self.next_topic_retirement) => {} + } + } + } + + /// Unsubscribes obsolete block topics according to a local Unix timestamp. + /// + /// Returns the number of subscriptions removed. Future topics are retained + /// and removed topics are not rejoined if the local clock moves backwards. + pub fn retire_block_topics(&mut self, timestamp: u64) -> usize { + let active_topics = self.handler.topics_at(timestamp); + let mut retired = 0; + for (version, topic) in [ + ("v1", &self.handler.blocks_v1_topic), + ("v2", &self.handler.blocks_v2_topic), + ("v3", &self.handler.blocks_v3_topic), + ] { + if !active_topics.contains(&topic.hash()) + && self.swarm.behaviour_mut().gossipsub.unsubscribe(topic) + { + info!(target: "gossip", topic = %topic, "Retired block gossip topic"); + Metrics::block_topic_retirements_total(version).increment(1); + retired += 1; + } + } + #[cfg(feature = "metrics")] + self.record_block_topic_metrics(); + retired + } + + /// Samples actual subscriptions and peer membership, including zeroes for retired topics. + /// Called at startup and on every retirement check, even when the network is idle. + #[cfg(feature = "metrics")] + pub fn record_block_topic_metrics(&self) { + let gossip = &self.swarm.behaviour().gossipsub; + for (version, topic) in [ + ("v1", &self.handler.blocks_v1_topic), + ("v2", &self.handler.blocks_v2_topic), + ("v3", &self.handler.blocks_v3_topic), + ("v4", &self.handler.blocks_v4_topic), + ] { + let hash = topic.hash(); + let subscribed = gossip.topics().any(|topic| *topic == hash); + Metrics::block_topic_subscribed(version).set(u8::from(subscribed)); + Metrics::block_topic_mesh_peers(version).set(gossip.mesh_peers(&hash).count() as f64); + let peers = gossip.all_peers().filter(|(_, topics)| topics.contains(&&hash)).count(); + Metrics::block_topic_peers(version).set(peers as f64); + } } /// Returns the number of connected peers. @@ -450,7 +536,9 @@ where } => { trace!(target: "gossip", topic = %message.topic, "Received message"); Metrics::gossip_event("message").increment(1.0); - if self.handler.topics().contains(&message.topic) { + // Subscription state makes retirement irreversible; the handler + // checks the clock once, before decoding, between sweeps. + if self.swarm.behaviour().gossipsub.topics().any(|topic| *topic == message.topic) { let (status, payload) = self.handler.handle(message); _ = self .swarm @@ -459,6 +547,9 @@ where .report_message_validation_result(&id, &src, status); return payload; } + if let Some(version) = self.handler.topic_version(&message.topic) { + Metrics::block_topic_blocked_total(version, "inbound").increment(1); + } } libp2p::gossipsub::Event::Subscribed { peer_id, topic } => { trace!(target: "gossip", peer_id = %peer_id, topic = ?topic, "Peer subscribed"); @@ -579,9 +670,206 @@ fn peerstore_eviction_candidate( #[cfg(test)] mod tests { use alloy_chains::Chain; + use alloy_eips::eip7685::EMPTY_REQUESTS_HASH; + use alloy_primitives::{B256, Signature}; + use alloy_rpc_types_engine::{ExecutionPayloadV2, ExecutionPayloadV3}; + use base_common_genesis::UpgradeConfig; + use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadV4, PayloadHash}; + use libp2p::gossipsub::{Message, MessageAcceptance}; + #[cfg(feature = "metrics")] + use metrics_exporter_prometheus::PrometheusBuilder; use super::*; + /// A real TCP gossip pair. The second peer retains its original subscriptions + /// to model a node that has not implemented topic retirement yet. + #[derive(std::fmt::Debug)] + pub struct GossipPair { + /// Peer running the production retirement loop. + pub retiring: GossipDriver, + /// Peer retaining the subscriptions established at startup. + pub legacy: GossipDriver, + } + + impl GossipPair { + /// Drives both real swarms and fails if their connection closes. + pub async fn poll(&mut self) -> Option { + tokio::select! { + event = self.retiring.next() => { + let event = event.expect("retiring swarm remains open"); + assert!(!matches!(event, SwarmEvent::ConnectionClosed { .. }), "{event:?}"); + self.retiring.handle_event(event) + } + // Poll the swarm directly so this peer keeps advertising old topics. + event = self.legacy.swarm.next() => { + let event = event.expect("legacy swarm remains open"); + assert!(!matches!(event, SwarmEvent::ConnectionClosed { .. }), "{event:?}"); + self.legacy.handle_event(event) + } + } + } + + /// Publishes a fresh V4 payload and waits for its validated delivery. + pub async fn publish_v4(&mut self) { + let mut block = crate::v4_valid_block(); + block.header.requests_hash = Some(EMPTY_REQUESTS_HASH); + let payload = BaseExecutionPayloadV4::from_v3_with_withdrawals_root( + ExecutionPayloadV3::from_block_slow(&block), + block.header.withdrawals_root.unwrap(), + ); + let envelope = NetworkPayloadEnvelope { + payload: BaseExecutionPayload::V4(payload), + signature: Signature::test_signature(), + payload_hash: PayloadHash(B256::ZERO), + parent_beacon_block_root: block.header.parent_beacon_block_root, + }; + let decoded = + NetworkPayloadEnvelope::decode_v4(&envelope.encode_v4().unwrap()).unwrap(); + let signing_hash = decoded + .payload_hash + .signature_message(self.retiring.handler.rollup_config.l2_chain_id.id()); + let signer = decoded.signature.recover_address_from_prehash(&signing_hash).unwrap(); + let (_sender, receiver) = tokio::sync::watch::channel(signer); + self.retiring.handler.signer_recv = receiver; + let expected_hash = envelope.payload.block_hash(); + self.legacy.publish(|handler| handler.blocks_v4_topic.clone(), Some(envelope)).unwrap(); + loop { + if let Some(received) = self.poll().await { + assert_eq!(received.payload.block_hash(), expected_hash); + break; + } + } + } + } + + #[tokio::test] + async fn tcp_peer_observes_retirement_without_losing_v4_propagation() { + tokio::time::timeout(Duration::from_secs(15), async { + let mut pair = GossipPair { retiring: test_driver(), legacy: test_driver() }; + let address = pair.retiring.start().await.unwrap(); + pair.legacy.start().await.unwrap(); + pair.legacy.swarm.dial(address).unwrap(); + let v3 = pair.retiring.handler.blocks_v3_topic.hash(); + let v4 = pair.retiring.handler.blocks_v4_topic.hash(); + + // Both old and future topic meshes must actually form before retirement. + while [&pair.retiring, &pair.legacy].iter().any(|driver| { + let gossip = &driver.swarm.behaviour().gossipsub; + gossip.mesh_peers(&v3).count() != 1 || gossip.mesh_peers(&v4).count() != 1 + }) { + pair.poll().await; + } + + // Activate Isthmus within its grace period, without a wall-clock sleep. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + pair.retiring.handler.rollup_config.upgrades.isthmus_time = Some(now); + pair.legacy.handler.rollup_config.upgrades.isthmus_time = Some(now); + assert_eq!(pair.retiring.retire_block_topics(now + 59), 0); + pair.publish_v4().await; + + // Advance the retirement action to the exact boundary. The legacy + // peer must learn the unsubscribe over the wire, not by shared state. + assert_eq!(pair.retiring.retire_block_topics(now + 60), 3); + loop { + if pair + .legacy + .swarm + .behaviour() + .gossipsub + .all_peers() + .any(|(peer, topics)| peer == pair.retiring.local_peer_id() && topics == [&v4]) + { + break; + } + pair.poll().await; + } + assert_eq!(pair.retiring.retire_block_topics(now), 0, "rollback must not rejoin"); + assert!(pair.retiring.swarm.is_connected(pair.legacy.local_peer_id())); + assert_eq!(pair.retiring.swarm.behaviour().gossipsub.mesh_peers(&v4).count(), 1); + assert_eq!(pair.legacy.swarm.behaviour().gossipsub.mesh_peers(&v4).count(), 1); + pair.publish_v4().await; + + #[cfg(feature = "metrics")] + { + let recorder = PrometheusBuilder::new().build_recorder(); + metrics::with_local_recorder(&recorder, || { + pair.retiring.record_block_topic_metrics(); + }); + let output = recorder.handle().render(); + // A legacy peer still advertises V3, but is no longer in our V3 mesh. + assert!(output.contains("base_node_block_topic_subscribed{version=\"v3\"} 0")); + assert!(output.contains("base_node_block_topic_peers{version=\"v3\"} 1")); + assert!(output.contains("base_node_block_topic_mesh_peers{version=\"v3\"} 0")); + assert!(output.contains("base_node_block_topic_subscribed{version=\"v4\"} 1")); + assert!(output.contains("base_node_block_topic_peers{version=\"v4\"} 1")); + assert!(output.contains("base_node_block_topic_mesh_peers{version=\"v4\"} 1")); + } + }) + .await + .expect("mesh formation, unsubscribe and V4 delivery must complete"); + } + + #[test] + #[cfg(feature = "metrics")] + fn topic_metrics_report_startup_retirement_and_rollback() { + let recorder = PrometheusBuilder::new().build_recorder(); + let handle = recorder.handle(); + metrics::with_local_recorder(&recorder, || { + let mut driver = test_driver(); + for version in ["v1", "v2", "v3", "v4"] { + assert!(handle.render().contains(&format!( + "base_node_block_topic_subscribed{{version=\"{version}\"}} 1" + ))); + } + driver.handler.rollup_config.upgrades.isthmus_time = Some(100); + driver.retire_block_topics(160); + driver.retire_block_topics(160); + driver.retire_block_topics(0); + let output = handle.render(); + for version in ["v1", "v2", "v3"] { + assert!(output.contains(&format!( + "base_node_block_topic_subscribed{{version=\"{version}\"}} 0" + ))); + assert!(output.contains(&format!( + "base_node_block_topic_retirements_total{{version=\"{version}\"}} 1" + ))); + } + assert!(output.contains("base_node_block_topic_subscribed{version=\"v4\"} 1")); + assert!(output.contains("base_node_block_topic_mesh_peers{version=\"v3\"} 0")); + assert!(output.contains("base_node_block_topic_peers{version=\"v4\"} 0")); + }); + } + + #[test] + #[cfg(feature = "metrics")] + fn startup_metrics_distinguish_skipped_topics_from_runtime_retirement() { + let recorder = PrometheusBuilder::new().build_recorder(); + metrics::with_local_recorder(&recorder, || { + let config = RollupConfig { + upgrades: UpgradeConfig { isthmus_time: Some(0), ..Default::default() }, + ..Default::default() + }; + let (_driver, _signer) = GossipDriver::::builder( + config, + Address::ZERO, + "/ip4/127.0.0.1/tcp/0".parse().unwrap(), + Keypair::generate_secp256k1(), + ) + .build() + .unwrap(); + }); + let output = recorder.handle().render(); + for version in ["v1", "v2", "v3"] { + assert!( + output.contains(&format!( + "base_node_block_topic_subscribed{{version=\"{version}\"}} 0" + )) + ); + } + assert!(output.contains("base_node_block_topic_subscribed{version=\"v4\"} 1")); + assert!(!output.contains("base_node_block_topic_retirements_total")); + } + fn test_driver() -> GossipDriver { let rollup_config = RollupConfig { l2_chain_id: Chain::base_mainnet(), @@ -601,6 +889,168 @@ mod tests { driver } + #[test] + fn obsolete_topics_leave_the_mesh_after_the_grace_period() { + let mut driver = test_driver(); + driver.handler.rollup_config.upgrades = UpgradeConfig { + canyon_time: Some(100), + ecotone_time: Some(200), + isthmus_time: Some(300), + ..Default::default() + }; + let all = [ + driver.handler.blocks_v1_topic.hash(), + driver.handler.blocks_v2_topic.hash(), + driver.handler.blocks_v3_topic.hash(), + driver.handler.blocks_v4_topic.hash(), + ]; + for (timestamp, first_topic, removed) in [ + (159, 0, 0), + (160, 1, 1), + (160, 1, 0), + (259, 1, 0), + (260, 2, 1), + (359, 2, 0), + (360, 3, 1), + (0, 3, 0), + ] { + assert_eq!(driver.retire_block_topics(timestamp), removed); + let mut subscribed = + driver.swarm.behaviour().gossipsub.topics().cloned().collect::>(); + subscribed.sort(); + assert_eq!(subscribed, all[first_topic..], "timestamp {timestamp}"); + } + } + + #[tokio::test(start_paused = true)] + async fn idle_polling_retires_topics_without_an_inbound_message() { + let mut driver = test_driver(); + // Complete the initial no-op retirement check, then cancel the idle + // poll. Subsequent checks must still run without a swarm event. + assert!(tokio::time::timeout(Duration::from_millis(1), driver.next()).await.is_err()); + assert_eq!(driver.swarm.behaviour().gossipsub.topics().count(), 4); + + // Make retirement due after startup without depending on wall-clock + // sleeps. Tokio's paused clock advances the periodic poll deadline. + driver.handler.rollup_config.upgrades.isthmus_time = Some(0); + assert!( + tokio::time::timeout( + GossipDriver::::TOPIC_RETIREMENT_INTERVAL * 2, + driver.next() + ) + .await + .is_err() + ); + assert_eq!( + driver.swarm.behaviour().gossipsub.topics().cloned().collect::>(), + [driver.handler.blocks_v4_topic.hash()] + ); + } + + #[test] + fn retired_topics_are_blocked_before_the_subscription_sweep() { + #[cfg(feature = "metrics")] + let recorder = PrometheusBuilder::new().build_recorder(); + #[cfg(feature = "metrics")] + let _metrics_guard = metrics::set_default_local_recorder(&recorder); + + let mut driver = test_driver(); + // Make the clock cutoff due while the startup subscription still exists. + driver.handler.rollup_config.upgrades.isthmus_time = Some(0); + let topic = driver.handler.blocks_v2_topic.hash(); + assert!(driver.swarm.behaviour().gossipsub.topics().any(|subscribed| *subscribed == topic)); + let envelope = NetworkPayloadEnvelope { + payload: BaseExecutionPayload::V2(ExecutionPayloadV2::from_block_slow( + &crate::v2_valid_block(), + )), + signature: Signature::test_signature(), + payload_hash: PayloadHash(B256::ZERO), + parent_beacon_block_root: None, + }; + assert!(matches!( + driver.publish(|handler| handler.blocks_v2_topic.clone(), Some(envelope)), + Err(PublishError::EncodeError(HandlerEncodeError::UnknownTopic(_))) + )); + let event = + SwarmEvent::Behaviour(Event::Gossipsub(Box::new(libp2p::gossipsub::Event::Message { + propagation_source: PeerId::random(), + message_id: MessageId(vec![1]), + message: Message { source: None, sequence_number: None, topic, data: vec![0xff] }, + }))); + assert!(driver.handle_event(event).is_none()); + #[cfg(feature = "metrics")] + { + let output = recorder.handle().render(); + for direction in ["inbound", "outbound"] { + assert!(output.contains(&format!( + "base_node_block_topic_blocked_total{{version=\"v2\",direction=\"{direction}\"}} 1" + )), "{output}"); + } + } + } + + #[test] + fn retired_topics_stay_disabled_after_a_clock_rollback() { + #[cfg(feature = "metrics")] + let recorder = PrometheusBuilder::new().build_recorder(); + #[cfg(feature = "metrics")] + let _metrics_guard = metrics::set_default_local_recorder(&recorder); + + let mut driver = test_driver(); + driver.handler.rollup_config.upgrades.isthmus_time = Some(100); + assert_eq!(driver.retire_block_topics(160), 3); + // Even if the clock/config subsequently permits an older version, + // GossipSub's ability to publish without subscribing must not revive it. + driver.handler.rollup_config.upgrades = UpgradeConfig::default(); + let envelope = NetworkPayloadEnvelope { + payload: BaseExecutionPayload::V2(ExecutionPayloadV2::from_block_slow( + &crate::v2_valid_block(), + )), + signature: Signature::test_signature(), + payload_hash: PayloadHash(B256::ZERO), + parent_beacon_block_root: None, + }; + let encoded = envelope.encode_v2().unwrap(); + let decoded = NetworkPayloadEnvelope::decode_v2(&encoded).unwrap(); + let signing_hash = + decoded.payload_hash.signature_message(driver.handler.rollup_config.l2_chain_id.id()); + let signer = decoded.signature.recover_address_from_prehash(&signing_hash).unwrap(); + let (_, receiver) = tokio::sync::watch::channel(signer); + driver.handler.signer_recv = receiver; + let message = Message { + source: None, + sequence_number: None, + topic: driver.handler.blocks_v2_topic.hash(), + data: encoded, + }; + // The restored policy and signature would otherwise accept this block. + assert!(matches!( + driver.handler.clone().handle(message.clone()), + (MessageAcceptance::Accept, Some(_)) + )); + + assert!(matches!( + driver.publish(|handler| handler.blocks_v2_topic.clone(), Some(envelope)), + Err(PublishError::EncodeError(HandlerEncodeError::UnknownTopic(_))) + )); + let event = + SwarmEvent::Behaviour(Event::Gossipsub(Box::new(libp2p::gossipsub::Event::Message { + propagation_source: PeerId::random(), + message_id: MessageId(vec![1]), + message, + }))); + assert!(driver.handle_event(event).is_none()); + #[cfg(feature = "metrics")] + { + let output = recorder.handle().render(); + for direction in ["inbound", "outbound"] { + assert!(output.contains(&format!( + "base_node_block_topic_blocked_total{{version=\"v2\",direction=\"{direction}\"}} 1" + )), "{output}"); + } + } + } + #[tokio::test] async fn clear_pending_connections_aborts_known_peer_dials() { let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); diff --git a/crates/consensus/gossip/src/handler.rs b/crates/consensus/gossip/src/handler.rs index 18713c2a63..5f5d00724f 100644 --- a/crates/consensus/gossip/src/handler.rs +++ b/crates/consensus/gossip/src/handler.rs @@ -1,15 +1,18 @@ //! Block Handler -use std::collections::{BTreeMap, HashSet}; +use std::{ + collections::{BTreeMap, HashSet}, + time::SystemTime, +}; use alloy_primitives::{Address, B256}; use base_common_genesis::RollupConfig; -use base_common_rpc_types_engine::NetworkPayloadEnvelope; +use base_common_rpc_types_engine::{NetworkPayloadEnvelope, PayloadEnvelopeError}; use libp2p::gossipsub::{IdentTopic, Message, MessageAcceptance, TopicHash}; use tokio::sync::watch::Receiver; use tracing::instrument; -use crate::HandlerEncodeError; +use crate::{HandlerEncodeError, Metrics}; /// This trait defines the functionality required to process incoming messages /// and determine their acceptance within the network. @@ -57,20 +60,30 @@ impl Handler for BlockHandler { ) )] fn handle(&mut self, msg: Message) -> (MessageAcceptance, Option) { - let decoded = if msg.topic == self.blocks_v1_topic.hash() { - NetworkPayloadEnvelope::decode_v1(&msg.data) - } else if msg.topic == self.blocks_v2_topic.hash() { - NetworkPayloadEnvelope::decode_v2(&msg.data) - } else if msg.topic == self.blocks_v3_topic.hash() { - NetworkPayloadEnvelope::decode_v3(&msg.data) - } else if msg.topic == self.blocks_v4_topic.hash() { - NetworkPayloadEnvelope::decode_v4(&msg.data) - } else { - warn!(target: "gossip", topic = ?msg.topic, "Received block with unknown topic"); - return (MessageAcceptance::Reject, None); - }; + let decode: fn(&[u8]) -> Result = + if msg.topic == self.blocks_v1_topic.hash() { + NetworkPayloadEnvelope::decode_v1 + } else if msg.topic == self.blocks_v2_topic.hash() { + NetworkPayloadEnvelope::decode_v2 + } else if msg.topic == self.blocks_v3_topic.hash() { + NetworkPayloadEnvelope::decode_v3 + } else if msg.topic == self.blocks_v4_topic.hash() { + NetworkPayloadEnvelope::decode_v4 + } else { + warn!(target: "gossip", topic = ?msg.topic, "Received block with unknown topic"); + return (MessageAcceptance::Reject, None); + }; + + // Do not decompress or SSZ-decode queued messages from a retired topic. + if !self.topics().contains(&msg.topic) { + if let Some(version) = self.topic_version(&msg.topic) { + Metrics::block_topic_blocked_total(version, "inbound").increment(1); + } + trace!(target: "gossip", topic = %msg.topic, "Ignoring retired block topic"); + return (MessageAcceptance::Ignore, None); + } - match decoded { + match decode(&msg.data) { Ok(envelope) => { tracing::Span::current() .record("block_hash", tracing::field::display(envelope.payload.block_hash())); @@ -90,18 +103,53 @@ impl Handler for BlockHandler { } } - /// The gossip topics accepted for new blocks + /// Topics not yet retired according to the local clock and configured forks. fn topics(&self) -> Vec { - vec![ + let timestamp = + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs(); + self.topics_at(timestamp) + } +} + +impl BlockHandler { + /// Maximum age of a gossip block, in seconds. + /// + /// Also defines the topic retirement grace period: after a replacement fork + /// has been active this long, every block from the old version is too old. + pub const MAX_BLOCK_AGE: u64 = 60; + + /// Returns a bounded version label for a known block topic. + /// Unknown peer-supplied topics must not become metric labels. + pub fn topic_version(&self, topic: &TopicHash) -> Option<&'static str> { + [ + ("v1", &self.blocks_v1_topic), + ("v2", &self.blocks_v2_topic), + ("v3", &self.blocks_v3_topic), + ("v4", &self.blocks_v4_topic), + ] + .into_iter() + .find_map(|(version, known)| (known.hash() == *topic).then_some(version)) + } + + /// Returns the non-retired gossip topics at a local Unix timestamp. + /// + /// Future topics remain subscribed ahead of activation so the mesh is ready + /// for a fork. An old topic retires once it can no longer carry a block in + /// the accepted timestamp window. This uses the local clock, never a peer's + /// claimed block timestamp, and respects custom fork configurations. + pub fn topics_at(&self, timestamp: u64) -> Vec { + let oldest_topic = self.topic(timestamp.saturating_sub(Self::MAX_BLOCK_AGE)).hash(); + [ self.blocks_v1_topic.hash(), self.blocks_v2_topic.hash(), self.blocks_v3_topic.hash(), self.blocks_v4_topic.hash(), ] + .into_iter() + .skip_while(|topic| *topic != oldest_topic) + .collect() } -} -impl BlockHandler { /// Creates a new [`BlockHandler`]. /// /// Requires the chain ID and a receiver channel for the unsafe block signer. @@ -135,11 +183,23 @@ impl BlockHandler { /// Encodes a [`NetworkPayloadEnvelope`] into a byte array /// based on the specified topic. + /// + /// Retired and unknown topics are rejected. Historical payloads can still + /// be encoded directly through the [`NetworkPayloadEnvelope`] codecs. + /// The local-clock cutoff applies even before the driver's next subscription + /// sweep. The driver separately checks subscription state so a clock rollback + /// cannot re-enable publishing on an already-unsubscribed topic. pub fn encode( &self, topic: IdentTopic, envelope: NetworkPayloadEnvelope, ) -> Result, HandlerEncodeError> { + if !self.topics().contains(&topic.hash()) { + if let Some(version) = self.topic_version(&topic.hash()) { + Metrics::block_topic_blocked_total(version, "outbound").increment(1); + } + return Err(HandlerEncodeError::UnknownTopic(topic.hash())); + } let encoded = match topic.hash() { hash if hash == self.blocks_v1_topic.hash() => envelope.encode_v1()?, hash if hash == self.blocks_v2_topic.hash() => envelope.encode_v2()?, @@ -162,10 +222,148 @@ mod tests { use base_common_genesis::{BaseUpgradeConfig, ChainGenesis, UpgradeConfig}; use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadV4, PayloadHash}; use base_protocol::BaseTimeUpdateTx; + #[cfg(feature = "metrics")] + use metrics_exporter_prometheus::PrometheusBuilder; use super::*; use crate::{v2_valid_block, v3_valid_block, v4_valid_block}; + #[test] + fn topics_retire_after_each_forks_gossip_age_window() { + let (_, signer) = tokio::sync::watch::channel(Address::ZERO); + let handler = BlockHandler::new( + RollupConfig { + upgrades: UpgradeConfig { + canyon_time: Some(100), + ecotone_time: Some(200), + isthmus_time: Some(300), + ..Default::default() + }, + ..Default::default() + }, + signer, + ); + let all = [ + handler.blocks_v1_topic.hash(), + handler.blocks_v2_topic.hash(), + handler.blocks_v3_topic.hash(), + handler.blocks_v4_topic.hash(), + ]; + + // At fork + 59, a block from the preceding second is still within the + // 60-second age window. At fork + 60, every pre-fork block is too old. + for (timestamp, first_topic) in [ + (0, 0), + (99, 0), + (100, 0), + (159, 0), + (160, 1), + (259, 1), + (260, 2), + (359, 2), + (360, 3), + (u64::MAX, 3), + ] { + assert_eq!(handler.topics_at(timestamp), all[first_topic..], "timestamp {timestamp}"); + } + } + + #[test] + fn topics_respect_unscheduled_simultaneous_and_genesis_forks() { + let (_, signer) = tokio::sync::watch::channel(Address::ZERO); + let mut handler = BlockHandler::new(RollupConfig::default(), signer); + let all = handler.topics_at(0); + assert_eq!(all.len(), 4); + assert_eq!(handler.topics_at(u64::MAX), all); + + // Later forks imply their predecessors, even if the earlier timestamps + // are omitted by a custom chain configuration. + handler.rollup_config.upgrades.isthmus_time = Some(100); + assert_eq!(handler.topics_at(159), all); + assert_eq!(handler.topics_at(160), [handler.blocks_v4_topic.hash()]); + + handler.rollup_config.upgrades.isthmus_time = Some(0); + assert_eq!(handler.topics_at(0), [handler.blocks_v4_topic.hash()]); + } + + #[test] + fn retired_topics_are_ignored_before_decode() { + #[cfg(feature = "metrics")] + let recorder = PrometheusBuilder::new().build_recorder(); + #[cfg(feature = "metrics")] + let _metrics_guard = metrics::set_default_local_recorder(&recorder); + + let (_, signer) = tokio::sync::watch::channel(Address::ZERO); + let mut handler = BlockHandler::new( + RollupConfig { + upgrades: UpgradeConfig { isthmus_time: Some(0), ..Default::default() }, + ..Default::default() + }, + signer, + ); + + for topic in [ + handler.blocks_v1_topic.hash(), + handler.blocks_v2_topic.hash(), + handler.blocks_v3_topic.hash(), + ] { + let message = Message { + source: None, + sequence_number: None, + topic, + // Invalid Snappy would be rejected, not ignored, if decoded. + data: vec![0xff], + }; + assert!(matches!(handler.handle(message), (MessageAcceptance::Ignore, None))); + } + for topic in [handler.blocks_v4_topic.hash(), IdentTopic::new("unknown").hash()] { + let message = Message { source: None, sequence_number: None, topic, data: vec![0xff] }; + assert!(matches!(handler.handle(message), (MessageAcceptance::Reject, None))); + } + #[cfg(feature = "metrics")] + { + let output = recorder.handle().render(); + for version in ["v1", "v2", "v3"] { + assert!(output.contains(&format!( + "base_node_block_topic_blocked_total{{version=\"{version}\",direction=\"inbound\"}} 1" + )), "{output}"); + } + assert!(!output.contains("unknown"), "unknown topics must not create metric labels"); + } + } + + #[test] + fn retired_topics_cannot_be_encoded_for_gossip() { + #[cfg(feature = "metrics")] + let recorder = PrometheusBuilder::new().build_recorder(); + #[cfg(feature = "metrics")] + let _metrics_guard = metrics::set_default_local_recorder(&recorder); + + let (_, signer) = tokio::sync::watch::channel(Address::ZERO); + let handler = BlockHandler::new( + RollupConfig { + upgrades: UpgradeConfig { isthmus_time: Some(0), ..Default::default() }, + ..Default::default() + }, + signer, + ); + let v2 = ExecutionPayloadV2::from_block_slow(&v2_valid_block()); + let envelope = NetworkPayloadEnvelope { + payload: BaseExecutionPayload::V2(v2), + signature: Signature::test_signature(), + payload_hash: PayloadHash(B256::ZERO), + parent_beacon_block_root: None, + }; + assert!(matches!( + handler.encode(handler.blocks_v2_topic.clone(), envelope), + Err(HandlerEncodeError::UnknownTopic(topic)) if topic == handler.blocks_v2_topic.hash() + )); + #[cfg(feature = "metrics")] + assert!(recorder.handle().render().contains( + "base_node_block_topic_blocked_total{version=\"v2\",direction=\"outbound\"} 1" + )); + } + #[test] fn denim_schedules_are_checked_before_gossip_acceptance() { let template = v4_valid_block(); @@ -491,7 +689,8 @@ mod tests { #[test] fn test_valid_decode_v4() { - let block = v4_valid_block(); + let mut block = v4_valid_block(); + block.header.requests_hash = Some(EMPTY_REQUESTS_HASH); let v3 = ExecutionPayloadV3::from_block_slow(&block); let v4 = BaseExecutionPayloadV4::from_v3_with_withdrawals_root( @@ -513,7 +712,11 @@ mod tests { let signer = envelope.signature.recover_address_from_prehash(&msg).unwrap(); let (_, unsafe_signer) = tokio::sync::watch::channel(signer); let mut handler = BlockHandler::new( - RollupConfig { l2_chain_id: Chain::base_mainnet(), ..Default::default() }, + RollupConfig { + l2_chain_id: Chain::base_mainnet(), + upgrades: UpgradeConfig { isthmus_time: Some(0), ..Default::default() }, + ..Default::default() + }, unsafe_signer, ); diff --git a/crates/consensus/gossip/src/lib.rs b/crates/consensus/gossip/src/lib.rs index 138d63daa5..d9cfa3434d 100644 --- a/crates/consensus/gossip/src/lib.rs +++ b/crates/consensus/gossip/src/lib.rs @@ -53,6 +53,8 @@ mod handler; pub use handler::{BlockHandler, Handler}; mod driver; +#[cfg(test)] +pub use driver::GossipPair; pub use driver::{GossipDriver, GossipDriverConfig}; mod block_validity; diff --git a/crates/consensus/gossip/src/metrics/mod.rs b/crates/consensus/gossip/src/metrics/mod.rs index 375b162136..a4339785fb 100644 --- a/crates/consensus/gossip/src/metrics/mod.rs +++ b/crates/consensus/gossip/src/metrics/mod.rs @@ -33,6 +33,27 @@ base_metrics::define_metrics! { #[describe("Number of peers connected to the libp2p gossip Swarm")] gossip_peer_count: gauge, + #[describe("Whether the local node is subscribed to a block topic (0 or 1)")] + #[label(name = "version", default = ["v1", "v2", "v3", "v4"])] + block_topic_subscribed: gauge, + + #[describe("Number of connected peers advertising a block topic")] + #[label(name = "version", default = ["v1", "v2", "v3", "v4"])] + block_topic_peers: gauge, + + #[describe("Number of peers in the local block topic mesh")] + #[label(name = "version", default = ["v1", "v2", "v3", "v4"])] + block_topic_mesh_peers: gauge, + + #[describe("Block topic subscriptions removed at runtime after their fork grace period")] + #[label(name = "version", default = ["v1", "v2", "v3"])] + block_topic_retirements_total: counter, + + #[describe("Block messages blocked by topic retirement or a removed subscription")] + #[label(name = "version", default = ["v1", "v2", "v3", "v4"])] + #[label(name = "direction", default = ["inbound", "outbound"])] + block_topic_blocked_total: counter, + #[describe("Number of peers dialed by the libp2p Swarm")] dial_peer: gauge, diff --git a/etc/systems/tests/gossip_topic_retirement.rs b/etc/systems/tests/gossip_topic_retirement.rs new file mode 100644 index 0000000000..8da67374f7 --- /dev/null +++ b/etc/systems/tests/gossip_topic_retirement.rs @@ -0,0 +1,88 @@ +//! Full-stack coverage for post-Isthmus gossip subscriptions and unsafe-head propagation. +//! Real consensus nodes expose peer-topic counts over RPC; batching is stopped before +//! selecting a future target so L1 derivation cannot substitute for unsafe propagation. + +use std::time::{Duration, SystemTime}; + +use alloy_eips::BlockNumberOrTag; +use alloy_provider::Provider; +use base_consensus_gossip::BlockHandler; +use base_consensus_rpc::{BaseP2PApiClient, RollupNodeApiClient}; +use base_system_tests::SystemTestStackBuilder; +use eyre::{Result, WrapErr}; +use jsonrpsee::http_client::HttpClientBuilder; +use tokio::time::{sleep, timeout}; + +#[tokio::test] +async fn retired_topics_preserve_connected_peers_and_unsafe_sync() -> Result<()> { + let system = SystemTestStackBuilder::new().with_l2_chain_id(84_538_477).build().await?; + let builder_rpc = HttpClientBuilder::default() + .build(system.l2_stack().builder_consensus_rpc_url().as_str())?; + let client_rpc = HttpClientBuilder::default() + .build(system.l2_stack().client_consensus_rpc_url().as_str())?; + let config = builder_rpc.rollup_config().await?; + let isthmus = config.upgrades.isthmus_time.expect("system stack enables Isthmus"); + + // Works whether the stack activates Isthmus at zero or at genesis time. + timeout(Duration::from_secs(90), async { + loop { + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?.as_secs(); + if now >= isthmus + BlockHandler::MAX_BLOCK_AGE { + let mut ready = true; + for rpc in [&builder_rpc, &client_rpc] { + let stats = rpc.opp2p_peer_stats().await?; + ready &= stats.connected > 0 + && stats.blocks_topic == 0 + && stats.blocks_topic_v2 == 0 + && stats.blocks_topic_v3 == 0 + && stats.blocks_topic_v4 > 0; + } + if ready { + return Ok::<_, eyre::Error>(()); + } + } + sleep(Duration::from_millis(250)).await; + } + }) + .await + .wrap_err("nodes did not retain V4 peering after old topics retired")??; + + // Stop production first so no in-flight batch can contain the target block. + system.l2_stack().builder_consensus().stop_sequencer().await?; + system.l2_stack().batcher().stop(); + let builder = system.l2_builder_provider()?; + let client = system.l2_client_provider()?; + let target = builder.get_block_number().await? + 5; + system.l2_stack().builder_consensus().start_sequencer().await?; + + timeout(Duration::from_secs(60), async { + loop { + let status = client_rpc.sync_status().await?; + if status.unsafe_l2.block_info.number >= target { + assert!(status.safe_l2.block_info.number < target, "target must arrive unsafely"); + let expected = builder + .get_block_by_number(BlockNumberOrTag::Number(target)) + .await? + .expect("builder produced target"); + let received = client + .get_block_by_number(BlockNumberOrTag::Number(target)) + .await? + .expect("client imported target"); + assert_eq!(received.header.hash, expected.header.hash); + for rpc in [&builder_rpc, &client_rpc] { + let stats = rpc.opp2p_peer_stats().await?; + assert!(stats.connected > 0 && stats.blocks_topic_v4 > 0); + assert_eq!( + (stats.blocks_topic, stats.blocks_topic_v2, stats.blocks_topic_v3), + (0, 0, 0) + ); + } + return Ok::<_, eyre::Error>(()); + } + sleep(Duration::from_millis(250)).await; + } + }) + .await + .wrap_err("client stopped importing unsafe blocks after topic retirement")??; + Ok(()) +} From 2035c59ac449e35237c12db032e22d8e1057c1f2 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 17 Sep 2026 15:08:15 +0000 Subject: [PATCH 032/299] feat(metrics): configure base batcher histogram buckets (#5076) Co-authored-by: Claude Co-authored-by: Cursor --- Cargo.lock | 2 + bin/base/src/cli.rs | 7 +-- crates/batcher/cli/Cargo.toml | 5 +++ crates/batcher/cli/src/lib.rs | 3 ++ crates/batcher/cli/src/metrics.rs | 62 ++++++++++++++++++++++++++ crates/utilities/cli/src/prometheus.rs | 25 ++++++++++- 6 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 crates/batcher/cli/src/metrics.rs diff --git a/Cargo.lock b/Cargo.lock index 70db8ca4f2..cc65ecad17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3045,6 +3045,8 @@ dependencies = [ "clap", "eyre", "humantime", + "metrics", + "metrics-exporter-prometheus", "tracing", "url", ] diff --git a/bin/base/src/cli.rs b/bin/base/src/cli.rs index 8a0ca3cb58..9fc9cb5c0f 100644 --- a/bin/base/src/cli.rs +++ b/bin/base/src/cli.rs @@ -50,10 +50,11 @@ impl BaseCli { let metrics_enabled = self.metrics.enabled; MetricsConfig::from(self.metrics) - .init_with(|| { - base_cli_utils::register_version_metrics!(); - }) + .init_with_builder(base_batcher_cli::configure_prometheus) .wrap_err("failed to install Prometheus recorder")?; + if metrics_enabled { + base_cli_utils::register_version_metrics!(); + } self.command.run(ChainResolver::new(self.chain), metrics_enabled) } diff --git a/crates/batcher/cli/Cargo.toml b/crates/batcher/cli/Cargo.toml index 64172a433a..eaef9e3c08 100644 --- a/crates/batcher/cli/Cargo.toml +++ b/crates/batcher/cli/Cargo.toml @@ -19,9 +19,14 @@ base-cli-utils.workspace = true base-tx-manager.workspace = true base-batcher-core.workspace = true alloy-signer-local.workspace = true +metrics-exporter-prometheus.workspace = true tracing = { workspace = true, features = ["std"] } base-runtime = { workspace = true, features = ["tokio"] } clap = { workspace = true, features = ["derive", "env"] } alloy-primitives = { workspace = true, features = ["std"] } base-batcher-encoder = { workspace = true, features = ["clap"] } base-batcher-service = { workspace = true, features = ["metrics"] } + +[dev-dependencies] +metrics.workspace = true +base-batcher-encoder = { workspace = true, features = ["metrics"] } diff --git a/crates/batcher/cli/src/lib.rs b/crates/batcher/cli/src/lib.rs index ee99a7afef..266a4eb36b 100644 --- a/crates/batcher/cli/src/lib.rs +++ b/crates/batcher/cli/src/lib.rs @@ -2,3 +2,6 @@ mod cli; pub use cli::{BatcherArgs, SignerCli}; + +mod metrics; +pub use metrics::configure_prometheus; diff --git a/crates/batcher/cli/src/metrics.rs b/crates/batcher/cli/src/metrics.rs new file mode 100644 index 0000000000..ee5be86d02 --- /dev/null +++ b/crates/batcher/cli/src/metrics.rs @@ -0,0 +1,62 @@ +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]; +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]; + +const HISTOGRAM_BUCKETS: &[(&str, &[f64])] = &[ + ("batcher.channel_compression_ratio", COMPRESSION_RATIO_BUCKETS), + ("batcher.blob_fill_ratio", BLOB_FILL_RATIO_BUCKETS), + ("batcher.channel_duration_blocks", CHANNEL_DURATION_BUCKETS), + ("batcher.l2_blocks_per_channel", L2_BLOCKS_PER_CHANNEL_BUCKETS), + ("batcher.blobs_per_tx", BLOBS_PER_TX_BUCKETS), +]; + +/// Configures Prometheus buckets for base batcher histograms. +pub fn configure_prometheus(builder: PrometheusBuilder) -> Result { + HISTOGRAM_BUCKETS.iter().copied().try_fold(builder, |builder, (metric, buckets)| { + builder.set_buckets_for_metric(Matcher::Full(metric.to_owned()), buckets) + }) +} + +#[cfg(test)] +mod tests { + use base_batcher_encoder::{BatcherMetrics, EncoderConfig}; + use metrics::with_local_recorder; + + use super::*; + + #[test] + fn blobs_per_tx_buckets_cover_protocol_limit() { + assert_eq!( + BLOBS_PER_TX_BUCKETS.last().copied(), + Some(EncoderConfig::MAX_BLOBS_PER_TX as f64) + ); + } + + #[test] + fn configured_histograms_render_as_prometheus_buckets() { + let recorder = configure_prometheus(PrometheusBuilder::new()).unwrap().build_recorder(); + let handle = recorder.handle(); + + with_local_recorder(&recorder, || { + BatcherMetrics::channel_compression_ratio().record(0.42); + BatcherMetrics::blob_fill_ratio().record(0.9); + BatcherMetrics::channel_duration_blocks().record(40.0); + BatcherMetrics::l2_blocks_per_channel().record(500.0); + BatcherMetrics::blobs_per_tx().record(6.0); + }); + + let rendered = handle.render(); + for (metric, _) in HISTOGRAM_BUCKETS { + let metric = metric.replace('.', "_"); + assert!(rendered.contains(&format!("# TYPE {metric} histogram"))); + assert!(rendered.contains(&format!("{metric}_bucket"))); + assert!(!rendered.contains(&format!("{metric}{{quantile="))); + } + } +} diff --git a/crates/utilities/cli/src/prometheus.rs b/crates/utilities/cli/src/prometheus.rs index 3bbe84b878..fcd553e7e2 100644 --- a/crates/utilities/cli/src/prometheus.rs +++ b/crates/utilities/cli/src/prometheus.rs @@ -19,8 +19,21 @@ impl PrometheusServer { /// Initialize a Prometheus metrics server on the given address and port. /// The interval specifies how often system metrics are collected, in seconds. pub fn init(addr: IpAddr, metrics_port: u16, interval: u64) -> Result<(), BuildError> { + Self::init_with_builder(addr, metrics_port, interval, Ok) + } + + /// Initialize a Prometheus metrics server with a customized builder. + pub fn init_with_builder( + addr: IpAddr, + metrics_port: u16, + interval: u64, + configure: F, + ) -> Result<(), BuildError> + where + F: FnOnce(PrometheusBuilder) -> Result, + { let prometheus_addr = SocketAddr::from((addr, metrics_port)); - let builder = PrometheusBuilder::new().with_http_listener(prometheus_addr); + let builder = configure(PrometheusBuilder::new().with_http_listener(prometheus_addr))?; builder.install()?; base_metrics::initialize_registered_metrics(); @@ -73,8 +86,16 @@ impl Default for MetricsConfig { impl MetricsConfig { /// Initialize the Prometheus metrics recorder. pub fn init(&self) -> Result<(), BuildError> { + self.init_with_builder(Ok) + } + + /// Initialize the Prometheus metrics recorder with a customized builder. + pub fn init_with_builder(&self, configure: F) -> Result<(), BuildError> + where + F: FnOnce(PrometheusBuilder) -> Result, + { if self.enabled { - PrometheusServer::init(self.addr, self.port, self.interval)?; + PrometheusServer::init_with_builder(self.addr, self.port, self.interval, configure)?; } Ok(()) } From ed1bcd8b381dc1a9f22bbcf9cc1a2779fe5e277e Mon Sep 17 00:00:00 2001 From: refcell Date: Thu, 17 Sep 2026 15:18:03 +0000 Subject: [PATCH 033/299] test(snapshots): Add Snapshot Discovery Coverage (#5108) * test(snapshots): cover CLI defaults and manifest discovery Amp-Thread-ID: https://ampcode.com/threads/T-01a0afbd-36e9-770e-aec3-f1c13fd27b10 Co-authored-by: Amp * test(snapshots): preserve parser sweep and parameterize ID cases Amp-Thread-ID: https://ampcode.com/threads/T-01a0afbd-36e9-770e-aec3-f1c13fd27b10 Co-authored-by: Amp * ci(snapshots): run advisory live checks on every PR Amp-Thread-ID: https://ampcode.com/threads/T-01a0afbd-36e9-770e-aec3-f1c13fd27b10 Co-authored-by: Amp * ci(snapshots): report live failures without masking check status Amp-Thread-ID: https://ampcode.com/threads/T-01a0afbd-36e9-770e-aec3-f1c13fd27b10 Co-authored-by: Amp * test(snapshots): exercise unified binary for live manifest checks Amp-Thread-ID: https://ampcode.com/threads/T-01a0afbd-36e9-770e-aec3-f1c13fd27b10 Co-authored-by: Amp --------- Co-authored-by: Amp --- .depot/workflows/snapshot-manifests.yml | 39 +++++ Cargo.lock | 3 + Justfile | 4 + bin/base/Cargo.toml | 4 + bin/base/tests/snapshot_defaults.rs | 186 ++++++++++++++++++++++++ crates/execution/cli/src/chainspec.rs | 17 +++ 6 files changed, 253 insertions(+) create mode 100644 .depot/workflows/snapshot-manifests.yml create mode 100644 bin/base/tests/snapshot_defaults.rs diff --git a/.depot/workflows/snapshot-manifests.yml b/.depot/workflows/snapshot-manifests.yml new file mode 100644 index 0000000000..9ce7187299 --- /dev/null +++ b/.depot/workflows/snapshot-manifests.yml @@ -0,0 +1,39 @@ +name: Snapshot Manifests +on: + pull_request: + merge_group: + schedule: + - cron: "30 7 * * *" + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true +env: + CARGO_TERM_COLOR: always + RUSTC_WRAPPER: "sccache" +permissions: + contents: read +jobs: + snapshot-manifests: + name: Snapshot Manifests + runs-on: depot-ubuntu-24.04-8 + timeout-minutes: 30 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + - uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + # Cargo builds this checkout's unified binary for CARGO_BIN_EXE_base. + - name: Test snapshot planning against local manifests + run: cargo test --locked --profile ci -p base --test snapshot_defaults + # Report failures normally; merge requirements are managed in branch protection. + - name: Check live discovery through the unified binary + if: github.event_name != 'merge_group' + run: cargo test --locked --profile ci -p base --test snapshot_defaults -- --ignored --nocapture diff --git a/Cargo.lock b/Cargo.lock index cc65ecad17..93e46e9ee2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2904,6 +2904,7 @@ name = "base" version = "0.0.0" dependencies = [ "alloy-chains", + "axum 0.8.9", "base-batcher-cli", "base-builder-cli", "base-builder-core", @@ -2930,7 +2931,9 @@ dependencies = [ "reth-cli-commands", "reth-cli-runner", "reth-cli-util", + "rstest", "serde", + "serde_json", "tempfile", "tokio", "tokio-util", diff --git a/Justfile b/Justfile index 4a7ce4afb8..5f3083288d 100644 --- a/Justfile +++ b/Justfile @@ -99,6 +99,10 @@ install-nextest: test: install-nextest build::contracts build::elfs cargo nextest run --workspace --all-features --exclude base-system-tests --no-fail-fast +# Checks published Base snapshot manifests without downloading snapshot archives (live network) +check-snapshot-manifests: + cargo test --locked -p base --test snapshot_defaults -- --ignored --nocapture + # Runs tests only for crates affected by changes vs main (excludes system tests) test-affected base="main": install-nextest build::contracts build::elfs #!/usr/bin/env bash diff --git a/bin/base/Cargo.toml b/bin/base/Cargo.toml index 8c4d847f5f..3a4956a9fd 100644 --- a/bin/base/Cargo.toml +++ b/bin/base/Cargo.toml @@ -57,10 +57,14 @@ serde = { workspace = true, features = ["derive"] } figment = { workspace = true, features = ["env", "toml"] } [dev-dependencies] +rstest.workspace = true reqwest.workspace = true tempfile.workspace = true +serde_json.workspace = true base-common-genesis.workspace = true figment = { workspace = true, features = ["test"] } +axum = { workspace = true, features = ["tokio", "http1"] } +tokio = { workspace = true, features = ["net", "time", "macros", "process", "rt-multi-thread"] } [features] jemalloc-prof = [ diff --git a/bin/base/tests/snapshot_defaults.rs b/bin/base/tests/snapshot_defaults.rs new file mode 100644 index 0000000000..025cc26a1c --- /dev/null +++ b/bin/base/tests/snapshot_defaults.rs @@ -0,0 +1,186 @@ +//! Subprocess regressions for Base snapshot defaults and metadata-only planning. +//! +//! Live discovery cases are ignored by default; run them with `just check-snapshot-manifests`. + +use std::{ + process::{Command, Output}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use axum::{ + Router, + http::{StatusCode, Uri}, +}; +use rstest::rstest; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::{net::TcpListener, process::Command as AsyncCommand, time::timeout}; + +/// Runs the unified binary's metadata-only download path in an isolated home. +#[derive(Debug)] +pub struct SnapshotCli; + +impl SnapshotCli { + /// Discovers or loads a manifest without creating a snapshot datadir. + pub async fn plan(chain: &str, manifest_url: Option<&str>) -> Output { + let home = TempDir::new().unwrap(); + let datadir = home.path().join("data"); + let mut command = AsyncCommand::new(env!("CARGO_BIN_EXE_base")); + command + .args([ + "--logs.stdout.quiet", + "snapshot", + "download", + "--chain", + chain, + "--non-interactive", + "--print-plan-json", + "--datadir", + ]) + .arg(&datadir) + .env("HOME", home.path()) + .env("XDG_CONFIG_HOME", home.path()) + .env("NO_COLOR", "1") + .env_remove("BASE_CHAIN") + .env_remove("BASE_NODE_LOG_DIR") + .env_remove("BASE_NODE_METRICS_ENABLED") + .kill_on_drop(true); + if let Some(url) = manifest_url { + command.args(["--manifest-url", url]); + } + + let output = timeout(Duration::from_secs(60), command.output()) + .await + .expect("snapshot planning exceeded 60 seconds") + .expect("failed to run base snapshot download"); + assert!(!datadir.exists(), "metadata-only planning created the snapshot datadir"); + output + } +} + +#[test] +fn snapshot_download_help_advertises_base_defaults() { + let isolated_home = TempDir::new().expect("failed to create isolated home directory"); + + let output = Command::new(env!("CARGO_BIN_EXE_base")) + .args(["snapshot", "download", "--help"]) + .env("NO_COLOR", "1") + .env("HOME", isolated_home.path()) + .env("XDG_CONFIG_HOME", isolated_home.path()) + .env_remove("BASE_CHAIN") + .output() + .expect("failed to run base snapshot download --help"); + + assert!( + output.status.success(), + "snapshot download help failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let help = String::from_utf8(output.stdout).expect("snapshot download help must be UTF-8"); + let help = help.split_whitespace().collect::>().join(" "); + for expected in [ + "Browse available snapshots at https://chain.base.org", + "https://mainnet-v2-snapshots.base.org (--chain mainnet)", + "https://sepolia-v2-snapshots.base.org (--chain sepolia)", + "https://zeronet-v2-snapshots.base.org (--chain zeronet)", + ] { + assert!(help.contains(expected), "snapshot download help is missing {expected:?}"); + } + for legacy_source in ["snapshots.reth.rs", "publicnode.com/snapshots"] { + assert!( + !help.contains(legacy_source), + "snapshot download help still advertises legacy source {legacy_source:?}" + ); + } +} + +#[rstest] +#[case::valid(StatusCode::OK, false, None)] +#[case::invalid_schema(StatusCode::OK, true, Some("storage_version"))] +#[case::http_error(StatusCode::NOT_FOUND, false, Some("404"))] +#[tokio::test] +async fn snapshot_plan_fetches_only_manifest( + #[case] status: StatusCode, + #[case] omit_storage_version: bool, + #[case] expected_error: Option<&str>, +) { + let mut manifest = json!({ + "chain_id": 763360, + "block": 1234, + "storage_version": 2, + "timestamp": 1700000000, + "components": { + "state": { + "file": "state.tar.zst", + "size": 73, + "output_files": [{ + "path": "mdbx.dat", + "size": 211, + "blake3": "0000000000000000000000000000000000000000000000000000000000000000" + }] + } + } + }); + if omit_storage_version { + manifest.as_object_mut().unwrap().remove("storage_version"); + } + let requests = Arc::new(Mutex::new(Vec::new())); + let request_log = Arc::clone(&requests); + let app = Router::new().fallback(move |uri: Uri| { + let requests = Arc::clone(&request_log); + let body = manifest.to_string(); + async move { + requests.lock().unwrap().push(uri.path().to_owned()); + if uri.path() == "/manifest.json" { + (status, body) + } else { + (StatusCode::NOT_FOUND, "archive requests are forbidden".to_owned()) + } + } + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let output = + SnapshotCli::plan("zeronet", Some(&format!("http://{address}/manifest.json"))).await; + server.abort(); + assert_eq!(*requests.lock().unwrap(), ["/manifest.json"]); + let stderr = String::from_utf8_lossy(&output.stderr); + if let Some(error) = expected_error { + assert!(!output.status.success(), "invalid manifest unexpectedly succeeded"); + assert!(stderr.contains(error), "expected {error:?} in {stderr}"); + } else { + assert!(output.status.success(), "snapshot planning failed: {stderr}"); + let plan: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(plan["chainId"], 763360); + assert_eq!(plan["block"], 1234); + assert_eq!(plan["totalDownloadSize"], 73); + assert_eq!(plan["totalOutputSize"], 211); + let archives = plan["archives"].as_array().unwrap(); + assert_eq!(archives.len(), 1); + assert_eq!(archives[0]["url"], format!("http://{address}/state.tar.zst")); + } +} + +#[rstest] +#[case::mainnet("mainnet", 8453)] +#[case::sepolia("sepolia", 84532)] +#[case::zeronet("zeronet", 763360)] +#[ignore = "queries production snapshot metadata; run with just check-snapshot-manifests"] +#[tokio::test] +async fn live_snapshot_plan(#[case] chain: &str, #[case] expected_id: u64) { + let output = SnapshotCli::plan(chain, None).await; + assert!( + output.status.success(), + "{chain} snapshot discovery failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let plan: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(plan["chainId"], expected_id); + let archives = plan["archives"].as_array().unwrap(); + assert!(!archives.is_empty(), "{chain} snapshot plan is empty"); + println!("{chain}: block {}, {} planned archives", plan["block"], archives.len()); +} diff --git a/crates/execution/cli/src/chainspec.rs b/crates/execution/cli/src/chainspec.rs index 0b36b78482..6a638fe4a9 100644 --- a/crates/execution/cli/src/chainspec.rs +++ b/crates/execution/cli/src/chainspec.rs @@ -33,6 +33,9 @@ pub fn chain_value_parser(s: &str) -> eyre::Result, eyre::Err #[cfg(test)] mod tests { + use reth_chainspec::EthChainSpec; + use rstest::rstest; + use super::*; #[test] @@ -44,4 +47,18 @@ mod tests { ); } } + + #[rstest] + #[case("mainnet", 8453)] + #[case("base", 8453)] + #[case("sepolia", 84532)] + #[case("base-sepolia", 84532)] + #[case("base_sepolia", 84532)] + #[case("zeronet", 763360)] + #[case("base-zeronet", 763360)] + #[case("dev", 84538453)] + fn parse_chain_id(#[case] chain: &str, #[case] expected_id: u64) { + let spec = BaseChainSpecParser::parse(chain).unwrap(); + assert_eq!(spec.chain().id(), expected_id); + } } From 8806083537327dbb2268be74302327c880d7a456 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 15:41:03 +0000 Subject: [PATCH 034/299] test(system): cover invalid unsafe payload progress (#5098) --- etc/systems/tests/invalid_new_payload.rs | 210 +++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 etc/systems/tests/invalid_new_payload.rs diff --git a/etc/systems/tests/invalid_new_payload.rs b/etc/systems/tests/invalid_new_payload.rs new file mode 100644 index 0000000000..d3fef4bb8b --- /dev/null +++ b/etc/systems/tests/invalid_new_payload.rs @@ -0,0 +1,210 @@ +//! System-style regression test for EL-invalid unsafe payload handling. + +use std::{sync::Arc, time::Duration}; + +use alloy_eips::{BlockNumberOrTag, eip2718::Encodable2718}; +use alloy_primitives::{Address, B256, Bloom, U256}; +use alloy_rpc_types_engine::{ + ExecutionPayloadV1, ForkchoiceUpdated, PayloadStatus, PayloadStatusEnum, +}; +use base_common_consensus::{BaseTxEnvelope, TxDeposit}; +use base_common_genesis::RollupConfig; +use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; +use base_consensus_derive::Signal; +use base_consensus_engine::{ + Engine, + test_utils::{MockEngineClient, TestEngineStateBuilder, test_engine_client_builder}, +}; +use base_consensus_node::{ + DerivationClientResult, EngineActorRequest, EngineError, EngineProcessor, + EngineRequestReceiver, NoopCheckpointWriter, ValidatorEngineRequestHandler, +}; +use base_protocol::{L1BlockInfoBedrock, L2BlockInfo}; +use eyre::{Result, WrapErr}; +use tokio::{ + sync::{mpsc, watch}, + time::{sleep, timeout}, +}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug)] +struct NoopDerivationClient; + +#[async_trait::async_trait] +impl base_consensus_node::EngineDerivationClient for NoopDerivationClient { + async fn notify_sync_completed(&self, _safe_head: L2BlockInfo) -> DerivationClientResult<()> { + Ok(()) + } + + async fn send_new_engine_safe_head( + &self, + _safe_head: L2BlockInfo, + ) -> DerivationClientResult<()> { + Ok(()) + } + + async fn send_signal(&self, _signal: Signal) -> DerivationClientResult<()> { + Ok(()) + } +} + +/// An EL-invalid unsafe payload must be consumed so the engine actor can accept an alternative +/// payload at the same height. Before the fix, the first payload was retried indefinitely and the +/// actor never received the following request. +#[tokio::test] +async fn el_invalid_unsafe_payload_does_not_block_following_payload() -> Result<()> { + let config = Arc::new(RollupConfig::default()); + let initial_head = l2_block_info(0, B256::with_last_byte(0xee), B256::ZERO); + let client = Arc::new( + test_engine_client_builder() + .with_config(Arc::clone(&config)) + .with_block_info_by_tag(BlockNumberOrTag::Latest, initial_head) + .with_new_payload_v2_response(invalid_payload_status()) + .with_fork_choice_updated_v3_response(valid_forkchoice_updated()) + .build(), + ); + let initial_state = TestEngineStateBuilder::new() + .with_unsafe_head(initial_head) + .with_safe_head(initial_head) + .with_finalized_head(initial_head) + .with_el_sync_finished(true) + .build(); + let (state_tx, state_rx) = watch::channel(initial_state); + let (queue_tx, _) = watch::channel(0usize); + let processor = EngineProcessor::new_with_checkpoint( + Arc::clone(&client), + Arc::clone(&config), + NoopDerivationClient, + Engine::new(initial_state, state_tx, queue_tx), + Arc::new(base_consensus_engine::NoopForkchoiceCheckpointReader), + Arc::new(NoopCheckpointWriter), + ); + let (request_tx, request_rx) = mpsc::channel(8); + let mut handle = ValidatorEngineRequestHandler::new(processor).start(request_rx); + + request_tx + .send(EngineActorRequest::ProcessUnsafeL2BlockRequest(Box::new(unsafe_payload( + initial_head.block_info.hash, + 1, + )))) + .await?; + + wait_for_new_payload_attempt(&client).await?; + client.set_new_payload_v2_response(valid_payload_status()).await; + + let valid_payload = unsafe_payload(initial_head.block_info.hash, 2); + let expected_hash = L2BlockInfo::from_payload_and_genesis( + valid_payload.execution_payload.clone(), + valid_payload.parent_beacon_block_root, + &config.genesis, + )? + .block_info + .hash; + request_tx + .send(EngineActorRequest::ProcessUnsafeL2BlockRequest(Box::new(valid_payload))) + .await?; + + let wanted_hash = expected_hash; + let mut state_updates = state_rx.clone(); + let state_update = timeout( + REQUEST_TIMEOUT, + state_updates + .wait_for(move |state| state.sync_state.unsafe_head().block_info.hash == wanted_hash), + ) + .await; + match state_update { + Ok(Ok(_)) => {} + Ok(Err(error)) => { + let result = handle.await.wrap_err("engine processor task panicked")?; + return Err(eyre::eyre!( + "engine state channel closed before following unsafe payload was inserted: {error:?}; engine result: {result:?}" + )); + } + Err(_) => { + return Err(eyre::eyre!( + "following unsafe payload was blocked by an EL-invalid payload; state: {:?}; engine finished: {}", + *state_rx.borrow(), + handle.is_finished(), + )); + } + } + + drop(request_tx); + let result = timeout(REQUEST_TIMEOUT, &mut handle) + .await + .wrap_err("engine processor did not stop after its request channel closed")? + .wrap_err("engine processor task panicked")?; + assert!( + matches!(result, Err(EngineError::ChannelClosed)), + "expected a clean ChannelClosed shutdown, got {result:?}" + ); + + Ok(()) +} + +async fn wait_for_new_payload_attempt(client: &MockEngineClient) -> Result<()> { + timeout(REQUEST_TIMEOUT, async { + loop { + if client.last_new_payload_v2().await.is_some() { + return; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .wrap_err("engine never attempted the EL-invalid payload")?; + Ok(()) +} + +fn invalid_payload_status() -> PayloadStatus { + PayloadStatus { + status: PayloadStatusEnum::Invalid { validation_error: "invalid transaction".into() }, + latest_valid_hash: None, + } +} + +const fn valid_payload_status() -> PayloadStatus { + PayloadStatus { status: PayloadStatusEnum::Valid, latest_valid_hash: None } +} + +const fn valid_forkchoice_updated() -> ForkchoiceUpdated { + ForkchoiceUpdated { payload_status: valid_payload_status(), payload_id: None } +} + +fn l2_block_info(number: u64, hash: B256, parent_hash: B256) -> L2BlockInfo { + L2BlockInfo { + block_info: base_protocol::BlockInfo { number, hash, parent_hash, timestamp: number }, + ..Default::default() + } +} + +fn unsafe_payload(parent_hash: B256, marker: u8) -> BaseExecutionPayloadEnvelope { + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: None, + execution_payload: BaseExecutionPayload::V1(ExecutionPayloadV1 { + parent_hash, + fee_recipient: Address::ZERO, + state_root: B256::with_last_byte(marker), + receipts_root: B256::ZERO, + logs_bloom: Bloom::ZERO, + prev_randao: B256::ZERO, + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: 1, + extra_data: Default::default(), + base_fee_per_gas: U256::ZERO, + block_hash: B256::with_last_byte(marker), + transactions: vec![l1_info_deposit_tx().into()], + }), + } +} + +fn l1_info_deposit_tx() -> Vec { + BaseTxEnvelope::from(TxDeposit { + input: L1BlockInfoBedrock::default().encode_calldata(), + ..Default::default() + }) + .encoded_2718() +} From 6dc19603fb31427ff7e1a6fdc3dfa5202489f930 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 15:42:09 +0000 Subject: [PATCH 035/299] feat(devnet): add base-bench just recipes (#5049) --- etc/docker/Justfile | 15 +++++++-------- etc/just/build.just | 4 ++++ etc/systems/README.md | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/etc/docker/Justfile b/etc/docker/Justfile index 02f4a14f35..0b89efc6c8 100644 --- a/etc/docker/Justfile +++ b/etc/docker/Justfile @@ -262,14 +262,13 @@ tests: _install-nextest _build-contracts tests-ci: _install-nextest _build-contracts RUST_MIN_STACK={{ system_test_min_stack }} cargo nextest run -P ci --locked -p base-system-tests --cargo-profile ci --no-fail-fast -# Runs system test benchmarks -bench name: - #!/usr/bin/env bash - set -euo pipefail - case "{{ name }}" in - b20-zk-proving) cargo bench -p base-system-tests --bench b20_zk_proving ;; - *) echo "unknown system test bench: {{ name }}" >&2; exit 1 ;; - esac +# Builds base-bench in release mode and runs a benchmark +bench *args: + cargo run --release -p base-system-tests --bin base-bench -- {{ args }} + +# Runs the B-20 ZK proving system benchmark +proofs-bench: + cargo bench -p base-system-tests --bench b20_zk_proving [private] _install-nextest: diff --git a/etc/just/build.just b/etc/just/build.just index 5b2eea759e..0c09ae4e74 100644 --- a/etc/just/build.just +++ b/etc/just/build.just @@ -37,6 +37,10 @@ elfs: node: cargo build --bin base-reth-node +# Builds the base-bench binary in release mode +base-bench: + cargo build --release -p base-system-tests --bin base-bench + # Builds the test contracts contracts: cd {{justfile_directory()}}/crates/utilities/test-utils/contracts && forge soldeer install && forge build diff --git a/etc/systems/README.md b/etc/systems/README.md index e1476c5d33..a8ee6dd61f 100644 --- a/etc/systems/README.md +++ b/etc/systems/README.md @@ -126,7 +126,7 @@ local devnet, runs the default 60-second plain-transfer profile, prints its load-test summary, and shuts everything down: ```bash -cargo run --release -p base-system-tests --bin base-bench +just devnet bench ``` It needs Docker, but it needs neither a snapshot nor a funded key. The generated @@ -150,7 +150,7 @@ CPU-bound far below the 400M block gas limit. mkdir -p results export BASE_BENCH_CLIENT_VERSION="base/v0.0.0-$(git rev-parse --short HEAD)" -cargo run --release -p base-system-tests --bin base-bench -- snapshot \ +just devnet bench snapshot \ --chain mainnet \ --builder-datadir "$BUILDER_DATADIR" \ --client-datadir "$CLIENT_DATADIR" \ From d6665a4a1fb86b2256a50b4e330f16479a576f4d Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Thu, 17 Sep 2026 16:14:44 +0000 Subject: [PATCH 036/299] fix(proof): reject unsupported chain IDs in the proof boot path (#5100) * fix(proof): reject unsupported chain IDs in the proof boot path The guest boot loader fell back to the oracle-provided rollup config for any chain ID without a compiled config, and to the oracle-provided L1 config for any L1 chain outside the built-in map. Proving is Base-only, so an unrecognized chain has no trusted configuration to execute against; the fallback only widened the set of node-served fields the guest would trust. Resolve the compiled chain config up front and reject anything outside the built-in Base set before the node-served rollup and L1 configs are read. The local devnet keeps its live node-served config because it is mutable by design, and it stays the only path that may take an L1 config from the oracle. The hosts validate the same policy before witness generation so a misconfigured worker fails with a clear error instead of building a witness that can never boot. The guest check remains the security boundary. Boot validation tests that mutate derivation parameters now boot as the devnet, the only supported chain that still executes the full node-served config. Co-authored-by: Cursor * refactor(proof): remove redundant chain support wrapper Co-authored-by: Codex --------- Co-authored-by: Cursor Co-authored-by: Codex --- Cargo.lock | 1 + crates/proof/host/Cargo.toml | 1 + crates/proof/host/src/error.rs | 3 + crates/proof/host/src/host.rs | 76 +++++++++++ crates/proof/proof/src/boot.rs | 211 +++++++++++++++---------------- crates/proof/proof/src/errors.rs | 10 ++ 6 files changed, 195 insertions(+), 107 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93e46e9ee2..01cb329eac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5346,6 +5346,7 @@ dependencies = [ "alloy-transport", "ark-ff 0.6.0", "async-trait", + "base-common-chains", "base-common-consensus", "base-common-evm", "base-common-genesis", diff --git a/crates/proof/host/Cargo.toml b/crates/proof/host/Cargo.toml index 7cfefc5e2a..5aea1b49dc 100644 --- a/crates/proof/host/Cargo.toml +++ b/crates/proof/host/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] # base base-protocol.workspace = true +base-common-chains.workspace = true base-common-consensus.workspace = true base-proof-mpt.workspace = true base-common-evm.workspace = true diff --git a/crates/proof/host/src/error.rs b/crates/proof/host/src/error.rs index cff8b5976f..fe987017cc 100644 --- a/crates/proof/host/src/error.rs +++ b/crates/proof/host/src/error.rs @@ -16,6 +16,9 @@ pub enum HostError { /// A custom error message. #[error("{0}")] Custom(String), + /// The configured L2 chain is not a built-in Base chain. + #[error("Unsupported L2 chain ID: {0}")] + UnsupportedChain(u64), /// Block not found error. #[error("Block not found: {0}")] BlockNotFound(B256), diff --git a/crates/proof/host/src/host.rs b/crates/proof/host/src/host.rs index a7e1e12fab..093c8e5e4a 100644 --- a/crates/proof/host/src/host.rs +++ b/crates/proof/host/src/host.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use alloy_provider::{Network, RootProvider}; +use base_common_chains::ChainConfig; use base_common_evm::BaseEvmFactory; use base_common_genesis::RollupConfig; use base_common_network::Base; @@ -39,11 +40,27 @@ impl Host { Self { config } } + /// Rejects a configuration for a chain the proof program cannot prove. + /// + /// The guest re-checks this when it loads its boot info and that check is the security + /// boundary; failing here keeps the host from spending witness generation on a chain that can + /// never boot, and surfaces the misconfiguration as a host error. + pub const fn validate_supported_chain(&self) -> Result<()> { + let chain_id = self.config.prover.l2_chain_id; + if ChainConfig::by_chain_id(chain_id).is_none() { + return Err(HostError::UnsupportedChain(chain_id)); + } + + Ok(()) + } + /// Starts the preimage server, communicating with the client over the provided channels. pub async fn start_server(&self, hint: C, preimage: C) -> Result>> where C: Channel + Send + Sync + 'static, { + self.validate_supported_chain()?; + let task_handle = if let Some(data_dir) = &self.config.data_dir { warn!( l2_chain_id = self.config.prover.l2_chain_id, @@ -101,6 +118,8 @@ impl Host { where W: WitnessOracle + std::fmt::Debug + 'static, { + self.validate_supported_chain()?; + let witness = Arc::new(witness); let providers = self.create_providers().await?; @@ -259,3 +278,60 @@ async fn rpc_provider(url: &str) -> Result> { .await .map_err(|e| HostError::Custom(format!("failed to connect to RPC at {url}: {e}"))) } + +#[cfg(test)] +mod tests { + use alloy_genesis::ChainConfig; + use base_proof_primitives::ProofRequest; + + use super::*; + use crate::ProverConfig; + + const UNSUPPORTED_CHAIN_ID: u64 = 999_999_999; + + fn host_for_chain(l2_chain_id: u64) -> Host { + Host::new(HostConfig { + request: ProofRequest::default(), + prover: ProverConfig { + l1_eth_url: "http://127.0.0.1:1".to_string(), + l2_eth_url: "http://127.0.0.1:1".to_string(), + l2_node_url: "http://127.0.0.1:1".to_string(), + l1_beacon_url: "http://127.0.0.1:1".to_string(), + l2_chain_id, + rollup_config: RollupConfig::default(), + l1_config: ChainConfig::default(), + enable_experimental_witness_endpoint: false, + }, + data_dir: None, + }) + } + + #[test] + fn accepts_built_in_base_chain() { + host_for_chain(8453).validate_supported_chain().expect("Base mainnet should be supported"); + } + + #[test] + fn rejects_chain_without_a_built_in_config() { + let err = host_for_chain(UNSUPPORTED_CHAIN_ID) + .validate_supported_chain() + .expect_err("unsupported chain should be rejected"); + + assert!(matches!(err, HostError::UnsupportedChain(UNSUPPORTED_CHAIN_ID))); + } + + /// The preimage server backs witness generation, so it must refuse to start for a chain the + /// guest cannot boot — before any RPC is dialed. + #[tokio::test] + async fn start_server_rejects_unsupported_chain() { + let hint = BidirectionalChannel::new().expect("hint channel should be created"); + let preimage = BidirectionalChannel::new().expect("preimage channel should be created"); + + let err = host_for_chain(UNSUPPORTED_CHAIN_ID) + .start_server(hint.host, preimage.host) + .await + .expect_err("preimage server should not start"); + + assert!(matches!(err, HostError::UnsupportedChain(UNSUPPORTED_CHAIN_ID))); + } +} diff --git a/crates/proof/proof/src/boot.rs b/crates/proof/proof/src/boot.rs index 7d6e8fc24a..8aae5a697c 100644 --- a/crates/proof/proof/src/boot.rs +++ b/crates/proof/proof/src/boot.rs @@ -41,9 +41,9 @@ pub const L2_CLAIM_BLOCK_NUMBER_KEY: U256 = uint!(4_U256); /// The local key identifier for the L2 chain ID. /// -/// This key retrieves the L2 network identifier, which is used to load the -/// appropriate rollup configuration and ensure network-specific validation -/// rules are applied correctly. +/// This key retrieves the L2 network identifier, which is used to load the appropriate rollup +/// configuration and ensure network-specific validation rules are applied correctly. Chain IDs +/// outside the built-in Base set are rejected during boot loading. pub const L2_CHAIN_ID_KEY: U256 = uint!(5_U256); /// The local key identifier for the L2 rollup configuration. @@ -56,9 +56,9 @@ pub const L2_ROLLUP_CONFIG_KEY: U256 = uint!(6_U256); /// The local key identifier for the L1 chain configuration. /// -/// This key is used as a fallback to retrieve the chain configuration from -/// the preimage oracle when no hardcoded configuration is available for the -/// given chain ID. Oracle-loaded configs require additional validation. +/// This key is used as a fallback to retrieve the chain configuration from the preimage oracle when +/// no built-in configuration is available for the L1 chain ID. Only the local devnet can reach that +/// fallback; every other supported chain pins a built-in L1 configuration. pub const L1_CONFIG_KEY: U256 = uint!(7_U256); /// The local key identifier for the proposer address. @@ -162,11 +162,13 @@ pub struct BootInfo { /// /// **Security**: Fixed built-in chains use trusted compiled derivation parameters. Their /// oracle-provided contract-backed upgrade timestamps are separately bound by `schedule_id`; - /// the mutable local devnet and unknown chains use the oracle-provided configuration. + /// only the mutable local devnet uses the oracle-provided configuration. Chain IDs outside the + /// built-in Base set are rejected. pub rollup_config: RollupConfig, /// An optional configuration for the l1 chain associated with the l2 chain. /// - /// **Security**: Loaded from built-in config (secure) or oracle (requires validation). + /// **Security**: Loaded from the built-in config for every supported chain. Only the mutable + /// local devnet may fall back to the oracle-provided configuration. pub l1_config: ChainConfig, /// The proposer address that will submit the proof transaction on-chain. /// @@ -245,6 +247,7 @@ impl BootInfo { /// - **Slice conversion errors**: Invalid data format for numeric values /// - **Serde errors**: Failed to deserialize rollup configuration /// - **Missing data**: Required boot parameters not available in oracle + /// - **Unsupported chain**: The committed L2 chain ID is not a built-in Base chain pub async fn load(oracle: &O) -> Result where O: PreimageOracleClient + Send, @@ -286,8 +289,13 @@ impl BootInfo { .map_err(OracleProviderError::SliceConversion)?, ); - let activation_admin_address = - base_common_chains::ChainConfig::beryl_activation_admin_address_by_chain_id(chain_id); + // Base is the only chain family this program proves. An unsupported chain ID has no + // compiled configuration to execute against, so reject it before reading — let alone + // trusting — any node-served rollup or L1 configuration. + let chain_config = base_common_chains::ChainConfig::by_chain_id(chain_id) + .ok_or(OracleProviderError::UnknownChainId(chain_id))?; + + let activation_admin_address = chain_config.beryl_activation_admin_address(); let ser_cfg = oracle .get(PreimageKey::new_local(L2_ROLLUP_CONFIG_KEY.to())) @@ -308,11 +316,10 @@ impl BootInfo { // Fixed built-in chains must execute with their compiled static derivation parameters. Only // contract-backed activation timestamps may come from the node, because ScheduleId commits // them separately. The local devnet is mutable, so its live node-served config is required. - let trusted_chain_config = - base_common_chains::ChainConfig::by_chain_id(chain_id).filter(|chain_config| { - chain_config.chain_id != base_common_chains::ChainConfig::DEVNET.chain_id - }); - let mut rollup_config = if let Some(chain_config) = trusted_chain_config { + let is_devnet = chain_config.chain_id == base_common_chains::ChainConfig::DEVNET.chain_id; + let mut rollup_config = if is_devnet { + oracle_rollup_config + } else { let mut trusted_rollup_config = chain_config.rollup_config(); for upgrade in BaseUpgrade::CONTRACT_VARIANTS { trusted_rollup_config.apply_upgrade_activation( @@ -321,34 +328,31 @@ impl BootInfo { ); } trusted_rollup_config - } else { - warn!( - target: "boot_loader", - chain_id, - "no fixed trusted rollup config available, falling back to preimage oracle; insecure in production without additional validation" - ); - oracle_rollup_config }; - // Attempt to load the L1 config from the rollup config's L1 chain ID. If there is no config - // for the chain, fall back to loading the config from the preimage oracle. - let l1_config = if let Some(config) = - base_common_chains::L1_CONFIGS.get(&rollup_config.l1_chain_id) - { - config.clone() - } else { - warn!( - target: "boot_loader", - chain_id = rollup_config.l1_chain_id, - "no l1 config found in built-in mapping, falling back to preimage oracle; insecure in production without additional validation" - ); - let ser_cfg = oracle - .get(PreimageKey::new_local(L1_CONFIG_KEY.to())) - .await - .map_err(OracleProviderError::Preimage)?; + // Every supported chain except the devnet pins its L1 chain ID in the compiled config, and + // each of those L1 chains has a built-in config. Only the devnet, whose L1 is deployed + // locally, may fall back to the node-served L1 config. + let l1_config = + if let Some(config) = base_common_chains::L1_CONFIGS.get(&rollup_config.l1_chain_id) { + config.clone() + } else if is_devnet { + warn!( + target: "boot_loader", + l1_chain_id = rollup_config.l1_chain_id, + "no built-in l1 config for the devnet L1, falling back to preimage oracle" + ); + let ser_cfg = oracle + .get(PreimageKey::new_local(L1_CONFIG_KEY.to())) + .await + .map_err(OracleProviderError::Preimage)?; - serde_json::from_slice(&ser_cfg).map_err(OracleProviderError::Serde)? - }; + serde_json::from_slice(&ser_cfg).map_err(OracleProviderError::Serde)? + } else { + return Err(OracleProviderError::MissingL1ChainConfig { + l1_chain_id: rollup_config.l1_chain_id, + }); + }; debug!( target: "boot_loader", @@ -500,7 +504,12 @@ mod tests { use super::*; - const ORACLE_CHAIN_ID: u64 = 999_999_999; + /// A chain ID outside the built-in Base set. + const UNSUPPORTED_CHAIN_ID: u64 = 999_999_999; + + /// The local devnet is the only supported chain that executes with the full node-served rollup + /// config, so tests that mutate derivation parameters boot as the devnet. + const DEVNET_CHAIN_ID: u64 = BaseChainConfig::DEVNET.chain_id; struct MockOracle { data: Vec<(PreimageKey, Vec)>, @@ -824,7 +833,7 @@ mod tests { oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 100u64.to_be_bytes().to_vec()); - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let err = BootInfo::load(&oracle).await.expect_err("zero block time should fail"); assert!(matches!(err, OracleProviderError::InvalidL2BlockTime)); @@ -841,7 +850,7 @@ mod tests { oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 100u64.to_be_bytes().to_vec()); - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let err = BootInfo::load(&oracle).await.expect_err("pre-genesis claim should fail"); assert!(matches!( @@ -863,7 +872,7 @@ mod tests { oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 100u64.to_be_bytes().to_vec()); - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let err = BootInfo::load(&oracle).await.expect_err("active Zenith upgrade should fail"); assert!(matches!(err, OracleProviderError::UncommittedZenithUpgrade)); @@ -885,7 +894,7 @@ mod tests { oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, CLAIM_BLOCK.to_be_bytes().to_vec()); - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let boot_info = BootInfo::load(&oracle).await.expect("boot info should load"); @@ -928,7 +937,7 @@ mod tests { oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, CLAIM_BLOCK.to_be_bytes().to_vec()); oracle.insert(L2_SCHEDULE_BLOCK_NUMBER_KEY, SCHEDULE_BLOCK.to_be_bytes().to_vec()); - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let boot_info = BootInfo::load(&oracle).await.expect("pre-Zenith subrange should load despite pin"); @@ -957,7 +966,7 @@ mod tests { oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, CLAIM_BLOCK.to_be_bytes().to_vec()); oracle.insert(L2_SCHEDULE_BLOCK_NUMBER_KEY, SCHEDULE_BLOCK.to_be_bytes().to_vec()); - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let err = BootInfo::load(&oracle) .await @@ -988,7 +997,7 @@ mod tests { if schedule_block != CLAIM_BLOCK { oracle.insert(L2_SCHEDULE_BLOCK_NUMBER_KEY, schedule_block.to_be_bytes().to_vec()); } - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let boot_info = BootInfo::load(&oracle).await.expect("boot info should load"); let mut expected_rollup_config = rollup_config.clone(); @@ -1025,7 +1034,7 @@ mod tests { oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 1_003u64.to_be_bytes().to_vec()); oracle.insert(L2_SCHEDULE_BLOCK_NUMBER_KEY, 1_007u64.to_be_bytes().to_vec()); - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let boot_info = BootInfo::load(&oracle).await.expect("Zenith activates after the claimed Denim block"); @@ -1036,7 +1045,7 @@ mod tests { oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 1_007u64.to_be_bytes().to_vec()); - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let err = BootInfo::load(&oracle).await.expect_err("Zenith is active at the claimed Denim block"); @@ -1055,7 +1064,7 @@ mod tests { oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 1u64.to_be_bytes().to_vec()); - oracle.insert_rollup_config(ORACLE_CHAIN_ID, &rollup_config); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); let err = BootInfo::load(&oracle).await.expect_err("zero L2 genesis timestamp should fail"); assert!(matches!(err, OracleProviderError::InvalidL2GenesisTimestamp)); @@ -1070,7 +1079,7 @@ mod tests { oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 40_308_263u64.to_be_bytes().to_vec()); - oracle.insert(L2_CHAIN_ID_KEY, ORACLE_CHAIN_ID.to_be_bytes().to_vec()); + oracle.insert(L2_CHAIN_ID_KEY, BaseChainConfig::MAINNET.chain_id.to_be_bytes().to_vec()); oracle.insert( L2_ROLLUP_CONFIG_KEY, serde_json::to_vec(&rollup_config).expect("rollup config should serialize"), @@ -1080,89 +1089,77 @@ mod tests { assert!(matches!( err, OracleProviderError::RollupConfigChainIdMismatch { - boot_chain_id: ORACLE_CHAIN_ID, + boot_chain_id: 8453, rollup_config_chain_id: 84532, } )); } #[tokio::test] - async fn accepts_oracle_rollup_config_with_matching_chain_id() { + async fn rejects_unsupported_chain_id() { let rollup_config = BaseChainConfig::SEPOLIA.rollup_config(); - let mut rollup_config_value = - serde_json::to_value(&rollup_config).expect("rollup config should convert to value"); - rollup_config_value["l2_chain_id"] = serde_json::json!(ORACLE_CHAIN_ID); - rollup_config_value["base"]["beryl"] = serde_json::Value::Null; let mut oracle = MockOracle::new(); oracle.insert(L1_HEAD_KEY, B256::repeat_byte(0x11).to_vec()); oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 40_308_263u64.to_be_bytes().to_vec()); - oracle.insert(L2_CHAIN_ID_KEY, ORACLE_CHAIN_ID.to_be_bytes().to_vec()); - oracle.insert( - L2_ROLLUP_CONFIG_KEY, - serde_json::to_vec(&rollup_config_value).expect("rollup config should serialize"), - ); + // The oracle config agrees with the committed chain ID, so the chain ID binding check + // passes and only the supported-chain gate can reject this boot. + oracle.insert_rollup_config(UNSUPPORTED_CHAIN_ID, &rollup_config); - let boot_info = BootInfo::load(&oracle).await.expect("boot info should load"); - - assert_eq!(boot_info.chain_id, ORACLE_CHAIN_ID); - assert_eq!(boot_info.activation_admin_address, None); - assert_eq!(boot_info.rollup_config.l2_chain_id.id(), ORACLE_CHAIN_ID); + let err = + BootInfo::load(&oracle).await.expect_err("unsupported chain ID should be rejected"); + assert!(matches!(err, OracleProviderError::UnknownChainId(UNSUPPORTED_CHAIN_ID))); } #[tokio::test] - async fn accepts_pre_beryl_oracle_chain_without_activation_admin() { - let rollup_config = BaseChainConfig::SEPOLIA.rollup_config(); - let mut rollup_config_value = - serde_json::to_value(&rollup_config).expect("rollup config should convert to value"); - rollup_config_value["l2_chain_id"] = serde_json::json!(ORACLE_CHAIN_ID); - rollup_config_value["base"] = serde_json::json!({ "beryl": u64::MAX }); + async fn falls_back_to_oracle_l1_config_for_a_devnet_l1() { + const DEVNET_L1_CHAIN_ID: u64 = 31_337; + + assert!( + !base_common_chains::L1_CONFIGS.contains_key(&DEVNET_L1_CHAIN_ID), + "premise: the devnet L1 has no built-in config" + ); + let mut rollup_config = BaseChainConfig::DEVNET.rollup_config(); + rollup_config.l1_chain_id = DEVNET_L1_CHAIN_ID; + rollup_config.genesis.l2_time = 1_000; let mut oracle = MockOracle::new(); oracle.insert(L1_HEAD_KEY, B256::repeat_byte(0x11).to_vec()); oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); - oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 40_308_263u64.to_be_bytes().to_vec()); - oracle.insert(L2_CHAIN_ID_KEY, ORACLE_CHAIN_ID.to_be_bytes().to_vec()); + oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 100u64.to_be_bytes().to_vec()); + oracle.insert_rollup_config(DEVNET_CHAIN_ID, &rollup_config); oracle.insert( - L2_ROLLUP_CONFIG_KEY, - serde_json::to_vec(&rollup_config_value).expect("rollup config should serialize"), + L1_CONFIG_KEY, + serde_json::to_vec(&ChainConfig { chain_id: DEVNET_L1_CHAIN_ID, ..Default::default() }) + .expect("L1 config should serialize"), ); - let boot_info = BootInfo::load(&oracle).await.expect("pre-Beryl boot info should load"); + let boot_info = BootInfo::load(&oracle).await.expect("devnet boot info should load"); - assert_eq!(boot_info.activation_admin_address, None); - assert_eq!(boot_info.rollup_config.upgrades.base.beryl, None); + assert_eq!(boot_info.l1_config.chain_id, DEVNET_L1_CHAIN_ID); } - #[tokio::test] - async fn rejects_oracle_rollup_config_with_beryl_and_no_activation_admin() { - let rollup_config = BaseChainConfig::SEPOLIA.rollup_config(); - let mut rollup_config_value = - serde_json::to_value(&rollup_config).expect("rollup config should convert to value"); - rollup_config_value["l2_chain_id"] = serde_json::json!(ORACLE_CHAIN_ID); - rollup_config_value["base"] = serde_json::json!({ "beryl": 1 }); - - let mut oracle = MockOracle::new(); - oracle.insert(L1_HEAD_KEY, B256::repeat_byte(0x11).to_vec()); - oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); - oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); - oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 40_308_263u64.to_be_bytes().to_vec()); - oracle.insert(L2_CHAIN_ID_KEY, ORACLE_CHAIN_ID.to_be_bytes().to_vec()); - oracle.insert( - L2_ROLLUP_CONFIG_KEY, - serde_json::to_vec(&rollup_config_value).expect("rollup config should serialize"), - ); - - let err = BootInfo::load(&oracle) - .await - .expect_err("Beryl-enabled oracle config without activation admin should fail"); - assert!(matches!( - err, - OracleProviderError::MissingActivationAdminAddress { chain_id: ORACLE_CHAIN_ID } - )); + /// `load` resolves the activation admin and the L1 config from compiled data and treats a gap in + /// either as fatal. Neither gap exists for the chains that ship today, so this pins the + /// invariant here rather than letting a newly added chain fail inside the enclave. + #[test] + fn every_supported_chain_compiles_its_boot_dependencies() { + for chain_config in BaseChainConfig::all() { + assert!( + chain_config.beryl_activation_admin_address().is_some(), + "chain {} has no Beryl activation admin address", + chain_config.chain_id + ); + assert!( + base_common_chains::L1_CONFIGS.contains_key(&chain_config.l1_chain_id), + "chain {} has no built-in config for L1 chain {}", + chain_config.chain_id, + chain_config.l1_chain_id + ); + } } /// Builds an oracle with all required boot keys present for a built-in chain, so that only the diff --git a/crates/proof/proof/src/errors.rs b/crates/proof/proof/src/errors.rs index 185c421489..3c0eb332c7 100644 --- a/crates/proof/proof/src/errors.rs +++ b/crates/proof/proof/src/errors.rs @@ -110,6 +110,16 @@ pub enum OracleProviderError { /// * `0` - The unknown chain ID that was encountered #[error("Unknown chain ID: {0}")] UnknownChainId(u64), + /// A supported L2 chain resolved an L1 chain without a built-in configuration. + /// + /// Only the mutable local devnet may take its L1 configuration from the oracle. For every other + /// supported chain the L1 chain ID is compiled in, so a missing built-in L1 config means the + /// chain configuration itself is inconsistent and the proof must not execute. + #[error("Missing built-in L1 chain config for L1 chain ID: {l1_chain_id}")] + MissingL1ChainConfig { + /// The L1 chain ID that has no built-in configuration. + l1_chain_id: u64, + }, /// Rollup config L2 chain ID does not match the boot chain ID. /// /// This error occurs when an oracle-provided rollup config claims to be for From 20abd91b101efc0517f0dd0b42d94fc9c51b45e4 Mon Sep 17 00:00:00 2001 From: Haardik Date: Thu, 17 Sep 2026 16:17:09 +0000 Subject: [PATCH 037/299] perf(snapshotter): parallelize database zstd compression (#5118) Share the snapshot thread budget across native zstd workers while keeping database archives and their R2 uploads concurrent. Add streamed multipart progress and drain logging. Amp-Thread-ID: https://ampcode.com/threads/T-01a0affb-cbb2-76cd-b6d4-03df5282d6a0 Co-authored-by: Amp --- crates/infra/snapshotter/src/config.rs | 7 ++- crates/infra/snapshotter/src/upload.rs | 58 ++++++++++++++++--- crates/utilities/reth-cli/Cargo.toml | 2 +- .../reth-cli/src/snapshot_manifest.rs | 27 ++++++++- 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/crates/infra/snapshotter/src/config.rs b/crates/infra/snapshotter/src/config.rs index f93b17de81..4a259b6066 100644 --- a/crates/infra/snapshotter/src/config.rs +++ b/crates/infra/snapshotter/src/config.rs @@ -91,15 +91,16 @@ pub struct SnapshotterConfig { /// Maximum number of threads for snapshot archive creation. /// - /// Defaults to Rayon's global thread count, normally the available CPU count. + /// This budget is shared between parallel archive creation and each database archive's native + /// zstd workers. Defaults to Rayon's global thread count, normally the available CPU count. #[arg(long)] pub snapshot_threads: Option, /// Maximum number of archive streams compressed and uploaded concurrently. /// /// The default of four preserves parallel compression of the state, RocksDB-index, and - /// proofs databases while leaving capacity for another archive. Each active stream can retain roughly 1.25 `GiB` of compressed data - /// while an S3 multipart part is uploaded and retried; lower this on memory-constrained nodes. + /// proofs databases while leaving capacity for another archive. Completed 128 `MiB` parts are + /// also bounded by the global streaming-part limit; lower this on memory-constrained nodes. #[arg(long, env = "SNAPSHOTTER_MAX_STREAMING_ARCHIVES", default_value = "4")] pub max_streaming_archives: NonZeroUsize, diff --git a/crates/infra/snapshotter/src/upload.rs b/crates/infra/snapshotter/src/upload.rs index 5aa2db6019..a841702d17 100644 --- a/crates/infra/snapshotter/src/upload.rs +++ b/crates/infra/snapshotter/src/upload.rs @@ -26,8 +26,8 @@ use aws_sdk_s3::{ types::{CompletedMultipartUpload, CompletedPart, Delete, ObjectIdentifier}, }; use base_reth_cli::{ - ChunkFilename, ComponentManifest, SnapshotArchiveSink, SnapshotArchiveWriter, SnapshotManifest, - SnapshotManifestExt, + ChunkFilename, ComponentManifest, ProgressDisplay, SnapshotArchiveSink, SnapshotArchiveWriter, + SnapshotManifest, SnapshotManifestExt, }; use bytes::Bytes; use futures::stream::{self, StreamExt, TryStreamExt}; @@ -35,13 +35,13 @@ use tokio::{ runtime::Handle, sync::{OwnedSemaphorePermit, Semaphore, mpsc}, task::JoinHandle, - time::sleep, + time::{MissedTickBehavior, interval, sleep}, }; use tracing::{debug, error, info, warn}; use crate::{ config::DEFAULT_MAX_STREAMING_PART_UPLOADS, - progress::{UploadProgress, UploadStage}, + progress::{PROGRESS_LOG_INTERVAL, UploadProgress, UploadStage}, }; /// Maximum number of concurrent file uploads. @@ -158,9 +158,9 @@ pub struct SnapshotUploader { /// /// Snapshot archive generation is synchronous (tar and zstd write through `io::Write`), whereas /// the AWS SDK is asynchronous. This type bridges those models with a bounded channel: complete -/// 640 `MiB` parts are handed to an async task, and the synchronous producer blocks when that task -/// has not consumed the previous part. Consequently, archive bytes are never staged in a local -/// file and memory remains bounded per active archive. +/// 128 `MiB` parts are handed to an async task, and the synchronous producer blocks when the global +/// queued/in-flight part limit is reached. Consequently, archive bytes are never staged in a local +/// file and aggregate memory remains bounded. /// /// Call [`Self::finish`] only after the archive writer has been finalized (for zstd, after /// `Encoder::finish`). Then call [`Self::complete`] to wait for S3 to complete the multipart @@ -547,9 +547,15 @@ impl SnapshotUploader { let result = async { let mut completed_parts = Vec::new(); let mut uploads = futures::stream::FuturesUnordered::new(); + let started = std::time::Instant::now(); + let mut progress = interval(PROGRESS_LOG_INTERVAL); + progress.set_missed_tick_behavior(MissedTickBehavior::Skip); + progress.tick().await; + let mut bytes_received = 0u64; let mut bytes_uploaded = 0u64; let mut part_number = 1i32; let mut received_finish = false; + let mut input_finished_at = None; while !received_finish || !uploads.is_empty() { tokio::select! { @@ -559,6 +565,7 @@ impl SnapshotUploader { bail!("streaming multipart upload for {key} exceeds S3's 10,000-part limit"); } let part_len = u64::try_from(bytes.len())?; + bytes_received = bytes_received.saturating_add(part_len); let number = part_number; let part_key = key.clone(); let part_upload_id = upload_id.clone(); @@ -570,6 +577,17 @@ impl SnapshotUploader { } Some(StreamingUploadMessage::Finish) => { received_finish = true; + input_finished_at = Some(std::time::Instant::now()); + info!( + key = %key, + compressed_size = %ProgressDisplay::bytes(bytes_received as f64), + uploaded_size = %ProgressDisplay::bytes(bytes_uploaded as f64), + pending_size = %ProgressDisplay::bytes(bytes_received.saturating_sub(bytes_uploaded) as f64), + parts_completed = completed_parts.len(), + parts_in_flight = uploads.len(), + elapsed = %ProgressDisplay::duration(started.elapsed()), + "streamed archive compression complete, draining uploads" + ); } None => bail!("streaming archive writer for {key} was dropped before it finalized the zstd stream"), }, @@ -578,6 +596,20 @@ impl SnapshotUploader { completed_parts.push(completed); bytes_uploaded = bytes_uploaded.saturating_add(part_len); } + _ = progress.tick() => { + info!( + key = %key, + uploaded_size = %ProgressDisplay::bytes(bytes_uploaded as f64), + received_size = %ProgressDisplay::bytes(bytes_received as f64), + pending_size = %ProgressDisplay::bytes(bytes_received.saturating_sub(bytes_uploaded) as f64), + speed = %ProgressDisplay::speed(bytes_uploaded as f64 / started.elapsed().as_secs_f64()), + parts_completed = completed_parts.len(), + parts_in_flight = uploads.len(), + input_finished = received_finish, + elapsed = %ProgressDisplay::duration(started.elapsed()), + "uploading streamed snapshot archive (progress)" + ); + } } } @@ -589,6 +621,7 @@ impl SnapshotUploader { } completed_parts.sort_unstable_by_key(|part| part.part_number); + let parts_completed = completed_parts.len(); self.complete_streaming_multipart_upload( &key, @@ -597,6 +630,17 @@ impl SnapshotUploader { completed_parts, ) .await?; + info!( + key = %key, + uploaded_size = %ProgressDisplay::bytes(bytes_uploaded as f64), + speed = %ProgressDisplay::speed(bytes_uploaded as f64 / started.elapsed().as_secs_f64()), + parts_completed, + upload_drain_elapsed = %ProgressDisplay::duration( + input_finished_at.map_or(Duration::ZERO, |finished| finished.elapsed()) + ), + elapsed = %ProgressDisplay::duration(started.elapsed()), + "streamed snapshot archive upload complete" + ); Ok(bytes_uploaded) } .await; diff --git a/crates/utilities/reth-cli/Cargo.toml b/crates/utilities/reth-cli/Cargo.toml index b94619100f..61546b280c 100644 --- a/crates/utilities/reth-cli/Cargo.toml +++ b/crates/utilities/reth-cli/Cargo.toml @@ -13,7 +13,6 @@ workspace = true [dependencies] tar.workspace = true -zstd.workspace = true anyhow.workspace = true blake3.workspace = true rayon.workspace = true @@ -24,6 +23,7 @@ reth-node-core.workspace = true reth-prune-types.workspace = true reth-cli-commands.workspace = true base-common-chains.workspace = true +zstd = { workspace = true, features = ["zstdmt"] } [dev-dependencies] tempfile.workspace = true diff --git a/crates/utilities/reth-cli/src/snapshot_manifest.rs b/crates/utilities/reth-cli/src/snapshot_manifest.rs index 61caf817f1..d11740a30e 100644 --- a/crates/utilities/reth-cli/src/snapshot_manifest.rs +++ b/crates/utilities/reth-cli/src/snapshot_manifest.rs @@ -459,13 +459,28 @@ impl SnapshotGenerator { single_components.push(("proofs", "proofs.tar.zst", proofs_files)); } + // Each archive needs one Rayon worker to read and tar its files. Divide the remaining + // thread budget between zstd's native worker pools so all database archives continue to + // compress and stream concurrently without multiplying the configured CPU limit. + let zstd_workers_per_archive = rayon::current_num_threads() + .checked_div(single_components.len()) + .unwrap_or_default() + .saturating_sub(1) + .try_into() + .unwrap_or(u32::MAX); + // These source trees and output archives are independent. Package them on the shared // Rayon pool so the snapshotter's `--snapshot-threads` limit applies to this work too. let packaged_single_components = single_components .into_par_iter() .map(|(component, archive_name, files)| { - let (size, output_files) = - package_single_component(archive_sink, component, archive_name, &files)?; + let (size, output_files) = package_single_component( + archive_sink, + component, + archive_name, + &files, + zstd_workers_per_archive, + )?; let decompressed_size = output_files.iter().map(|file| file.size).sum(); info!( component = %component, @@ -738,6 +753,7 @@ fn package_single_component( component: &str, archive_name: &str, files: &[PlannedFile], + zstd_workers: u32, ) -> Result<(u64, Vec)> { if files.is_empty() { bail!("cannot package empty archive: {archive_name}"); @@ -749,6 +765,7 @@ fn package_single_component( component = %component, raw_size = %ProgressDisplay::bytes(raw_size as f64), file_count = files.len(), + zstd_workers, "packaging database component" ); let compressed_bytes = Arc::new(AtomicU64::new(0)); @@ -760,6 +777,7 @@ fn package_single_component( files, Some(&mut progress), compressed_bytes, + zstd_workers, )?; Ok((packaged.size, packaged.output_files)) } @@ -787,6 +805,7 @@ fn write_chunk_archive( &planned, None, Arc::new(AtomicU64::new(0)), + 0, ) } @@ -814,10 +833,14 @@ fn write_archive_from_planned_files( files: &[PlannedFile], progress: Option<&mut CompressionProgress>, compressed_bytes: Arc, + zstd_workers: u32, ) -> Result { let writer = CountingWriter::new(archive_sink.create_archive(archive_name)?, compressed_bytes); let mut encoder = zstd::Encoder::new(writer, 0)?; encoder.include_checksum(true)?; + if zstd_workers > 0 { + encoder.multithread(zstd_workers)?; + } let mut builder = tar::Builder::new(encoder); let output_files = From b246a2ba5dfa9b0b1985f15243cef07a1dcd386b Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 16:38:49 +0000 Subject: [PATCH 038/299] fix(derive): reset on missing L2 ancestor during denim scan (#5110) --- .../src/stages/batch/batch_validator.rs | 103 ++++++++++++++++-- .../consensus/derive/src/traits/providers.rs | 14 ++- 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/crates/consensus/derive/src/stages/batch/batch_validator.rs b/crates/consensus/derive/src/stages/batch/batch_validator.rs index d7aac32529..aa4d8f9ed7 100644 --- a/crates/consensus/derive/src/stages/batch/batch_validator.rs +++ b/crates/consensus/derive/src/stages/batch/batch_validator.rs @@ -1,6 +1,6 @@ //! Contains the [`BatchValidator`] stage. -use alloc::{boxed::Box, string::ToString, sync::Arc, vec::Vec}; +use alloc::{boxed::Box, sync::Arc, vec::Vec}; use core::fmt::Debug; use alloy_eips::BlockNumHash; @@ -12,7 +12,10 @@ use super::NextBatchProvider; use crate::{ Metrics, errors::{PipelineError, PipelineErrorKind, ResetError}, - traits::{AttributesProvider, L2ChainProvider, OriginAdvancer, OriginProvider, StageReset}, + traits::{ + AttributesProvider, BatchValidationProviderDerive, L2ChainProvider, OriginAdvancer, + OriginProvider, StageReset, + }, types::PipelineResult, }; @@ -259,8 +262,14 @@ where let ancestor = match self.provider.l2_block_info_by_number(number).await { Ok(ancestor) => ancestor, Err(error) => { - self.pending_batch = Some((next_batch, inclusion_block)); - return Err(PipelineError::Provider(error.to_string()).temp()); + let kind = + ::provider_error_into_pipeline_error( + error, + ); + if matches!(kind, PipelineErrorKind::Temporary(_)) { + self.pending_batch = Some((next_batch, inclusion_block)); + } + return Err(kind); } }; if ancestor.block_info.hash == next_batch.parent_hash { @@ -366,20 +375,56 @@ where #[cfg(test)] mod tests { - use alloc::{sync::Arc, vec, vec::Vec}; + use alloc::{boxed::Box, sync::Arc, vec, vec::Vec}; - use alloy_eips::BlockNumHash; + use alloy_eips::{BlockId, BlockNumHash}; use alloy_primitives::B256; + use async_trait::async_trait; + use base_common_consensus::BaseBlock; use base_common_genesis::{BaseUpgradeConfig, RollupConfig, SystemConfig, UpgradeConfig}; - use base_protocol::{Batch, BlockInfo, L2BlockInfo, SingleBatch, SpanBatch}; + use base_protocol::{ + Batch, BatchValidationProvider, BlockInfo, L2BlockInfo, SingleBatch, SpanBatch, + }; use tracing::Level; use crate::{ - AttributesProvider, BatchValidator, NextBatchProvider, OriginAdvancer, PipelineError, - PipelineErrorKind, PipelineResult, ResetError, StageReset, + AttributesProvider, BatchValidator, L2ChainProvider, NextBatchProvider, OriginAdvancer, + PipelineError, PipelineErrorKind, PipelineResult, ResetError, StageReset, test_utils::{TestL2ChainProvider, TestNextBatchProvider}, }; + #[derive(Debug, Default, Clone, Copy)] + struct ResettingL2ChainProvider; + + #[async_trait] + impl BatchValidationProvider for ResettingL2ChainProvider { + type Error = ResetError; + + async fn l2_block_info_by_number( + &mut self, + number: u64, + ) -> Result { + Err(ResetError::BlockNotFound(BlockId::Number(number.into()))) + } + + async fn block_by_number(&mut self, number: u64) -> Result { + Err(ResetError::BlockNotFound(BlockId::Number(number.into()))) + } + } + + #[async_trait] + impl L2ChainProvider for ResettingL2ChainProvider { + type Error = ResetError; + + async fn system_config_by_number( + &mut self, + number: u64, + _: Arc, + ) -> Result::Error> { + Err(ResetError::BlockNotFound(BlockId::Number(number.into()))) + } + } + #[tokio::test] async fn test_batch_validator_origin_behind_eof() { let cfg = Arc::new(RollupConfig::default()); @@ -742,4 +787,44 @@ mod tests { assert!(bv.pending_batch.is_none()); assert!(bv.prev.flushed); } + + #[tokio::test] + async fn test_denim_validator_does_not_retry_reset_class_ancestry_error() { + let origin = BlockInfo { number: 1, hash: B256::repeat_byte(0x11), ..Default::default() }; + let parent = L2BlockInfo { + block_info: BlockInfo { + number: 3, + hash: B256::repeat_byte(0x33), + ..Default::default() + }, + l1_origin: BlockNumHash { number: 0, ..Default::default() }, + ..Default::default() + }; + let batch = SingleBatch { + parent_hash: B256::repeat_byte(0xff), + epoch_num: origin.number, + epoch_hash: origin.hash, + ..Default::default() + }; + let mut prev = TestNextBatchProvider::new(vec![Ok(Batch::Single(batch))]); + prev.origin = Some(origin); + let cfg = Arc::new(RollupConfig { + block_time: 2, + upgrades: UpgradeConfig { + holocene_time: Some(0), + base: BaseUpgradeConfig { denim: Some(0), ..Default::default() }, + ..Default::default() + }, + ..Default::default() + }); + let mut bv = BatchValidator::new(cfg, prev, ResettingL2ChainProvider); + bv.origin = Some(origin); + bv.l1_blocks = vec![origin, origin]; + + assert!(matches!( + bv.next_batch(parent).await.unwrap_err(), + PipelineErrorKind::Reset(ResetError::BlockNotFound(_)) + )); + assert!(bv.pending_batch.is_none()); + } } diff --git a/crates/consensus/derive/src/traits/providers.rs b/crates/consensus/derive/src/traits/providers.rs index a368fa2dd7..ddf2f22364 100644 --- a/crates/consensus/derive/src/traits/providers.rs +++ b/crates/consensus/derive/src/traits/providers.rs @@ -51,13 +51,23 @@ pub trait L2ChainProvider: BatchValidationProviderDerive { /// A super-trait for [`BatchValidationProvider`] that binds `Self::Error` to have a conversion into /// [`PipelineErrorKind`]. -pub trait BatchValidationProviderDerive: BatchValidationProvider {} +pub trait BatchValidationProviderDerive: BatchValidationProvider { + /// Converts a [`BatchValidationProvider::Error`] into [`PipelineErrorKind`]. + fn provider_error_into_pipeline_error( + error: ::Error, + ) -> PipelineErrorKind; +} // Auto-implement the [BatchValidationProviderDerive] trait for all types that implement // [BatchValidationProvider] where the error can be converted into [PipelineErrorKind]. impl BatchValidationProviderDerive for T where T: BatchValidationProvider, - ::Error: Into, + PipelineErrorKind: From<::Error>, { + fn provider_error_into_pipeline_error( + error: ::Error, + ) -> PipelineErrorKind { + PipelineErrorKind::from(error) + } } From 31a31ab9304b3c20711ebf442dd228f20d0d8efb Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 16:48:18 +0000 Subject: [PATCH 039/299] fix(ci): pin base-anvil package smoke upgrade to denim (#5122) --- .depot/workflows/base-anvil-package.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.depot/workflows/base-anvil-package.yml b/.depot/workflows/base-anvil-package.yml index 9f12334cf5..415b5970f9 100644 --- a/.depot/workflows/base-anvil-package.yml +++ b/.depot/workflows/base-anvil-package.yml @@ -20,12 +20,17 @@ on: description: "base/base-std ref to smoke test against" required: false default: main + base_upgrade: + description: "base upgrade passed to anvil --base" + required: false + default: denim concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false env: BASE_ANVIL_REF: ${{ github.event.inputs.base_anvil_ref || '98e7839c65f64aee9627b69a9b98b79afaeb1fae' }} BASE_STD_REF: ${{ github.event.inputs.base_std_ref || 'main' }} + BASE_UPGRADE: ${{ github.event.inputs.base_upgrade || 'denim' }} CARGO_TERM_COLOR: always REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/base-anvil RUSTC_WRAPPER: "sccache" @@ -187,6 +192,7 @@ jobs: ANVIL_BIN="$BASE_ANVIL_DIR/target/release/anvil" \ FORGE_BIN="$BASE_ANVIL_DIR/target/release/forge" \ ANVIL_LOG="$RUNNER_TEMP/base-std-anvil.log" \ + BASE_UPGRADE="$BASE_UPGRADE" \ make fork-tests - name: Upload anvil log if: always() && matrix.settings.smoke_test @@ -232,12 +238,14 @@ jobs: --arg std_repo "base/base-std" \ --arg std_ref "$BASE_STD_REF" \ --arg std_sha "$BASE_STD_SHA" \ + --arg base_upgrade "$BASE_UPGRADE" \ --argjson smoke_tested "$SMOKE_TESTED" \ '{ package: $package, platform: $platform, base: {repo: $base_repo, ref: $base_ref, sha: $base_sha}, base_anvil: {repo: $anvil_repo, ref: $anvil_ref, sha: $anvil_sha}, + base_upgrade: $base_upgrade, base_std: { repo: $std_repo, ref: $std_ref, @@ -329,7 +337,7 @@ jobs: primary_tag="sha-$GITHUB_SHA" tags=("$REGISTRY_IMAGE:$primary_tag") - if [[ "$GITHUB_REF_NAME" == "main" && "$BASE_ANVIL_REF" == "98e7839c65f64aee9627b69a9b98b79afaeb1fae" && "$BASE_STD_REF" == "main" ]]; then + if [[ "$GITHUB_REF_NAME" == "main" && "$BASE_ANVIL_REF" == "98e7839c65f64aee9627b69a9b98b79afaeb1fae" && "$BASE_STD_REF" == "main" && "$BASE_UPGRADE" == "denim" ]]; then tags+=("$REGISTRY_IMAGE:main") tags+=("$REGISTRY_IMAGE:main-$commit_date-$short_sha") else From 155af79cde05c01472ee1f5ec6b6d023ecffb6f6 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 17:13:38 +0000 Subject: [PATCH 040/299] perf(systems): use host runtime sizing for snapshot stacks (#5113) * perf(systems): use host runtime sizing for snapshot stacks * style: fix formatting for snapshot host runtime sizing --- etc/systems/src/l2/in_process_builder.rs | 8 ++++++-- etc/systems/src/l2/in_process_client.rs | 8 ++++++-- etc/systems/src/l2/mod.rs | 2 +- etc/systems/src/l2/runtime.rs | 19 +++++++++++++++++++ etc/systems/src/l2/shadow_sequencer.rs | 2 ++ etc/systems/src/l2/snapshot_stack.rs | 6 ++++-- etc/systems/src/l2/stack.rs | 4 +++- etc/systems/src/lib.rs | 8 ++++---- 8 files changed, 45 insertions(+), 12 deletions(-) diff --git a/etc/systems/src/l2/in_process_builder.rs b/etc/systems/src/l2/in_process_builder.rs index d541373801..cb9060b9d3 100644 --- a/etc/systems/src/l2/in_process_builder.rs +++ b/etc/systems/src/l2/in_process_builder.rs @@ -34,12 +34,14 @@ use tempfile::TempDir; use tracing::warn; use url::Url; -use super::TestNodeRuntime; +use super::InProcessNodeRuntime; use crate::{config::BUILDER, setup::BUILDER_ENODE_ID}; /// Configuration for starting an in-process builder. #[derive(Debug)] pub struct InProcessBuilderConfig { + /// Runtime sizing policy for the execution node. + pub runtime: InProcessNodeRuntime, /// Pre-built chain specification. pub chain_spec: Arc, /// Existing caller-owned datadir. A temporary datadir is created when omitted. @@ -137,7 +139,9 @@ impl InProcessBuilder { .wrap_err("Failed to write JWT secret")?; let runtime = RuntimeBuilder::new( - TestNodeRuntime::config() + config + .runtime + .config() .with_tokio(TokioConfig::existing_handle(tokio::runtime::Handle::current())), ) .build()?; diff --git a/etc/systems/src/l2/in_process_client.rs b/etc/systems/src/l2/in_process_client.rs index 4c8adae30d..ec59596899 100644 --- a/etc/systems/src/l2/in_process_client.rs +++ b/etc/systems/src/l2/in_process_client.rs @@ -35,7 +35,7 @@ use tempfile::TempDir; use tracing::warn; use url::Url; -use super::TestNodeRuntime; +use super::InProcessNodeRuntime; type BuiltExtensions = (Vec>, Option); @@ -51,6 +51,8 @@ pub enum ChainSpecSource { /// Configuration for starting an in-process client node. #[derive(Debug)] pub struct InProcessClientConfig { + /// Runtime sizing policy for the execution node. + pub runtime: InProcessNodeRuntime, /// Chain specification source. pub chain_spec: ChainSpecSource, /// Existing caller-owned datadir. A temporary datadir is created when omitted. @@ -134,7 +136,9 @@ impl InProcessClient { let (data_dir, temp_dir) = Self::prepare_datadir(config.datadir.clone())?; let runtime = RuntimeBuilder::new( - TestNodeRuntime::config() + config + .runtime + .config() .with_tokio(TokioConfig::existing_handle(tokio::runtime::Handle::current())), ) .build()?; diff --git a/etc/systems/src/l2/mod.rs b/etc/systems/src/l2/mod.rs index c65f002a36..11f84dc655 100644 --- a/etc/systems/src/l2/mod.rs +++ b/etc/systems/src/l2/mod.rs @@ -4,7 +4,7 @@ mod config; pub use config::L2ContainerConfig; mod runtime; -pub use runtime::TestNodeRuntime; +pub use runtime::{InProcessNodeRuntime, TestNodeRuntime}; mod in_process_batcher; pub use in_process_batcher::{InProcessBatcher, InProcessBatcherConfig}; diff --git a/etc/systems/src/l2/runtime.rs b/etc/systems/src/l2/runtime.rs index a5d8ca37d9..d8ef9971b1 100644 --- a/etc/systems/src/l2/runtime.rs +++ b/etc/systems/src/l2/runtime.rs @@ -2,6 +2,25 @@ use reth_tasks::{RayonConfig, RuntimeConfig}; +/// Runtime sizing policy for an in-process execution node. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InProcessNodeRuntime { + /// Use Reth's host-derived worker pool sizing for benchmarks and developer networks. + Host, + /// Use bounded worker pools to keep concurrent system tests from oversubscribing CI hosts. + SystemTest, +} + +impl InProcessNodeRuntime { + /// Returns the Reth runtime configuration for this policy. + pub fn config(self) -> RuntimeConfig { + match self { + Self::Host => RuntimeConfig::default(), + Self::SystemTest => TestNodeRuntime::config(), + } + } +} + /// Small, fixed Rayon thread-pool sizing for the reth runtime backing an in-process test node. /// /// The in-process nodes share the test Tokio runtime, but Reth's default Rayon pools are sized diff --git a/etc/systems/src/l2/shadow_sequencer.rs b/etc/systems/src/l2/shadow_sequencer.rs index 170c58f631..6581b72da3 100644 --- a/etc/systems/src/l2/shadow_sequencer.rs +++ b/etc/systems/src/l2/shadow_sequencer.rs @@ -27,6 +27,7 @@ use url::Url; use super::{ InProcessBuilder, InProcessBuilderConfig, InProcessConsensus, InProcessConsensusConfig, + InProcessNodeRuntime, }; /// Configuration for starting a single [`ShadowSequencer`]. @@ -83,6 +84,7 @@ impl ShadowSequencer { let chain_spec = InProcessBuilderConfig::chain_spec_from_genesis_json(&config.l2_genesis) .wrap_err("Failed to parse shadow builder L2 chain spec")?; let builder = InProcessBuilder::start(InProcessBuilderConfig { + runtime: InProcessNodeRuntime::SystemTest, chain_spec, datadir: None, jwt_secret: config.jwt_secret, diff --git a/etc/systems/src/l2/snapshot_stack.rs b/etc/systems/src/l2/snapshot_stack.rs index ef55692f73..07a04d5a89 100644 --- a/etc/systems/src/l2/snapshot_stack.rs +++ b/etc/systems/src/l2/snapshot_stack.rs @@ -19,8 +19,8 @@ use url::Url; use super::{ ChainSpecSource, InProcessBuilder, InProcessBuilderConfig, InProcessClient, InProcessClientConfig, InProcessFollowConsensus, InProcessFollowConsensusConfig, - InProcessStandaloneSequencer, InProcessStandaloneSequencerConfig, L2ContainerConfig, - SnapshotBoundary, + InProcessNodeRuntime, InProcessStandaloneSequencer, InProcessStandaloneSequencerConfig, + L2ContainerConfig, SnapshotBoundary, }; use crate::{DevnetBlockInterval, DevnetSnapshotConfig}; @@ -89,6 +89,7 @@ impl SnapshotL2Stack { let jwt_secret = JwtSecret::random(); let builder = InProcessBuilder::start(InProcessBuilderConfig { + runtime: InProcessNodeRuntime::Host, chain_spec: Arc::clone(&chain_spec), datadir: Some(config.snapshot.builder_datadir), jwt_secret, @@ -136,6 +137,7 @@ impl SnapshotL2Stack { )?); let client = InProcessClient::start(InProcessClientConfig { + runtime: InProcessNodeRuntime::Host, chain_spec: ChainSpecSource::Parsed(chain_spec), datadir: Some(config.snapshot.client_datadir), jwt_secret, diff --git a/etc/systems/src/l2/stack.rs b/etc/systems/src/l2/stack.rs index b2c5f5e837..cae211677c 100644 --- a/etc/systems/src/l2/stack.rs +++ b/etc/systems/src/l2/stack.rs @@ -33,7 +33,7 @@ use super::{ ChainSpecSource, InProcessBatcher, InProcessBatcherConfig, InProcessBuilder, InProcessBuilderConfig, InProcessClient, InProcessClientConfig, InProcessConsensus, InProcessConsensusConfig, InProcessFollowConsensus, InProcessFollowConsensusConfig, - L2ContainerConfig, ShadowSequencer, ShadowSequencerConfig, + InProcessNodeRuntime, L2ContainerConfig, ShadowSequencer, ShadowSequencerConfig, }; use crate::config::{ANVIL_ACCOUNT_1, BATCHER, SEQUENCER}; @@ -212,6 +212,7 @@ impl L2Stack { // 1. Start the builder (in-process EL). let builder_config = InProcessBuilderConfig { + runtime: InProcessNodeRuntime::SystemTest, chain_spec: builder_chain_spec, datadir: config.builder_datadir, jwt_secret: config.jwt_secret, @@ -298,6 +299,7 @@ impl L2Stack { }; let client_config = InProcessClientConfig { + runtime: InProcessNodeRuntime::SystemTest, chain_spec: ChainSpecSource::GenesisJson(config.l2_genesis.clone()), datadir: config.client_datadir, jwt_secret: config.jwt_secret, diff --git a/etc/systems/src/lib.rs b/etc/systems/src/lib.rs index 65e7ef77d6..4bc7ac5d2c 100644 --- a/etc/systems/src/lib.rs +++ b/etc/systems/src/lib.rs @@ -67,10 +67,10 @@ pub use l2::{ ChainSpecSource, InProcessBatcher, InProcessBatcherConfig, InProcessBuilder, InProcessBuilderConfig, InProcessClient, InProcessClientConfig, InProcessConsensus, InProcessConsensusConfig, InProcessFollowConsensus, InProcessFollowConsensusConfig, - InProcessStandaloneSequencer, InProcessStandaloneSequencerConfig, L2ClientConsensus, - L2ClientConsensusMode, L2ContainerConfig, L2Stack, L2StackConfig, ShadowSequencer, - ShadowSequencerConfig, ShadowSequencersConfig, SnapshotBoundary, SnapshotL2Stack, - SnapshotL2StackConfig, TestNodeRuntime, + InProcessNodeRuntime, InProcessStandaloneSequencer, InProcessStandaloneSequencerConfig, + L2ClientConsensus, L2ClientConsensusMode, L2ContainerConfig, L2Stack, L2StackConfig, + ShadowSequencer, ShadowSequencerConfig, ShadowSequencersConfig, SnapshotBoundary, + SnapshotL2Stack, SnapshotL2StackConfig, TestNodeRuntime, }; mod network; From 7f2ec21b9a3972c247f1245dbc8b46f6c98af497 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 17:18:21 +0000 Subject: [PATCH 041/299] feat(benchmarks): log snapshot validator progress (#5111) --- crates/consensus/service/src/follow/runtime.rs | 13 ++++++++++++- crates/infra/load-tests/src/runner/status.rs | 5 +++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/consensus/service/src/follow/runtime.rs b/crates/consensus/service/src/follow/runtime.rs index cac1412450..72a9b7f0af 100644 --- a/crates/consensus/service/src/follow/runtime.rs +++ b/crates/consensus/service/src/follow/runtime.rs @@ -1,4 +1,8 @@ -use std::{fmt::Debug, sync::Arc, time::Duration}; +use std::{ + fmt::Debug, + sync::Arc, + time::{Duration, Instant}, +}; use alloy_eips::BlockNumberOrTag; use base_protocol::{BlockInfo, L2BlockInfo}; @@ -80,7 +84,14 @@ where } info!(target: "follow", block = current_block, "Inserting source payload"); + let validation_started_at = Instant::now(); engine.insert_payload(payload).await?; + info!( + target: "follow", + block = current_block, + elapsed = ?validation_started_at.elapsed(), + "Source payload validated" + ); if !insert_delay.is_zero() { debug!( target: "follow", diff --git a/crates/infra/load-tests/src/runner/status.rs b/crates/infra/load-tests/src/runner/status.rs index 08522f1848..5aadb1f2ee 100644 --- a/crates/infra/load-tests/src/runner/status.rs +++ b/crates/infra/load-tests/src/runner/status.rs @@ -100,8 +100,9 @@ impl LoadTestDisplay { /// Returns the `MultiProgress` that manages the progress bars. Pass it to /// [`LoadTestDisplay::new`] after the run duration is known. pub fn init_tracing() -> Result> { - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("warn,base_load_tests=info,base_load_tester=info")); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new("warn,base_load_tests=info,base_load_tester=info,follow=info") + }); let interactive = Self::terminal_supported(io::stderr().is_terminal()); let multi_progress = interactive .then(|| MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(10))); From c4ea4e9e8b7b584eb7260efad4f25580e4632b4a Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 17:18:42 +0000 Subject: [PATCH 042/299] feat(devnet): configure snapshot block gas limits (#5115) * feat(devnet): configure snapshot block gas limits * style: fix formatting for snapshot block gas limit --- docs/guides/SNAPSHOT_BENCHMARKS.md | 5 +++ etc/systems/README.md | 11 +++--- etc/systems/src/benchmark_cli.rs | 14 ++++++-- etc/systems/src/devnet_cli.rs | 34 +++++++++++++++++- etc/systems/src/l2/snapshot_stack.rs | 52 ++++++++++++++++++++++++---- etc/systems/src/system_config.rs | 22 ++++++++++++ 6 files changed, 125 insertions(+), 13 deletions(-) diff --git a/docs/guides/SNAPSHOT_BENCHMARKS.md b/docs/guides/SNAPSHOT_BENCHMARKS.md index b7034939b2..5ca0fb2420 100644 --- a/docs/guides/SNAPSHOT_BENCHMARKS.md +++ b/docs/guides/SNAPSHOT_BENCHMARKS.md @@ -76,6 +76,11 @@ datadirs are expected to be disposable writable clones. Pass a nonzero `--shutdown-timeout-seconds` (for example, `10`) to request the old bounded graceful teardown. +The local sequencer defaults to a 10 Ggas block limit for 2s workloads and a 1 Ggas block limit for +200ms workloads, keeping theoretical block capacity at 5 Ggas/s for both cadences. Pass +`--block-gas-limit ` to override the cadence default for a benchmark run. The load-test YAML's +`block_gas_limit` setting only controls load-generator pacing and does not change produced blocks. + Each `--output-dir` is a self-contained `base/benchmark` input directory: ```text diff --git a/etc/systems/README.md b/etc/systems/README.md index a8ee6dd61f..4ff29e5b15 100644 --- a/etc/systems/README.md +++ b/etc/systems/README.md @@ -86,7 +86,9 @@ cargo run -p base-system-tests --bin base-devnet -- snapshot \ ``` Use `--block-interval 200ms` for the subsecond variant. The first descendant activates `BaseTime` -metadata and subsequent blocks advance on a deterministic 200ms schedule. +metadata and subsequent blocks advance on a deterministic 200ms schedule. Snapshot devnets default +to a 10 Ggas block limit at 2s and a 1 Ggas block limit at 200ms, preserving 5 Ggas/s of theoretical +capacity at either cadence. Pass `--block-gas-limit ` to override the cadence default. Startup validates the selected chain ID, the boundary L1-info transaction, `SystemConfig`, and sequence number. It waits for the builder to extend the snapshot and for the client to follow before @@ -109,9 +111,10 @@ cast balance "$FUNDER_ADDRESS" --rpc-url "$BUILDER_RPC" ``` The runtime JSON contains `status`, `chain_id`, `boundary_number`, `boundary_hash`, -`block_interval_ms`, `builder_rpc_url`, `builder_flashblocks_url`, and `client_rpc_url`. Dynamic -ports are the default and are safest for automation. `--stable-ports` binds the builder and client -RPCs to ports 7545 and 8545, respectively, but fails if those ports are occupied. +`block_interval_ms`, `block_gas_limit`, `builder_rpc_url`, `builder_flashblocks_url`, and +`client_rpc_url`. Dynamic ports are the default and are safest for automation. `--stable-ports` +binds the builder and client RPCs to ports 7545 and 8545, respectively, but fails if those ports are +occupied. To pin a run to a known snapshot boundary, pass all three of `--expected-head-number`, `--expected-head-hash`, and `--expected-head-timestamp`. Startup fails before load generation if diff --git a/etc/systems/src/benchmark_cli.rs b/etc/systems/src/benchmark_cli.rs index c092c47f16..c42a83d4b1 100644 --- a/etc/systems/src/benchmark_cli.rs +++ b/etc/systems/src/benchmark_cli.rs @@ -4,6 +4,7 @@ use std::{ collections::BTreeMap, fs, io::Write as _, + num::NonZeroU64, path::{Path, PathBuf}, sync::{ Arc, @@ -47,7 +48,7 @@ pub enum BenchmarkCommand { /// Run the default transfer benchmark against a fresh temporary local devnet. Local, /// Run one load test against a Base snapshot continuation. - Snapshot(SnapshotBenchmarkArgs), + Snapshot(Box), /// Aggregate selected snapshot run artifacts into one report metadata file. Aggregate(AggregateBenchmarkArgs), } @@ -100,6 +101,10 @@ pub struct SnapshotBenchmarkArgs { /// Stable build identifier for visualizer comparisons. #[arg(long, env = "BASE_BENCH_CLIENT_VERSION")] pub client_version: Option, + /// Block gas limit for locally produced descendants. Defaults to 10 Ggas for 2s blocks and + /// 1 Ggas for 200ms blocks. + #[arg(long)] + pub block_gas_limit: Option, /// Maximum time to wait for graceful shutdown after writing results. Zero terminates the /// process immediately because snapshot datadirs are disposable. #[arg(long, default_value_t = 0)] @@ -330,6 +335,7 @@ impl SnapshotBenchmarkArgs { unreachable!("snapshot constructor must create snapshot state") }; snapshot.block_interval = block_interval; + snapshot.block_gas_limit = self.block_gas_limit.map(NonZeroU64::get); snapshot.eip1559_elasticity_override = Some(SNAPSHOT_BENCHMARK_EIP1559_ELASTICITY); snapshot.prefund = Some(DevnetPrefund { address: funder_key.address(), amount: PREFUND_AMOUNT_WEI }); @@ -625,7 +631,7 @@ impl SnapshotBenchmarkArgs { #[cfg(test)] mod tests { - use std::fs; + use std::{fs, num::NonZeroU64}; use clap::Parser; @@ -659,6 +665,7 @@ mod tests { assert_eq!(args.benchmark_run, "snapshot-throughput"); assert!(args.run_id.is_none()); assert!(args.client_version.is_none()); + assert!(args.block_gas_limit.is_none()); assert_eq!(args.shutdown_timeout_seconds, 0); } @@ -683,6 +690,8 @@ mod tests { "manual-run-id", "--shutdown-timeout-seconds", "30", + "--block-gas-limit", + "12000000000", ]); let Some(BenchmarkCommand::Snapshot(args)) = cli.command else { @@ -690,6 +699,7 @@ mod tests { }; assert_eq!(args.output_dir.to_string_lossy(), "result-dir"); assert_eq!(args.run_id.as_deref(), Some("manual-run-id")); + assert_eq!(args.block_gas_limit.map(NonZeroU64::get), Some(12_000_000_000)); assert_eq!(args.shutdown_timeout_seconds, 30); } diff --git a/etc/systems/src/devnet_cli.rs b/etc/systems/src/devnet_cli.rs index 32debee072..83e926fdd9 100644 --- a/etc/systems/src/devnet_cli.rs +++ b/etc/systems/src/devnet_cli.rs @@ -1,6 +1,6 @@ //! Command-line launcher for development networks. -use std::path::PathBuf; +use std::{num::NonZeroU64, path::PathBuf}; use alloy_primitives::{Address, B256}; use clap::{Args, Parser, Subcommand}; @@ -62,6 +62,10 @@ pub struct SnapshotArgs { /// Interval between locally produced blocks. #[arg(long, value_enum, default_value_t)] pub block_interval: DevnetBlockInterval, + /// Block gas limit for locally produced descendants. Defaults to 10 Ggas for 2s blocks and + /// 1 Ggas for 200ms blocks. + #[arg(long)] + pub block_gas_limit: Option, /// Account to mint ETH to in the first local descendant block. #[arg(long)] pub prefund_address: Option
, @@ -95,6 +99,8 @@ pub struct SnapshotRuntime { pub boundary_hash: B256, /// Configured interval between local blocks, in milliseconds. pub block_interval_ms: u64, + /// Configured block gas limit for local descendants. + pub block_gas_limit: u64, /// Builder execution JSON-RPC URL. pub builder_rpc_url: String, /// Builder Flashblocks WebSocket URL. @@ -149,6 +155,7 @@ impl SnapshotArgs { }; snapshot.expected_head = expected_head; snapshot.block_interval = self.block_interval; + snapshot.block_gas_limit = self.block_gas_limit.map(NonZeroU64::get); snapshot.prefund = self .prefund_address .map(|address| DevnetPrefund { address, amount: self.prefund_amount }); @@ -165,6 +172,7 @@ impl SnapshotArgs { println!("snapshot devnet ready"); println!("builder RPC: {}", runtime.builder_rpc_url); println!("client RPC: {}", runtime.client_rpc_url); + println!("block gas: {}", runtime.block_gas_limit); println!("runtime: {}", self.runtime_file.display()); println!("press Ctrl-C to stop"); tokio::signal::ctrl_c().await.wrap_err("failed to listen for Ctrl-C")?; @@ -183,6 +191,7 @@ impl SnapshotRuntime { boundary_number: boundary.head.number, boundary_hash: boundary.head.hash, block_interval_ms: stack.block_interval().duration().as_millis() as u64, + block_gas_limit: stack.block_gas_limit(), builder_rpc_url: stack.builder_rpc_url()?.to_string(), builder_flashblocks_url: stack.builder_flashblocks_url()?.to_string(), client_rpc_url: stack.client_rpc_url()?.to_string(), @@ -192,6 +201,8 @@ impl SnapshotRuntime { #[cfg(test)] mod tests { + use std::num::NonZeroU64; + use clap::Parser; use super::{DevnetCli, DevnetCommand}; @@ -222,5 +233,26 @@ mod tests { assert_eq!(args.builder_datadir.to_str(), Some("/tmp/builder")); assert!(args.prefund_address.is_some()); assert_eq!(args.block_interval, DevnetBlockInterval::TwoHundredMilliseconds); + assert!(args.block_gas_limit.is_none()); + } + + #[test] + fn parses_snapshot_block_gas_limit() { + let cli = DevnetCli::try_parse_from([ + "base-devnet", + "snapshot", + "--builder-datadir", + "/tmp/builder", + "--client-datadir", + "/tmp/client", + "--block-gas-limit", + "12000000000", + ]) + .unwrap(); + + let DevnetCommand::Snapshot(args) = cli.command else { + panic!("expected snapshot command") + }; + assert_eq!(args.block_gas_limit.map(NonZeroU64::get), Some(12_000_000_000)); } } diff --git a/etc/systems/src/l2/snapshot_stack.rs b/etc/systems/src/l2/snapshot_stack.rs index 07a04d5a89..e0501bff76 100644 --- a/etc/systems/src/l2/snapshot_stack.rs +++ b/etc/systems/src/l2/snapshot_stack.rs @@ -57,6 +57,7 @@ pub struct SnapshotL2Stack { follow_consensus: Option, follow_config: Option, block_interval: DevnetBlockInterval, + block_gas_limit: u64, rollup_config: Arc, chain_id: u64, } @@ -125,10 +126,13 @@ impl SnapshotL2Stack { first_block_timestamp > boundary.head.timestamp, "snapshot boundary timestamp must be earlier than the local schedule anchor" ); - let system_config = Self::with_eip1559_elasticity_override( + let system_config = Self::with_snapshot_overrides( boundary.system_config, + block_interval, + config.snapshot.block_gas_limit, config.snapshot.eip1559_elasticity_override, ); + let block_gas_limit = system_config.gas_limit; let rollup_config = Arc::new(Self::anchored_rollup_config( (*canonical_rollup_config).clone(), boundary.head.number, @@ -216,6 +220,7 @@ impl SnapshotL2Stack { follow_consensus: None, follow_config: Some(follow_config), block_interval, + block_gas_limit, rollup_config: canonical_rollup_config, chain_id: chain.l2_chain_id, }) @@ -298,11 +303,15 @@ impl SnapshotL2Stack { chain_spec } - /// Applies the benchmark-only EIP-1559 elasticity override to locally sequenced payloads. - const fn with_eip1559_elasticity_override( + /// Applies local snapshot overrides to the recovered system configuration. + fn with_snapshot_overrides( mut system_config: SystemConfig, + block_interval: DevnetBlockInterval, + block_gas_limit: Option, elasticity_override: Option, ) -> SystemConfig { + system_config.gas_limit = + block_gas_limit.unwrap_or_else(|| block_interval.snapshot_block_gas_limit()); if let Some(elasticity) = elasticity_override { system_config.eip1559_elasticity = Some(elasticity); } @@ -369,6 +378,11 @@ impl SnapshotL2Stack { self.block_interval } + /// Returns the block gas limit for locally produced descendants. + pub const fn block_gas_limit(&self) -> u64 { + self.block_gas_limit + } + /// Returns the L2 chain ID of the continued snapshot. pub const fn chain_id(&self) -> u64 { self.chain_id @@ -513,7 +527,7 @@ mod tests { } #[test] - fn snapshot_elasticity_override_preserves_other_system_config_fields() { + fn snapshot_overrides_replace_gas_limit_and_elasticity() { let original = SystemConfig { gas_limit: 1_200_000_000, eip1559_denominator: Some(50), @@ -522,14 +536,40 @@ mod tests { ..Default::default() }; - let overridden = SnapshotL2Stack::with_eip1559_elasticity_override(original, Some(1)); + let overridden = SnapshotL2Stack::with_snapshot_overrides( + original, + DevnetBlockInterval::TwoSeconds, + Some(12_000_000_000), + Some(1), + ); assert_eq!(overridden.eip1559_elasticity, Some(1)); assert_eq!(overridden.eip1559_denominator, original.eip1559_denominator); - assert_eq!(overridden.gas_limit, original.gas_limit); + assert_eq!(overridden.gas_limit, 12_000_000_000); assert_eq!(overridden.min_base_fee, original.min_base_fee); } + #[test] + fn snapshot_gas_limit_defaults_follow_block_interval() { + let original = SystemConfig { gas_limit: 1_200_000_000, ..Default::default() }; + + let two_second = SnapshotL2Stack::with_snapshot_overrides( + original, + DevnetBlockInterval::TwoSeconds, + None, + None, + ); + let subsecond = SnapshotL2Stack::with_snapshot_overrides( + original, + DevnetBlockInterval::TwoHundredMilliseconds, + None, + None, + ); + + assert_eq!(two_second.gas_limit, 10_000_000_000); + assert_eq!(subsecond.gas_limit, 1_000_000_000); + } + #[test] fn schedule_anchor_uses_startup_lead() { let now = std::time::UNIX_EPOCH + std::time::Duration::from_secs(2_000_000_000); diff --git a/etc/systems/src/system_config.rs b/etc/systems/src/system_config.rs index d6749213b4..0883563082 100644 --- a/etc/systems/src/system_config.rs +++ b/etc/systems/src/system_config.rs @@ -72,6 +72,16 @@ impl DevnetBlockInterval { Self::TwoHundredMilliseconds => Duration::from_millis(200), } } + + /// Returns the default block gas limit for snapshot-backed development networks. + /// + /// Both cadences expose five billion gas per second of theoretical block capacity. + pub const fn snapshot_block_gas_limit(self) -> u64 { + match self { + Self::TwoSeconds => 10_000_000_000, + Self::TwoHundredMilliseconds => 1_000_000_000, + } + } } /// Chain inputs used to continue a Base snapshot. @@ -172,6 +182,11 @@ pub struct DevnetSnapshotConfig { /// Block interval for locally produced descendants. #[serde(default)] pub block_interval: DevnetBlockInterval, + /// Optional block gas limit override for locally produced descendants. + /// + /// When omitted, the limit is selected from [`Self::block_interval`]. + #[serde(default)] + pub block_gas_limit: Option, /// Optional elasticity override for locally sequenced snapshot descendants. /// /// This changes only the synthetic standalone sequencer's payload attributes; it does not @@ -353,6 +368,7 @@ impl DevnetConfig { expected_head: None, prefund: None, block_interval: DevnetBlockInterval::default(), + block_gas_limit: None, eip1559_elasticity_override: None, })), stable: StableSystemTestConfig::standard(), @@ -381,6 +397,10 @@ impl DevnetConfig { ); ensure!(snapshot.builder_datadir.is_dir(), "builder snapshot datadir does not exist"); ensure!(snapshot.client_datadir.is_dir(), "client snapshot datadir does not exist"); + ensure!( + snapshot.block_gas_limit != Some(0), + "snapshot block gas limit override must be greater than zero" + ); ensure!( snapshot.eip1559_elasticity_override != Some(0), "snapshot EIP-1559 elasticity override must be greater than zero" @@ -467,6 +487,8 @@ mod tests { let DevnetL2State::Snapshot(snapshot) = &config.l2_state else { panic!("snapshot constructor must create snapshot state") }; + assert_eq!(snapshot.block_gas_limit, None); + assert_eq!(snapshot.block_interval.snapshot_block_gas_limit(), 10_000_000_000); assert_eq!(snapshot.eip1559_elasticity_override, None); config.validate().expect("Base mainnet snapshot config should be valid"); } From 23e7ebc017b67dead45e97dee11f92d9c5d25bfa Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 17:18:52 +0000 Subject: [PATCH 043/299] feat(load-tests): allow runtime-wired swap addresses (#5099) --- crates/infra/load-tests/README.md | 5 + .../infra/load-tests/src/config/real_token.rs | 90 +++++++++++++++-- .../load-tests/src/config/test_config.rs | 97 ++++++++++++++++--- 3 files changed, 171 insertions(+), 21 deletions(-) diff --git a/crates/infra/load-tests/README.md b/crates/infra/load-tests/README.md index c2ff468026..3599f54f32 100644 --- a/crates/infra/load-tests/README.md +++ b/crates/infra/load-tests/README.md @@ -277,6 +277,11 @@ Swap payloads randomly choose direction for each generated transaction, alternat `real_token_setup` runs a pre-test phase before the measured loop: it wraps sender ETH into WETH, acquires the paired token through the configured acquisition route if the sender's balance is below `amount_per_sender`, and approves all measured routers for both tokens. When present and enabled, it replaces fixture-token minting (`swap_token_amount`). +When this config is executed via `base-bench local --workload-config`, setting +`deploy_devnet_swap_harness: true` on the workload entry deploys a fresh +devnet USDC token plus Uniswap/Aerodrome router shims and auto-wires all swap +and acquisition addresses. In that mode, these address fields can be omitted. + ```yaml real_token_setup: enabled: true diff --git a/crates/infra/load-tests/src/config/real_token.rs b/crates/infra/load-tests/src/config/real_token.rs index d73943c907..e84abdecfa 100644 --- a/crates/infra/load-tests/src/config/real_token.rs +++ b/crates/infra/load-tests/src/config/real_token.rs @@ -16,7 +16,10 @@ pub struct RealTokenSetupConfig { #[serde(default)] pub allow_chain_id_8453: bool, /// WETH contract address. - pub weth: Address, + /// + /// Required unless a harness flow auto-wires swap setup addresses. + #[serde(default)] + pub weth: Option
, /// Target WETH balance to leave each sender with after setup. pub weth_amount_per_sender: U256, /// Non-WETH token setup. @@ -50,7 +53,10 @@ where #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RealTokenPairTokenConfig { /// Token contract address. - pub token: Address, + /// + /// Required unless a harness flow auto-wires swap setup addresses. + #[serde(default)] + pub token: Option
, /// Target pair-token balance per sender. pub amount_per_sender: U256, /// Explicit setup route for acquiring the pair token. @@ -64,7 +70,10 @@ pub enum RealTokenAcquisitionConfig { /// Uniswap V3 `exactInputSingle` route. UniswapV3ExactInput { /// Router contract address. - router: Address, + /// + /// Required unless a harness flow auto-wires swap setup addresses. + #[serde(default)] + router: Option
, /// Fee tier. #[serde(default = "default_uniswap_v3_fee")] fee: u32, @@ -77,7 +86,10 @@ pub enum RealTokenAcquisitionConfig { /// Aerodrome Slipstream `exactInputSingle` route. AerodromeClExactInput { /// Router contract address. - router: Address, + /// + /// Required unless a harness flow auto-wires swap setup addresses. + #[serde(default)] + router: Option
, /// Tick spacing. #[serde(default = "default_aerodrome_tick_spacing")] tick_spacing: i32, @@ -106,7 +118,12 @@ pub(super) fn parse_real_token_setup( )); } - let weth = setup.weth; + let weth = setup.weth.ok_or_else(|| { + BaselineError::Config( + "real_token_setup weth is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; let weth_amount_per_sender = setup.weth_amount_per_sender; if weth_amount_per_sender == U256::ZERO { return Err(BaselineError::Config( @@ -114,7 +131,12 @@ pub(super) fn parse_real_token_setup( )); } - let pair_token = setup.pair_token.token; + let pair_token = setup.pair_token.token.ok_or_else(|| { + BaselineError::Config( + "real_token_setup pair_token token is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; if pair_token == weth { return Err(BaselineError::Config( "real_token_setup pair_token must differ from weth".into(), @@ -157,7 +179,21 @@ fn validate_real_token_pair_matches_swaps( for tx in transactions { let (token_in, token_out) = match &tx.tx_type { TxTypeConfig::UniswapV3 { token_in, token_out, .. } - | TxTypeConfig::AerodromeCl { token_in, token_out, .. } => (*token_in, *token_out), + | TxTypeConfig::AerodromeCl { token_in, token_out, .. } => { + let token_in = token_in.ok_or_else(|| { + BaselineError::Config( + "real_token_setup requires swap token_in (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; + let token_out = token_out.ok_or_else(|| { + BaselineError::Config( + "real_token_setup requires swap token_out (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; + (token_in, token_out) + } TxTypeConfig::Transfer | TxTypeConfig::Calldata { .. } | TxTypeConfig::Erc20 { .. } @@ -196,6 +232,12 @@ fn parse_real_token_acquisition( amount_in, min_amount_out, } => { + let router = router.ok_or_else(|| { + BaselineError::Config( + "real_token_setup uniswap_v3_exact_input router is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; let max_u24: u32 = (1 << 24) - 1; if *fee > max_u24 { return Err(BaselineError::Config(format!( @@ -208,7 +250,7 @@ fn parse_real_token_acquisition( )); } Ok(RealTokenAcquisition::UniswapV3ExactInput { - router: *router, + router, fee: *fee, amount_in: *amount_in, min_amount_out: *min_amount_out, @@ -220,6 +262,12 @@ fn parse_real_token_acquisition( amount_in, min_amount_out, } => { + let router = router.ok_or_else(|| { + BaselineError::Config( + "real_token_setup aerodrome_cl_exact_input router is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; if !(-8_388_608..=8_388_607).contains(tick_spacing) { return Err(BaselineError::Config(format!( "real_token_setup acquisition tick_spacing {tick_spacing} exceeds i24 range" @@ -231,7 +279,7 @@ fn parse_real_token_acquisition( )); } Ok(RealTokenAcquisition::AerodromeClExactInput { - router: *router, + router, tick_spacing: *tick_spacing, amount_in: *amount_in, min_amount_out: *min_amount_out, @@ -435,4 +483,28 @@ transactions: let err = config.parse_real_token_setup(8453).unwrap_err(); assert!(err.to_string().contains("WETH/pair token")); } + + #[test] + fn real_token_setup_reports_missing_addresses_for_harnessless_runs() { + let yaml = r#" +transaction_submission_rpcs: http://localhost:8545 +flashblocks_ws: ws://localhost:7111 +real_token_setup: + enabled: true + weth_amount_per_sender: "800000000000000000" + pair_token: + amount_per_sender: "1000000000" + acquisition: + type: uniswap_v3_exact_input + fee: 500 + amount_in: "10000000000000000" +transactions: + - weight: 100 + type: uniswap_v3 + fee: 500 +"#; + let config = TestConfig::from_yaml(yaml).unwrap(); + let err = config.parse_real_token_setup(84532).unwrap_err(); + assert!(err.to_string().contains("real_token_setup weth is required")); + } } diff --git a/crates/infra/load-tests/src/config/test_config.rs b/crates/infra/load-tests/src/config/test_config.rs index f66e634637..c5fb39f434 100644 --- a/crates/infra/load-tests/src/config/test_config.rs +++ b/crates/infra/load-tests/src/config/test_config.rs @@ -301,11 +301,20 @@ pub enum TxTypeConfig { /// Uniswap V3 style swap. UniswapV3 { /// Router contract address. - router: Address, + /// + /// Required unless a harness flow auto-wires swap addresses. + #[serde(default)] + router: Option
, /// Input token address. - token_in: Address, + /// + /// Required unless a harness flow auto-wires swap addresses. + #[serde(default)] + token_in: Option
, /// Output token address. - token_out: Address, + /// + /// Required unless a harness flow auto-wires swap addresses. + #[serde(default)] + token_out: Option
, /// Fee tier (default 3000 = 0.3%). #[serde(default = "default_uniswap_v3_fee")] fee: u32, @@ -329,11 +338,20 @@ pub enum TxTypeConfig { /// Aerodrome Slipstream (concentrated liquidity) swap. AerodromeCl { /// CL Router contract address. - router: Address, + /// + /// Required unless a harness flow auto-wires swap addresses. + #[serde(default)] + router: Option
, /// Input token address. - token_in: Address, + /// + /// Required unless a harness flow auto-wires swap addresses. + #[serde(default)] + token_in: Option
, /// Output token address. - token_out: Address, + /// + /// Required unless a harness flow auto-wires swap addresses. + #[serde(default)] + token_out: Option
, /// Tick spacing for the pool. #[serde(default = "default_aerodrome_tick_spacing")] tick_spacing: i32, @@ -757,10 +775,28 @@ impl TestConfig { reverse_max_amount, "uniswap_v3 reverse", )?; + let router = router.ok_or_else(|| { + BaselineError::Config( + "uniswap_v3 router is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; + let token_in = token_in.ok_or_else(|| { + BaselineError::Config( + "uniswap_v3 token_in is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; + let token_out = token_out.ok_or_else(|| { + BaselineError::Config( + "uniswap_v3 token_out is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; TxType::UniswapV3 { - router: *router, - token_in: *token_in, - token_out: *token_out, + router, + token_in, + token_out, fee: *fee, min_amount: *min_amount, max_amount: *max_amount, @@ -791,10 +827,28 @@ impl TestConfig { "aerodrome_cl tick_spacing {tick_spacing} exceeds i24 range" ))); } + let router = router.ok_or_else(|| { + BaselineError::Config( + "aerodrome_cl router is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; + let token_in = token_in.ok_or_else(|| { + BaselineError::Config( + "aerodrome_cl token_in is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; + let token_out = token_out.ok_or_else(|| { + BaselineError::Config( + "aerodrome_cl token_out is required (set deploy_devnet_swap_harness for fresh-devnet auto-deploy)" + .into(), + ) + })?; TxType::AerodromeCl { - router: *router, - token_in: *token_in, - token_out: *token_out, + router, + token_in, + token_out, tick_spacing: *tick_spacing, min_amount: *min_amount, max_amount: *max_amount, @@ -1527,6 +1581,25 @@ transactions: } } + #[test] + fn swap_configs_allow_missing_addresses_until_runtime_resolution() { + let yaml = r#" +transaction_submission_rpcs: http://localhost:8545 +flashblocks_ws: ws://localhost:7111 +transactions: + - weight: 50 + type: uniswap_v3 + fee: 500 + - weight: 50 + type: aerodrome_cl + tick_spacing: 100 +"#; + + let config = TestConfig::from_yaml(yaml).unwrap(); + let error = config.to_load_config(Some(84538453)).unwrap_err(); + assert!(error.to_string().contains("uniswap_v3 router is required")); + } + #[test] fn batch_size_defaults_to_rpc_limit() { let config = From f312f6472bb571a7bbfd34e4bf738bc618fc15ce Mon Sep 17 00:00:00 2001 From: William Law Date: Thu, 17 Sep 2026 17:19:22 +0000 Subject: [PATCH 044/299] fix(cli): resume proofs downloads with parallel Range streams (#5082) * fix(cli): resume proofs downloads after stream interrupts A 300+ GiB single GET through Cloudflare drops mid-body. Treat that as a Range resume instead of a hard error that panics the node. Co-authored-by: Cursor * fix(cli): download proofs with parallel Range streams A 324 GiB single GET through Cloudflare is slow and drops. Split the archive across --download-concurrency Range requests, matching reth. Co-authored-by: Cursor * fix(cli): harden proofs download retries and sidecar writes Exponential backoff on 429/5xx, keep .part when Content-Length is missing, and persist range progress under one lock with async writes. Co-authored-by: Cursor * fix(cli): time out stalled proofs download reads A CDN edge can keep a TCP socket open with no bytes. 120s read timeout turns that hang into an error so Range resume can continue. Co-authored-by: Cursor * fix(cli): reject stale proofs sidecar and preallocated .part A crashed parallel download leaves a full-sized zero-padded .part. Sequential must not promote that file, and parallel must not trust a sidecar unless the .part size still matches. Co-authored-by: Cursor * test(cli): cover proofs sidecar resume and sequential leftover The crash-restart path and sequential-to-parallel handoff were untested. Co-authored-by: Cursor * fix(cli): quiet proofs retry logs and match snapshotter progress Stream drops are expected, so retry them at debug. Progress ticks now use human-byte progress, speed, eta, and elapsed. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- crates/execution/cli/src/commands/download.rs | 1303 ++++++++++++++++- 1 file changed, 1232 insertions(+), 71 deletions(-) diff --git a/crates/execution/cli/src/commands/download.rs b/crates/execution/cli/src/commands/download.rs index 57cfb331bf..6605554ca1 100644 --- a/crates/execution/cli/src/commands/download.rs +++ b/crates/execution/cli/src/commands/download.rs @@ -6,20 +6,29 @@ use std::{ ffi::OsString, + io::SeekFrom, path::{Path, PathBuf}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, }; use base_execution_chainspec::BaseChainSpec; +use base_reth_cli::ProgressDisplay; use clap::Parser; use eyre::Result; -use futures::StreamExt; +use futures::{StreamExt, future::try_join_all}; use reth_chainspec::EthChainSpec; use reth_cli::chainspec::ChainSpecParser; use reth_cli_commands::download::{DownloadCommand, DownloadDefaults}; use reth_node_core::args::DatadirArgs; -use tokio::io::AsyncWriteExt; -use tracing::info; +use tokio::{ + io::{AsyncSeekExt, AsyncWriteExt}, + sync::Mutex, +}; +use tracing::{debug, info}; /// Download Base node snapshots from R2 storage. /// @@ -64,7 +73,8 @@ impl> BaseDownloadCommand { if let (Some(data_dir), Some(chain_id)) = (data_dir, chain_id) { let target_dir = data_dir.data_dir().to_path_buf(); - ProofsDownloader::run(&target_dir, chain_id).await?; + let concurrency = resolve_download_concurrency_arg(std::env::args_os()); + ProofsDownloader::run(&target_dir, chain_id, concurrency).await?; } Ok(()) @@ -95,6 +105,134 @@ fn resolve_datadir_args(args: impl IntoIterator) -> DatadirArgs datadir_args } +/// Matches reth's `--download-concurrency` default. +const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 8; + +/// Extracts `--download-concurrency` so proofs use the same parallel budget as reth. +fn resolve_download_concurrency_arg(args: impl IntoIterator) -> usize { + let mut concurrency = DEFAULT_DOWNLOAD_CONCURRENCY; + let mut args = args.into_iter(); + + while let Some(arg) = args.next() { + let Some(arg) = arg.to_str() else { continue }; + + if arg == "--download-concurrency" { + if let Some(value) = args.next() + && let Ok(parsed) = value.to_string_lossy().parse::() + { + concurrency = parsed; + } + continue; + } + + if let Some(value) = arg.strip_prefix("--download-concurrency=") + && let Ok(parsed) = value.parse::() + { + concurrency = parsed; + } + } + + concurrency.max(1) +} + +/// Splits `total` bytes into `parts` contiguous half-open ranges. +fn split_byte_ranges(total: u64, parts: usize) -> Vec> { + let parts = parts.max(1); + if total == 0 { + return Vec::new(); + } + + let parts = parts.min(total as usize); + let chunk = total / parts as u64; + let rem = total % parts as u64; + let mut ranges = Vec::with_capacity(parts); + let mut start = 0u64; + for i in 0..parts { + let extra = u64::from((i as u64) < rem); + let end = start + chunk + extra; + ranges.push(start..end); + start = end; + } + ranges +} + +/// Writes parallel-range resume state next to the `.part` file. +async fn persist_range_sidecar( + path: &Path, + concurrency: usize, + expected_size: u64, + written: &[u64], +) -> Result<()> { + let mut line = format!("{concurrency} {expected_size}"); + for amount in written { + line.push(' '); + line.push_str(&amount.to_string()); + } + tokio::fs::write(path, line).await?; + Ok(()) +} + +/// Delay before an idle proofs download retry. +/// +/// Stream drops stay at 1s. HTTP 429/5xx use exponential backoff so a +/// rate-limiting CDN is not hammered. +fn retry_delay(idle_attempts: u32, throttled: bool) -> Duration { + if !throttled { + return Duration::from_secs(1); + } + + let multiplier = 1u64 << idle_attempts.saturating_sub(1).min(4); + Duration::from_secs((2 * multiplier).min(30)) +} + +/// Logs proofs download progress using the same fields as snapshot compression. +fn log_proofs_download_progress(downloaded: u64, expected: u64, started: Instant, baseline: u64) { + let elapsed = started.elapsed(); + let session_done = downloaded.saturating_sub(baseline); + let session_total = expected.saturating_sub(baseline); + let speed = session_done as f64 / elapsed.as_secs_f64(); + let eta = ProgressDisplay::eta(session_done, session_total, elapsed) + .map_or_else(|| "unknown".to_string(), |eta| eta.to_string()); + info!( + target: "reth::cli", + progress = %ProgressDisplay::human_byte_progress(downloaded, expected), + speed = %ProgressDisplay::speed(speed), + eta = %eta, + elapsed = %ProgressDisplay::duration(elapsed), + "Proofs download progress" + ); +} + +/// Loads parallel-range resume state when it matches this download. +fn load_range_sidecar(path: &Path, concurrency: usize, expected_size: u64) -> Option> { + let text = std::fs::read_to_string(path).ok()?; + let mut parts = text.split_whitespace(); + let stored_concurrency = parts.next()?.parse::().ok()?; + let stored_expected = parts.next()?.parse::().ok()?; + if stored_concurrency != concurrency || stored_expected != expected_size { + return None; + } + + let written = parts.map(|part| part.parse().ok()).collect::>>()?; + (written.len() == concurrency).then_some(written) +} + +/// Trusts a sidecar only when the `.part` file is already the declared size. +/// +/// A missing or resized `.part` means the sidecar describes bytes that are +/// no longer on disk, so resume must start from zero. +fn load_trusted_range_sidecar( + path: &Path, + concurrency: usize, + expected_size: u64, + part_len: u64, +) -> Option> { + if part_len != expected_size { + return None; + } + load_range_sidecar(path, concurrency, expected_size) +} + /// Extracts `--manifest-url` so proofs reuse the same snapshot reth downloaded. fn resolve_manifest_url_arg(args: impl IntoIterator) -> Option { let mut args = args.into_iter(); @@ -200,6 +338,25 @@ struct ProofsManifestEntry { archive_url: String, } +/// Consecutive Range-resume attempts that write no new bytes before failing. +/// +/// Resets on progress so a multi-hundred-GiB Cloudflare GET can drop many +/// times without aborting the process. +const MAX_IDLE_DOWNLOAD_ATTEMPTS: u32 = 8; + +/// Idle read timeout so a stalled CDN socket fails into retry instead of hanging. +const DOWNLOAD_READ_TIMEOUT: Duration = Duration::from_secs(120); + +/// Shared write-progress for one parallel proofs download. +#[derive(Clone)] +struct RangeProgress { + sidecar_path: PathBuf, + concurrency: usize, + expected_size: u64, + written: Arc>>, + progress: Arc, +} + /// Downloads the proofs database from a snapshot manifest. /// /// Encapsulates the full pipeline: manifest fetch → archive download with @@ -209,7 +366,7 @@ struct ProofsDownloader; impl ProofsDownloader { /// Runs the full proofs download pipeline for the given chain. - async fn run(target_dir: &Path, chain_id: u64) -> Result<()> { + async fn run(target_dir: &Path, chain_id: u64, concurrency: usize) -> Result<()> { let manifest_url = match resolve_manifest_url_arg(std::env::args_os()) { Some(url) => url, None => { @@ -218,17 +375,21 @@ impl ProofsDownloader { } }; - Self::run_from_manifest(target_dir, &manifest_url).await + Self::run_from_manifest(target_dir, &manifest_url, concurrency).await } /// Runs the full proofs download pipeline from a manifest URL. - async fn run_from_manifest(target_dir: &Path, manifest_url: &str) -> Result<()> { + async fn run_from_manifest( + target_dir: &Path, + manifest_url: &str, + concurrency: usize, + ) -> Result<()> { let entry = Self::fetch_manifest_entry(manifest_url).await?; let cache_dir = target_dir.join(".snapshot-cache"); tokio::fs::create_dir_all(&cache_dir).await?; - let archive_path = Self::download_archive(&entry, &cache_dir).await?; + let archive_path = Self::download_archive(&entry, &cache_dir, concurrency).await?; Self::extract_and_cleanup(&archive_path, target_dir, &cache_dir).await } @@ -289,17 +450,25 @@ impl ProofsDownloader { Ok(ProofsManifestEntry { file_name, expected_size, archive_url }) } - /// Downloads the proofs archive with resume support and size verification. + /// Downloads the proofs archive with in-process resume and size verification. + /// + /// `concurrency > 1` splits the archive into parallel Range streams. An + /// existing sequential `.part` leftover is finished as a single stream so + /// an interrupted one-stream download is not thrown away. async fn download_archive( entry: &ProofsManifestEntry, cache_dir: &Path, + concurrency: usize, ) -> Result { let dest_path = cache_dir.join(&entry.file_name); let part_path = cache_dir.join(format!("{}.part", entry.file_name)); + let sidecar_path = cache_dir.join(format!("{}.part.ranges", entry.file_name)); + let concurrency = concurrency.max(1); - let mut existing_size = tokio::fs::metadata(&part_path).await.map(|m| m.len()).unwrap_or(0); + let existing_size = tokio::fs::metadata(&part_path).await.map(|m| m.len()).unwrap_or(0); + let has_sidecar = tokio::fs::try_exists(&sidecar_path).await.unwrap_or(false); - if existing_size == entry.expected_size { + if existing_size == entry.expected_size && !has_sidecar { info!(target: "reth::cli", "Part file already matches expected size, skipping download"); tokio::fs::rename(&part_path, &dest_path).await?; return Ok(dest_path); @@ -313,70 +482,335 @@ impl ProofsDownloader { "Part file exceeds expected size, restarting proofs download" ); tokio::fs::remove_file(&part_path).await.ok(); - existing_size = 0; + tokio::fs::remove_file(&sidecar_path).await.ok(); } - let client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(30)) - .build()?; + let sequential_leftover = + existing_size > 0 && existing_size < entry.expected_size && !has_sidecar; - let mut request = client.get(&entry.archive_url); - if existing_size > 0 { - request = request.header("Range", format!("bytes={existing_size}-")); - info!(target: "reth::cli", resume_from = existing_size, "Resuming proofs download"); + if concurrency == 1 || sequential_leftover { + if sequential_leftover && concurrency > 1 { + info!( + target: "reth::cli", + existing_size, + "Finishing existing sequential proofs .part before using parallel ranges" + ); + } + return Self::download_archive_sequential(entry, cache_dir).await; } - info!(target: "reth::cli", url = %entry.archive_url, "Downloading proofs database"); + Self::download_archive_parallel(entry, &part_path, &dest_path, &sidecar_path, concurrency) + .await + } - let response = request.send().await.map_err(|e| { - eyre::eyre!("failed to download proofs from {}: {e}", entry.archive_url) - })?; - let status = response.status(); + /// Downloads the proofs archive as a single resumable stream. + /// + /// Stream drops and truncated bodies retry from the current `.part` offset. + /// A complete HTTP entity whose size does not match the manifest is still + /// a hard error. + async fn download_archive_sequential( + entry: &ProofsManifestEntry, + cache_dir: &Path, + ) -> Result { + let dest_path = cache_dir.join(&entry.file_name); + let part_path = cache_dir.join(format!("{}.part", entry.file_name)); + let sidecar_path = cache_dir.join(format!("{}.part.ranges", entry.file_name)); - if !status.is_success() { - eyre::bail!("proofs download failed with HTTP {status}: {}", entry.archive_url); + if tokio::fs::try_exists(&sidecar_path).await.unwrap_or(false) { + info!( + target: "reth::cli", + "Discarding parallel range sidecar and preallocated .part before sequential download" + ); + tokio::fs::remove_file(&sidecar_path).await.ok(); + tokio::fs::remove_file(&part_path).await.ok(); } - let is_resume = status == reqwest::StatusCode::PARTIAL_CONTENT; + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(30)) + .read_timeout(DOWNLOAD_READ_TIMEOUT) + .build()?; - if existing_size > 0 && !is_resume { - info!(target: "reth::cli", "Server returned full response despite range request, restarting download"); - tokio::fs::remove_file(&part_path).await.ok(); - } + info!(target: "reth::cli", url = %entry.archive_url, "Downloading proofs database"); - let mut file = tokio::fs::OpenOptions::new() - .create(true) - .append(is_resume) - .write(!is_resume) - .truncate(!is_resume) - .open(&part_path) - .await?; + let mut idle_attempts = 0u32; + loop { + let mut existing_size = + tokio::fs::metadata(&part_path).await.map(|m| m.len()).unwrap_or(0); - let mut downloaded: u64 = if is_resume { existing_size } else { 0 }; - let mut last_log = tokio::time::Instant::now(); + if existing_size == entry.expected_size { + info!(target: "reth::cli", "Part file already matches expected size, skipping download"); + tokio::fs::rename(&part_path, &dest_path).await?; + return Ok(dest_path); + } - let mut stream = response.bytes_stream(); - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.map_err(|e| { - eyre::eyre!("stream interrupted downloading {}: {e}", entry.archive_url) - })?; - file.write_all(&chunk).await?; - downloaded += chunk.len() as u64; - if last_log.elapsed() >= std::time::Duration::from_secs(30) { + if existing_size > entry.expected_size { info!( target: "reth::cli", - downloaded_mb = downloaded / (1024 * 1024), - expected_mb = entry.expected_size / (1024 * 1024), - "Proofs download progress" + existing_size, + expected_size = entry.expected_size, + "Part file exceeds expected size, restarting proofs download" ); - last_log = tokio::time::Instant::now(); + tokio::fs::remove_file(&part_path).await.ok(); + existing_size = 0; + } + + let mut request = client.get(&entry.archive_url); + if existing_size > 0 { + request = request.header("Range", format!("bytes={existing_size}-")); + info!(target: "reth::cli", resume_from = existing_size, "Resuming proofs download"); + } + + let response = match request.send().await { + Ok(response) => response, + Err(error) => { + Self::wait_before_retry( + &mut idle_attempts, + false, + false, + &format!("failed to download proofs from {}: {error}", entry.archive_url), + ) + .await?; + continue; + } + }; + let status = response.status(); + + if !status.is_success() { + if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS { + Self::wait_before_retry( + &mut idle_attempts, + false, + true, + &format!( + "proofs download failed with HTTP {status}: {}", + entry.archive_url + ), + ) + .await?; + continue; + } + eyre::bail!("proofs download failed with HTTP {status}: {}", entry.archive_url); } + + let is_resume = status == reqwest::StatusCode::PARTIAL_CONTENT; + + if existing_size > 0 && !is_resume { + info!(target: "reth::cli", "Server returned full response despite range request, restarting download"); + tokio::fs::remove_file(&part_path).await.ok(); + } + + let start_size = if is_resume { existing_size } else { 0 }; + let content_length = response.content_length(); + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(is_resume) + .write(!is_resume) + .truncate(!is_resume) + .open(&part_path) + .await?; + + let mut downloaded = start_size; + let started = Instant::now(); + let mut last_log = started; + let mut stream_error = None; + + let mut stream = response.bytes_stream(); + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + file.write_all(&chunk).await?; + downloaded += chunk.len() as u64; + if last_log.elapsed() >= Duration::from_secs(30) { + log_proofs_download_progress( + downloaded, + entry.expected_size, + started, + start_size, + ); + last_log = Instant::now(); + } + } + Err(error) => { + stream_error = Some(error); + break; + } + } + } + file.flush().await?; + file.shutdown().await?; + + let downloaded_size = tokio::fs::metadata(&part_path).await?.len(); + let written_this_attempt = downloaded_size.saturating_sub(start_size); + let entity_complete = stream_error.is_none() + && content_length.map_or(downloaded_size == entry.expected_size, |len| { + written_this_attempt >= len + }); + + if !entity_complete { + let reason = match (stream_error, content_length) { + (Some(error), _) => { + format!("stream interrupted downloading {}: {error}", entry.archive_url) + } + (None, Some(len)) => format!( + "truncated body downloading {}: received {written_this_attempt} of {len} bytes", + entry.archive_url + ), + (None, None) => format!( + "incomplete download without Content-Length {}: {downloaded_size}/{} bytes", + entry.archive_url, entry.expected_size + ), + }; + Self::wait_before_retry( + &mut idle_attempts, + downloaded_size > start_size, + false, + &reason, + ) + .await?; + continue; + } + + if downloaded_size != entry.expected_size { + tokio::fs::remove_file(&part_path).await.ok(); + eyre::bail!( + "proofs archive size mismatch: downloaded {downloaded_size} bytes, \ + manifest declares {} bytes — archive may be truncated or corrupt", + entry.expected_size + ); + } + + tokio::fs::rename(&part_path, &dest_path).await?; + return Ok(dest_path); } - file.shutdown().await?; + } + + /// Waits before retrying an interrupted proofs download. + /// + /// Progress resets the idle counter so a large Cloudflare GET can drop + /// many times. Eight idle attempts without new bytes is treated as stalled. + /// HTTP 429/5xx use exponential backoff; stream drops stay at 1s. + async fn wait_before_retry( + idle_attempts: &mut u32, + made_progress: bool, + throttled: bool, + reason: &str, + ) -> Result<()> { + if made_progress { + *idle_attempts = 0; + debug!(target: "reth::cli", error = %reason, "Proofs download interrupted, resuming"); + return Ok(()); + } + + *idle_attempts += 1; + if *idle_attempts >= MAX_IDLE_DOWNLOAD_ATTEMPTS { + eyre::bail!( + "proofs download stalled after {MAX_IDLE_DOWNLOAD_ATTEMPTS} attempts without progress: {reason}" + ); + } + + let delay = retry_delay(*idle_attempts, throttled); + debug!( + target: "reth::cli", + error = %reason, + idle_attempts = *idle_attempts, + retry_in_secs = delay.as_secs(), + "Proofs download interrupted without progress, retrying" + ); + tokio::time::sleep(delay).await; + Ok(()) + } + + /// Downloads the proofs archive with parallel Range streams. + async fn download_archive_parallel( + entry: &ProofsManifestEntry, + part_path: &Path, + dest_path: &Path, + sidecar_path: &Path, + concurrency: usize, + ) -> Result { + let ranges = split_byte_ranges(entry.expected_size, concurrency); + let part_len = tokio::fs::metadata(part_path).await.map(|m| m.len()).unwrap_or(0); + let written = + load_trusted_range_sidecar(sidecar_path, concurrency, entry.expected_size, part_len) + .unwrap_or_else(|| vec![0; ranges.len()]); + let initial_progress: u64 = written.iter().sum(); + + persist_range_sidecar(sidecar_path, concurrency, entry.expected_size, &written).await?; + + let file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(part_path) + .await?; + file.set_len(entry.expected_size).await?; + drop(file); + + info!( + target: "reth::cli", + url = %entry.archive_url, + streams = ranges.len(), + resume = %ProgressDisplay::bytes(initial_progress as f64), + expected = %ProgressDisplay::bytes(entry.expected_size as f64), + "Downloading proofs database with parallel Range requests" + ); + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(30)) + .read_timeout(DOWNLOAD_READ_TIMEOUT) + .pool_max_idle_per_host(concurrency) + .build()?; + let progress = Arc::new(AtomicU64::new(initial_progress)); + let written = Arc::new(Mutex::new(written)); + + let ticker = { + let progress = Arc::clone(&progress); + let expected_size = entry.expected_size; + let started = Instant::now(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.tick().await; + loop { + interval.tick().await; + log_proofs_download_progress( + progress.load(Ordering::Relaxed), + expected_size, + started, + initial_progress, + ); + } + }) + }; + + let range_progress = RangeProgress { + sidecar_path: sidecar_path.to_path_buf(), + concurrency, + expected_size: entry.expected_size, + written, + progress, + }; + + let downloads = ranges.iter().enumerate().map(|(range_idx, range)| { + let client = client.clone(); + let url = entry.archive_url.clone(); + let part_path = part_path.to_path_buf(); + let range_progress = range_progress.clone(); + let range = range.clone(); + async move { + Self::download_range(&client, &url, &part_path, range, range_idx, range_progress) + .await + } + }); + + let result = try_join_all(downloads).await; + ticker.abort(); + result?; - let downloaded_size = tokio::fs::metadata(&part_path).await?.len(); + let downloaded_size = tokio::fs::metadata(part_path).await?.len(); if downloaded_size != entry.expected_size { - tokio::fs::remove_file(&part_path).await.ok(); + tokio::fs::remove_file(part_path).await.ok(); + tokio::fs::remove_file(sidecar_path).await.ok(); eyre::bail!( "proofs archive size mismatch: downloaded {downloaded_size} bytes, \ manifest declares {} bytes — archive may be truncated or corrupt", @@ -384,8 +818,136 @@ impl ProofsDownloader { ); } - tokio::fs::rename(&part_path, &dest_path).await?; - Ok(dest_path) + tokio::fs::remove_file(sidecar_path).await.ok(); + tokio::fs::rename(part_path, dest_path).await?; + Ok(dest_path.to_path_buf()) + } + + /// Downloads one byte range, retrying stream drops from the current offset. + async fn download_range( + client: &reqwest::Client, + url: &str, + part_path: &Path, + range: std::ops::Range, + range_idx: usize, + range_progress: RangeProgress, + ) -> Result<()> { + let range_len = range.end.saturating_sub(range.start); + let mut idle_attempts = 0u32; + + loop { + let already = range_progress.written.lock().await[range_idx]; + if already >= range_len { + return Ok(()); + } + + let abs_start = range.start + already; + let response = match client + .get(url) + .header("Range", format!("bytes={abs_start}-{}", range.end.saturating_sub(1))) + .send() + .await + { + Ok(response) => response, + Err(error) => { + Self::wait_before_retry( + &mut idle_attempts, + false, + false, + &format!( + "failed to download proofs range {abs_start}-{} from {url}: {error}", + range.end + ), + ) + .await?; + continue; + } + }; + + let status = response.status(); + if status != reqwest::StatusCode::PARTIAL_CONTENT { + if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS { + Self::wait_before_retry( + &mut idle_attempts, + false, + true, + &format!("proofs range download failed with HTTP {status}: {url}"), + ) + .await?; + continue; + } + eyre::bail!( + "expected HTTP 206 for proofs range {abs_start}-{}, got {status}: {url}", + range.end + ); + } + + let content_length = response.content_length(); + let mut file = tokio::fs::OpenOptions::new().write(true).open(part_path).await?; + file.seek(SeekFrom::Start(abs_start)).await?; + + let mut offset = abs_start; + let mut stream_error = None; + let mut stream = response.bytes_stream(); + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + file.write_all(&chunk).await?; + offset += chunk.len() as u64; + range_progress.progress.fetch_add(chunk.len() as u64, Ordering::Relaxed); + } + Err(error) => { + stream_error = Some(error); + break; + } + } + } + file.flush().await?; + + let done = offset.saturating_sub(range.start).min(range_len); + { + let mut written = range_progress.written.lock().await; + written[range_idx] = done; + persist_range_sidecar( + &range_progress.sidecar_path, + range_progress.concurrency, + range_progress.expected_size, + &written, + ) + .await?; + } + + if done >= range_len { + return Ok(()); + } + + let written_this_attempt = offset.saturating_sub(abs_start); + let truncated = content_length.is_some_and(|len| written_this_attempt < len); + let reason = match stream_error { + Some(error) => { + format!( + "stream interrupted downloading proofs range {abs_start}-{} from {url}: {error}", + range.end + ) + } + None if truncated => { + format!( + "truncated proofs range {abs_start}-{} from {url}: received {written_this_attempt} of {} bytes", + range.end, + content_length.unwrap_or(0) + ) + } + None => { + format!( + "incomplete proofs range {abs_start}-{} from {url}: {done}/{range_len} bytes", + range.end + ) + } + }; + + Self::wait_before_retry(&mut idle_attempts, written_this_attempt > 0, false, &reason) + .await?; + } } /// Extracts the archive and cleans up the cache directory. @@ -421,16 +983,24 @@ impl ProofsDownloader { #[cfg(test)] mod tests { - use std::path::Path; + use std::{ + path::Path, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; use axum::{ Router, + body::{Body, Bytes}, extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, routing::get, }; use clap::Parser; + use futures::stream; use super::*; use crate::chainspec::BaseChainSpecParser; @@ -511,25 +1081,179 @@ mod tests { (base_url, handle) } + fn parse_byte_range(headers: &HeaderMap, len: usize) -> Option<(usize, usize)> { + let spec = headers.get("Range")?.to_str().ok()?.strip_prefix("bytes=")?; + let (start, end) = spec.split_once('-')?; + let start = start.parse::().ok()?; + let end = if end.is_empty() { len.saturating_sub(1) } else { end.parse::().ok()? }; + let end = end.min(len.saturating_sub(1)); + (start < len && start <= end).then_some((start, end)) + } + async fn handle_range(State(data): State>, headers: HeaderMap) -> impl IntoResponse { - if let Some(range) = headers.get("Range").and_then(|v| v.to_str().ok()) - && let Some(start_str) = range.strip_prefix("bytes=") - && let Ok(start) = start_str.trim_end_matches('-').parse::() - && start < data.len() - { + if let Some((start, end)) = parse_byte_range(&headers, data.len()) { return ( StatusCode::PARTIAL_CONTENT, [( axum::http::header::CONTENT_RANGE, - format!("bytes {}-{}/{}", start, data.len() - 1, data.len()), + format!("bytes {start}-{end}/{}", data.len()), )], - data[start..].to_vec(), + data[start..=end].to_vec(), ) .into_response(); } (StatusCode::OK, data).into_response() } + #[derive(Clone)] + struct RecordingRangeState { + data: Vec, + requests: Arc>>, + } + + /// Serves Range GETs and records each inclusive byte range that was asked for. + async fn start_recording_range_server( + archive_bytes: Vec, + ) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let requests = Arc::new(Mutex::new(Vec::new())); + let app = Router::new().route("/proofs.tar.zst", get(handle_recording_range)).with_state( + RecordingRangeState { data: archive_bytes, requests: Arc::clone(&requests) }, + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://127.0.0.1:{}", addr.port()); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + + (base_url, requests, handle) + } + + async fn handle_recording_range( + State(state): State, + headers: HeaderMap, + ) -> impl IntoResponse { + if let Some((start, end)) = parse_byte_range(&headers, state.data.len()) { + state.requests.lock().await.push((start as u64, end as u64)); + return ( + StatusCode::PARTIAL_CONTENT, + [( + axum::http::header::CONTENT_RANGE, + format!("bytes {start}-{end}/{}", state.data.len()), + )], + state.data[start..=end].to_vec(), + ) + .into_response(); + } + + state.requests.lock().await.push((0, state.data.len().saturating_sub(1) as u64)); + (StatusCode::OK, state.data).into_response() + } + + #[derive(Clone)] + struct DropThenRangeState { + data: Vec, + requests: Arc, + } + + async fn start_drop_then_range_server( + archive_bytes: Vec, + ) -> (String, Arc, tokio::task::JoinHandle<()>) { + let requests = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route("/proofs.tar.zst", get(handle_drop_then_range)).with_state( + DropThenRangeState { data: archive_bytes, requests: Arc::clone(&requests) }, + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://127.0.0.1:{}", addr.port()); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + + (base_url, requests, handle) + } + + async fn handle_drop_then_range( + State(state): State, + headers: HeaderMap, + ) -> impl IntoResponse { + let request_n = state.requests.fetch_add(1, Ordering::SeqCst); + let (start, end) = parse_byte_range(&headers, state.data.len()) + .unwrap_or_else(|| (0, state.data.len().saturating_sub(1))); + + if request_n == 0 { + let remaining = end.saturating_sub(start) + 1; + let drop_at = start + remaining / 2; + let first = Bytes::from(state.data[start..drop_at].to_vec()); + let body = Body::from_stream(stream::iter([ + Ok::<_, std::io::Error>(first), + Err(std::io::Error::other("error decoding response body")), + ])); + let status = if headers.get("Range").is_some() { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }; + return (status, body).into_response(); + } + + ( + StatusCode::PARTIAL_CONTENT, + [( + axum::http::header::CONTENT_RANGE, + format!("bytes {start}-{end}/{}", state.data.len()), + )], + state.data[start..=end].to_vec(), + ) + .into_response() + } + + #[derive(Clone)] + struct ConcurrentRangeState { + data: Vec, + in_flight: Arc, + max_in_flight: Arc, + } + + async fn start_concurrent_range_server( + archive_bytes: Vec, + ) -> (String, Arc, tokio::task::JoinHandle<()>) { + let max_in_flight = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route("/proofs.tar.zst", get(handle_concurrent_range)).with_state( + ConcurrentRangeState { + data: archive_bytes, + in_flight: Arc::new(AtomicUsize::new(0)), + max_in_flight: Arc::clone(&max_in_flight), + }, + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://127.0.0.1:{}", addr.port()); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + + (base_url, max_in_flight, handle) + } + + async fn handle_concurrent_range( + State(state): State, + headers: HeaderMap, + ) -> impl IntoResponse { + let current = state.in_flight.fetch_add(1, Ordering::SeqCst) + 1; + state.max_in_flight.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(80)).await; + let response = handle_range(State(state.data), headers).await; + state.in_flight.fetch_sub(1, Ordering::SeqCst); + response + } + #[test] fn proofs_flag_is_parsed() { let cli = TestCli::parse_from(["test", "--proofs"]); @@ -616,6 +1340,86 @@ mod tests { assert_eq!(url, None, "missing --manifest-url should fall back to snapshot API discovery"); } + #[test] + fn resolve_download_concurrency_arg_defaults_to_reth() { + let concurrency = + resolve_download_concurrency_arg([OsString::from("test"), OsString::from("--proofs")]); + + assert_eq!( + concurrency, DEFAULT_DOWNLOAD_CONCURRENCY, + "proofs should use reth's default --download-concurrency" + ); + } + + #[test] + fn resolve_download_concurrency_arg_reads_separate_flag() { + let concurrency = resolve_download_concurrency_arg([ + OsString::from("test"), + OsString::from("--download-concurrency"), + OsString::from("16"), + ]); + + assert_eq!(concurrency, 16, "proofs should reuse --download-concurrency"); + } + + #[test] + fn resolve_download_concurrency_arg_reads_equals_syntax() { + let concurrency = resolve_download_concurrency_arg([ + OsString::from("test"), + OsString::from("--download-concurrency=4"), + ]); + + assert_eq!(concurrency, 4, "proofs should reuse --download-concurrency=VALUE"); + } + + #[test] + fn resolve_download_concurrency_arg_rejects_zero() { + let concurrency = resolve_download_concurrency_arg([ + OsString::from("test"), + OsString::from("--download-concurrency"), + OsString::from("0"), + ]); + + assert_eq!(concurrency, 1, "zero concurrency should be clamped to one stream"); + } + + #[test] + fn split_byte_ranges_covers_the_whole_file() { + let ranges = split_byte_ranges(10, 4); + + assert_eq!( + ranges, + vec![0..3, 3..6, 6..8, 8..10], + "remainder should land on the first ranges" + ); + assert_eq!(ranges.last().map(|range| range.end), Some(10)); + } + + #[test] + fn split_byte_ranges_clamps_parts_to_file_size() { + let ranges = split_byte_ranges(3, 8); + + assert_eq!(ranges, vec![0..1, 1..2, 2..3], "cannot split into more streams than bytes"); + } + + #[test] + fn retry_delay_keeps_stream_drops_at_one_second() { + assert_eq!(retry_delay(1, false), std::time::Duration::from_secs(1)); + assert_eq!(retry_delay(8, false), std::time::Duration::from_secs(1)); + } + + #[test] + fn retry_delay_uses_exponential_backoff_when_throttled() { + assert_eq!(retry_delay(1, true), std::time::Duration::from_secs(2)); + assert_eq!(retry_delay(2, true), std::time::Duration::from_secs(4)); + assert_eq!(retry_delay(3, true), std::time::Duration::from_secs(8)); + assert_eq!( + retry_delay(5, true), + std::time::Duration::from_secs(30), + "backoff should cap at 30s" + ); + } + async fn start_snapshot_api_server( listing: serde_json::Value, ) -> (String, tokio::task::JoinHandle<()>) { @@ -835,7 +1639,7 @@ mod tests { let (manifest_url, handle) = start_test_server(manifest, archive).await; let target = tempfile::tempdir().unwrap(); - ProofsDownloader::run_from_manifest(target.path(), &manifest_url) + ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) .await .expect("full pipeline should succeed"); @@ -925,7 +1729,7 @@ mod tests { archive_url: format!("{base_url}/proofs.tar.zst"), }; - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path()).await.unwrap(); + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await.unwrap(); let downloaded = std::fs::read(&dest).unwrap(); assert_eq!(downloaded.len(), archive.len(), "resumed download should produce full archive"); @@ -934,6 +1738,363 @@ mod tests { handle.abort(); } + #[tokio::test] + async fn download_archive_retries_after_stream_interrupt() { + let archive = + create_proofs_archive(&[("proofs/data.mdb", b"complete-proof-data-after-cf-drop")]); + let (base_url, requests, handle) = start_drop_then_range_server(archive.clone()).await; + + let cache_dir = tempfile::tempdir().unwrap(); + let entry = ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("{base_url}/proofs.tar.zst"), + }; + + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1) + .await + .expect("stream drop should resume in-process instead of failing"); + let downloaded = std::fs::read(&dest).unwrap(); + + assert_eq!( + downloaded, archive, + "in-process resume after stream drop should yield the full archive" + ); + assert!( + requests.load(Ordering::SeqCst) >= 2, + "stream drop should trigger a Range retry, got {} requests", + requests.load(Ordering::SeqCst) + ); + assert!( + !cache_dir.path().join("proofs.tar.zst.part").exists(), + "completed download should rename .part into place" + ); + + handle.abort(); + } + + #[tokio::test] + async fn download_archive_retries_when_body_ends_without_content_length() { + let archive = + create_proofs_archive(&[("proofs/data.mdb", b"resume-after-silent-truncation")]); + let requests = Arc::new(AtomicUsize::new(0)); + let app = + Router::new().route("/proofs.tar.zst", get(handle_short_stream_then_range)).with_state( + DropThenRangeState { data: archive.clone(), requests: Arc::clone(&requests) }, + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://127.0.0.1:{}", addr.port()); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + + let cache_dir = tempfile::tempdir().unwrap(); + let entry = ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("{base_url}/proofs.tar.zst"), + }; + + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1) + .await + .expect("a clean close without Content-Length should resume, not delete .part"); + + assert_eq!(std::fs::read(&dest).unwrap(), archive); + assert!( + requests.load(Ordering::SeqCst) >= 2, + "silent truncation should retry, got {} requests", + requests.load(Ordering::SeqCst) + ); + assert!( + !cache_dir.path().join("proofs.tar.zst.part").exists(), + "completed download should rename .part into place" + ); + + handle.abort(); + } + + async fn handle_short_stream_then_range( + State(state): State, + headers: HeaderMap, + ) -> impl IntoResponse { + let request_n = state.requests.fetch_add(1, Ordering::SeqCst); + if request_n == 0 { + let half = state.data.len() / 2; + let first = Bytes::from(state.data[..half].to_vec()); + let body = Body::from_stream(stream::iter([Ok::<_, std::io::Error>(first)])); + return (StatusCode::OK, body).into_response(); + } + + handle_range(State(state.data), headers).await.into_response() + } + + #[tokio::test] + async fn download_archive_uses_parallel_range_requests() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"parallel-range-proof-data")]); + let (base_url, max_in_flight, handle) = + start_concurrent_range_server(archive.clone()).await; + + let cache_dir = tempfile::tempdir().unwrap(); + let entry = ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("{base_url}/proofs.tar.zst"), + }; + + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) + .await + .expect("parallel Range download should succeed"); + + assert_eq!( + std::fs::read(&dest).unwrap(), + archive, + "assembled ranges should match the archive" + ); + assert!( + max_in_flight.load(Ordering::SeqCst) >= 2, + "download-concurrency=4 should issue overlapping Range requests, max in-flight was {}", + max_in_flight.load(Ordering::SeqCst) + ); + assert!( + !cache_dir.path().join("proofs.tar.zst.part.ranges").exists(), + "range sidecar should be removed after a complete parallel download" + ); + + handle.abort(); + } + + fn write_test_sidecar(path: &Path, concurrency: usize, expected_size: u64, written: &[u64]) { + let mut line = format!("{concurrency} {expected_size}"); + for amount in written { + line.push(' '); + line.push_str(&amount.to_string()); + } + std::fs::write(path, line).unwrap(); + } + + #[test] + fn load_trusted_range_sidecar_rejects_missing_or_short_part() { + let dir = tempfile::tempdir().unwrap(); + let sidecar = dir.path().join("proofs.tar.zst.part.ranges"); + write_test_sidecar(&sidecar, 4, 100, &[25, 25, 25, 25]); + + assert_eq!( + load_trusted_range_sidecar(&sidecar, 4, 100, 0), + None, + "sidecar without a full-sized .part must not skip ranges" + ); + assert_eq!( + load_trusted_range_sidecar(&sidecar, 4, 100, 50), + None, + "sidecar with a resized .part must not skip ranges" + ); + assert_eq!( + load_trusted_range_sidecar(&sidecar, 4, 100, 100), + Some(vec![25, 25, 25, 25]), + "sidecar should be trusted only when .part is the declared size" + ); + } + + /// Verifies a crash-restart with a trusted sidecar only fetches unfinished ranges. + #[tokio::test] + async fn download_archive_parallel_resumes_from_trusted_sidecar() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"resume-two-of-four-ranges")]); + let ranges = split_byte_ranges(archive.len() as u64, 4); + assert_eq!(ranges.len(), 4, "archive must split into four ranges"); + + let mut part = vec![0u8; archive.len()]; + for range in &ranges[..2] { + let start = range.start as usize; + let end = range.end as usize; + part[start..end].copy_from_slice(&archive[start..end]); + } + + let (base_url, requests, handle) = start_recording_range_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + let part_path = cache_dir.path().join("proofs.tar.zst.part"); + std::fs::write(&part_path, &part).unwrap(); + write_test_sidecar( + &cache_dir.path().join("proofs.tar.zst.part.ranges"), + 4, + archive.len() as u64, + &[ranges[0].end - ranges[0].start, ranges[1].end - ranges[1].start, 0, 0], + ); + + let entry = ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("{base_url}/proofs.tar.zst"), + }; + + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) + .await + .expect("trusted sidecar resume should finish the remaining ranges"); + + assert_eq!( + std::fs::read(&dest).unwrap(), + archive, + "resumed ranges plus already-written ranges should match the archive" + ); + + let mut got = requests.lock().await.clone(); + got.sort_unstable(); + assert_eq!( + got, + vec![(ranges[2].start, ranges[2].end - 1), (ranges[3].start, ranges[3].end - 1),], + "only the two incomplete ranges should be fetched" + ); + + handle.abort(); + } + + /// Verifies an in-flight sequential `.part` is finished as one stream, not `set_len`. + #[tokio::test] + async fn download_archive_finishes_sequential_leftover_before_parallel() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"finish-sequential-leftover")]); + let half = archive.len() / 2; + assert!(half > 0 && half < archive.len(), "archive must have a sequential midpoint"); + + let (base_url, requests, handle) = start_recording_range_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + std::fs::write(cache_dir.path().join("proofs.tar.zst.part"), &archive[..half]).unwrap(); + + let entry = ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("{base_url}/proofs.tar.zst"), + }; + + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) + .await + .expect("sequential leftover should finish as one stream"); + + assert_eq!( + std::fs::read(&dest).unwrap(), + archive, + "sequential leftover resume should yield the full archive" + ); + assert_eq!( + requests.lock().await.clone(), + vec![(half as u64, archive.len() as u64 - 1)], + "existing sequential .part must resume with one open-ended Range, not parallel splits" + ); + assert!( + !cache_dir.path().join("proofs.tar.zst.part.ranges").exists(), + "sequential leftover must not write a parallel sidecar" + ); + + handle.abort(); + } + + #[tokio::test] + async fn download_archive_sequential_discards_stale_parallel_part() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"not-zeros-from-set-len")]); + let (base_url, handle) = start_range_aware_server(archive.clone()).await; + + let cache_dir = tempfile::tempdir().unwrap(); + let part_path = cache_dir.path().join("proofs.tar.zst.part"); + std::fs::write(&part_path, vec![0u8; archive.len()]).unwrap(); + write_test_sidecar( + &cache_dir.path().join("proofs.tar.zst.part.ranges"), + 4, + archive.len() as u64, + &[0, 0, 0, 0], + ); + + let entry = ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("{base_url}/proofs.tar.zst"), + }; + + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1) + .await + .expect("sequential download should not accept a preallocated parallel .part"); + + assert_eq!( + std::fs::read(&dest).unwrap(), + archive, + "stale parallel .part filled with zeros must be re-downloaded" + ); + assert!( + !cache_dir.path().join("proofs.tar.zst.part.ranges").exists(), + "stale sidecar should be removed" + ); + + handle.abort(); + } + + #[tokio::test] + async fn download_archive_parallel_ignores_sidecar_without_part_file() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"download-over-missing-part")]); + let (base_url, _max_in_flight, handle) = + start_concurrent_range_server(archive.clone()).await; + + let cache_dir = tempfile::tempdir().unwrap(); + write_test_sidecar( + &cache_dir.path().join("proofs.tar.zst.part.ranges"), + 4, + archive.len() as u64, + &[ + archive.len() as u64 / 4, + archive.len() as u64 / 4, + archive.len() as u64 / 4, + archive.len() as u64 - 3 * (archive.len() as u64 / 4), + ], + ); + + let entry = ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("{base_url}/proofs.tar.zst"), + }; + + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) + .await + .expect("missing .part should re-download instead of trusting the sidecar"); + + assert_eq!( + std::fs::read(&dest).unwrap(), + archive, + "sidecar without a matching .part must not leave zero-filled holes" + ); + + handle.abort(); + } + + #[tokio::test] + async fn download_archive_parallel_retries_after_stream_interrupt() { + let archive = + create_proofs_archive(&[("proofs/data.mdb", b"parallel-proof-data-after-cf-drop")]); + let (base_url, requests, handle) = start_drop_then_range_server(archive.clone()).await; + + let cache_dir = tempfile::tempdir().unwrap(); + let entry = ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("{base_url}/proofs.tar.zst"), + }; + + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) + .await + .expect("parallel stream drop should resume the failed range in-process"); + + assert_eq!( + std::fs::read(&dest).unwrap(), + archive, + "parallel resume after stream drop should yield the full archive" + ); + assert!( + requests.load(Ordering::SeqCst) >= 2, + "dropped range should retry, got {} requests", + requests.load(Ordering::SeqCst) + ); + + handle.abort(); + } + #[tokio::test] async fn download_archive_restarts_when_server_ignores_range() { let archive = create_proofs_archive(&[("proofs/data.mdb", b"fresh-data")]); @@ -969,7 +2130,7 @@ mod tests { archive_url: format!("{base_url}/proofs.tar.zst"), }; - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path()).await.unwrap(); + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await.unwrap(); let downloaded = std::fs::read(&dest).unwrap(); assert_eq!(downloaded, archive, "should discard stale .part and download fresh archive"); @@ -1008,7 +2169,7 @@ mod tests { archive_url: format!("{base_url}/proofs.tar.zst"), }; - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path()).await.unwrap(); + let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await.unwrap(); assert_eq!(dest, cache_dir.path().join("proofs.tar.zst")); assert_eq!(std::fs::read(&dest).unwrap(), archive); @@ -1049,7 +2210,7 @@ mod tests { archive_url: format!("{base_url}/proofs.tar.zst"), }; - let result = ProofsDownloader::download_archive(&entry, cache_dir.path()).await; + let result = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await; assert!(result.is_err(), "size mismatch should fail"); let err = result.unwrap_err().to_string(); From 1f3e8c3749a2f0a3bdbd1f73c6fc543089e17532 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 17:20:50 +0000 Subject: [PATCH 045/299] fix(load-tests): record the terminal canonical block (#5116) --- crates/infra/load-tests/src/runner/pacing.rs | 88 ++++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/crates/infra/load-tests/src/runner/pacing.rs b/crates/infra/load-tests/src/runner/pacing.rs index d1e8fe8b98..120af8c47a 100644 --- a/crates/infra/load-tests/src/runner/pacing.rs +++ b/crates/infra/load-tests/src/runner/pacing.rs @@ -25,10 +25,10 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; use super::{ - BlockWatcher, DisplaySnapshot, FlashblockWatcher, GasPricer, InclusionPulse, InclusionSource, - LoadRunner, LoadTestDisplay, LoadTestStage, PipelineStartConfig, PreparedTransaction, - PresignBuffer, QueuedSubmitFailures, ResultsTracker, SignedBatch, SignedTransaction, - SubmissionPipeline, SubmitEvent, TxType, ValidityRouter, + BlockPulse, BlockWatcher, DisplaySnapshot, FlashblockWatcher, GasPricer, InclusionPulse, + InclusionSource, LoadRunner, LoadTestDisplay, LoadTestStage, PipelineStartConfig, + PreparedTransaction, PresignBuffer, QueuedSubmitFailures, ResultsTracker, SignedBatch, + SignedTransaction, SubmissionPipeline, SubmitEvent, TxType, ValidityRouter, }; use crate::{ BaselineError, Result, @@ -1611,16 +1611,38 @@ impl LoadRunner { let fallback_refill_interval = safety_interval; let mut last_pulse_at = Instant::now().checked_sub(fallback_refill_interval).unwrap_or_else(Instant::now); + let measurement_window = drain_state.results_tracker.measurement_window(); + let measurement_end_block = measurement_window.end_block; + let mut last_recorded_canonical_block = measurement_window.start_block; loop { drain_state.drain_run_events(); if stop_flag.load(Ordering::SeqCst) || config.deadline.is_some_and(|deadline| Instant::now() >= deadline) - || drain_state.results_tracker.measurement_finished() { return Ok(()); } + if drain_state.results_tracker.measurement_finished() { + if last_recorded_canonical_block + .zip(measurement_end_block) + .is_some_and(|(recorded, end)| recorded >= end) + { + return Ok(()); + } + + let Some(pulse) = inclusion_pulse_rx.recv().await else { + return Ok(()); + }; + let Some(canonical) = pulse.canonical else { + continue; + }; + enqueue_state.base_fee_tx.send_replace(canonical.base_fee); + Self::record_terminal_canonical_cycle(canonical, &config, drain_state); + last_recorded_canonical_block = Some(canonical.number); + continue; + } + tokio::select! { biased; maybe_pulse = inclusion_pulse_rx.recv() => { @@ -1633,8 +1655,13 @@ impl LoadRunner { } last_pulse_at = pulse.observed_at; if drain_state.results_tracker.measurement_finished() { - return Ok(()); + if let Some(canonical) = pulse.canonical { + Self::record_terminal_canonical_cycle(canonical, &config, drain_state); + last_recorded_canonical_block = Some(canonical.number); + } + continue; } + let canonical_block = pulse.canonical.map(|block| block.number); Self::run_refill_cycle( enqueue_state, pulse, @@ -1643,6 +1670,9 @@ impl LoadRunner { drain_state, ) .await?; + if canonical_block.is_some() { + last_recorded_canonical_block = canonical_block; + } } _ = safety_tick.tick() => { if last_pulse_at.elapsed() >= fallback_refill_interval { @@ -1672,6 +1702,49 @@ impl LoadRunner { } } + /// Records the canonical block that closes a block-count measurement without refilling the + /// submission pipeline after the measurement window has ended. + fn record_terminal_canonical_cycle( + canonical: BlockPulse, + config: &BlockAlignedEnqueueConfig, + drain_state: &mut EnqueueDrainState<'_>, + ) { + let plan_started = Instant::now(); + let depth_gas = drain_state.mempool_depth_gas(); + let plan = config.controller.plan( + canonical.observed_at, + canonical.gas_limit, + depth_gas, + drain_state.results_tracker.confirmed_gas(), + 0, + ); + let plan_time = plan_started.elapsed(); + drain_state.collector.record_pacing_cycle(PacingCycleObservation { + elapsed: canonical + .observed_at + .saturating_duration_since(config.controller.measurement_started_at), + source: PacingCycleSource::Canonical, + block_observed: true, + block_gas_used: canonical.gas_used, + block_gas_limit: canonical.gas_limit, + our_included_gas: canonical.our_included_gas, + pre_refill_depth_gas: depth_gas, + post_refill_depth_gas: depth_gas, + queued_gas: *drain_state.queued_gas, + floor_gas: plan.floor_gas, + offered_gas: 0, + capacity_limited: false, + chain_bound: depth_gas >= plan.ceiling_gas, + presign_starved: false, + availability_lag: Some( + canonical.observed_at.saturating_duration_since(canonical.expected_boundary), + ), + plan_time, + submit_time: None, + refill_lag: None, + }); + } + fn buffer_presigned_chunk( buffer: &mut PresignBuffer, progress: &mut EnqueueProgress, @@ -2458,7 +2531,8 @@ mod tests { run_result.expect("enqueue loop should exit cleanly"); let summary = collector.summarize(Duration::from_secs(1), None); - assert_eq!(summary.pacing.canonical_cycles, 1); + assert_eq!(summary.pacing.canonical_cycles, 2); + assert_eq!(summary.pacing.blocks_observed, 2); assert!(results_tracker.measurement_finished()); } From ae61a205d54ae23dd151599eaac948f77988bdbc Mon Sep 17 00:00:00 2001 From: refcell Date: Thu, 17 Sep 2026 17:37:12 +0000 Subject: [PATCH 046/299] feat(devnet): add dynamic upgrades dashboard (#5058) Co-authored-by: Codex --- etc/docker/README.md | 34 + .../devnet/grafana/dashboards/devnet.json | 12 + .../grafana/dashboards/dynamic-upgrades.json | 2418 +++++++++++++++++ .../devnet/grafana/test-dynamic-upgrades.py | 173 ++ 4 files changed, 2637 insertions(+) create mode 100644 etc/scripts/devnet/grafana/dashboards/dynamic-upgrades.json create mode 100644 etc/scripts/devnet/grafana/test-dynamic-upgrades.py diff --git a/etc/docker/README.md b/etc/docker/README.md index 22ec858f5f..306948c6b4 100644 --- a/etc/docker/README.md +++ b/etc/docker/README.md @@ -207,6 +207,40 @@ To observe the L1 schedule without dynamically applying it, start devnet in metr UPGRADE_SIGNAL_MODE=metrics-only just devnet up ``` +### Dynamic upgrades dashboard + +Open the [Base Devnet Control Room](http://localhost:3000/d/devnet-overview) and +select **Dynamic Upgrades**, or open the +[dashboard directly](http://localhost:3000/d/dynamic-upgrades). Grafana provisions +it automatically from `etc/scripts/devnet/grafana/dashboards/dynamic-upgrades.json`; +no manual import or additional service is required. The control-room link retains +the time range and node filters. Leave both layers selected to compare execution +(`el`) and consensus (`cl`), then filter by upgrade or instance to investigate. + +Use the schedule and propagation panels while running the upgrade-signal commands +above. Activation timestamps and minimum protocol versions describe the schedule +**observed from L1**, not proof of successful application or node readiness. +Timestamp zero means unscheduled; protocol versions are encoded as +`major * 1_000_000 + minor * 1_000 + patch`. Divergence compares only the selected, +reporting nodes and layers, so check scrape availability and reporter coverage too. + +**No data is not a healthy zero.** Upgrade counters are created lazily, and an +apply-status gauge may not exist until a live apply is attempted. Counter increases +can miss the first event; fail-closed counters are best-effort because the process +may exit before Prometheus scrapes them. Check node logs and scrape availability +when investigating a halt. Empty-schedule reads are per layer and deliberately +ignore the upgrade filter. In `metrics-only` mode, observing a schedule does not +apply it. + +Validate the dashboard queries and edge cases without starting a devnet (requires +Python 3 and Docker): + +```bash +python3 etc/scripts/devnet/grafana/test-dynamic-upgrades.py +``` + +### Building individual images + To build a specific Rust service image directly: ```bash diff --git a/etc/scripts/devnet/grafana/dashboards/devnet.json b/etc/scripts/devnet/grafana/dashboards/devnet.json index 908fce9904..8b4be7f084 100644 --- a/etc/scripts/devnet/grafana/dashboards/devnet.json +++ b/etc/scripts/devnet/grafana/dashboards/devnet.json @@ -21,6 +21,18 @@ "graphTooltip": 1, "id": null, "links": [ + { + "asDropdown": false, + "icon": "dashboard", + "includeVars": true, + "keepTime": true, + "tags": [], + "targetBlank": false, + "title": "Dynamic Upgrades", + "tooltip": "Inspect live L1 upgrade schedules and EL/CL propagation", + "type": "link", + "url": "/d/dynamic-upgrades" + }, { "asDropdown": false, "icon": "dashboard", diff --git a/etc/scripts/devnet/grafana/dashboards/dynamic-upgrades.json b/etc/scripts/devnet/grafana/dashboards/dynamic-upgrades.json new file mode 100644 index 0000000000..fc0eddb340 --- /dev/null +++ b/etc/scripts/devnet/grafana/dashboards/dynamic-upgrades.json @@ -0,0 +1,2418 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "#56D5EA", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "liveNow": false, + "refresh": "10s", + "schemaVersion": 39, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ] + }, + "timezone": "browser", + "weekStart": "", + "description": "Observe and diagnose dynamic upgrade signals on the local Base devnet: L1 schedules, EL/CL propagation, live apply failures, and read health.", + "links": [ + { + "asDropdown": false, + "icon": "dashboard", + "includeVars": true, + "keepTime": true, + "tags": [], + "targetBlank": false, + "title": "Devnet Control Room", + "tooltip": "Devnet Control Room", + "type": "link", + "url": "/d/devnet-overview" + }, + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Prometheus", + "tooltip": "Prometheus", + "type": "link", + "url": "http://localhost:9090" + } + ], + "panels": [ + { + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "content": "# 🔵 Base Devnet · Dynamic Upgrades\nFollow **L1 ProtocolVersions → EL / CL observations → live apply**. Devnet defaults to `runtime-admin`; `metrics-only` observes without applying and `startup-apply` applies only at startup.\n\n**Observed does not mean applied or ready.** Activation `0` means unscheduled; positive values are L2 Unix seconds. Upgrade labels are names (`azul`, `denim`, …). Protocol versions encode `major × 1,000,000 + minor × 1,000 + patch` (`1001000` = v1.1.0).\n\n**Missing telemetry is not healthy zero.** Apply gauges appear only after an attempt; counter rates can miss first events (see raw counters). Fail-closed may exit before scraping: check availability and fatal logs. Availability ignores layer/upgrade filters; empty-schedule reads ignore the upgrade filter.", + "mode": "markdown" + }, + "pluginVersion": "10.4.3", + "type": "text", + "transparent": true + }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 6 + }, + "id": 2, + "title": "Triage · Availability, observation, and live-apply state", + "type": "row", + "collapsed": false, + "panels": [] + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Fraction of selected Prometheus node targets reachable. Reachability does not prove the upgrade poller is active; layer/upgrade filters do not apply.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 0, + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "color": "#8A95A5", + "text": "NO TELEMETRY" + } + } + } + ], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 0.5 + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "percentunit", + "min": 0, + "max": 1 + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 7 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "avg(up{job=~\"$node_job\",instance=~\"$instance\"})", + "legendFormat": "", + "range": false, + "instant": true, + "refId": "A" + } + ], + "title": "Reachable nodes", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Count of selected node/layer pairs with successful-read telemetry. Compare with your selected topology; a missing poller must not be inferred healthy.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#56D5EA" + }, + "decimals": 0, + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "color": "#8A95A5", + "text": "NO TELEMETRY" + } + } + } + ], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 7 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "count(count by (job, instance, layer) (reth_base_upgrade_signal_last_l1_read_block{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}))", + "legendFormat": "", + "range": false, + "instant": true, + "refId": "A" + } + ], + "title": "Reporting readers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Number of selected per-upgrade apply-failure gauges raised. One failed schedule can raise multiple upgrades. Absence also occurs before the first live apply; zero is not a readiness verdict.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds", + "fixedColor": "#56D5EA" + }, + "decimals": 0, + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "color": "#8A95A5", + "text": "NO TELEMETRY" + } + } + } + ], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 8, + "y": 7 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "sum(reth_base_upgrade_signal_apply_failed{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"})", + "legendFormat": "", + "range": false, + "instant": true, + "refId": "A" + } + ], + "title": "Failing series", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Upgrades with differing observed activation timestamps across selected instances AND layers. Requires at least two reporting series; keep both layers and all nodes selected for fleet comparison.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds", + "fixedColor": "#56D5EA" + }, + "decimals": 0, + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "color": "#8A95A5", + "text": "NO TELEMETRY" + } + } + } + ], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 12, + "y": 7 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "sum(((max by (upgrade) (reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}) - min by (upgrade) (reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"})) and on (upgrade) (count by (upgrade) (reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}) > 1)) > bool 0)", + "legendFormat": "", + "range": false, + "instant": true, + "refId": "A" + } + ], + "title": "Divergent upgrades", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Observed counter increases in the dashboard range; can miss the first event or process exit before scrape. No telemetry is not zero events.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds", + "fixedColor": "#56D5EA" + }, + "decimals": 0, + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "color": "#8A95A5", + "text": "NO TELEMETRY" + } + } + } + ], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 7 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "sum(increase(reth_base_upgrade_signal_fail_closed_total{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}[$__range]))", + "legendFormat": "", + "range": false, + "instant": true, + "refId": "A" + } + ], + "title": "Halts · range", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Sum of per-node/layer failed-read increases, deduplicated across upgrade labels. A shared read may still be reported by both layers. First events may be missed.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds", + "fixedColor": "#56D5EA" + }, + "decimals": 0, + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "color": "#8A95A5", + "text": "NO TELEMETRY" + } + } + } + ], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 20, + "y": 7 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "sum(max by (job, instance, layer) (increase(reth_base_upgrade_signal_l1_read_errors_total{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}[$__range])))", + "legendFormat": "", + "range": false, + "instant": true, + "refId": "A" + } + ], + "title": "Read errors · range", + "type": "stat" + }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 12 + }, + "id": 9, + "title": "Observed L1 schedule · Per-node values, not proof of application", + "type": "row", + "collapsed": false, + "panels": [] + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Each selected node and layer retains its own observed schedule. Zero means unscheduled, not the Unix epoch. Positive times may already be in the past.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 0, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none", + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false, + "minWidth": 70 + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Activation (Unix seconds)" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "0": { + "text": "Unscheduled" + } + } + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Upgrade" + }, + "properties": [ + { + "id": "custom.width", + "value": 85 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Node role" + }, + "properties": [ + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Instance" + }, + "properties": [ + { + "id": "custom.width", + "value": 170 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Layer" + }, + "properties": [ + { + "id": "custom.width", + "value": 60 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 13 + }, + "id": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Upgrade" + } + ] + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "max by (job, instance, layer, upgrade) (reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"})", + "legendFormat": "", + "range": false, + "instant": true, + "refId": "A", + "format": "table" + } + ], + "title": "Activation schedule · L2 Unix seconds", + "type": "table", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true + }, + "indexByName": { + "upgrade": 0, + "job": 1, + "instance": 2, + "layer": 3, + "Value": 4 + }, + "renameByName": { + "upgrade": "Upgrade", + "job": "Node role", + "instance": "Instance", + "layer": "Layer", + "Value": "Activation (Unix seconds)" + } + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Sticky result of the most recent live apply: 1 = failing; 0 = successful. Absent before any apply attempt. A process exit can remove the gauge; use availability and logs.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 0, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short", + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false, + "minWidth": 70 + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Apply failed" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "0": { + "color": "green", + "text": "Last apply succeeded" + }, + "1": { + "color": "red", + "text": "Failing" + } + } + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Upgrade" + }, + "properties": [ + { + "id": "custom.width", + "value": 85 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Node role" + }, + "properties": [ + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Instance" + }, + "properties": [ + { + "id": "custom.width", + "value": 170 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Layer" + }, + "properties": [ + { + "id": "custom.width", + "value": 60 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 13 + }, + "id": 11, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Upgrade" + } + ] + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "max by (job, instance, layer, upgrade) (reth_base_upgrade_signal_apply_failed{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"})", + "legendFormat": "", + "range": false, + "instant": true, + "refId": "A", + "format": "table" + } + ], + "title": "Latest live apply state · Per upgrade", + "type": "table", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true + }, + "indexByName": { + "upgrade": 0, + "job": 1, + "instance": 2, + "layer": 3, + "Value": 4 + }, + "renameByName": { + "upgrade": "Upgrade", + "job": "Node role", + "instance": "Instance", + "layer": "Layer", + "Value": "Apply failed" + } + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Raw L2 Unix timestamps read from L1. Step changes show observations, not successful local application. Zero means unscheduled.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 0, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 12, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}", + "legendFormat": "{{instance}} · {{layer}} · {{upgrade}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Observed activation timestamp", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Encoded minimum protocol version: major × 1,000,000 + minor × 1,000 + patch. This gauge does not expose the node binary version or establish readiness.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 0, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 13, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "reth_base_upgrade_signal_expected_protocol_version{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}", + "legendFormat": "{{instance}} · {{layer}} · {{upgrade}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Expected minimum protocol version", + "type": "timeseries" + }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 30 + }, + "id": 14, + "title": "Propagation · Compare every selected node and both layers", + "type": "row", + "collapsed": false, + "panels": [] + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Difference across all selected nodes AND layers for each upgrade. Zero means agreement among reporters only. At least two series are required; missing reporters are not detected by this comparison.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": null, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 31 + }, + "id": 15, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "(max by (upgrade) (reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}) - min by (upgrade) (reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"})) and on (upgrade) (count by (upgrade) (reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}) > 1)", + "legendFormat": "{{upgrade}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Activation disagreement · max − min", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Difference in encoded protocol minimum across all selected nodes AND layers. Requires two reporting series; no data is not agreement.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 0, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 31 + }, + "id": 16, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "(max by (upgrade) (reth_base_upgrade_signal_expected_protocol_version{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}) - min by (upgrade) (reth_base_upgrade_signal_expected_protocol_version{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"})) and on (upgrade) (count by (upgrade) (reth_base_upgrade_signal_expected_protocol_version{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}) > 1)", + "legendFormat": "{{upgrade}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Protocol-version disagreement · max − min", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Number of selected node/layer series observed for each upgrade. A drop can make divergence disappear without convergence. This is not an expected-topology coverage check.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": null, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 39 + }, + "id": 17, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "count by (upgrade) (reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"})", + "legendFormat": "{{upgrade}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Schedule reporters per upgrade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Lowest last-read block across the selected upgrades for each node/layer. A stalled line may indicate polling/read failures or a stalled L1; this is block progress, not a last-read wall-clock timestamp.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 0, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 39 + }, + "id": 18, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "min by (job, instance, layer) (reth_base_upgrade_signal_last_l1_read_block{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"})", + "legendFormat": "{{instance}} · {{layer}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Last successful L1 read block", + "type": "timeseries" + }, + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 47 + }, + "id": 19, + "title": "Diagnostics · Counter rates and recorded events", + "type": "row", + "collapsed": false, + "panels": [] + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Observed live value changes per upgrade, not successful applies. Counters appear on first event; rates require two samples.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": null, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 48 + }, + "id": 20, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "sum by (job, instance, layer, upgrade) (rate(reth_base_upgrade_signal_signal_updates_total{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}[$__rate_interval]))", + "legendFormat": "{{instance}} · {{layer}} · {{upgrade}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Observed signal updates", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Failed schedule attempts per second, deduplicated across upgrades. Applies affect the whole schedule and increment every included upgrade. Missing counters do not prove success.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": null, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 48 + }, + "id": 21, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "max by (job, instance, layer) (rate(reth_base_upgrade_signal_apply_failures_total{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}[$__rate_interval]))", + "legendFormat": "{{instance}} · {{layer}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Live apply failures", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Failed read attempts per second, deduplicated across upgrade labels. A single read failure increments every known upgrade; shared reads can appear in both layers.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": null, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 56 + }, + "id": 22, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "max by (job, instance, layer) (rate(reth_base_upgrade_signal_l1_read_errors_total{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}[$__rate_interval]))", + "legendFormat": "{{instance}} · {{layer}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "L1 read errors", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Successful reads returning an empty schedule are rejected, leaving the previous live schedule in place. This layer-only metric intentionally ignores the upgrade filter.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": null, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 56 + }, + "id": 23, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(reth_base_upgrade_signal_empty_schedule_reads_total{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\"}[$__rate_interval])", + "legendFormat": "{{instance}} · {{layer}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Empty L1 schedules", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Best-effort observed event increases over the rate window. The process can exit before scraping or after the first sample, so a blank/zero chart does not rule out a halt. Check node logs and reachability.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": null, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 64 + }, + "id": 24, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "increase(reth_base_upgrade_signal_fail_closed_total{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"}[$__rate_interval])", + "legendFormat": "{{instance}} · {{layer}} · {{upgrade}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Fail-closed events", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Prometheus scrape success for each selected node target. Independent of layer/upgrade selection. A missing target or absent metric is not a successful scrape.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": null, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bool", + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 64 + }, + "id": 25, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "up{job=~\"$node_job\",instance=~\"$instance\"}", + "legendFormat": "{{instance}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "title": "Node target availability", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "description": "Raw current counter values retain the first recorded event that rates can miss. Counters reset on process restart and are not range totals. L1 errors and apply failures duplicate schedule events per upgrade: do not sum these rows. Empty-schedule rows have no upgrade label and ignore that filter.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 0, + "mappings": [], + "noValue": "NO TELEMETRY", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none", + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 72 + }, + "id": 26, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Upgrade" + } + ] + }, + "pluginVersion": "10.4.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "{__name__=~\"reth_base_upgrade_signal_(signal_updates|apply_failures|l1_read_errors|fail_closed)_total\",job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\",upgrade=~\"$upgrade\"} or reth_base_upgrade_signal_empty_schedule_reads_total{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\"}", + "legendFormat": "", + "range": false, + "instant": true, + "refId": "A", + "format": "table" + } + ], + "title": "Recorded per-upgrade event counters · Since process start", + "type": "table", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": false + }, + "indexByName": { + "__name__": 0, + "job": 1, + "instance": 2, + "layer": 3, + "upgrade": 4, + "Value": 5 + }, + "renameByName": { + "upgrade": "Upgrade", + "job": "Node role", + "instance": "Instance", + "layer": "Layer", + "Value": "Recorded events", + "__name__": "Metric" + } + } + } + ] + } + ], + "tags": [ + "base", + "devnet", + "upgrades" + ], + "templating": { + "list": [ + { + "allValue": "l2_builder|l2_sequencer|l2_client|l2_base_rpc|l2_shadow_validator", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "definition": "label_values(up{job=~\"l2_builder|l2_sequencer|l2_client|l2_base_rpc|l2_shadow_validator\"}, job)", + "hide": 0, + "includeAll": true, + "label": "Node role", + "multi": true, + "name": "node_job", + "options": [], + "query": { + "query": "label_values(up{job=~\"l2_builder|l2_sequencer|l2_client|l2_base_rpc|l2_shadow_validator\"}, job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "definition": "label_values(up{job=~\"$node_job\"}, instance)", + "hide": 0, + "includeAll": true, + "label": "Node instance", + "multi": true, + "name": "instance", + "options": [], + "query": { + "query": "label_values(up{job=~\"$node_job\"}, instance)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "hide": 0, + "includeAll": true, + "label": "Layer", + "multi": true, + "name": "layer", + "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "el", + "value": "el" + }, + { + "selected": false, + "text": "cl", + "value": "cl" + } + ], + "query": "el,cl", + "skipUrlSync": false, + "type": "custom" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "definition": "label_values(reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\"}, upgrade)", + "hide": 0, + "includeAll": true, + "label": "Upgrade", + "multi": true, + "name": "upgrade", + "options": [], + "query": { + "query": "label_values(reth_base_upgrade_signal_activation_timestamp{job=~\"$node_job\",instance=~\"$instance\",layer=~\"$layer\"}, upgrade)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] + }, + "title": "Base Devnet Dynamic Upgrades", + "uid": "dynamic-upgrades", + "version": 1 +} diff --git a/etc/scripts/devnet/grafana/test-dynamic-upgrades.py b/etc/scripts/devnet/grafana/test-dynamic-upgrades.py new file mode 100644 index 0000000000..42aae11946 --- /dev/null +++ b/etc/scripts/devnet/grafana/test-dynamic-upgrades.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Check the dashboard's actual PromQL with the devnet's Prometheus image. + +Run with Python 3 and Docker; no running devnet or Python dependencies required. +Temporary JSON documents are also valid YAML input for promtool. +""" + +import json +from pathlib import Path +import re +import subprocess +import tempfile + + +DIRECTORY = Path(__file__).resolve().parent +PREFIX = "reth_base_upgrade_signal_" + + +def panels(dashboard): + for panel in dashboard.get("panels", []): + yield panel + yield from panels(panel) + + +def interpolate(expression, defaults, **overrides): + variables = { + **defaults, + "__rate_interval": "5m", + "__interval": "1m", + "__range": "10m", + **overrides, + } + for name, value in variables.items(): + expression = expression.replace("${" + name + "}", value) + expression = expression.replace("$" + name, value) + if "$" in expression: + raise ValueError(f"Unresolved Grafana variable: {expression}") + return expression + + +def series(metric, value, *, instance="base-builder:7090", layer="el", upgrade="azul"): + labels = { + "job": "l2_builder", + "instance": instance, + "layer": layer, + "upgrade": upgrade, + } + if upgrade is None: + del labels["upgrade"] + label_string = ",".join(f"{key}={json.dumps(value)}" for key, value in labels.items()) + return {"series": f"{PREFIX}{metric}{{{label_string}}}", "values": value} + + +def sample(labels, value): + return {"labels": labels, "value": value} + + +def main(): + dashboard = json.loads((DIRECTORY / "dashboards/dynamic-upgrades.json").read_text()) + defaults = {variable["name"]: variable["allValue"] + for variable in dashboard["templating"]["list"]} + expressions = { + (panel["title"], target["refId"]): target["expr"] + for panel in panels(dashboard) + for target in panel.get("targets", []) + if "expr" in target + } + + def query(title, **variables): + return interpolate(expressions[(title, "A")], defaults, **variables) + + tests = [] + + def case(name, inputs, *expectations): + tests.append({ + "name": name, + "interval": "1m", + "input_series": inputs, + "promql_expr_test": [ + {"expr": expression, "eval_time": "10m", "exp_samples": expected} + for expression, expected in expectations + ], + }) + + divergence = query("Activation disagreement · max − min") + failures = query("Failing series") + errors = query("L1 read errors") + divergent_upgrades = query("Divergent upgrades") + aligned = [ + series("activation_timestamp", "100+0x10"), + series("activation_timestamp", "100+0x10", instance="base-client:8090"), + series("activation_timestamp", "100+0x10", layer="cl"), + ] + case("missing telemetry is not agreement or zero failures", [], + (divergence, []), (divergent_upgrades, []), (failures, []), (errors, []), + (query("Reporting readers"), [])) + case("one reporter cannot establish agreement", aligned[:1], + (divergence, []), (divergent_upgrades, [])) + case("aligned nodes and layers", aligned, + (divergence, [sample('{upgrade="azul"}', 0)])) + case("node disagreement is visible", [aligned[0], + series("activation_timestamp", "140+0x10", instance="base-client:8090")], + (divergence, [sample('{upgrade="azul"}', 40)])) + case("layer disagreement is visible", [aligned[0], + series("activation_timestamp", "160+0x10", layer="cl")], + (divergence, [sample('{upgrade="azul"}', 60)])) + case("never versus scheduled is disagreement", [aligned[0], + series("activation_timestamp", "0+0x10", layer="cl")], + (divergence, [sample('{upgrade="azul"}', 100)])) + case("upgrade identities are compared separately", aligned + [ + series("activation_timestamp", "200+0x10", upgrade="beryl"), + series("activation_timestamp", "220+0x10", upgrade="beryl", layer="cl")], + (divergence, [sample('{upgrade="azul"}', 0), sample('{upgrade="beryl"}', 20)]), + (divergent_upgrades, [sample("{}", 1)])) + case("layer filter narrows comparisons", aligned, + (query("Activation disagreement · max − min", layer="cl"), [])) + case("reported apply failures remain visible", [ + series("apply_failed", "0+0x10"), + series("apply_failed", "1+0x10", layer="cl")], + (failures, [sample("{}", 1)])) + case("L1 errors are not multiplied by upgrade labels", [ + series("l1_read_errors_total", "0+60x10", upgrade=upgrade) + for upgrade in ("azul", "beryl", "denim")], + (errors, [sample('{instance="base-builder:7090",job="l2_builder",layer="el"}', 1)])) + case("protocol-version divergence includes layers", [ + series("expected_protocol_version", "1+0x10"), + series("expected_protocol_version", "2+0x10", layer="cl")], + (query("Protocol-version disagreement · max − min"), [sample('{upgrade="azul"}', 1)])) + case("coverage counts reporters not upgrade series", [ + series("last_l1_read_block", "100+0x10"), + series("last_l1_read_block", "100+0x10", upgrade="beryl"), + series("last_l1_read_block", "100+0x10", layer="cl")], + (query("Reporting readers"), [sample("{}", 2)])) + case("empty schedules have no upgrade label", [ + series("empty_schedule_reads_total", "0+60x10", upgrade=None)], + (query("Empty L1 schedules", upgrade="azul"), + [sample('{instance="base-builder:7090",job="l2_builder",layer="el"}', 1)])) + case("failed scrapes are not healthy", [ + {"series": 'up{job="l2_builder",instance="base-builder:7090"}', "values": "1+0x10"}, + {"series": 'up{job="l2_client",instance="base-client:8090"}', "values": "0+0x10"}, + {"series": 'up{job="conductor",instance="op-conductor-0:6090"}', "values": "1+0x10"}], + (query("Reachable nodes"), [sample("{}", 0.5)])) + case("counter resets preserve the error rate", [ + series("l1_read_errors_total", "0 60 120 180 240 300 360 60 120 180 240")], + (errors, [sample('{instance="base-builder:7090",job="l2_builder",layer="el"}', 1)])) + first_event = series("l1_read_errors_total", "_ _ _ _ _ _ _ _ _ _ 1") + case("first event remains visible before a rate can be calculated", [first_event], + (errors, []), + (query("Recorded per-upgrade event counters · Since process start"), + [sample(first_event["series"], 1)])) + + # Parse every target, including panels that do not need semantic fixtures. + rules = [{"record": f"dashboard_expression_{index}", "expr": interpolate(expression, defaults)} + for index, expression in enumerate(expressions.values())] + compose = (DIRECTORY.parents[2] / "docker/docker-compose.yml").read_text() + image = re.search(r"image:\s*(prom/prometheus:[^\s]+)", compose).group(1) + with tempfile.TemporaryDirectory(prefix="dynamic-upgrades-promql-") as temporary: + directory = Path(temporary) + (directory / "rules.json").write_text(json.dumps({"groups": [ + {"name": "dashboard", "rules": rules}, + ]})) + (directory / "tests.json").write_text(json.dumps({ + "rule_files": ["rules.json"], "evaluation_interval": "1m", "tests": tests, + })) + command = ["docker", "run", "--rm", "--network", "none", "--entrypoint", "promtool", + "--volume", f"{directory}:/tests:ro", "--workdir", "/tests", image] + subprocess.run(command + ["check", "rules", "rules.json"], check=True) + subprocess.run(command + ["test", "rules", "tests.json"], check=True) + print(f"Validated {len(expressions)} dashboard queries and {len(tests)} semantic scenarios.") + + +if __name__ == "__main__": + main() From 280f742440f56e7fd062434a323b4200b21058d6 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 17:52:51 +0000 Subject: [PATCH 047/299] feat(load-tests): observe canonical heads independently (#5117) * feat(load-tests): observe canonical heads independently * style(load-tests): make canonical head watcher public * fix(load-tests): export canonical head watcher from crate root --- .../load-tests/src/config/test_config.rs | 1 + crates/infra/load-tests/src/lib.rs | 2 +- .../src/runner/canonical_head_watcher.rs | 76 +++++++++++++++++++ crates/infra/load-tests/src/runner/config.rs | 9 +++ crates/infra/load-tests/src/runner/mod.rs | 3 + crates/infra/load-tests/src/runner/pacing.rs | 20 ++++- .../load-tests/src/runner/results_tracker.rs | 5 ++ etc/systems/src/benchmark_cli.rs | 3 +- etc/systems/src/l2/snapshot_stack.rs | 5 ++ 9 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 crates/infra/load-tests/src/runner/canonical_head_watcher.rs diff --git a/crates/infra/load-tests/src/config/test_config.rs b/crates/infra/load-tests/src/config/test_config.rs index c5fb39f434..d68b111150 100644 --- a/crates/infra/load-tests/src/config/test_config.rs +++ b/crates/infra/load-tests/src/config/test_config.rs @@ -705,6 +705,7 @@ impl TestConfig { batch_size: self.batch_size as usize, max_gas_price: crate::runner::DEFAULT_MAX_GAS_PRICE, flashblocks_ws: self.flashblocks_ws.clone(), + canonical_heads_ws: None, fresh_recipient_ratio: self.fresh_recipient_ratio, validity_ratio: self.validity.ratio, validity_predicates: self.validity.to_templates()?, diff --git a/crates/infra/load-tests/src/lib.rs b/crates/infra/load-tests/src/lib.rs index acdacb2337..80ab55dce0 100644 --- a/crates/infra/load-tests/src/lib.rs +++ b/crates/infra/load-tests/src/lib.rs @@ -47,7 +47,7 @@ pub use workload::{ mod runner; pub use runner::{ AdaptiveBackoff, BatchTxError, BlockClock, BlockMatch, BlockNumberBound, BlockObservation, - BlockPulse, BlockReceipt, BlockWatcher, DEFAULT_MAX_GAS_PRICE, + BlockPulse, BlockReceipt, BlockWatcher, CanonicalHeadWatcher, DEFAULT_MAX_GAS_PRICE, DEFAULT_MAX_IN_FLIGHT_PER_SENDER, DisplaySnapshot, FUNDING_MAX_FEE_BASE_FEE_MULTIPLIER, Fees, FlashblockInclusion, FlashblockWatcher, GasPricer, InclusionPulse, InclusionSource, InjectLimit, InjectPlan, LoadConfig, LoadRunner, LoadTestDisplay, LoadTestStage, diff --git a/crates/infra/load-tests/src/runner/canonical_head_watcher.rs b/crates/infra/load-tests/src/runner/canonical_head_watcher.rs new file mode 100644 index 0000000000..5381219bd0 --- /dev/null +++ b/crates/infra/load-tests/src/runner/canonical_head_watcher.rs @@ -0,0 +1,76 @@ +//! Lightweight canonical head observation over `eth_subscribe(newHeads)`. + +use alloy_provider::{Provider, ProviderBuilder}; +use futures::StreamExt; +use tokio_util::sync::CancellationToken; +use tracing::{info, warn}; +use url::Url; + +use super::ResultsTracker; + +/// Observes canonical headers independently of transaction-hash scanning. +/// +/// Header notifications establish the measurement boundary promptly. The polling block watcher +/// continues to scan full blocks and remains the sole source of inclusion-aware pacing pulses. +#[derive(Debug)] +pub struct CanonicalHeadWatcher { + ws_url: Url, + results_tracker: ResultsTracker, + cancel_token: CancellationToken, +} + +impl CanonicalHeadWatcher { + /// Creates a canonical head watcher. + pub const fn new( + ws_url: Url, + results_tracker: ResultsTracker, + cancel_token: CancellationToken, + ) -> Self { + Self { ws_url, results_tracker, cancel_token } + } + + /// Starts the watcher in a background task. + pub fn start(self) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { self.run().await }) + } + + async fn run(&self) { + let provider = match ProviderBuilder::new().connect(self.ws_url.as_str()).await { + Ok(provider) => provider, + Err(error) => { + warn!(error = %error, ws_url = %self.ws_url, "canonical head WebSocket connection failed"); + return; + } + }; + let subscription = match provider.subscribe_blocks().await { + Ok(subscription) => subscription, + Err(error) => { + warn!(error = %error, ws_url = %self.ws_url, "canonical head subscription failed"); + return; + } + }; + info!(ws_url = %self.ws_url, "started canonical head watcher"); + + let mut stream = subscription.into_stream(); + let mut last_number = None; + loop { + let header = tokio::select! { + biased; + _ = self.cancel_token.cancelled() => break, + header = stream.next() => { + let Some(header) = header else { + warn!("canonical head subscription ended"); + break; + }; + header + } + }; + let number = header.number; + if last_number.is_some_and(|last| number <= last) { + continue; + } + last_number = Some(number); + self.results_tracker.observe_measurement_block(number); + } + } +} diff --git a/crates/infra/load-tests/src/runner/config.rs b/crates/infra/load-tests/src/runner/config.rs index 7cab9200d2..1f27aed27d 100644 --- a/crates/infra/load-tests/src/runner/config.rs +++ b/crates/infra/load-tests/src/runner/config.rs @@ -292,6 +292,8 @@ pub struct LoadConfig { pub max_gas_price: u128, /// Optional builder flashblocks WebSocket used for early inclusion signals. pub flashblocks_ws: Option, + /// Optional canonical `newHeads` WebSocket used for lightweight block-boundary pacing. + pub canonical_heads_ws: Option, /// Fraction of transactions that draw a fresh recipient address instead of cycling through /// the sender pool. Used to drive account-trie fan-out for account-create workloads. pub fresh_recipient_ratio: f64, @@ -334,6 +336,7 @@ impl LoadConfig { batch_size: crate::rpc::MAX_BATCH_RPC_SIZE, max_gas_price: DEFAULT_MAX_GAS_PRICE, flashblocks_ws: None, + canonical_heads_ws: None, fresh_recipient_ratio: 0.0, validity_ratio: 0.0, validity_predicates: Vec::new(), @@ -446,6 +449,12 @@ impl LoadConfig { if self.flashblocks_ws.as_ref().is_some_and(|url| !matches!(url.scheme(), "ws" | "wss")) { return Err(BaselineError::Config("flashblocks_ws must use ws:// or wss://".into())); } + if self.canonical_heads_ws.as_ref().is_some_and(|url| !matches!(url.scheme(), "ws" | "wss")) + { + return Err(BaselineError::Config( + "canonical_heads_ws must use ws:// or wss://".into(), + )); + } Ok(()) } diff --git a/crates/infra/load-tests/src/runner/mod.rs b/crates/infra/load-tests/src/runner/mod.rs index 0beb360dda..7d961d41fc 100644 --- a/crates/infra/load-tests/src/runner/mod.rs +++ b/crates/infra/load-tests/src/runner/mod.rs @@ -15,6 +15,9 @@ pub use flashblock_watcher::FlashblockWatcher; mod block_watcher; pub use block_watcher::{BlockClock, BlockPulse, BlockWatcher}; +mod canonical_head_watcher; +pub use canonical_head_watcher::CanonicalHeadWatcher; + mod inclusion; pub use inclusion::{InclusionPulse, InclusionSource}; diff --git a/crates/infra/load-tests/src/runner/pacing.rs b/crates/infra/load-tests/src/runner/pacing.rs index 120af8c47a..efc6549027 100644 --- a/crates/infra/load-tests/src/runner/pacing.rs +++ b/crates/infra/load-tests/src/runner/pacing.rs @@ -25,10 +25,10 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; use super::{ - BlockPulse, BlockWatcher, DisplaySnapshot, FlashblockWatcher, GasPricer, InclusionPulse, - InclusionSource, LoadRunner, LoadTestDisplay, LoadTestStage, PipelineStartConfig, - PreparedTransaction, PresignBuffer, QueuedSubmitFailures, ResultsTracker, SignedBatch, - SignedTransaction, SubmissionPipeline, SubmitEvent, TxType, ValidityRouter, + BlockPulse, BlockWatcher, CanonicalHeadWatcher, DisplaySnapshot, FlashblockWatcher, GasPricer, + InclusionPulse, InclusionSource, LoadRunner, LoadTestDisplay, LoadTestStage, + PipelineStartConfig, PreparedTransaction, PresignBuffer, QueuedSubmitFailures, ResultsTracker, + SignedBatch, SignedTransaction, SubmissionPipeline, SubmitEvent, TxType, ValidityRouter, }; use crate::{ BaselineError, Result, @@ -466,6 +466,10 @@ impl LoadRunner { ) .start(), ); + let canonical_head_watcher_task = self.config.canonical_heads_ws.clone().map(|ws_url| { + CanonicalHeadWatcher::new(ws_url, results_tracker.clone(), watcher_cancel.clone()) + .start() + }); let flashblock_watcher_task = self.config.flashblocks_ws.clone().map(|ws_url| { FlashblockWatcher::new( ws_url, @@ -1123,6 +1127,14 @@ impl LoadRunner { _ => {} } } + if let Some(task) = canonical_head_watcher_task { + match tokio::time::timeout(Duration::from_secs(2), task).await { + Ok(Err(error)) if error.is_panic() => { + warn!(error = %error, "canonical head watcher panicked"); + } + _ => {} + } + } if let Some(task) = flashblock_watcher_task { match tokio::time::timeout(Duration::from_secs(2), task).await { Ok(Err(error)) if error.is_panic() => { diff --git a/crates/infra/load-tests/src/runner/results_tracker.rs b/crates/infra/load-tests/src/runner/results_tracker.rs index ffc48a513d..280efda94c 100644 --- a/crates/infra/load-tests/src/runner/results_tracker.rs +++ b/crates/infra/load-tests/src/runner/results_tracker.rs @@ -555,6 +555,11 @@ impl ResultsTracker { self.inner.read().measurement_finished } + /// Records that a canonical head was observed, independently of transaction hash scanning. + pub fn observe_measurement_block(&self, block_number: u64) { + self.inner.write().observe_measurement_block(block_number); + } + /// Returns the configured measurement window boundaries. pub fn measurement_window(&self) -> MeasurementWindow { let inner = self.inner.read(); diff --git a/etc/systems/src/benchmark_cli.rs b/etc/systems/src/benchmark_cli.rs index c42a83d4b1..efa6657085 100644 --- a/etc/systems/src/benchmark_cli.rs +++ b/etc/systems/src/benchmark_cli.rs @@ -462,7 +462,8 @@ impl SnapshotBenchmarkArgs { .then(|| stack.builder_flashblocks_url()) .transpose()?; test_config.chain_id = Some(stack.chain_id()); - let load_config = test_config.to_load_config(None)?; + let mut load_config = test_config.to_load_config(None)?; + load_config.canonical_heads_ws = Some(stack.builder_ws_url()?); let funder_address = funder_key.address(); let sequencer_metrics = PrometheusBlockCollector::start(builder_rpc.clone(), stack.builder_metrics_url()?) diff --git a/etc/systems/src/l2/snapshot_stack.rs b/etc/systems/src/l2/snapshot_stack.rs index e0501bff76..e0e9072173 100644 --- a/etc/systems/src/l2/snapshot_stack.rs +++ b/etc/systems/src/l2/snapshot_stack.rs @@ -393,6 +393,11 @@ impl SnapshotL2Stack { self.builder.rpc_url() } + /// Returns the builder WebSocket RPC URL. + pub fn builder_ws_url(&self) -> Result { + self.builder.ws_url() + } + /// Returns the client RPC URL. pub fn client_rpc_url(&self) -> Result { self.client.rpc_url() From 9f0d07ff9e18a798b07f610babd825b106f7b193 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 17 Sep 2026 18:13:33 +0000 Subject: [PATCH 048/299] ci: add Denim base-std fork tests (#5074) * ci: add Denim base-std fork tests Co-authored-by: Codex * ci(fork-tests): inject FOUNDRY_BASE so forge matches anvil's upgrade Fork tests run in forge's own EVM, not the anvil node, so forge must inject the same Base upgrade as anvil. Forge was pinned to base-std's `[profile.fork] base = true` (base-anvil's default upgrade, Cobalt/V2), so the Denim suite silently tested V2 precompiles and its V3-behavior tests (executor-on-every-path, inverted policy IDs) failed. Set FOUNDRY_BASE=${matrix.upgrade} so forge and anvil stay in lockstep per fork. Co-Authored-By: Claude * Clarify fork-test FOUNDRY_BASE comment Co-authored-by: rayyan224 <62478924+rayyan224@users.noreply.github.com> * ci(fork-tests): bump Denim base-std pin to include the fork harness fix Points the Denim entry at base-std 7321d891 (fix(fork): forward BASE_UPGRADE to forge via FOUNDRY_BASE, #231), so the pinned harness derives forge's base upgrade from BASE_UPGRADE on its own. Co-Authored-By: Claude --------- Co-authored-by: Codex Co-authored-by: Claude Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .depot/workflows/base-std-fork-tests.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.depot/workflows/base-std-fork-tests.yml b/.depot/workflows/base-std-fork-tests.yml index cfaa74e74d..c229f901eb 100644 --- a/.depot/workflows/base-std-fork-tests.yml +++ b/.depot/workflows/base-std-fork-tests.yml @@ -37,7 +37,11 @@ jobs: - fork: Cobalt key: cobalt upgrade: cobalt - base_std_ref: 4571b325c1b9c4d6e21c59d2b7f9b4b60f62ec7c + base_std_ref: v1.1.0 + - fork: Denim + key: denim + upgrade: denim + base_std_ref: 7321d891c9039040b5dad7248618de6fe5973661 env: FORK_NAME: ${{ matrix.fork }} FORK_KEY: ${{ matrix.key }} @@ -45,6 +49,9 @@ jobs: BASE_STD_REF: ${{ matrix.base_std_ref }} # Consumed by base-std's fork harness to launch `anvil --base $BASE_UPGRADE`. BASE_UPGRADE: ${{ matrix.upgrade }} + # Runs fork tests with forge's `base` upgrade set to the selected fork. + # Figment maps FOUNDRY_BASE onto the `base` key. + FOUNDRY_BASE: ${{ matrix.upgrade }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 From 5fa5b05b8b28a15d19e7214be337aa56404c14ae Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 19:10:43 +0000 Subject: [PATCH 049/299] feat(bench): add fresh-devnet workload suite (#5130) * feat(bench): add fresh devnet workload suite * fix(bench): activate B-20 feature on fresh devnet * fix(bench): derive visualizer chain id from devnet config --- Cargo.lock | 1 + etc/benchmarks/fresh-devnet.yml | 57 +++ etc/systems/Cargo.toml | 1 + etc/systems/README.md | 35 ++ etc/systems/src/benchmark_cli.rs | 455 +++++++++++++++++++++-- etc/systems/src/l2/in_process_builder.rs | 7 +- etc/systems/src/lib.rs | 4 +- 7 files changed, 523 insertions(+), 37 deletions(-) create mode 100644 etc/benchmarks/fresh-devnet.yml diff --git a/Cargo.lock b/Cargo.lock index 01cb329eac..92070824a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6374,6 +6374,7 @@ dependencies = [ "reth-tasks", "serde", "serde_json", + "serde_yaml", "sqlx", "tempfile", "testcontainers", diff --git a/etc/benchmarks/fresh-devnet.yml b/etc/benchmarks/fresh-devnet.yml new file mode 100644 index 0000000000..358a6b7edc --- /dev/null +++ b/etc/benchmarks/fresh-devnet.yml @@ -0,0 +1,57 @@ +# Fresh-devnet workload suite for `base-bench local --workload-config`. +# +# `base-bench` provisions a separate empty devnet for every entry, replaces all +# endpoint settings with that devnet's dynamically allocated endpoints, and +# writes a base/benchmark visualizer bundle to --output-dir. Omitted load-test +# settings use TestConfig's portable defaults: a 60-second, 20M gas/s, two- +# second-cadence test. +# +# `uniswap-swap` deliberately is not included here. Its WETH, USDC, and router +# addresses are real Base Sepolia contracts, so it needs a funded Sepolia +# snapshot rather than an empty devnet. Keep it in the snapshot benchmark suite. +benchmark_workloads: + - workload: b20-transfer + transaction_payload: b20-transfer-existing + sender_count: 400 + in_flight_per_sender: 128 + fresh_recipient_ratio: 0.0 + # createB20 reserves a 10M-gas limit; 0.01 ETH cannot cover it at the devnet base fee. + funding_amount: "100000000000000000" + b20_mint_amount: "1000000000000000000000" + transactions: + - weight: 100 + type: b20 + + # High-concurrency fresh-recipient transfer profile. + - workload: eth-new + transaction_payload: eth-transfer-fresh + sender_count: 1000 + # 1024 caused funding setup to exceed Reth's HTTP connection limit. + in_flight_per_sender: 32 + fresh_recipient_ratio: 1.0 + transactions: + - weight: 100 + type: transfer + + # Keep this workload for comparison with the external benchmark. + - workload: eth-existing + transaction_payload: eth-transfer-existing + sender_count: 1000 + # 1024 caused funding setup to exceed Reth's HTTP connection limit. + in_flight_per_sender: 32 + fresh_recipient_ratio: 0.0 + transactions: + - weight: 100 + type: transfer + + - workload: blake2f-50000 + transaction_payload: blake2f-rounds-50000 + sender_count: 400 + in_flight_per_sender: 128 + fresh_recipient_ratio: 0.0 + transactions: + - weight: 100 + type: precompile + target: blake2f + rounds: 50000 + iterations: 1 diff --git a/etc/systems/Cargo.toml b/etc/systems/Cargo.toml index a678ca0bb3..0f708c4fec 100644 --- a/etc/systems/Cargo.toml +++ b/etc/systems/Cargo.toml @@ -113,6 +113,7 @@ reqwest.workspace = true chrono.workspace = true prometheus-scraper = { version = "0.2.0", default-features = false } tempfile.workspace = true +serde_yaml.workspace = true async-trait.workspace = true clap = { workspace = true, features = ["derive", "env"] } tracing = { workspace = true, features = ["std"] } diff --git a/etc/systems/README.md b/etc/systems/README.md index 4ff29e5b15..c0978de05d 100644 --- a/etc/systems/README.md +++ b/etc/systems/README.md @@ -137,6 +137,41 @@ datadirs are temporary and are removed during shutdown. Use the explicit `base-bench snapshot` arguments below for reproducible snapshot benchmarks and report artifacts. +## Run the fresh-devnet workload suite + +The checked-in fresh-devnet suite runs every workload on a distinct empty +devnet, so token state, accounts, the transaction pool, and caches cannot leak +between scenarios. It currently covers B-20 transfers (with Beryl activated at +genesis), high-concurrency ETH transfers to new and existing recipients, and a +50,000-round Blake2f precompile profile: + +```sh +cargo run --release -p base-system-tests --bin base-bench -- local \ + --workload-config etc/benchmarks/fresh-devnet.yml \ + --output-dir results/fresh-devnet \ + --client-version "base/$(git rev-parse --short HEAD)" +``` + +The command writes one native load-test sidecar per workload plus a top-level +visualizer manifest: + +```text +results/fresh-devnet/ +├── metadata.json +├── suite-results.json +├── fresh-devnet-b20-transfer/load-test-result.json +├── fresh-devnet-eth-new/load-test-result.json +├── fresh-devnet-eth-existing/load-test-result.json +└── fresh-devnet-blake2f-50000/load-test-result.json +``` + +`metadata.json` and the load-test sidecars are directly consumable by the +static visualizer in `base/benchmark`; link this output directory to that +repository's ignored `output/` directory and run its normal production build. +The real-token Uniswap V3 workload is intentionally excluded from this suite: +it depends on funded WETH, USDC, and router contracts at Base Sepolia addresses +and therefore belongs in a snapshot benchmark, not a blank devnet. + `base-bench snapshot` owns the process lifecycle around one load test: it generates an ephemeral funder, deposits funds to it in the first local descendant, replaces placeholder endpoints in the YAML with dynamically allocated builder endpoints, runs the load generator, writes JSON, and shuts diff --git a/etc/systems/src/benchmark_cli.rs b/etc/systems/src/benchmark_cli.rs index efa6657085..b1ef5956e9 100644 --- a/etc/systems/src/benchmark_cli.rs +++ b/etc/systems/src/benchmark_cli.rs @@ -1,7 +1,7 @@ -//! Command-line orchestration for snapshot-backed benchmarks. +//! Command-line orchestration for fresh-devnet and snapshot-backed benchmarks. use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, fs, io::Write as _, num::NonZeroU64, @@ -18,19 +18,22 @@ use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types_eth::BlockNumberOrTag; use alloy_signer_local::PrivateKeySigner; use base_common_network::Base; +use base_common_precompiles::ActivationFeature; use base_load_tests::{ BaselineError, LoadTestDisplay, LoadTestExecutor, LoadTestRunHooks, LoadTestRunOptions, - MetricsSummary, TestConfig, + MetricsSummary, TestConfig, TxTypeConfig, }; use chrono::{DateTime, SecondsFormat, Utc}; use clap::{Args, Parser, Subcommand}; use eyre::{Result, WrapErr}; +use serde::{Deserialize, Serialize}; use crate::{ - ANVIL_ACCOUNT_1, DevnetBlockInterval, DevnetConfig, DevnetL2State, DevnetPrefund, - PrometheusBlockCollector, SnapshotBenchmarkReportConfig, SnapshotBenchmarkResult, - SnapshotBlockMetrics, SnapshotChainConfig, SnapshotL2Stack, SystemTestStackBuilder, - VisualizerMetadata, VisualizerRun, + ANVIL_ACCOUNT_1, ANVIL_ACCOUNT_5, B20PrecompileClient, DevnetBlockInterval, DevnetConfig, + DevnetL2State, DevnetPrefund, PrometheusBlockCollector, SnapshotBenchmarkReportConfig, + SnapshotBenchmarkResult, SnapshotBlockMetrics, SnapshotChainConfig, SnapshotL2Stack, + SystemTestStack, SystemTestStackBuilder, VisualizerMetadata, VisualizerRun, + VisualizerRunResult, VisualizerSequencerMetrics, VisualizerValidatorMetrics, }; /// Base benchmark launcher. @@ -45,14 +48,82 @@ pub struct BenchmarkCli { /// Supported benchmark targets. #[derive(Debug, Subcommand)] pub enum BenchmarkCommand { - /// Run the default transfer benchmark against a fresh temporary local devnet. - Local, + /// Run one benchmark or a workload suite against fresh temporary local devnets. + Local(LocalBenchmarkArgs), /// Run one load test against a Base snapshot continuation. Snapshot(Box), /// Aggregate selected snapshot run artifacts into one report metadata file. Aggregate(AggregateBenchmarkArgs), } +/// Arguments for a fresh-devnet benchmark. +#[derive(Debug, Args, Default)] +pub struct LocalBenchmarkArgs { + /// Load-test YAML. Its endpoint fields are replaced with fresh-devnet endpoints. + #[arg(long, conflicts_with = "workload_config")] + pub load_test_config: Option, + /// YAML workload suite. Each workload runs against its own empty temporary devnet. + #[arg(long, conflicts_with = "load_test_config")] + pub workload_config: Option, + /// Directory for a single result or a workload-suite visualizer bundle. + #[arg(long)] + pub output_dir: Option, + /// Stable client build label written into workload-suite visualizer metadata. + #[arg(long, env = "BASE_BENCH_CLIENT_VERSION")] + pub client_version: Option, +} + +/// One entry in a fresh-devnet workload suite. +#[derive(Debug, Deserialize)] +pub struct LocalBenchmarkWorkload { + /// Stable workload identifier and output-directory name. + pub workload: String, + /// Human-readable payload label for the visualizer. + pub transaction_payload: String, + /// Load-test settings for this workload. + #[serde(flatten)] + pub test_config: TestConfig, +} + +/// YAML configuration for a fresh-devnet workload suite. +#[derive(Debug, Deserialize)] +pub struct LocalBenchmarkWorkloadConfig { + /// Workloads to run serially. Every workload receives a newly initialized devnet. + pub benchmark_workloads: Vec, +} + +/// Machine-readable outcome for one fresh-devnet workload. +#[derive(Debug, Serialize)] +pub struct LocalBenchmarkWorkloadResult { + /// Stable workload identifier. + pub workload: String, + /// Human-readable payload label. + pub transaction_payload: String, + /// Relative output directory when a load-test sidecar was written. + pub output_dir: String, + /// Whether the load test completed without a terminal error. + pub success: bool, + /// Terminal error, when startup or execution failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Machine-readable outcomes for a fresh-devnet workload suite. +#[derive(Debug, Serialize)] +pub struct LocalBenchmarkWorkloadResults { + /// Results in the same order as the workload configuration. + pub runs: Vec, +} + +/// In-memory outcome from one local load-test invocation. +#[derive(Debug)] +pub struct LocalBenchmarkResult { + /// Native load-test metrics, written to `load-test-result.json` when requested. + pub summary: MetricsSummary, + /// A terminal load-test error preserved after its metrics were written. + pub error: Option, +} + /// Arguments for aggregating self-contained snapshot benchmark artifacts. #[derive(Debug, Args)] pub struct AggregateBenchmarkArgs { @@ -117,31 +188,75 @@ const PREFUND_AMOUNT_WEI: u128 = 1_000_000_000_000_000_000_000; /// benchmark load cannot raise the base fee and strand already-submitted transaction nonce lanes. const SNAPSHOT_BENCHMARK_EIP1559_ELASTICITY: u32 = 1; const RESULT_FILE_NAME: &str = "benchmark-result.json"; +const LOCAL_RESULT_FILE_NAME: &str = "load-test-result.json"; +const FRESH_DEVNET_BLOCK_TIME: Duration = Duration::from_secs(2); +const FRESH_DEVNET_AZUL_ACTIVATION_BLOCK: u64 = 0; +const FRESH_DEVNET_BERYL_ACTIVATION_BLOCK: u64 = 3; +const FRESH_DEVNET_BERYL_READY_BLOCK: u64 = FRESH_DEVNET_BERYL_ACTIVATION_BLOCK + 1; +const FRESH_DEVNET_BERYL_READY_TIMEOUT: Duration = Duration::from_secs(30); impl BenchmarkCli { /// Runs the selected benchmark case. pub async fn run(self) -> Result<()> { let _progress = LoadTestDisplay::init_tracing(); match self.command { - None | Some(BenchmarkCommand::Local) => Self::run_default_local().await, + None => LocalBenchmarkArgs::default().run().await, + Some(BenchmarkCommand::Local(args)) => args.run().await, Some(BenchmarkCommand::Snapshot(args)) => args.run().await, Some(BenchmarkCommand::Aggregate(args)) => args.run(), } } +} + +impl LocalBenchmarkArgs { + /// Runs either one configured local benchmark or a suite of isolated local benchmarks. + pub async fn run(self) -> Result<()> { + if let Some(workload_config) = &self.workload_config { + let output_dir = self.output_dir.as_deref().ok_or_else(|| { + eyre::eyre!("--output-dir is required when --workload-config is provided") + })?; + return self.run_workload_suite(workload_config, output_dir).await; + } + + let test_config = self + .load_test_config + .as_ref() + .map(TestConfig::load) + .transpose() + .wrap_err("failed to load fresh-devnet benchmark configuration")? + .unwrap_or_default(); + let result = self.run_one(test_config).await?; + if let Some(error) = result.error { + eyre::bail!(error) + } + Ok(()) + } - /// Starts an isolated fresh devnet, runs the portable default transfer profile, and tears the - /// stack down. This deliberately has no flags or persistent datadir: it is the quick local - /// smoke benchmark. Snapshot benchmarks retain their explicit arguments for repeatable, - /// shareable performance runs. - async fn run_default_local() -> Result<()> { + /// Starts an isolated fresh devnet, executes one load-test configuration, and tears it down. + /// + /// The configuration's RPC endpoints are deliberately ignored: every run targets the dynamic + /// endpoints assigned to its newly created devnet. A B-20 workload schedules Azul and Beryl, + /// then activates the B-20 asset feature before creating its token fixtures. + pub async fn run_one(&self, mut test_config: TestConfig) -> Result { let devnet = DevnetConfig::standard(); let chain_id = devnet.l2_chain_id; let funder_key = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_1.private_key) .wrap_err("failed to construct the fresh-devnet funder key")?; - let stack = SystemTestStackBuilder::new().with_devnet_config(devnet).build().await?; + let requires_b20_activation = Self::requires_beryl(&test_config); + let mut stack_builder = SystemTestStackBuilder::new().with_devnet_config(devnet); + if requires_b20_activation { + stack_builder = stack_builder + .with_base_azul_activation_block(FRESH_DEVNET_AZUL_ACTIVATION_BLOCK) + .with_base_beryl_activation_block(FRESH_DEVNET_BERYL_ACTIVATION_BLOCK); + } + let stack = stack_builder.build().await?; - println!("running default transfer benchmark against a fresh temporary devnet"); - let run_result: Result<()> = async { + if requires_b20_activation { + Self::activate_b20_asset_feature(&stack, chain_id).await?; + } + + println!("running benchmark against a fresh temporary devnet"); + let run_result: Result = async { let builder_rpc = stack.l2_rpc_url()?; let client_rpc = stack.l2_client_rpc_url()?; let flashblocks_ws = stack @@ -150,16 +265,18 @@ impl BenchmarkCli { .flashblocks_url() .parse() .wrap_err("invalid fresh-devnet Flashblocks URL")?; - let test_config = TestConfig { - transaction_submission_rpcs: vec![builder_rpc], - query_rpc: Some(client_rpc), - flashblocks_ws: Some(flashblocks_ws), - chain_id: Some(chain_id), - skip_drain: true, - ..Default::default() - }; + test_config.transaction_submission_rpcs = vec![builder_rpc]; + test_config.query_rpc = Some(client_rpc); + test_config.txpool_nodes.clear(); + test_config.flashblocks_ws = Some(flashblocks_ws); + test_config.chain_id = Some(chain_id); let load_config = test_config.to_load_config(None)?; + eyre::ensure!( + load_config.block_time == FRESH_DEVNET_BLOCK_TIME, + "fresh devnet uses a 2s block time, but the workload requests {}", + test_config.block_time + ); let output = LoadTestExecutor::run_prepared( test_config, load_config, @@ -176,16 +293,212 @@ impl BenchmarkCli { ) .await?; println!("{}", serde_json::to_string_pretty(&output.summary)?); - if let Some(error) = output.run_error { - return Err(error.into()); - } - Ok(()) + self.write_result(&output.summary)?; + Ok(LocalBenchmarkResult { + summary: output.summary, + error: output.run_error.map(|error| error.to_string()), + }) } .await; let shutdown_result = stack.shutdown().await; - run_result?; - shutdown_result + let result = run_result?; + shutdown_result?; + Ok(result) + } + + /// Runs every workload in a YAML suite serially and writes a base/benchmark visualizer bundle. + pub async fn run_workload_suite( + &self, + workload_config_path: &Path, + output_dir: &Path, + ) -> Result<()> { + let workload_config = Self::load_workload_config(workload_config_path)?; + eyre::ensure!( + !workload_config.benchmark_workloads.is_empty(), + "fresh-devnet workload configuration contains no benchmark_workloads" + ); + fs::create_dir_all(output_dir).wrap_err_with(|| { + format!( + "failed to create fresh-devnet benchmark output directory {}", + output_dir.display() + ) + })?; + + let client_version = + self.client_version.clone().unwrap_or_else(|| "base/unknown".to_string()); + let chain_id = DevnetConfig::standard().l2_chain_id; + let mut result_runs = Vec::with_capacity(workload_config.benchmark_workloads.len()); + let mut visualizer_runs = Vec::with_capacity(workload_config.benchmark_workloads.len()); + let mut workload_names = BTreeSet::new(); + + for workload in workload_config.benchmark_workloads { + eyre::ensure!( + workload_names.insert(workload.workload.clone()), + "fresh-devnet workload configuration contains duplicate workload {:?}", + workload.workload + ); + let output_dir_name = format!("fresh-devnet-{}", workload.workload); + let workload_output_dir = Self::workload_output_dir(output_dir, &output_dir_name)?; + let result = Self { output_dir: Some(workload_output_dir), ..Self::default() } + .run_one(workload.test_config) + .await; + + let (summary, success, error) = match result { + Ok(result) => { + let success = result.error.is_none(); + (Some(result.summary), success, result.error) + } + Err(error) => (None, false, Some(format!("{error:?}"))), + }; + let complete = summary.is_some(); + let gas_per_second = + summary.as_ref().map(|summary| summary.throughput.gps).unwrap_or_default(); + + result_runs.push(LocalBenchmarkWorkloadResult { + workload: workload.workload.clone(), + transaction_payload: workload.transaction_payload.clone(), + output_dir: output_dir_name.clone(), + success, + error: error.clone(), + }); + visualizer_runs.push(VisualizerRun { + id: format!("fresh-devnet-{}-{}", workload.workload, Utc::now().timestamp_millis()), + source_file: "base-fresh-devnet".to_string(), + output_dir: output_dir_name, + test_name: format!("Base fresh-devnet {}", workload.workload), + test_description: format!( + "{} load test against a newly initialized empty Base devnet", + workload.transaction_payload + ), + test_config: BTreeMap::from([ + ( + "BenchmarkRun".to_string(), + serde_json::Value::String("fresh-devnet".to_string()), + ), + ("Scenario".to_string(), serde_json::Value::String(workload.workload)), + ("ChainId".to_string(), chain_id.into()), + ("BlockTimeMilliseconds".to_string(), 2_000.into()), + ("NodeType".to_string(), serde_json::Value::String("fresh-devnet".to_string())), + ( + "TransactionPayload".to_string(), + serde_json::Value::String(workload.transaction_payload), + ), + ( + "ClientVersion".to_string(), + serde_json::Value::String(client_version.clone()), + ), + ]), + result: VisualizerRunResult { + success, + complete, + client_version: client_version.clone(), + // This is the end-to-end load-test GPS, not a per-node Prometheus scrape. + // It fills the visualizer's headline field while the detailed load-test page + // renders the native MetricsSummary sidecar. + sequencer_metrics: VisualizerSequencerMetrics { gas_per_second }, + validator_metrics: VisualizerValidatorMetrics { gas_per_second }, + artifacts: if complete { + BTreeMap::from([( + "loadTestResult".to_string(), + LOCAL_RESULT_FILE_NAME.to_string(), + )]) + } else { + BTreeMap::new() + }, + }, + created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + }); + } + + let suite_results = LocalBenchmarkWorkloadResults { runs: result_runs }; + Self::write_json( + &output_dir.join("suite-results.json"), + &suite_results, + "fresh-devnet workload results", + )?; + Self::write_json( + &output_dir.join("metadata.json"), + &VisualizerMetadata { runs: visualizer_runs }, + "fresh-devnet visualizer metadata", + )?; + + let failed = suite_results.runs.iter().filter(|run| !run.success).count(); + eyre::ensure!(failed == 0, "{failed} fresh-devnet benchmark workload(s) failed"); + Ok(()) + } + + /// Writes a single load-test sidecar for a local benchmark when an output directory was set. + pub fn write_result(&self, summary: &MetricsSummary) -> Result<()> { + let Some(output_dir) = &self.output_dir else { return Ok(()) }; + let result_path = output_dir.join(LOCAL_RESULT_FILE_NAME); + Self::write_json(&result_path, summary, "fresh-devnet load-test result")?; + println!("benchmark result: {}", result_path.display()); + Ok(()) + } + + /// Loads a fresh-devnet workload suite from YAML. + pub fn load_workload_config(path: &Path) -> Result { + let contents = fs::read_to_string(path).wrap_err_with(|| { + format!("failed to read workload configuration {}", path.display()) + })?; + serde_yaml::from_str(&contents) + .wrap_err_with(|| format!("failed to parse workload configuration {}", path.display())) + } + + /// Returns the output directory for one workload after validating that it cannot escape root. + pub fn workload_output_dir(root: &Path, workload: &str) -> Result { + eyre::ensure!( + !workload.is_empty() + && workload != "." + && workload != ".." + && !workload.contains('/') + && !workload.contains('\\'), + "workload must be a single directory name: {workload:?}" + ); + Ok(root.join(workload)) + } + + /// Writes one JSON artifact and ensures its parent directory exists. + pub fn write_json(path: &Path, value: &T, artifact: &str) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + eyre::eyre!("{artifact} path has no parent directory: {}", path.display()) + })?; + fs::create_dir_all(parent).wrap_err_with(|| { + format!("failed to create {artifact} directory {}", parent.display()) + })?; + fs::write(path, serde_json::to_vec_pretty(value)?) + .wrap_err_with(|| format!("failed to write {artifact} {}", path.display())) + } + + /// Returns whether a workload needs Base Beryl enabled in its empty devnet. + pub fn requires_beryl(test_config: &TestConfig) -> bool { + test_config + .transactions + .iter() + .any(|transaction| matches!(transaction.tx_type, TxTypeConfig::B20)) + } + + /// Waits for Beryl and activates the B-20 asset feature with the devnet sequencer account. + pub async fn activate_b20_asset_feature(stack: &SystemTestStack, chain_id: u64) -> Result<()> { + let provider = stack.l2_builder_provider()?; + tokio::time::timeout(FRESH_DEVNET_BERYL_READY_TIMEOUT, async { + loop { + if provider.get_block_number().await? >= FRESH_DEVNET_BERYL_READY_BLOCK { + return Ok::<_, eyre::Error>(()); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + }) + .await + .wrap_err("timed out waiting for Beryl to activate on the fresh devnet")??; + + let activation_admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("failed to construct the fresh-devnet Beryl activation-admin key")?; + B20PrecompileClient::new(&provider, &activation_admin, chain_id) + .activate_feature(ActivationFeature::B20Asset.id()) + .await + .wrap_err("failed to activate the B-20 asset feature on the fresh devnet") } } @@ -634,9 +947,13 @@ impl SnapshotBenchmarkArgs { mod tests { use std::{fs, num::NonZeroU64}; + use base_load_tests::MetricsSummary; use clap::Parser; - use super::{AggregateBenchmarkArgs, BenchmarkCli, BenchmarkCommand, SnapshotBenchmarkArgs}; + use super::{ + AggregateBenchmarkArgs, BenchmarkCli, BenchmarkCommand, LocalBenchmarkArgs, + LocalBenchmarkWorkloadConfig, SnapshotBenchmarkArgs, + }; #[test] fn parses_snapshot_benchmark_defaults() { @@ -793,7 +1110,77 @@ mod tests { fn parses_explicit_fresh_local_benchmark() { let cli = BenchmarkCli::parse_from(["base-bench", "local"]); - assert!(matches!(cli.command, Some(BenchmarkCommand::Local))); + let Some(BenchmarkCommand::Local(args)) = cli.command else { + panic!("expected local benchmark command"); + }; + assert!(args.load_test_config.is_none()); + assert!(args.workload_config.is_none()); + assert!(args.output_dir.is_none()); + } + + #[test] + fn parses_fresh_devnet_workload_suite() { + let cli = BenchmarkCli::parse_from([ + "base-bench", + "local", + "--workload-config", + "etc/benchmarks/fresh-devnet.yml", + "--output-dir", + "results/fresh-devnet", + "--client-version", + "base/test", + ]); + + let Some(BenchmarkCommand::Local(args)) = cli.command else { + panic!("expected local benchmark command"); + }; + assert_eq!( + args.workload_config, + Some(std::path::PathBuf::from("etc/benchmarks/fresh-devnet.yml")) + ); + assert_eq!(args.output_dir, Some(std::path::PathBuf::from("results/fresh-devnet"))); + assert_eq!(args.client_version.as_deref(), Some("base/test")); + } + + #[test] + fn parses_checked_in_fresh_devnet_workload_configuration() { + let config: LocalBenchmarkWorkloadConfig = + serde_yaml::from_str(include_str!("../../benchmarks/fresh-devnet.yml")).unwrap(); + + assert_eq!(config.benchmark_workloads.len(), 4); + assert_eq!(config.benchmark_workloads[0].workload, "b20-transfer"); + assert_eq!(config.benchmark_workloads[0].transaction_payload, "b20-transfer-existing"); + assert_eq!(config.benchmark_workloads[0].test_config.sender_count, 400); + assert_eq!(config.benchmark_workloads[0].test_config.funding_amount, "100000000000000000"); + assert_eq!(config.benchmark_workloads[1].workload, "eth-new"); + assert_eq!(config.benchmark_workloads[1].test_config.sender_count, 1000); + assert_eq!(config.benchmark_workloads[2].workload, "eth-existing"); + assert_eq!(config.benchmark_workloads[3].workload, "blake2f-50000"); + } + + #[test] + fn writes_local_load_test_sidecar_when_output_is_requested() { + let output = tempfile::tempdir().unwrap(); + let args = LocalBenchmarkArgs { + output_dir: Some(output.path().to_path_buf()), + ..Default::default() + }; + let summary = MetricsSummary { measurement_block_count: 10, ..Default::default() }; + + args.write_result(&summary).unwrap(); + + let written: serde_json::Value = + serde_json::from_slice(&fs::read(output.path().join("load-test-result.json")).unwrap()) + .unwrap(); + assert_eq!(written["measurement_block_count"], 10); + } + + #[test] + fn rejects_workload_names_that_escape_the_result_root() { + let root = tempfile::tempdir().unwrap(); + + assert!(LocalBenchmarkArgs::workload_output_dir(root.path(), "../escape").is_err()); + assert!(LocalBenchmarkArgs::workload_output_dir(root.path(), "b20-transfer").is_ok()); } #[test] diff --git a/etc/systems/src/l2/in_process_builder.rs b/etc/systems/src/l2/in_process_builder.rs index cb9060b9d3..af0a03e64f 100644 --- a/etc/systems/src/l2/in_process_builder.rs +++ b/etc/systems/src/l2/in_process_builder.rs @@ -21,7 +21,7 @@ use base_txpool_rpc::{SendRawTransactionValidityConfig, SendRawTransactionValidi use eyre::{Result, WrapErr, eyre}; use reth_db::{ ClientVersion, DatabaseEnv, init_db, - mdbx::{DatabaseArguments, KILOBYTE, MEGABYTE, MaxReadTransactionDuration}, + mdbx::{DatabaseArguments, GIGABYTE, KILOBYTE, MaxReadTransactionDuration}, }; use reth_node_builder::{NodeBuilder, NodeConfig, NodeHandle}; use reth_node_core::{ @@ -494,7 +494,10 @@ fn create_test_db(db_path: &std::path::Path) -> Result { db_path, DatabaseArguments::new(ClientVersion::default()) .with_max_read_transaction_duration(Some(MaxReadTransactionDuration::Unbounded)) - .with_geometry_max_size(Some(4 * MEGABYTE)) + // This is a virtual MDBX map limit rather than an eagerly allocated file. A 4 MiB + // map can overflow during the high-concurrency fresh-devnet benchmarks before their + // duration elapses, killing the Engine API and leaving the consensus task retrying. + .with_geometry_max_size(Some(4 * GIGABYTE)) .with_growth_step(Some(4 * KILOBYTE)), ) .wrap_err("Failed to initialize database")?; diff --git a/etc/systems/src/lib.rs b/etc/systems/src/lib.rs index 4bc7ac5d2c..06a4d1de00 100644 --- a/etc/systems/src/lib.rs +++ b/etc/systems/src/lib.rs @@ -17,7 +17,9 @@ pub use b20::{B20CreateConfig, B20PrecompileClient}; mod benchmark_cli; pub use benchmark_cli::{ - AggregateBenchmarkArgs, BenchmarkCli, BenchmarkCommand, SnapshotBenchmarkArgs, + AggregateBenchmarkArgs, BenchmarkCli, BenchmarkCommand, LocalBenchmarkArgs, + LocalBenchmarkResult, LocalBenchmarkWorkload, LocalBenchmarkWorkloadConfig, + LocalBenchmarkWorkloadResult, LocalBenchmarkWorkloadResults, SnapshotBenchmarkArgs, }; mod benchmark_report; From decd9873bf9e7e77d1d6c068f209ada7d8efb59d Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 17 Sep 2026 21:41:04 +0000 Subject: [PATCH 050/299] fix(rpc): use shared insertion sender for mempool submissions (#5060) --- Cargo.lock | 4 + crates/execution/rpc/Cargo.toml | 9 + crates/execution/rpc/benches/insertion.rs | 405 ++++++++++++++++++++ crates/execution/rpc/src/eth/transaction.rs | 5 +- 4 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 crates/execution/rpc/benches/insertion.rs diff --git a/Cargo.lock b/Cargo.lock index 92070824a1..c3478f75dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4562,6 +4562,8 @@ dependencies = [ "alloy-rpc-types-engine", "alloy-rpc-types-eth 2.4.1", "alloy-serde 2.4.1", + "alloy-signer 2.4.1", + "alloy-signer-local 2.4.1", "alloy-transport", "alloy-transport-http", "async-trait", @@ -4579,6 +4581,7 @@ dependencies = [ "base-metrics", "base-observability-events", "base-protocol", + "criterion", "derive_more 2.1.1", "eyre", "futures", @@ -4595,6 +4598,7 @@ dependencies = [ "reth-chainspec", "reth-errors", "reth-evm", + "reth-network-api", "reth-node-api", "reth-node-builder", "reth-payload-util", diff --git a/crates/execution/rpc/Cargo.toml b/crates/execution/rpc/Cargo.toml index b172e1b3dd..32a65ef01c 100644 --- a/crates/execution/rpc/Cargo.toml +++ b/crates/execution/rpc/Cargo.toml @@ -93,9 +93,18 @@ base-metrics.workspace = true metrics = { workspace = true, optional = true } [dev-dependencies] +criterion.workspace = true +alloy-signer.workspace = true +reth-network-api.workspace = true +alloy-signer-local.workspace = true base-execution-chainspec.workspace = true +base-execution-evm = { workspace = true, features = ["rpc"] } reth-provider = { workspace = true, features = ["test-utils"] } +[[bench]] +name = "insertion" +harness = false + [features] default = [ "metrics" ] client = [ diff --git a/crates/execution/rpc/benches/insertion.rs b/crates/execution/rpc/benches/insertion.rs new file mode 100644 index 0000000000..cd0948a696 --- /dev/null +++ b/crates/execution/rpc/benches/insertion.rs @@ -0,0 +1,405 @@ +//! Transaction insertion benchmarks through the production `BaseTransactionPool` wrapper. +//! +//! Compares exactly the call boundary changed by the shared-insertion-sender patch, excluding the +//! RPC hook's unchanged prep (EIP-8130 check, event emission, raw broadcast) from both routes: +//! - `direct`: `Pool::add_transaction` — the direct insertion call the pre-patch hook made +//! - `sender/N`: `EthApi::add_pool_transaction` — reth's batch sender, the adopted route, with +//! `--txpool.max-batch-size` N +//! +//! Both run over the same `BasePoolBuilder`-style stack: `BaseTransactionValidator` inside reth's +//! pool inside `BaseTransactionPool`, with L1-data-fee enforcement disabled for mock state. State +//! is `MockEthProvider` (not MDBX) with no JSON-RPC/network/signature-recovery layers; signing and +//! encoding happen once outside all timed regions. +//! +//! Two measurement modes: +//! - Saturated (default): standard Criterion `iter_custom` sampling of whole-workload elapsed +//! time. Criterion reports elapsed throughput, not per-transaction latencies. +//! - Fixed rate (`BASE_BENCH_RATE`, e.g. 5000 or 10000): absolute scheduled arrivals, independent +//! of completions. Use `--test` for one workload per case and repeat externally. The concurrency +//! cap delays API submission, not arrival timestamps: latency includes any virtual backlog. +//! `FIXED_RATE` reports scheduled-arrival-to-completion p50/p95/p99, call-duration p99, and +//! submit-delay p99 (generator/timer lateness plus waiting for the concurrency cap). These are +//! different distributions; do not subtract their percentiles. They are not Criterion CIs. +//! +//! Reth's batch processor does not exit when its channel closes, so every repetition builds a +//! fresh pool plus `reth_tasks::Runtime` and tears them down with graceful shutdown before the +//! next iteration: no batch processors, validation workers, or pools survive across iterations. +//! +//! Environment: +//! - `BASE_BENCH_TXS`: submissions per iteration (default 131072; must be positive) +//! - `BASE_BENCH_CONCURRENCY`: bounded in-flight submissions (default 64; must be positive) +//! - `BASE_BENCH_RATE`: offered transactions/second for the open-loop mode (must be positive) + +use std::{ + pin::Pin, + sync::{Arc, OnceLock}, + time::{Duration, Instant}, +}; + +use alloy_consensus::{SignableTransaction, TxEip1559}; +use alloy_eips::Encodable2718; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use base_common_chains::ChainConfig; +use base_common_consensus::{BasePrimitives, BaseTransactionSigned}; +use base_common_rpc_types::BaseRpcTypes; +use base_execution_chainspec::BaseChainSpec; +use base_execution_evm::BaseEvmConfig; +use base_execution_rpc::{ + BaseEthApi, BaseTimeCache, + eth::{receipt::BaseReceiptConverter, transaction::BaseTxInfoMapper}, +}; +use base_execution_txpool::{ + BaseOrdering, BasePooledTransaction, BaseTransactionPool, BaseTransactionValidator, +}; +use criterion::{ + BenchmarkId, Criterion, SamplingMode, Throughput, criterion_group, criterion_main, +}; +use futures::{ + stream, + stream::{FuturesUnordered, StreamExt}, +}; +use reth_network_api::noop::NoopNetwork; +use reth_primitives_traits::Recovered; +use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; +use reth_rpc::EthApi; +use reth_rpc_convert::RpcConverter; +use reth_rpc_eth_api::node::RpcNodeCoreAdapter; +use reth_tasks::Runtime; +use reth_transaction_pool::{ + Pool, PoolConfig, SubPoolLimit, TransactionOrigin, TransactionPool, + blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, +}; + +type TestProvider = MockEthProvider>; +type TestEvmConfig = BaseEvmConfig; +type BenchPool = BaseTransactionPool< + TestProvider, + InMemoryBlobStore, + TestEvmConfig, + BasePooledTransaction, + BaseOrdering, +>; +type TestNode = RpcNodeCoreAdapter; +type TestRpcConvert = RpcConverter< + BaseRpcTypes, + TestEvmConfig, + BaseReceiptConverter, + (), + BaseTxInfoMapper, +>; +type TestEthApi = BaseEthApi; + +const SIGNERS: usize = 64; +const FUNDED_BALANCE_WEI: u128 = 100_000_000_000_000_000_000; // 100 ETH +const RECIPIENT: Address = Address::new([0x42; 20]); + +type Submission = Result; +type Job = Pin + Send>>; + +#[derive(Debug, Clone, Copy)] +enum Route { + /// Pre-patch route: direct insertion on the pool. + Direct, + /// Adopted route: reth's batch sender with a given `--txpool.max-batch-size`. + Sender, +} + +struct BenchConfig { + txs: usize, + concurrency: usize, + /// `BASE_BENCH_RATE`: `Some(rate)` selects the open-loop fixed-rate mode. + rate: Option, +} + +fn bench_config() -> &'static BenchConfig { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(|| { + let txs = + std::env::var("BASE_BENCH_TXS").ok().and_then(|v| v.parse().ok()).unwrap_or(131_072); + let concurrency = + std::env::var("BASE_BENCH_CONCURRENCY").ok().and_then(|v| v.parse().ok()).unwrap_or(64); + let rate = std::env::var("BASE_BENCH_RATE").ok().and_then(|v| v.parse().ok()); + assert!(txs > 0, "BASE_BENCH_TXS must be positive"); + assert!(concurrency > 0, "BASE_BENCH_CONCURRENCY must be positive"); + assert!(rate.is_none_or(|rate| rate > 0), "BASE_BENCH_RATE must be positive"); + BenchConfig { txs, concurrency, rate } + }) +} + +fn deterministic_signer(i: usize) -> PrivateKeySigner { + let mut bytes = [0u8; 32]; + bytes[0] = (i + 1) as u8; + PrivateKeySigner::from_bytes(&bytes.into()).unwrap() +} + +/// Builds the funded, signed, valid EIP-1559 transfer set once; the pool consumes clones. +fn prepared() -> &'static Vec<(BasePooledTransaction, Bytes)> { + static PREPARED: OnceLock> = OnceLock::new(); + PREPARED.get_or_init(|| { + let txs = bench_config().txs; + let chain_id = ChainConfig::mainnet().chain_id; + (0..txs) + .map(|i| { + let signer = deterministic_signer(i % SIGNERS); + let nonce = (i / SIGNERS) as u64; + let tx = TxEip1559 { + chain_id, + nonce, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 10_000_000_000, + gas_limit: 21_000, + to: TxKind::Call(RECIPIENT), + value: U256::ZERO, + access_list: Default::default(), + input: Default::default(), + }; + let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap(); + let signed: BaseTransactionSigned = tx.into_signed(signature).into(); + let recovered = Recovered::new_unchecked(signed, signer.address()); + let encoded = recovered.encoded_2718().into(); + let encoded_length = recovered.encode_2718_len(); + (BasePooledTransaction::new(recovered, encoded_length), encoded) + }) + .collect() + }) +} + +/// Mirrors `BasePoolBuilder::build_pool` wiring over mock state: the Base validator executor +/// inside reth's pool inside `BaseTransactionPool`, with `tasks` driving spawned validation and +/// batch-processor tasks so they can be shut down after each iteration. +fn bench_setup(max_batch_size: usize, tasks: Runtime) -> (TestEthApi, BenchPool) { + let chain_spec = Arc::new(BaseChainSpec::mainnet()); + let provider = MockEthProvider::::new() + .with_chain_spec(Arc::clone(&chain_spec)) + .with_genesis_block(); + let evm_config = BaseEvmConfig::base(Arc::clone(&chain_spec)); + provider.extend_accounts( + (0..SIGNERS) + .map(|i| { + let signer = deterministic_signer(i); + (signer.address(), ExtendedAccount::new(0, U256::from(FUNDED_BALANCE_WEI))) + }) + .collect::>(), + ); + + let validator = EthTransactionValidatorBuilder::new(provider.clone(), evm_config.clone()) + .build_with_tasks(tasks.clone(), InMemoryBlobStore::default()) + .map(|inner| BaseTransactionValidator::new(inner).require_l1_data_gas_fee(false)); + // Bench-only raised limits: defaults (10k txs per sub-pool, 16 account slots) cannot hold + // the default prepared set (2,048 transactions per signer). + let subpool = SubPoolLimit { max_txs: 200_000, max_size: 200 * 1024 * 1024 }; + let config = PoolConfig { + pending_limit: subpool, + basefee_limit: subpool, + queued_limit: subpool, + max_account_slots: 4096, + ..PoolConfig::default() + }; + let pool = BaseTransactionPool::new( + Pool::new(validator, BaseOrdering::default(), InMemoryBlobStore::default(), config), + BaseOrdering::default(), + ); + + let base_time = BaseTimeCache::default(); + let rpc_converter = + RpcConverter::new(BaseReceiptConverter::new(provider.clone(), base_time.clone())) + .with_mapper(BaseTxInfoMapper::new(provider.clone(), base_time.clone())); + let eth_api = EthApi::builder(provider, pool.clone(), NoopNetwork::default(), evm_config) + .with_rpc_converter(rpc_converter) + .task_spawner(tasks) + .max_batch_size(max_batch_size) + .build_inner(); + (BaseEthApi::new(eth_api, None, U256::ZERO, base_time), pool) +} + +fn submission_job(route: Route, api: &TestEthApi, tx: BasePooledTransaction) -> Job { + // Identical harness ownership for both routes; borrow the underlying pool for direct calls. + let api = api.clone(); + Box::pin(async move { + match route { + Route::Direct => api + .eth_api() + .pool() + .add_transaction(TransactionOrigin::Local, tx) + .await + .map(|outcome| outcome.hash) + .map_err(|err| err.to_string()), + Route::Sender => api + .eth_api() + .add_pool_transaction(TransactionOrigin::Local, tx) + .await + .map(|outcome| outcome.hash) + .map_err(|err| err.to_string()), + } + }) +} + +/// Tears down the shared task runtime so no batch processor, validation worker, or pool task +/// survives into the next iteration. Reth's batch processor parks forever on its channel, so the +/// graceful shutdown signal (not channel close) is what retires it before the runtime drops. +async fn shutdown_tasks(tasks: Runtime) { + tasks.graceful_shutdown(); + if let Some(manager) = tasks.take_task_manager_handle() { + manager.await.expect("task manager join failed").expect("critical task panicked"); + } +} + +/// Runs one saturated workload in a fresh runtime/pool and returns only the timed submission +/// duration. Setup, validation asserts, and teardown happen outside the measured region. +async fn run_saturated(route: Route, max_batch_size: usize, config: &BenchConfig) -> Duration { + let tasks = Runtime::test(); + let (api, pool) = bench_setup(max_batch_size, tasks.clone()); + let jobs: Vec = prepared() + .iter() + .take(config.txs) + .cloned() + .map(|tx| submission_job(route, &api, tx.0)) + .collect(); + + let start = Instant::now(); + let results: Vec = + stream::iter(jobs).buffer_unordered(config.concurrency).collect().await; + let elapsed = start.elapsed(); + + for result in &results { + assert!(result.is_ok(), "insertion failed: {:?}", result.as_ref().err()); + } + assert_eq!(pool.pool_size().total, config.txs, "pool must retain every transaction"); + + drop(results); + drop(api); + drop(pool); + shutdown_tasks(tasks).await; + elapsed +} + +/// Absolute arrivals form a virtual queue: a concurrency cap delays submission, never the +/// scheduled arrival. Thus overload remains visible in latency and outstanding-at-end counts. +async fn run_fixed_rate(route: Route, max_batch_size: usize, config: &BenchConfig) -> Duration { + let BenchConfig { txs, concurrency, rate } = *config; + let rate = rate.expect("fixed-rate mode requires BASE_BENCH_RATE"); + let tasks = Runtime::test(); + let (api, pool) = bench_setup(max_batch_size, tasks.clone()); + let mut source = prepared().clone().into_iter(); + let mut in_flight = FuturesUnordered::new(); + let mut samples = Vec::with_capacity(txs); + let mut submitted = 0; + let start = Instant::now(); + let deadline = |i: usize| { + start + Duration::from_nanos((i as u128 * 1_000_000_000 / u128::from(rate)) as u64) + }; + let offered_end = deadline(txs); + + while samples.len() < txs { + while submitted < txs + && in_flight.len() < concurrency + && deadline(submitted) <= Instant::now() + { + let scheduled = deadline(submitted); + let tx = source.next().expect("one transaction per arrival"); + let job = submission_job(route, &api, tx.0); + in_flight.push(async move { + let called = Instant::now(); + let outcome = job.await; + (scheduled, called, Instant::now(), outcome) + }); + submitted += 1; + } + if submitted < txs && in_flight.len() < concurrency { + tokio::select! { + result = in_flight.next(), if !in_flight.is_empty() => { + samples.push(result.expect("nonempty in-flight set")); + } + _ = tokio::time::sleep_until(deadline(submitted).into()) => {} + } + } else { + samples.push(in_flight.next().await.expect("outstanding submission")); + } + } + let finished = samples.iter().map(|sample| sample.2).max().unwrap(); + let elapsed = finished.duration_since(start); + let failures = samples.iter().filter(|sample| sample.3.is_err()).count(); + assert_eq!(failures, 0, "every submission must be accepted"); + assert_eq!(submitted, txs); + assert_eq!(pool.pool_size().total, txs, "pool must retain every transaction"); + let outstanding = samples.iter().filter(|sample| sample.2 > offered_end).count(); + let mut latency = Vec::with_capacity(txs); + let mut call = Vec::with_capacity(txs); + let mut delay = Vec::with_capacity(txs); + for (scheduled, called, completed, _) in samples { + latency.push(completed.duration_since(scheduled).as_micros()); + call.push(completed.duration_since(called).as_micros()); + delay.push(called.duration_since(scheduled).as_micros()); + } + latency.sort_unstable(); + call.sort_unstable(); + delay.sort_unstable(); + let pct = |values: &[u128], p: f64| values[((values.len() - 1) as f64 * p).round() as usize]; + println!( + "FIXED_RATE route={route:?} batch={max_batch_size} offered={rate} achieved={:.1} n={txs} conc={concurrency} p50_us={} p95_us={} p99_us={} call_p99_us={} submit_delay_p99_us={} outstanding_at_end={outstanding} drain_ms={:.3} failures={failures}", + txs as f64 / elapsed.as_secs_f64(), + pct(&latency, 0.50), + pct(&latency, 0.95), + pct(&latency, 0.99), + pct(&call, 0.99), + pct(&delay, 0.99), + finished.saturating_duration_since(offered_end).as_secs_f64() * 1000.0, + ); + drop(api); + drop(pool); + shutdown_tasks(tasks).await; + elapsed +} + +fn bench_insertion(c: &mut Criterion) { + let config: &'static BenchConfig = bench_config(); + // Prepare deterministic inputs before Criterion's warmup and measurement. + let _ = prepared(); + + let mut group = c.benchmark_group("insertion"); + group.sample_size(10); + group.sampling_mode(SamplingMode::Flat); + group.warm_up_time(Duration::from_millis(500)); + group.throughput(Throughput::Elements(config.txs as u64)); + + let bench_case = + |group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>, + id: BenchmarkId, + batch: usize| { + group.bench_function(id, |b| { + b.iter_custom(|iters| { + let mut total = Duration::ZERO; + for _ in 0..iters { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .build() + .expect("bench tokio runtime should build"); + let route = if batch == 0 { Route::Direct } else { Route::Sender }; + total += rt.block_on(async { + if config.rate.is_some() { + run_fixed_rate(route, batch.max(1), config).await + } else { + run_saturated(route, batch.max(1), config).await + } + }); + // Drop joins this iteration's async/blocking workers after their shutdown. + drop(rt); + } + total + }); + }); + }; + + bench_case(&mut group, BenchmarkId::new("direct", config.txs as u64), 0); + for batch in [1usize, 8, 32, 128, 256, 512, 1024, 2048, 4096, 8192, 16384] { + bench_case(&mut group, BenchmarkId::new("sender", batch as u64), batch); + } + + group.finish(); +} + +criterion_group!(benches, bench_insertion); +criterion_main!(benches); diff --git a/crates/execution/rpc/src/eth/transaction.rs b/crates/execution/rpc/src/eth/transaction.rs index 360c273cec..c9ad9464e6 100644 --- a/crates/execution/rpc/src/eth/transaction.rs +++ b/crates/execution/rpc/src/eth/transaction.rs @@ -101,8 +101,9 @@ where // submit the transaction to the pool with the given origin let AddedTransactionOutcome { hash, .. } = self - .pool() - .add_transaction(origin, pool_transaction) + .inner + .eth_api + .add_pool_transaction(origin, pool_transaction) .await .map_err(Self::Error::from_eth_err)?; From 17e85d250123e31745b1ea2c23c633e122a3d56e Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 17 Sep 2026 21:49:42 +0000 Subject: [PATCH 051/299] fix(txpool-rpc): batch validity transaction insertion (#5064) Co-authored-by: Toshi --- crates/execution/txpool-rpc/Cargo.toml | 2 +- crates/execution/txpool-rpc/src/extension.rs | 3 +- crates/execution/txpool-rpc/src/rpc.rs | 124 ++++++++++++------- 3 files changed, 81 insertions(+), 48 deletions(-) diff --git a/crates/execution/txpool-rpc/Cargo.toml b/crates/execution/txpool-rpc/Cargo.toml index b26dd54433..5528645b6b 100644 --- a/crates/execution/txpool-rpc/Cargo.toml +++ b/crates/execution/txpool-rpc/Cargo.toml @@ -36,6 +36,7 @@ jsonrpsee.workspace = true # misc serde.workspace = true +tokio.workspace = true tracing.workspace = true [dev-dependencies] @@ -46,7 +47,6 @@ base-execution-chainspec.workspace = true base-test-utils.workspace = true eyre.workspace = true -tokio.workspace = true httpmock.workspace = true alloy-eips.workspace = true serde_json.workspace = true diff --git a/crates/execution/txpool-rpc/src/extension.rs b/crates/execution/txpool-rpc/src/extension.rs index acf8850960..f8092a0fb5 100644 --- a/crates/execution/txpool-rpc/src/extension.rs +++ b/crates/execution/txpool-rpc/src/extension.rs @@ -73,11 +73,12 @@ impl BaseNodeExtension for SendRawTransactionValidityExtension { fn apply(self: Box, builder: NodeHooks) -> NodeHooks { let config = self.config; builder.add_rpc_module(move |ctx: &mut BaseRpcContext<'_>| { + let transaction_sender = ctx.registry.eth_api().eth_api().tx_batch_sender().clone(); let api = SendRawTransactionValidityApiImpl::with_validity_limits( - ctx.pool().clone(), ctx.provider().clone(), config.max_validity_predicates, config.max_validity_expiry_secs, + transaction_sender, ); ctx.modules.merge_configured(api.into_rpc())?; Ok(()) diff --git a/crates/execution/txpool-rpc/src/rpc.rs b/crates/execution/txpool-rpc/src/rpc.rs index 886e4eb832..287ebe2271 100644 --- a/crates/execution/txpool-rpc/src/rpc.rs +++ b/crates/execution/txpool-rpc/src/rpc.rs @@ -20,9 +20,9 @@ use jsonrpsee::{ types::{ErrorCode, ErrorObjectOwned}, }; use reth_chainspec::ChainSpecProvider; -use reth_rpc_eth_types::error::RpcPoolError; +use reth_rpc_eth_types::EthApiError; use reth_storage_api::BlockReaderIdExt; -use reth_transaction_pool::{PoolTransaction, TransactionOrigin, TransactionPool}; +use reth_transaction_pool::{BatchTxRequest, PoolTransaction, TransactionOrigin, TransactionPool}; use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; @@ -112,57 +112,59 @@ pub struct TransactionStatusApiImpl { /// Local mempool-ingress implementation for validity-bearing transactions. #[derive(Debug, Clone)] -pub struct SendRawTransactionValidityApiImpl { - pool: Pool, +pub struct SendRawTransactionValidityApiImpl { provider: Provider, max_validity_predicates: usize, max_validity_expiry_secs: u64, + transaction_sender: tokio::sync::mpsc::UnboundedSender>, } -impl SendRawTransactionValidityApiImpl { - /// Creates a validity transaction ingress backed by the given pool and default limits. - /// - /// The provider fork-gates the RPC method on the Zenith hard fork. - pub const fn new(pool: Pool, provider: Provider) -> Self { +impl SendRawTransactionValidityApiImpl { + /// Creates a validity transaction ingress backed by the shared pool sender and default limits. + pub const fn new( + provider: Provider, + transaction_sender: tokio::sync::mpsc::UnboundedSender< + BatchTxRequest, + >, + ) -> Self { Self::with_validity_limits( - pool, provider, DEFAULT_MAX_VALIDITY_PREDICATES, DEFAULT_MAX_VALIDITY_EXPIRY_SECS, + transaction_sender, ) } /// Creates a validity transaction ingress with a predicate limit and default expiry window. - /// - /// The provider fork-gates the RPC method on the Zenith hard fork. pub const fn with_max_validity_predicates( - pool: Pool, provider: Provider, max_validity_predicates: usize, + transaction_sender: tokio::sync::mpsc::UnboundedSender< + BatchTxRequest, + >, ) -> Self { Self::with_validity_limits( - pool, provider, max_validity_predicates, DEFAULT_MAX_VALIDITY_EXPIRY_SECS, + transaction_sender, ) } /// Creates a validity transaction ingress with explicit predicate and expiry limits. - /// - /// The expiry limit is expressed in seconds and converted to blocks using the active full- - /// block cadence at submission time. pub const fn with_validity_limits( - pool: Pool, provider: Provider, max_validity_predicates: usize, max_validity_expiry_secs: u64, + transaction_sender: tokio::sync::mpsc::UnboundedSender< + BatchTxRequest, + >, ) -> Self { - Self { pool, provider, max_validity_predicates, max_validity_expiry_secs } + Self { provider, max_validity_predicates, max_validity_expiry_secs, transaction_sender } } } -impl SendRawTransactionValidityApiImpl +impl SendRawTransactionValidityApiImpl where Provider: BlockReaderIdExt + ChainSpecProvider, { @@ -252,10 +254,8 @@ impl TransactionStatusApiServer } #[async_trait] -impl SendRawTransactionValidityApiServer - for SendRawTransactionValidityApiImpl +impl SendRawTransactionValidityApiServer for SendRawTransactionValidityApiImpl where - Pool: TransactionPool + 'static, Provider: BlockReaderIdExt + ChainSpecProvider + 'static, { async fn send_raw_transaction_validity( @@ -328,13 +328,16 @@ where ); // Retain predicates for canonical forwarding to builders. - self.pool - .add_transaction( - TransactionOrigin::Private, - transaction.with_validity_predicates(options.validity), - ) + let transaction = transaction.with_validity_predicates(options.validity); + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + self.transaction_sender + .send(BatchTxRequest::new(TransactionOrigin::Private, transaction, response_tx)) + .map_err(|_| ErrorObjectOwned::from(EthApiError::BatchTxSendError))?; + response_rx .await - .map_err(|error| ErrorObjectOwned::from(RpcPoolError::from(error)))?; + .map_err(EthApiError::from) + .and_then(|result| result.map_err(EthApiError::from)) + .map_err(ErrorObjectOwned::from)?; Ok(tx_hash) } @@ -425,6 +428,21 @@ mod tests { NoopTransactionPool::::new() } + fn test_transaction_sender() + -> tokio::sync::mpsc::UnboundedSender> { + tokio::sync::mpsc::unbounded_channel().0 + } + + fn validity_rpc(provider: Provider) -> SendRawTransactionValidityApiImpl + where + Provider: BlockReaderIdExt + ChainSpecProvider + 'static, + { + let (processor, transaction_sender) = + reth_transaction_pool::BatchTxProcessor::new(validity_pool(), 1); + tokio::spawn(processor); + SendRawTransactionValidityApiImpl::new(provider, transaction_sender) + } + fn validity_request(tx: Bytes) -> (Bytes, SendRawTransactionValidityOptions) { ( tx, @@ -473,7 +491,10 @@ mod tests { #[test] fn max_validity_expiry_blocks_uses_the_active_full_block_cadence() { - let legacy = SendRawTransactionValidityApiImpl::new(validity_pool(), pre_zenith_provider()); + let legacy = SendRawTransactionValidityApiImpl::new( + pre_zenith_provider(), + test_transaction_sender(), + ); assert_eq!(legacy.max_validity_expiry_blocks(0), 30); let denim_provider = MockEthProvider::::new() @@ -483,7 +504,8 @@ mod tests { .build(), )) .with_genesis_block(); - let denim = SendRawTransactionValidityApiImpl::new(validity_pool(), denim_provider); + let denim = + SendRawTransactionValidityApiImpl::new(denim_provider, test_transaction_sender()); assert_eq!(denim.max_validity_expiry_blocks(0), 300); let transition_provider = MockEthProvider::::new() @@ -494,7 +516,7 @@ mod tests { )) .with_genesis_block(); let transition = - SendRawTransactionValidityApiImpl::new(validity_pool(), transition_provider); + SendRawTransactionValidityApiImpl::new(transition_provider, test_transaction_sender()); assert_eq!(transition.max_validity_expiry_blocks(0), 300); } @@ -652,7 +674,7 @@ mod tests { let capture = TransactionEventCapture::install(); let signer = PrivateKeySigner::random(); let raw = signed_eip1559(&signer, 0, 1); - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), zenith_provider()); + let rpc = validity_rpc(zenith_provider()); let options = SendRawTransactionValidityOptions { validity: all_predicate_variants() }; let tx_hash = rpc.send_raw_transaction_validity(raw, options).await.unwrap_or_else(|_| { @@ -679,7 +701,8 @@ mod tests { #[test] fn send_raw_transaction_validity_method_is_registered() { - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), zenith_provider()); + let rpc = + SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); let module = SendRawTransactionValidityApiServer::into_rpc(rpc); assert!(module.method_names().any(|name| name == "base_sendRawTransactionValidity")); @@ -689,7 +712,10 @@ mod tests { async fn send_raw_transaction_validity_rejects_eip8130_before_zenith() { let signer = PrivateKeySigner::random(); let raw = signed_eip8130(&signer); - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), pre_zenith_provider()); + let rpc = SendRawTransactionValidityApiImpl::new( + pre_zenith_provider(), + test_transaction_sender(), + ); let options = SendRawTransactionValidityOptions { validity: all_predicate_variants() }; let error = rpc @@ -709,7 +735,7 @@ mod tests { let capture = TransactionEventCapture::install(); let signer = PrivateKeySigner::random(); let raw = signed_eip1559(&signer, 0, 1); - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), pre_zenith_provider()); + let rpc = validity_rpc(pre_zenith_provider()); let options = SendRawTransactionValidityOptions { validity: all_predicate_variants() }; let error = rpc @@ -730,7 +756,8 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_rejects_malformed_transaction() { - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), zenith_provider()); + let rpc = + SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); let (raw, options) = validity_request(Bytes::from_static(&[0xff])); let error = rpc @@ -745,9 +772,9 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_enforces_configured_predicate_limit() { let rpc = SendRawTransactionValidityApiImpl::with_max_validity_predicates( - validity_pool(), zenith_provider(), 2, + test_transaction_sender(), ); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity = vec![options.validity[0].clone(); 3]; @@ -764,7 +791,8 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_rejects_empty_predicates() { - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), zenith_provider()); + let rpc = + SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity.clear(); @@ -779,7 +807,8 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_requires_block_number_expiry() { - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), zenith_provider()); + let rpc = + SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity = vec![ValidityPredicate::BlockNumber { op: base_execution_txpool::ValidityOperator::GreaterThanOrEqual, @@ -807,7 +836,7 @@ mod tests { body: BlockBody::default(), }, ); - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), provider); + let rpc = SendRawTransactionValidityApiImpl::new(provider, test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity = vec![ValidityPredicate::BlockNumber { op: base_execution_txpool::ValidityOperator::LessThanOrEqual, @@ -825,7 +854,8 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_rejects_storage_value_outside_mask() { - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), zenith_provider()); + let rpc = + SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity = vec![ValidityPredicate::Storage { address: Address::repeat_byte(0xab), @@ -846,7 +876,8 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_rejects_unsatisfiable_flashblock_index() { - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), zenith_provider()); + let rpc = + SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); // A flashblock-index predicate that only holds at index 0, which pooled // transactions never reach, would park forever if admitted. @@ -927,7 +958,8 @@ mod tests { async fn send_raw_transaction_validity_rejects_expired_block_number_bound() { // The genesis block is the latest committed block, so the block being built is 1. // A predicate capping inclusion at block 0 can never be satisfied. - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), zenith_provider()); + let rpc = + SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity = vec![ValidityPredicate::BlockNumber { op: base_execution_txpool::ValidityOperator::LessThanOrEqual, @@ -955,7 +987,7 @@ mod tests { body: BlockBody::default(), }, ); - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), provider); + let rpc = SendRawTransactionValidityApiImpl::new(provider, test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity = vec![ValidityPredicate::BlockNumber { op: base_execution_txpool::ValidityOperator::LessThan, @@ -987,7 +1019,7 @@ mod tests { body: BlockBody::default(), }, ); - let rpc = SendRawTransactionValidityApiImpl::new(validity_pool(), provider); + let rpc = validity_rpc(provider); let mut options = SendRawTransactionValidityOptions { validity: vec![] }; options.validity = vec![ValidityPredicate::BlockNumber { op: base_execution_txpool::ValidityOperator::Equal, From 774de82f4db1e2aa4528f518cf5f08558d84d0dc Mon Sep 17 00:00:00 2001 From: refcell Date: Thu, 17 Sep 2026 22:57:21 +0000 Subject: [PATCH 052/299] chore(consensus): Remove Legacy Sync Request Shim (#5081) * chore(consensus): remove legacy sync request shim Co-authored-by: Codex * test(gossip): drop redundant V4 shim removal coverage Co-authored-by: Codex --------- Co-authored-by: Codex --- Cargo.lock | 16 ---- Cargo.toml | 1 - crates/consensus/gossip/Cargo.toml | 1 - crates/consensus/gossip/README.md | 4 + crates/consensus/gossip/src/behaviour.rs | 8 +- crates/consensus/gossip/src/builder.rs | 17 +--- crates/consensus/gossip/src/driver.rs | 79 +------------------ crates/consensus/gossip/src/error.rs | 6 -- crates/consensus/gossip/src/event.rs | 13 --- crates/consensus/gossip/src/metrics/mod.rs | 3 - crates/consensus/gossip/src/rpc/request.rs | 6 +- crates/consensus/gossip/src/rpc/types.rs | 6 +- crates/consensus/service/Cargo.toml | 1 - .../service/tests/actors/network/p2p.rs | 17 ++++ docs/guides/P2P.md | 8 +- 15 files changed, 35 insertions(+), 151 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3478f75dd..5610b23bf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4005,7 +4005,6 @@ dependencies = [ "ipnet", "libp2p", "libp2p-identity", - "libp2p-stream", "lru 0.16.4", "metrics", "metrics-exporter-prometheus", @@ -4076,7 +4075,6 @@ dependencies = [ "httpmock", "jsonrpsee", "libp2p", - "libp2p-stream", "metrics", "metrics-util", "mockall", @@ -12413,20 +12411,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "libp2p-stream" -version = "0.4.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6bd8025c80205ec2810cfb28b02f362ab48a01bee32c50ab5f12761e033464" -dependencies = [ - "futures", - "libp2p-core", - "libp2p-identity", - "libp2p-swarm", - "rand 0.8.7", - "tracing", -] - [[package]] name = "libp2p-swarm" version = "0.47.1" diff --git a/Cargo.toml b/Cargo.toml index c13f5b42c0..b6b86bff2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -512,7 +512,6 @@ discv5 = "0.10" libp2p = "0.56.0" multihash = "0.19" libp2p-identity = "0.2.12" -libp2p-stream = "0.4.0-alpha" unsigned-varint = { version = "0.8", default-features = false } # serde diff --git a/crates/consensus/gossip/Cargo.toml b/crates/consensus/gossip/Cargo.toml index 4fadca4f94..280543948f 100644 --- a/crates/consensus/gossip/Cargo.toml +++ b/crates/consensus/gossip/Cargo.toml @@ -33,7 +33,6 @@ base-common-rpc-types-engine = { workspace = true, features = ["std", "serde"] } # Networking snap.workspace = true futures.workspace = true -libp2p-stream.workspace = true ipnet = { workspace = true, features = ["serde"] } discv5 = { workspace = true, features = ["libp2p"] } libp2p-identity = { workspace = true, features = ["secp256k1"] } diff --git a/crates/consensus/gossip/README.md b/crates/consensus/gossip/README.md index 35520db4aa..5c32401682 100644 --- a/crates/consensus/gossip/README.md +++ b/crates/consensus/gossip/README.md @@ -7,6 +7,9 @@ that subscribes to unsafe block topics and propagates validated payloads. `Block validates incoming blocks against the rollup config, `ConnectionGater` enforces IP-based rate limits and peer bans, and Prometheus metrics are recorded via `Metrics`. +Base does not advertise or implement the legacy op-node `payload_by_number` request-response +protocol. This does not affect execution-layer synchronization or HTTP follow-mode payload fetching. + Block topics retire according to the configured fork schedule once every pre-fork block is outside the 60-second gossip age window. Startup skips already-retired topics, and the driver checks for retirement every five seconds while running, including when idle. Inbound decoding @@ -33,6 +36,7 @@ Coverage includes a real-TCP mixed-subscription peer test in this crate, product delivery into an action-test verifier (`unsafe_gossip`), and a Docker-backed system test (`gossip_topic_retirement`) that checks peer-topic RPCs and unsafe sync with batching stopped. + ## Key Components - [`GossipDriver`]: Main driver managing the libp2p swarm and event handling diff --git a/crates/consensus/gossip/src/behaviour.rs b/crates/consensus/gossip/src/behaviour.rs index 7cba0c8c0d..12c824dfb2 100644 --- a/crates/consensus/gossip/src/behaviour.rs +++ b/crates/consensus/gossip/src/behaviour.rs @@ -42,10 +42,6 @@ pub struct Behaviour { /// Enables the identify protocol. #[debug(skip)] pub identify: libp2p::identify::Behaviour, - /// Enables the sync request/response protocol. - /// See `` - #[debug(skip)] - pub sync_req_resp: libp2p_stream::Behaviour, } impl Behaviour { @@ -83,8 +79,6 @@ impl Behaviour { .with_agent_version("base".to_string()), ); - let sync_req_resp = libp2p_stream::Behaviour::new(); - let subscriptions = handlers .iter() .flat_map(|handler| { @@ -109,7 +103,7 @@ impl Behaviour { info!(target: "gossip", topic = %topic, "Subscribed"); } - Ok(Self { connection_limits, identify, ping, gossipsub, sync_req_resp }) + Ok(Self { connection_limits, identify, ping, gossipsub }) } } diff --git a/crates/consensus/gossip/src/builder.rs b/crates/consensus/gossip/src/builder.rs index 493c0629ea..65c2d78283 100644 --- a/crates/consensus/gossip/src/builder.rs +++ b/crates/consensus/gossip/src/builder.rs @@ -6,8 +6,8 @@ use alloy_primitives::Address; use base_common_genesis::RollupConfig; use base_consensus_peers::{PeerMonitoring, PeerScoreLevel}; use libp2p::{ - Multiaddr, StreamProtocol, SwarmBuilder, gossipsub::Config, identity::Keypair, - noise::Config as NoiseConfig, tcp::Config as TcpConfig, yamux::Config as YamuxConfig, + Multiaddr, SwarmBuilder, gossipsub::Config, identity::Keypair, noise::Config as NoiseConfig, + tcp::Config as TcpConfig, yamux::Config as YamuxConfig, }; use tokio::sync::watch::{self}; @@ -160,7 +160,6 @@ impl GossipDriverBuilder { let addr = self.gossip_addr; let signer_recv = self.signer; let rollup_config = self.rollup_config; - let l2_chain_id = rollup_config.l2_chain_id; let block_time = rollup_config.block_time; let (signer_tx, signer_rx) = watch::channel(signer_recv); @@ -227,16 +226,6 @@ impl GossipDriverBuilder { } } - // Let's setup the sync request/response protocol stream. - let mut sync_handler = behaviour.sync_req_resp.new_control(); - - let protocol = format!("/opstack/req/payload_by_number/{l2_chain_id}/0/"); - let sync_protocol_name = StreamProtocol::try_from_owned(protocol) - .map_err(|_| GossipDriverBuilderError::SetupSyncReqRespError)?; - let sync_protocol = sync_handler - .accept(sync_protocol_name) - .map_err(|_| GossipDriverBuilderError::SyncReqRespAlreadyAccepted)?; - // Build the swarm with DNS+TCP transport. // Note: with_dns() must be called after with_tcp() to wrap TCP with DNS resolution. debug!(target: "gossip", peer_id = %keypair.public().to_peer_id(), "Building Swarm"); @@ -266,8 +255,6 @@ impl GossipDriverBuilder { swarm, addr, handler, - sync_handler, - sync_protocol, gate, GossipDriverConfig { max_identify_peerstore_peers, diff --git a/crates/consensus/gossip/src/driver.rs b/crates/consensus/gossip/src/driver.rs index eee6ee1677..3151874aad 100644 --- a/crates/consensus/gossip/src/driver.rs +++ b/crates/consensus/gossip/src/driver.rs @@ -7,13 +7,13 @@ use std::{ time::{Duration, Instant, SystemTime}, }; -use alloy_primitives::{Address, hex}; +use alloy_primitives::Address; use base_common_genesis::RollupConfig; use base_common_rpc_types_engine::NetworkPayloadEnvelope; use base_consensus_peers::{EnrValidation, PeerMonitoring, PeerUtils}; use derive_more::Debug; use discv5::Enr; -use futures::{AsyncWriteExt, stream::StreamExt}; +use futures::stream::StreamExt; use libp2p::{ Multiaddr, PeerId, Swarm, TransportError, gossipsub::{IdentTopic, MessageId}, @@ -23,7 +23,6 @@ use libp2p::{ }, }; use libp2p_identity::Keypair; -use libp2p_stream::IncomingStreams; use lru::LruCache; #[cfg(test)] pub use tests::GossipPair; @@ -61,17 +60,6 @@ pub struct GossipDriver { pub addr: Multiaddr, /// The [`BlockHandler`]. pub handler: BlockHandler, - /// A [`libp2p_stream::Control`] instance. Can be used to control the sync request/response - #[debug(skip)] - pub sync_handler: libp2p_stream::Control, - /// The inbound streams for the sync request/response protocol. - /// - /// This is an option to allow to take the underlying value when the gossip driver gets - /// activated. - /// - /// TODO: remove the sync-req-resp protocol once it is fully deprecated upstream. - #[debug(skip)] - pub sync_protocol: Option, /// LRU cache of identify metadata keyed by [`PeerId`]. pub peerstore: LruCache, /// If set, the gossip layer will monitor peer scores and ban peers that are below a given @@ -113,8 +101,6 @@ where swarm: Swarm, addr: Multiaddr, handler: BlockHandler, - sync_handler: libp2p_stream::Control, - sync_protocol: IncomingStreams, gate: G, config: GossipDriverConfig, ) -> Self { @@ -125,8 +111,6 @@ where peerstore: LruCache::new(config.max_identify_peerstore_peers), peer_monitoring: config.peer_monitoring, peer_connection_start: Default::default(), - sync_handler, - sync_protocol: Some(sync_protocol), connection_gate: gate, connection_limits_config: config.connection_limits_config, ping: Arc::new(Mutex::new(Default::default())), @@ -175,63 +159,10 @@ where Ok(Some(id)) } - /// Handles the sync request/response protocol. - /// - /// This is a mock handler that supports the `payload_by_number` protocol. - /// It always returns: not found (1), version (0). `` - /// - /// ## Note - /// - /// This is used to ensure peer nodes are not penalizing base-nodes for not supporting it. - /// This feature is being deprecated upstream. Once it is fully removed we will remove this handler. - pub(super) fn sync_protocol_handler(&mut self) { - let Some(mut sync_protocol) = self.sync_protocol.take() else { - return; - }; - - // Spawn a single task to handle all inbound sync substreams serially. - // The response is a constant 2-byte write — no I/O wait benefits from per-stream - // concurrency. Inlining eliminates per-substream task allocation and bounds - // heap exposure. - tokio::spawn(async move { - loop { - let Some((peer_id, mut inbound_stream)) = sync_protocol.next().await else { - warn!(target: "gossip", "The sync protocol stream has ended"); - return; - }; - - trace!(target: "gossip", peer_id = %peer_id, "Received sync request"); - Metrics::sync_requests().increment(1); - - // We return: not found (1), version (0). `` - // Response format: = - // No payload is returned. - const OUTPUT: [u8; 2] = hex!("0100"); - const WRITE_TIMEOUT: Duration = Duration::from_secs(5); - - match tokio::time::timeout(WRITE_TIMEOUT, inbound_stream.write_all(&OUTPUT)).await { - Ok(Ok(())) => {} - Ok(Err(e)) => { - error!(target: "gossip", error = %e, peer_id = %peer_id, "Failed to write sync response"); - } - Err(_) => { - warn!(target: "gossip", peer_id = %peer_id, "Sync response write timed out"); - } - } - } - }); - } - - /// Starts the libp2p Swarm. - /// - /// - Starts the sync request/response protocol handler. - /// - Tells the swarm to listen on the given [`Multiaddr`]. + /// Starts the libp2p swarm listening on the configured [`Multiaddr`]. /// /// Waits for the swarm to start listen before returning and connecting to peers. pub async fn start(&mut self) -> Result> { - // Start the sync request/response protocol handler. - self.sync_protocol_handler(); - match self.swarm.listen_on(self.addr.clone()) { Ok(id) => loop { if let SwarmEvent::NewListenAddr { address, listener_id } = @@ -471,10 +402,6 @@ where }); } Event::Identify(e) => self.handle_identify_event(*e), - // Don't do anything with stream events as this should be unreachable code. - Event::Stream => { - error!(target: "gossip", "Stream events should not be emitted!"); - } }; None diff --git a/crates/consensus/gossip/src/error.rs b/crates/consensus/gossip/src/error.rs index 299d498a99..b1a80c4d6c 100644 --- a/crates/consensus/gossip/src/error.rs +++ b/crates/consensus/gossip/src/error.rs @@ -62,12 +62,6 @@ pub enum GossipDriverBuilderError { /// An error when building the gossip behaviour. #[error("error building gossip behaviour")] BehaviourError(BehaviourError), - /// An error when setting up the sync request/response protocol. - #[error("error setting up sync request/response protocol")] - SetupSyncReqRespError, - /// The sync request/response protocol has already been accepted. - #[error("sync request/response protocol already accepted")] - SyncReqRespAlreadyAccepted, } /// An error type representing reasons why a peer connection cannot be established. diff --git a/crates/consensus/gossip/src/event.rs b/crates/consensus/gossip/src/event.rs index d57780e2d0..a2596235f8 100644 --- a/crates/consensus/gossip/src/event.rs +++ b/crates/consensus/gossip/src/event.rs @@ -28,12 +28,6 @@ pub enum Event { /// and network identity. Used for protocol negotiation and compatibility /// checking. Identify(Box), - - /// Stream protocol event for request-response communication. - /// - /// Handles direct peer-to-peer communication outside of the gossip mesh, - /// typically used for block synchronization requests. - Stream, } impl From for Event { @@ -57,13 +51,6 @@ impl From for Event { } } -impl From<()> for Event { - /// Converts () to [Event] - fn from(_value: ()) -> Self { - Self::Stream - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/consensus/gossip/src/metrics/mod.rs b/crates/consensus/gossip/src/metrics/mod.rs index a4339785fb..27cb06835d 100644 --- a/crates/consensus/gossip/src/metrics/mod.rs +++ b/crates/consensus/gossip/src/metrics/mod.rs @@ -147,7 +147,4 @@ base_metrics::define_metrics! { #[describe("Distribution of block versions")] #[label(name = "version", default = ["v1", "v2", "v3", "v4"])] block_version: counter, - - #[describe("Sync protocol substream requests")] - sync_requests: counter, } diff --git a/crates/consensus/gossip/src/rpc/request.rs b/crates/consensus/gossip/src/rpc/request.rs index 4bc69b1580..41f7194dcf 100644 --- a/crates/consensus/gossip/src/rpc/request.rs +++ b/crates/consensus/gossip/src/rpc/request.rs @@ -444,9 +444,8 @@ impl P2pRpcRequest { // See `` behavioral_penalty: Default::default(), }, - // We only support a shim implementation for the req/resp - // protocol so we're not - // computing scores for it. + // Retained for RPC compatibility. Base does not support + // the legacy request/response sync protocol. req_resp: Default::default(), }, }, @@ -516,7 +515,6 @@ impl P2pRpcRequest { "/ipfs/ping/1.0.0".to_string(), "/meshsub/1.2.0".to_string(), "/ipfs/id/1.0.0".to_string(), - format!("/opstack/req/payload_by_number/{chain_id}/0/"), "/meshsub/1.0.0".to_string(), "/floodsub/1.0.0".to_string(), ]), diff --git a/crates/consensus/gossip/src/rpc/types.rs b/crates/consensus/gossip/src/rpc/types.rs index f8dedada62..ce51d099dc 100644 --- a/crates/consensus/gossip/src/rpc/types.rs +++ b/crates/consensus/gossip/src/rpc/types.rs @@ -124,10 +124,10 @@ pub struct GossipScores { pub behavioral_penalty: f64, } -/// Request-response protocol scoring metrics. +/// Legacy request-response scoring fields retained for RPC response compatibility. /// -/// Tracks peer performance in direct request-response interactions outside -/// of the gossip mesh, such as block synchronization requests. +/// Base does not support the legacy block-sync request-response protocol and +/// reports zeroes for these fields. /// /// Reference: #[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize)] diff --git a/crates/consensus/service/Cargo.toml b/crates/consensus/service/Cargo.toml index 29321d1568..39a3d96c3e 100644 --- a/crates/consensus/service/Cargo.toml +++ b/crates/consensus/service/Cargo.toml @@ -65,7 +65,6 @@ opentelemetry.workspace = true async-trait.workspace = true async-stream.workspace = true tokio-stream.workspace = true -libp2p-stream.workspace = true http-body-util.workspace = true strum = { workspace = true, features = ["derive"] } tracing = { workspace = true, features = ["std"] } diff --git a/crates/consensus/service/tests/actors/network/p2p.rs b/crates/consensus/service/tests/actors/network/p2p.rs index 4fbe8ff008..89c4e0b797 100644 --- a/crates/consensus/service/tests/actors/network/p2p.rs +++ b/crates/consensus/service/tests/actors/network/p2p.rs @@ -12,6 +12,23 @@ async fn test_p2p_network_conn() -> anyhow::Result<()> { network_1.is_connected_to_with_retries(&network_2).await?; + for network in [&network_1, &network_2] { + let local = network.peer_info().await?; + let peers = network.peers().await?; + // Check both the local RPC's advertised capabilities and the remote + // capabilities learned over Identify, not just the connection count. + for info in std::iter::once(&local).chain(peers.peers.values()) { + let protocols = info.protocols.as_ref().expect("peer protocols available"); + assert!(protocols.iter().any(|protocol| protocol.starts_with("/meshsub/"))); + assert!( + protocols + .iter() + .all(|protocol| !protocol.starts_with("/opstack/req/payload_by_number/")), + "legacy sync must not be advertised: {protocols:?}" + ); + } + } + Ok(()) } diff --git a/docs/guides/P2P.md b/docs/guides/P2P.md index ef63e9b379..9020dbdc0b 100644 --- a/docs/guides/P2P.md +++ b/docs/guides/P2P.md @@ -464,17 +464,15 @@ pub struct Behaviour { pub ping: libp2p::ping::Behaviour, pub gossipsub: libp2p::gossipsub::Behaviour, pub identify: libp2p::identify::Behaviour, - pub sync_req_resp: libp2p_stream::Behaviour, } ``` The `ping` behaviour sends periodic keepalive pings to connected peers and measures round-trip times. The `gossipsub` behaviour handles the actual block gossip. The `identify` behaviour exchanges capability information between peers when they first connect (the Base node advertises its agent -version as `"base"`). The `sync_req_resp` behaviour supports a legacy request-response protocol -called `payload_by_number` that is part of the legacy rollup P2P spec. This is being deprecated, and the Base -implementation responds with "not found" to all requests, but it is still present so that legacy -peers don't penalize Base nodes for not supporting it. +version as `"base"`). Base does not support the legacy op-node `payload_by_number` +request-response protocol. Consensus-layer block propagation uses GossipSub; execution-layer +block synchronization and follow mode's HTTP payload fetching are separate paths. The `GossipDriver` ([`gossip/src/driver.rs`](https://github.com/base/base/blob/main/crates/consensus/gossip/src/driver.rs)) From 51ada2027f82cc573d0b16f5eff228b0c4665dd3 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 17 Sep 2026 23:04:14 +0000 Subject: [PATCH 053/299] fix(system-tests): wait for batcher safe head (#5134) --- etc/systems/src/l2/in_process_batcher.rs | 122 ++++++++++++++++++++++- etc/systems/src/smoke.rs | 68 +++++++++---- 2 files changed, 170 insertions(+), 20 deletions(-) diff --git a/etc/systems/src/l2/in_process_batcher.rs b/etc/systems/src/l2/in_process_batcher.rs index 7714b50c78..f577e3e99c 100644 --- a/etc/systems/src/l2/in_process_batcher.rs +++ b/etc/systems/src/l2/in_process_batcher.rs @@ -4,17 +4,26 @@ //! dependency for the batch submission layer. Mirrors the pattern used by //! [`InProcessConsensus`](super::InProcessConsensus). +use std::time::Duration; + use alloy_primitives::B256; use alloy_signer_local::PrivateKeySigner; use base_batcher_encoder::DaType; use base_batcher_service::{BatcherConfig, BatcherService}; +use base_consensus_rpc::SyncStatusApiClient; +use base_protocol::BlockInfo; use base_runtime::TokioRuntime; use base_tx_manager::SignerConfig; use eyre::Result; -use tokio::{sync::watch, task::JoinHandle}; +use jsonrpsee::http_client::HttpClientBuilder; +use tokio::{sync::watch, task::JoinHandle, time::sleep}; use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; use url::Url; +const INITIAL_SAFE_L2_HEAD_TIMEOUT: Duration = Duration::from_secs(60); +const INITIAL_SAFE_L2_HEAD_POLL_INTERVAL: Duration = Duration::from_millis(250); + /// Configuration for starting an in-process batcher. #[derive(Debug, Clone)] pub struct InProcessBatcherConfig { @@ -46,6 +55,8 @@ impl std::fmt::Debug for InProcessBatcher { impl InProcessBatcher { /// Starts an in-process batcher with the given configuration. pub async fn start(config: InProcessBatcherConfig) -> Result { + Self::wait_for_initial_safe_l2_head(&config.rollup_rpc_url).await?; + let signer = PrivateKeySigner::from_bytes(&config.batcher_key) .map_err(|e| eyre::eyre!("invalid batcher key: {e}"))?; let mut batcher_config = BatcherConfig { @@ -74,6 +85,65 @@ impl InProcessBatcher { Ok(Self { cancellation, failure_rx, _handle: handle }) } + /// Waits for the rollup node to initialize the safe L2 head used by batcher startup. + /// + /// The batcher reads this head during setup to anchor its channel timestamps. A consensus RPC + /// endpoint can be reachable before initial derivation has populated the head, especially when + /// several system-test stacks share an L1 fixture. Waiting here turns that startup race into a + /// bounded, diagnosable wait rather than an immediate `safe L2 head is empty` failure. + pub async fn wait_for_initial_safe_l2_head(rollup_rpc_url: &Url) -> Result<()> { + let client = + HttpClientBuilder::default().build(rollup_rpc_url.as_str()).map_err(|error| { + eyre::eyre!("failed to build batcher readiness RPC client: {error}") + })?; + let mut last_status = None; + let mut last_error = None; + + let wait = async { + loop { + match client.sync_status().await { + Ok(status) if status.safe_l2.block_info != BlockInfo::default() => { + info!( + safe_l2 = status.safe_l2.block_info.number, + unsafe_l2 = status.unsafe_l2.block_info.number, + current_l1 = status.current_l1.number, + "safe L2 head is ready for in-process batcher startup" + ); + return; + } + Ok(status) => { + debug!( + safe_l2 = ?status.safe_l2, + unsafe_l2 = ?status.unsafe_l2, + current_l1 = ?status.current_l1, + "waiting for consensus to initialize safe L2 head before batcher startup" + ); + last_status = Some(status); + } + Err(error) => { + warn!( + error = %error, + "optimism_syncStatus failed while waiting for batcher safe L2 head" + ); + last_error = Some(error.to_string()); + } + } + sleep(INITIAL_SAFE_L2_HEAD_POLL_INTERVAL).await; + } + }; + + tokio::time::timeout(INITIAL_SAFE_L2_HEAD_TIMEOUT, wait) + .await + .map_err(|_| { + eyre::eyre!( + "timed out after {:?} waiting for consensus to initialize the safe L2 head before \ + batcher startup; last_sync_status={last_status:?}; last_sync_status_error={last_error:?}", + INITIAL_SAFE_L2_HEAD_TIMEOUT + ) + })?; + Ok(()) + } + /// Returns the batcher failure if its service task has exited with an error. pub fn failure(&self) -> Option { self.failure_rx.borrow().clone() @@ -90,3 +160,53 @@ impl Drop for InProcessBatcher { self.cancellation.cancel(); } } + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use alloy_primitives::B256; + use base_protocol::{BlockInfo, SyncStatus}; + use jsonrpsee::{RpcModule, server::ServerBuilder, types::ErrorObjectOwned}; + + use super::InProcessBatcher; + + #[derive(Clone)] + struct SyncStatusServer { + requests: Arc, + ready_status: SyncStatus, + } + + #[tokio::test] + async fn batcher_startup_waits_for_safe_l2_head() { + let mut ready_status = SyncStatus::default(); + ready_status.safe_l2.block_info = BlockInfo::new(B256::repeat_byte(1), 1, B256::ZERO, 1); + let server = SyncStatusServer { requests: Arc::new(AtomicUsize::new(0)), ready_status }; + let rpc_server = ServerBuilder::default().build("127.0.0.1:0").await.unwrap(); + let address = rpc_server.local_addr().unwrap(); + let mut module = RpcModule::new(server.clone()); + module + .register_async_method("optimism_syncStatus", |_, server, _| async move { + let status = if server.requests.fetch_add(1, Ordering::SeqCst) == 0 { + SyncStatus::default() + } else { + server.ready_status.clone() + }; + Ok::<_, ErrorObjectOwned>(status) + }) + .unwrap(); + let handle = rpc_server.start(module); + + InProcessBatcher::wait_for_initial_safe_l2_head( + &format!("http://{address}").parse().unwrap(), + ) + .await + .unwrap(); + + handle.stop().unwrap(); + assert!(server.requests.load(Ordering::SeqCst) >= 2); + } +} diff --git a/etc/systems/src/smoke.rs b/etc/systems/src/smoke.rs index 05efeb1134..fd6380401c 100644 --- a/etc/systems/src/smoke.rs +++ b/etc/systems/src/smoke.rs @@ -48,10 +48,10 @@ fn deploy_against_shared_l1( runtime: &SharedL1Runtime, output_dir: &std::path::Path, l2_chain_id: u64, -) -> Result<(L1GenesisOutput, L2DeploymentOutput)> { - // All consumers share the fixture's funded deployer account. Serialize live deployments - // across nextest processes so independently constructed op-deployer instances do not race - // the account nonce. +) -> Result<(L1GenesisOutput, L2DeploymentOutput, std::fs::File)> { + // All consumers share the fixture's funded deployer account. Keep the lock after deployment: + // an L2 stack's first batcher setup reads its consensus safe head, so the caller must retain + // the lock until the stack has completed its shared-L1 bootstrap. let deployment_lock_path = std::env::temp_dir() .join(format!("base-system-tests-{}.deployment.lock", runtime.network_name)); let deployment_lock = OpenOptions::new() @@ -79,6 +79,7 @@ fn deploy_against_shared_l1( Ok(( L1GenesisOutput::from_output_dir(output_dir), L2DeploymentOutput::from_output_dir(output_dir), + deployment_lock, )) } @@ -410,6 +411,23 @@ pub struct SystemTestStackBuilder { } impl SystemTestStackBuilder { + /// Initializes tracing so Nextest includes in-process node diagnostics from a failed attempt. + /// + /// Nextest executes retries in separate test processes. Its captured output for the original + /// attempt is retained only when the subscriber writes through the libtest test writer. + /// Initialize this before any in-process node starts, while preserving a subscriber explicitly + /// installed by a binary or embedding application. + pub fn initialize_test_tracing() { + let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + "warn,base_system_tests=debug,base_batcher_service=debug,base_consensus=info".into() + }); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_test_writer() + .with_ansi(false) + .try_init(); + } + const fn has_custom_fork_activation(&self) -> bool { self.isthmus_activation_block.is_some() || self.base_azul_activation_block.is_some() @@ -664,6 +682,8 @@ impl SystemTestStackBuilder { /// Builds and starts the system test stack. pub async fn build(mut self) -> Result { + Self::initialize_test_tracing(); + if self.shared_l1.is_none() && !self.l1_fault_injection && !self.has_custom_fork_activation() @@ -742,20 +762,25 @@ impl SystemTestStackBuilder { } let shared_l1 = self.shared_l1.clone(); - let (l1_genesis, l2_deployment) = if let Some(shared_l1) = &shared_l1 { - let output_dir = output_dir.clone(); - let shared_l1 = shared_l1.clone(); - tokio::task::spawn_blocking(move || { - deploy_against_shared_l1(&shared_l1, &output_dir, l2_chain_id) - }) - .await - .wrap_err("shared L1 deployment task panicked")?? - } else { - tokio::task::spawn_blocking(move || setup.generate_genesis()) - .await - .wrap_err("Genesis setup task panicked")? - .wrap_err("Failed to generate L1/L2 genesis")? - }; + let (l1_genesis, l2_deployment, shared_l1_bootstrap_lock) = + if let Some(shared_l1) = &shared_l1 { + let output_dir = output_dir.clone(); + let shared_l1 = shared_l1.clone(); + let (l1_genesis, l2_deployment, deployment_lock) = + tokio::task::spawn_blocking(move || { + deploy_against_shared_l1(&shared_l1, &output_dir, l2_chain_id) + }) + .await + .wrap_err("shared L1 deployment task panicked")??; + (l1_genesis, l2_deployment, Some(deployment_lock)) + } else { + let (l1_genesis, l2_deployment) = + tokio::task::spawn_blocking(move || setup.generate_genesis()) + .await + .wrap_err("Genesis setup task panicked")? + .wrap_err("Failed to generate L1/L2 genesis")?; + (l1_genesis, l2_deployment, None) + }; let (l1_container_config, l2_container_config) = if self.devnet_config.use_stable_ports { let config = &self.devnet_config.stable; @@ -914,7 +939,12 @@ impl SystemTestStackBuilder { extra_client_extensions: self.extra_client_extensions, }; - let l2_stack = L2Stack::start(l2_config).await.wrap_err("Failed to start L2 stack")?; + // When this stack attached to the shared L1, retain its deployment lock until the L2 + // bootstrap (including the batcher's initial safe-head wait) is complete. This makes the + // lock cover every shared-L1 readiness condition required before batch submission starts. + let l2_stack_result = L2Stack::start(l2_config).await; + drop(shared_l1_bootstrap_lock); + let l2_stack = l2_stack_result.wrap_err("Failed to start L2 stack")?; Ok(SystemTestStack { _temp_dir: temp_dir, From 782ffc4fe7e04958fc7b0a023b2e1adcb1f3ffbe Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 17 Sep 2026 23:05:32 +0000 Subject: [PATCH 054/299] perf(payload): specialize predicate account hashing (#5092) --- crates/execution/payload/src/predicate_loads.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/execution/payload/src/predicate_loads.rs b/crates/execution/payload/src/predicate_loads.rs index 8ed881b51a..a1b4070eb6 100644 --- a/crates/execution/payload/src/predicate_loads.rs +++ b/crates/execution/payload/src/predicate_loads.rs @@ -20,7 +20,10 @@ //! wrapper as the evaluator issues them, short-circuit evaluation is respected: //! only the reads a predicate check actually performs are counted. -use alloy_primitives::{Address, B256, U256, map::HashSet}; +use alloy_primitives::{ + Address, B256, U256, + map::{AddressSet, HashSet}, +}; use revm::{ Database, state::{AccountInfo, Bytecode}, @@ -39,7 +42,7 @@ pub struct PredicateLoadTracker { /// Total storage-slot reads. slot_reads: u64, /// Distinct accounts read this block. - unique_accounts: HashSet
, + unique_accounts: AddressSet, /// Distinct storage slots read this block. unique_slots: HashSet<(Address, U256)>, } From 1eda0f7f4cebb823522e62f34fc3e513b1c450b1 Mon Sep 17 00:00:00 2001 From: Arjun Dureja Date: Fri, 18 Sep 2026 00:08:27 +0000 Subject: [PATCH 055/299] fix(rpc): include EIP-8130 transaction hash (#5140) * fix(rpc): include EIP-8130 transaction hash * style(rpc): apply nightly rustfmt grouping --- crates/common/rpc-types/src/transaction.rs | 38 ++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/common/rpc-types/src/transaction.rs b/crates/common/rpc-types/src/transaction.rs index 0bdf5be515..faccd14754 100644 --- a/crates/common/rpc-types/src/transaction.rs +++ b/crates/common/rpc-types/src/transaction.rs @@ -212,7 +212,7 @@ mod tx_serde { use base_common_consensus::BaseTxEnvelope; use serde::{Deserialize, Serialize, de::Error}; - use super::{Address, BlockHash, Transaction}; + use super::{Address, B256, BlockHash, Transaction}; /// Helper struct which will be flattened into the transaction and will only contain `from` /// field if inner [`BaseTxEnvelope`] did not consume it. @@ -220,6 +220,8 @@ mod tx_serde { struct OptionalFields { #[serde(default, skip_serializing_if = "Option::is_none")] from: Option
, + #[serde(default, skip_serializing_if = "Option::is_none")] + hash: Option, #[serde( default, rename = "gasPrice", @@ -290,6 +292,10 @@ mod tx_serde { // if inner transaction is a deposit, then don't serialize `from` directly let from = if inner.as_deposit().is_some() { None } else { Some(inner.signer()) }; + // EIP-8130's consensus serde omits its cached hash so deserialization recomputes it. + // Add the canonical hash at the RPC layer without duplicating hashes for other types. + let hash = inner.as_eip8130().map(|tx| *tx.hash()); + // if inner transaction has its own `gasPrice` don't serialize it in this struct. let effective_gas_price = effective_gas_price.filter(|_| inner.gas_price().is_none()); @@ -301,7 +307,7 @@ mod tx_serde { block_timestamp, block_timestamp_ms, deposit_receipt_version, - other: OptionalFields { from, effective_gas_price, deposit_nonce }, + other: OptionalFields { hash, from, effective_gas_price, deposit_nonce }, } } } @@ -358,6 +364,7 @@ mod tx_serde { mod tests { use alloc::vec; + use alloy_eips::eip2718::Encodable2718; use alloy_primitives::Bytes; use base_common_consensus::{Eip8130Signed, TxEip8130}; @@ -383,6 +390,19 @@ mod tests { similar_asserts::assert_eq!(deserialized, expected); } + #[test] + fn ordinary_transaction_serialization_is_unchanged() { + let rpc_tx = r#"{"blockHash":"0x7e5d03caac4eb2b613ae9c919ef3afcc8ed0e384f31ee746381d3c8739475d2a","blockNumber":"0x4","from":"0x7435ed30a8b4aeb0877cef0c6e8cffe834eb865f","gas":"0x5208","gasPrice":"0x23237dee","hash":"0x3f38cdc805c02e152bfed34471a3a13a786fed436b3aec0c3eca35d23e2cdd2c","input":"0x","nonce":"0xc","to":"0x4dde844b71bcdf95512fb4dc94e84fb67b512ed8","transactionIndex":"0x0","value":"0x1","type":"0x0","chainId":"0xc72dd9d5e883e","v":"0x18e5bb3abd10a0","r":"0x3d61f5d7e93eecd0669a31eb640ab3349e9e5868a44c2be1337c90a893b51990","s":"0xc55f44ba123af37d0e73ed75e578647c3f473805349936f64ea902ea9e03bc7"}"#; + + let tx = serde_json::from_str::(rpc_tx).unwrap(); + let serialized = serde_json::to_string(&tx).unwrap(); + + assert_eq!(serialized.matches("\"hash\":").count(), 1); + let actual = serde_json::from_str::(&serialized).unwrap(); + let expected = serde_json::from_str::(rpc_tx).unwrap(); + similar_asserts::assert_eq!(actual, expected); + } + #[test] fn can_serialize_eip8130() { let tx_body = TxEip8130 { @@ -403,6 +423,7 @@ mod tests { let sender_auth = Bytes::from_static(&[0xAB; 32]); let payer_auth = Bytes::new(); let signed = Eip8130Signed::new(tx_body, sender_auth, payer_auth); + let canonical_hash = alloy_primitives::keccak256(signed.encoded_2718()); let envelope = BaseTxEnvelope::Eip8130(signed); // Wrap the envelope with its explicit sender directly rather than going @@ -414,7 +435,7 @@ mod tests { let recovered = Recovered::new_unchecked(envelope, Address::with_last_byte(0x11)); let tx_info = BaseTransactionInfo { inner: alloy_rpc_types_eth::TransactionInfo { - hash: Some(B256::repeat_byte(0x42)), + hash: Some(canonical_hash), block_hash: Some(B256::repeat_byte(0x01)), block_number: Some(100), block_timestamp: Some(1_700_000_000), @@ -435,6 +456,7 @@ mod tests { let value: serde_json::Value = serde_json::from_str(&serialized).unwrap(); assert_eq!(value["type"], "0x79", "tx type byte exposed in RPC response"); + assert_eq!(value["hash"], serde_json::to_value(canonical_hash).unwrap()); assert_eq!(value["from"], "0x0000000000000000000000000000000000000011"); assert_eq!(value["blockNumber"], "0x64"); assert_eq!(value["blockTimestampMs"], "0x18bcfe568c8"); @@ -453,6 +475,16 @@ mod tests { assert_eq!(value["senderAuth"], format!("0x{}", "ab".repeat(32))); assert_eq!(value["payerAuth"], "0x"); + let mut untrusted_value = value.clone(); + untrusted_value["hash"] = serde_json::to_value(B256::ZERO).unwrap(); + let deserialized: Transaction = serde_json::from_value(untrusted_value).unwrap(); + let reserialized = serde_json::to_value(deserialized).unwrap(); + assert_eq!( + reserialized["hash"], + serde_json::to_value(canonical_hash).unwrap(), + "RPC deserialization recomputes the EIP-8130 hash from its payload" + ); + assert!(value.get("sourceHash").is_none(), "no deposit-only fields leak"); assert!(value.get("depositReceiptVersion").is_none()); assert!(value.get("mint").is_none()); From c3b8b2d41b395bdbf728c16fde2c83a2a4403b36 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 18 Sep 2026 16:47:49 +0000 Subject: [PATCH 056/299] feat(bench): support auto-deployed fresh-devnet swap workloads (#5131) * feat(bench): support auto-deployed swap harness workload * style(bench): format swap harness tests * fix(bench): make harness deploy async and harden log parsing --- etc/benchmarks/fresh-devnet.yml | 7 +- etc/systems/README.md | 11 +- etc/systems/src/benchmark_cli.rs | 315 ++++++++++++++++++++++++++++++- 3 files changed, 319 insertions(+), 14 deletions(-) diff --git a/etc/benchmarks/fresh-devnet.yml b/etc/benchmarks/fresh-devnet.yml index 358a6b7edc..ff8961c873 100644 --- a/etc/benchmarks/fresh-devnet.yml +++ b/etc/benchmarks/fresh-devnet.yml @@ -6,9 +6,10 @@ # settings use TestConfig's portable defaults: a 60-second, 20M gas/s, two- # second-cadence test. # -# `uniswap-swap` deliberately is not included here. Its WETH, USDC, and router -# addresses are real Base Sepolia contracts, so it needs a funded Sepolia -# snapshot rather than an empty devnet. Keep it in the snapshot benchmark suite. +# Optional swap workloads can set `deploy_devnet_swap_harness: true` to deploy +# a fresh devnet USDC token plus Uniswap/Aerodrome router shims per workload. +# In that mode, swap `router`/`token_in`/`token_out` and real-token setup +# `weth`/`pair_token.token`/acquisition `router` can be omitted. benchmark_workloads: - workload: b20-transfer transaction_payload: b20-transfer-existing diff --git a/etc/systems/README.md b/etc/systems/README.md index c0978de05d..ee48793f9f 100644 --- a/etc/systems/README.md +++ b/etc/systems/README.md @@ -141,8 +141,8 @@ report artifacts. The checked-in fresh-devnet suite runs every workload on a distinct empty devnet, so token state, accounts, the transaction pool, and caches cannot leak -between scenarios. It currently covers B-20 transfers (with Beryl activated at -genesis), high-concurrency ETH transfers to new and existing recipients, and a +between scenarios. It currently covers B-20 transfers (with the B-20 asset +feature activated before setup), high-concurrency ETH transfers to new and existing recipients, and a 50,000-round Blake2f precompile profile: ```sh @@ -168,9 +168,10 @@ results/fresh-devnet/ `metadata.json` and the load-test sidecars are directly consumable by the static visualizer in `base/benchmark`; link this output directory to that repository's ignored `output/` directory and run its normal production build. -The real-token Uniswap V3 workload is intentionally excluded from this suite: -it depends on funded WETH, USDC, and router contracts at Base Sepolia addresses -and therefore belongs in a snapshot benchmark, not a blank devnet. +Swap workloads can opt into fresh-devnet contract provisioning with +`deploy_devnet_swap_harness: true` on a workload entry. That mode deploys a +fresh devnet USDC token plus Uniswap/Aerodrome router shims for each workload, +then auto-wires swap and real-token setup addresses before execution. `base-bench snapshot` owns the process lifecycle around one load test: it generates an ephemeral funder, deposits funds to it in the first local descendant, replaces placeholder endpoints in the diff --git a/etc/systems/src/benchmark_cli.rs b/etc/systems/src/benchmark_cli.rs index b1ef5956e9..57d50e6522 100644 --- a/etc/systems/src/benchmark_cli.rs +++ b/etc/systems/src/benchmark_cli.rs @@ -14,6 +14,7 @@ use std::{ time::Duration, }; +use alloy_primitives::Address; use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types_eth::BlockNumberOrTag; use alloy_signer_local::PrivateKeySigner; @@ -27,6 +28,7 @@ use chrono::{DateTime, SecondsFormat, Utc}; use clap::{Args, Parser, Subcommand}; use eyre::{Result, WrapErr}; use serde::{Deserialize, Serialize}; +use tokio::process::Command; use crate::{ ANVIL_ACCOUNT_1, ANVIL_ACCOUNT_5, B20PrecompileClient, DevnetBlockInterval, DevnetConfig, @@ -80,6 +82,9 @@ pub struct LocalBenchmarkWorkload { pub workload: String, /// Human-readable payload label for the visualizer. pub transaction_payload: String, + /// Deploy a fresh devnet WETH/USDC swap harness and auto-wire swap addresses. + #[serde(default)] + pub deploy_devnet_swap_harness: bool, /// Load-test settings for this workload. #[serde(flatten)] pub test_config: TestConfig, @@ -194,6 +199,15 @@ const FRESH_DEVNET_AZUL_ACTIVATION_BLOCK: u64 = 0; const FRESH_DEVNET_BERYL_ACTIVATION_BLOCK: u64 = 3; const FRESH_DEVNET_BERYL_READY_BLOCK: u64 = FRESH_DEVNET_BERYL_ACTIVATION_BLOCK + 1; const FRESH_DEVNET_BERYL_READY_TIMEOUT: Duration = Duration::from_secs(30); +const DEVNET_WETH_ADDRESS: &str = "0x4200000000000000000000000000000000000006"; +const DEVNET_SWAP_CONTRACTS_DIR: &str = "../../crates/utilities/test-utils/contracts"; + +#[derive(Debug, Clone, Copy)] +struct DevnetSwapHarnessAddresses { + usdc: Address, + uniswap_router: Address, + aerodrome_router: Address, +} impl BenchmarkCli { /// Runs the selected benchmark case. @@ -225,7 +239,7 @@ impl LocalBenchmarkArgs { .transpose() .wrap_err("failed to load fresh-devnet benchmark configuration")? .unwrap_or_default(); - let result = self.run_one(test_config).await?; + let result = self.run_one(test_config, false).await?; if let Some(error) = result.error { eyre::bail!(error) } @@ -237,7 +251,11 @@ impl LocalBenchmarkArgs { /// The configuration's RPC endpoints are deliberately ignored: every run targets the dynamic /// endpoints assigned to its newly created devnet. A B-20 workload schedules Azul and Beryl, /// then activates the B-20 asset feature before creating its token fixtures. - pub async fn run_one(&self, mut test_config: TestConfig) -> Result { + pub async fn run_one( + &self, + mut test_config: TestConfig, + deploy_devnet_swap_harness: bool, + ) -> Result { let devnet = DevnetConfig::standard(); let chain_id = devnet.l2_chain_id; let funder_key = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_1.private_key) @@ -265,12 +283,18 @@ impl LocalBenchmarkArgs { .flashblocks_url() .parse() .wrap_err("invalid fresh-devnet Flashblocks URL")?; - test_config.transaction_submission_rpcs = vec![builder_rpc]; + test_config.transaction_submission_rpcs = vec![builder_rpc.clone()]; test_config.query_rpc = Some(client_rpc); test_config.txpool_nodes.clear(); test_config.flashblocks_ws = Some(flashblocks_ws); test_config.chain_id = Some(chain_id); + if deploy_devnet_swap_harness { + println!("deploying fresh devnet swap harness"); + let harness = Self::deploy_devnet_swap_harness(&builder_rpc).await?; + Self::apply_devnet_swap_harness(&mut test_config, harness)?; + } + let load_config = test_config.to_load_config(None)?; eyre::ensure!( load_config.block_time == FRESH_DEVNET_BLOCK_TIME, @@ -341,7 +365,7 @@ impl LocalBenchmarkArgs { let output_dir_name = format!("fresh-devnet-{}", workload.workload); let workload_output_dir = Self::workload_output_dir(output_dir, &output_dir_name)?; let result = Self { output_dir: Some(workload_output_dir), ..Self::default() } - .run_one(workload.test_config) + .run_one(workload.test_config, workload.deploy_devnet_swap_harness) .await; let (summary, success, error) = match result { @@ -428,6 +452,155 @@ impl LocalBenchmarkArgs { Ok(()) } + async fn deploy_devnet_swap_harness( + builder_rpc: &url::Url, + ) -> Result { + let contracts_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(DEVNET_SWAP_CONTRACTS_DIR); + eyre::ensure!( + contracts_dir.is_dir(), + "devnet swap harness contracts directory not found: {}", + contracts_dir.display() + ); + + let install_output = Command::new("forge") + .args(["soldeer", "install"]) + .current_dir(&contracts_dir) + .output() + .await + .wrap_err("failed to execute forge soldeer install for devnet swap harness")?; + if !install_output.status.success() { + eyre::bail!( + "forge soldeer install failed for devnet swap harness: {}", + String::from_utf8_lossy(&install_output.stderr) + ); + } + + let deploy_private_key = format!("{:#x}", ANVIL_ACCOUNT_1.private_key); + let deploy_output = Command::new("forge") + .arg("script") + .arg("script/DeployRealTokenSwapDevnet.s.sol:DeployRealTokenSwapDevnet") + .arg("--rpc-url") + .arg(builder_rpc.as_str()) + .arg("--private-key") + .arg(&deploy_private_key) + .arg("--broadcast") + .current_dir(&contracts_dir) + .output() + .await + .wrap_err("failed to execute devnet swap harness deployment")?; + let deploy_logs = format!( + "{}\n{}", + String::from_utf8_lossy(&deploy_output.stdout), + String::from_utf8_lossy(&deploy_output.stderr) + ); + if !deploy_output.status.success() { + eyre::bail!("devnet swap harness deployment failed:\n{deploy_logs}"); + } + Self::parse_devnet_swap_harness_addresses(&deploy_logs) + } + + fn parse_devnet_swap_harness_addresses( + deploy_logs: &str, + ) -> Result { + let usdc = Self::parse_deployed_address_from_logs(deploy_logs, "USDC:", "USDC")?; + let uniswap_router = Self::parse_deployed_address_from_logs( + deploy_logs, + "Uniswap router shim:", + "Uniswap router", + )?; + let aerodrome_router = Self::parse_deployed_address_from_logs( + deploy_logs, + "Aerodrome router shim:", + "Aerodrome router", + )?; + Ok(DevnetSwapHarnessAddresses { usdc, uniswap_router, aerodrome_router }) + } + + fn parse_deployed_address_from_logs( + deploy_logs: &str, + marker: &str, + deployment_name: &str, + ) -> Result
{ + for line in deploy_logs.lines() { + let Some((_, rest)) = line.split_once(marker) else { + continue; + }; + + for token in rest.split_whitespace() { + let token = token.trim_matches(|char| { + matches!(char, ',' | ';' | ':' | '(' | ')' | '[' | ']' | '{' | '}' | '"') + }); + if let Ok(address) = token.parse::
() { + return Ok(address); + } + } + + eyre::bail!("failed to parse deployed {deployment_name} address from line: {line}"); + } + + eyre::bail!("missing {deployment_name} address in devnet swap harness deployment") + } + + fn apply_devnet_swap_harness( + test_config: &mut TestConfig, + harness: DevnetSwapHarnessAddresses, + ) -> Result<()> { + let weth = DEVNET_WETH_ADDRESS + .parse::
() + .expect("valid devnet WETH predeploy address literal"); + let setup = test_config.real_token_setup.as_mut().ok_or_else(|| { + eyre::eyre!("deploy_devnet_swap_harness requires real_token_setup with enabled: true") + })?; + eyre::ensure!( + setup.enabled, + "deploy_devnet_swap_harness requires real_token_setup with enabled: true" + ); + + setup.weth = Some(weth); + setup.pair_token.token = Some(harness.usdc); + match &mut setup.pair_token.acquisition { + base_load_tests::RealTokenAcquisitionConfig::UniswapV3ExactInput { router, .. } => { + *router = Some(harness.uniswap_router); + } + base_load_tests::RealTokenAcquisitionConfig::AerodromeClExactInput { + router, .. + } => { + *router = Some(harness.aerodrome_router); + } + } + + let mut saw_swap = false; + for transaction in &mut test_config.transactions { + match &mut transaction.tx_type { + TxTypeConfig::UniswapV3 { router, token_in, token_out, .. } => { + *router = Some(harness.uniswap_router); + *token_in = Some(weth); + *token_out = Some(harness.usdc); + saw_swap = true; + } + TxTypeConfig::AerodromeCl { router, token_in, token_out, .. } => { + *router = Some(harness.aerodrome_router); + *token_in = Some(weth); + *token_out = Some(harness.usdc); + saw_swap = true; + } + TxTypeConfig::Transfer + | TxTypeConfig::Calldata { .. } + | TxTypeConfig::Erc20 { .. } + | TxTypeConfig::Storage { .. } + | TxTypeConfig::DoubleCounter { .. } + | TxTypeConfig::Precompile { .. } + | TxTypeConfig::Osaka { .. } + | TxTypeConfig::B20 => {} + } + } + eyre::ensure!( + saw_swap, + "deploy_devnet_swap_harness requires at least one uniswap_v3 or aerodrome_cl transaction" + ); + Ok(()) + } + /// Writes a single load-test sidecar for a local benchmark when an output directory was set. pub fn write_result(&self, summary: &MetricsSummary) -> Result<()> { let Some(output_dir) = &self.output_dir else { return Ok(()) }; @@ -947,12 +1120,13 @@ impl SnapshotBenchmarkArgs { mod tests { use std::{fs, num::NonZeroU64}; + use alloy_primitives::Address; use base_load_tests::MetricsSummary; use clap::Parser; use super::{ - AggregateBenchmarkArgs, BenchmarkCli, BenchmarkCommand, LocalBenchmarkArgs, - LocalBenchmarkWorkloadConfig, SnapshotBenchmarkArgs, + AggregateBenchmarkArgs, BenchmarkCli, BenchmarkCommand, DevnetSwapHarnessAddresses, + LocalBenchmarkArgs, LocalBenchmarkWorkloadConfig, SnapshotBenchmarkArgs, }; #[test] @@ -1150,6 +1324,7 @@ mod tests { assert_eq!(config.benchmark_workloads.len(), 4); assert_eq!(config.benchmark_workloads[0].workload, "b20-transfer"); assert_eq!(config.benchmark_workloads[0].transaction_payload, "b20-transfer-existing"); + assert!(!config.benchmark_workloads[0].deploy_devnet_swap_harness); assert_eq!(config.benchmark_workloads[0].test_config.sender_count, 400); assert_eq!(config.benchmark_workloads[0].test_config.funding_amount, "100000000000000000"); assert_eq!(config.benchmark_workloads[1].workload, "eth-new"); @@ -1158,6 +1333,134 @@ mod tests { assert_eq!(config.benchmark_workloads[3].workload, "blake2f-50000"); } + #[test] + fn parses_devnet_swap_harness_workload_without_contract_addresses() { + let yaml = r#" +benchmark_workloads: + - workload: uniswap-swap + transaction_payload: uniswap_v3-devnet-harness + deploy_devnet_swap_harness: true + transaction_submission_rpcs: http://localhost:8545 + query_rpc: http://localhost:8545 + txpool_nodes: [] + flashblocks_ws: ws://localhost:7111 + sender_count: 10 + in_flight_per_sender: 16 + transactions: + - weight: 100 + type: uniswap_v3 + fee: 500 + real_token_setup: + enabled: true + weth_amount_per_sender: "50000000000000000" + pair_token: + amount_per_sender: "10000000" + acquisition: + type: uniswap_v3_exact_input + fee: 500 + amount_in: "10000000000000000" +"#; + + let config: LocalBenchmarkWorkloadConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.benchmark_workloads.len(), 1); + assert!(config.benchmark_workloads[0].deploy_devnet_swap_harness); + } + + #[test] + fn parses_devnet_swap_harness_deployment_logs() { + let usdc = Address::repeat_byte(0x11); + let uniswap_router = Address::repeat_byte(0x22); + let aerodrome_router = Address::repeat_byte(0x33); + let logs = format!( + "compiling contracts\nUSDC: [{usdc}]\nUniswap router shim: ({uniswap_router})\nAerodrome router shim: {aerodrome_router};\ndeployment complete" + ); + + let parsed = LocalBenchmarkArgs::parse_devnet_swap_harness_addresses(&logs).unwrap(); + + assert_eq!(parsed.usdc, usdc); + assert_eq!(parsed.uniswap_router, uniswap_router); + assert_eq!(parsed.aerodrome_router, aerodrome_router); + } + + #[test] + fn devnet_swap_harness_log_parsing_requires_all_addresses() { + let logs = format!("USDC: {}", Address::repeat_byte(0x11)); + + let err = LocalBenchmarkArgs::parse_devnet_swap_harness_addresses(&logs).unwrap_err(); + + assert!(err.to_string().contains("missing Uniswap router address")); + } + + #[test] + fn apply_devnet_swap_harness_wires_swap_and_setup_addresses() { + let yaml = r#" +transaction_submission_rpcs: http://localhost:8545 +query_rpc: http://localhost:8545 +flashblocks_ws: ws://localhost:7111 +transactions: + - weight: 50 + type: uniswap_v3 + fee: 500 + - weight: 50 + type: aerodrome_cl + tick_spacing: 100 +real_token_setup: + enabled: true + weth_amount_per_sender: "50000000000000000" + pair_token: + amount_per_sender: "10000000" + acquisition: + type: uniswap_v3_exact_input + fee: 500 + amount_in: "10000000000000000" +"#; + + let mut config = base_load_tests::TestConfig::from_yaml(yaml).unwrap(); + let harness = DevnetSwapHarnessAddresses { + usdc: Address::repeat_byte(0x11), + uniswap_router: Address::repeat_byte(0x22), + aerodrome_router: Address::repeat_byte(0x33), + }; + + LocalBenchmarkArgs::apply_devnet_swap_harness(&mut config, harness).unwrap(); + + let setup = config.real_token_setup.expect("setup should be configured"); + assert_eq!(setup.weth, Some("0x4200000000000000000000000000000000000006".parse().unwrap())); + assert_eq!(setup.pair_token.token, Some(harness.usdc)); + match setup.pair_token.acquisition { + base_load_tests::RealTokenAcquisitionConfig::UniswapV3ExactInput { router, .. } => { + assert_eq!(router, Some(harness.uniswap_router)); + } + _ => panic!("expected uniswap acquisition route"), + } + + for transaction in config.transactions { + match transaction.tx_type { + base_load_tests::TxTypeConfig::UniswapV3 { + router, token_in, token_out, .. + } => { + assert_eq!(router, Some(harness.uniswap_router)); + assert_eq!(token_out, Some(harness.usdc)); + assert_eq!( + token_in, + Some("0x4200000000000000000000000000000000000006".parse().unwrap()) + ); + } + base_load_tests::TxTypeConfig::AerodromeCl { + router, token_in, token_out, .. + } => { + assert_eq!(router, Some(harness.aerodrome_router)); + assert_eq!(token_out, Some(harness.usdc)); + assert_eq!( + token_in, + Some("0x4200000000000000000000000000000000000006".parse().unwrap()) + ); + } + _ => panic!("expected swap tx type"), + } + } + } + #[test] fn writes_local_load_test_sidecar_when_output_is_requested() { let output = tempfile::tempdir().unwrap(); From 2c8d1ea7aa0aaa9ae27c8baa03ac6142c9e6f7b1 Mon Sep 17 00:00:00 2001 From: Haardik Date: Fri, 18 Sep 2026 16:56:11 +0000 Subject: [PATCH 057/299] feat(snapshot): improve proofs progress logging (#5154) Amp-Thread-ID: https://ampcode.com/threads/T-01a0b556-f12a-744b-a6c2-378934d8f018 Co-authored-by: Amp --- crates/execution/cli/src/commands/download.rs | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/execution/cli/src/commands/download.rs b/crates/execution/cli/src/commands/download.rs index 6605554ca1..15e40ede71 100644 --- a/crates/execution/cli/src/commands/download.rs +++ b/crates/execution/cli/src/commands/download.rs @@ -6,7 +6,7 @@ use std::{ ffi::OsString, - io::SeekFrom, + io::{Read, SeekFrom}, path::{Path, PathBuf}, sync::{ Arc, @@ -108,6 +108,9 @@ fn resolve_datadir_args(args: impl IntoIterator) -> DatadirArgs /// Matches reth's `--download-concurrency` default. const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 8; +/// Interval between proofs download and extraction progress logs. +const PROOFS_PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(3); + /// Extracts `--download-concurrency` so proofs use the same parallel budget as reth. fn resolve_download_concurrency_arg(args: impl IntoIterator) -> usize { let mut concurrency = DEFAULT_DOWNLOAD_CONCURRENCY; @@ -203,6 +206,47 @@ fn log_proofs_download_progress(downloaded: u64, expected: u64, started: Instant ); } +/// Reports extraction progress as compressed archive bytes are consumed. +struct ExtractionProgress { + inner: R, + processed: u64, + total: u64, + started: Instant, + last_log: Instant, +} + +impl ExtractionProgress { + fn new(inner: R, total: u64) -> Self { + let now = Instant::now(); + Self { inner, processed: 0, total, started: now, last_log: now } + } +} + +impl Read for ExtractionProgress { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let read = self.inner.read(buf)?; + self.processed = self.processed.saturating_add(read as u64).min(self.total); + + if read > 0 && self.last_log.elapsed() >= PROOFS_PROGRESS_LOG_INTERVAL { + let elapsed = self.started.elapsed(); + let speed = self.processed as f64 / elapsed.as_secs_f64(); + let eta = ProgressDisplay::eta(self.processed, self.total, elapsed) + .map_or_else(|| "unknown".to_string(), |eta| eta.to_string()); + info!( + target: "reth::cli", + progress = %ProgressDisplay::human_byte_progress(self.processed, self.total), + speed = %ProgressDisplay::speed(speed), + eta = %eta, + elapsed = %ProgressDisplay::duration(elapsed), + "Proofs extraction progress" + ); + self.last_log = Instant::now(); + } + + Ok(read) + } +} + /// Loads parallel-range resume state when it matches this download. fn load_range_sidecar(path: &Path, concurrency: usize, expected_size: u64) -> Option> { let text = std::fs::read_to_string(path).ok()?; @@ -621,7 +665,7 @@ impl ProofsDownloader { Ok(chunk) => { file.write_all(&chunk).await?; downloaded += chunk.len() as u64; - if last_log.elapsed() >= Duration::from_secs(30) { + if last_log.elapsed() >= PROOFS_PROGRESS_LOG_INTERVAL { log_proofs_download_progress( downloaded, entry.expected_size, @@ -769,7 +813,7 @@ impl ProofsDownloader { let expected_size = entry.expected_size; let started = Instant::now(); tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(30)); + let mut interval = tokio::time::interval(PROOFS_PROGRESS_LOG_INTERVAL); interval.tick().await; loop { interval.tick().await; @@ -974,7 +1018,9 @@ impl ProofsDownloader { fn extract_tar_zst(archive_path: &Path, target_dir: &Path) -> Result<()> { let file = std::fs::File::open(archive_path) .map_err(|e| eyre::eyre!("failed to open {}: {e}", archive_path.display()))?; - let decoder = zstd::Decoder::new(file)?; + let archive_size = file.metadata()?.len(); + let progress = ExtractionProgress::new(file, archive_size); + let decoder = zstd::Decoder::new(progress)?; let mut archive = tar::Archive::new(decoder); archive.unpack(target_dir)?; Ok(()) From bb5096d0cd3a7edf4a584e551a3ddd9ef756059c Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 18 Sep 2026 16:56:56 +0000 Subject: [PATCH 058/299] perf(bench): trim visualizer metrics (#5157) --- etc/systems/src/prometheus_metrics.rs | 112 ++++++++++++++++++-------- 1 file changed, 77 insertions(+), 35 deletions(-) diff --git a/etc/systems/src/prometheus_metrics.rs b/etc/systems/src/prometheus_metrics.rs index 2048f5ac84..bf080173d9 100644 --- a/etc/systems/src/prometheus_metrics.rs +++ b/etc/systems/src/prometheus_metrics.rs @@ -14,6 +14,57 @@ use prometheus_scraper::{ use tokio::{sync::watch, task::JoinHandle}; use url::Url; +/// Prometheus metrics rendered by the snapshot benchmark visualizer. +const VISUALIZER_METRICS: &[&str] = &[ + "reth_base_builder_flashblock_build_duration_avg", + "reth_base_builder_flashblock_count", + "reth_base_builder_payload_num_tx_gauge", + "reth_base_builder_payload_transaction_simulation_duration_avg", + "reth_base_builder_sequencer_tx_duration_avg", + "reth_base_builder_state_root_calculation_duration_avg", + "reth_base_builder_total_block_built_duration_avg", + "reth_base_builder_tx_simulation_duration_avg", + "reth_consensus_engine_beacon_backpressure_active", + "reth_consensus_engine_beacon_block_insert_total_duration_avg", + "reth_consensus_engine_beacon_new_payload_latency_avg", + "reth_consensus_engine_persistence_save_blocks_batch_size_avg", + "reth_consensus_engine_persistence_save_blocks_duration_seconds_avg", + "reth_db_freelist", + "reth_op_rbuilder_flashblock_build_duration", + "reth_op_rbuilder_flashblock_count", + "reth_op_rbuilder_payload_tx_simulation_duration", + "reth_op_rbuilder_sequencer_tx_duration", + "reth_op_rbuilder_state_root_calculation_duration", + "reth_op_rbuilder_total_block_built_duration", + "reth_sync_block_validation_deferred_trie_compute_duration_avg", + "reth_sync_block_validation_hashed_post_state_size_avg", + "reth_sync_block_validation_state_root_duration", + "reth_sync_block_validation_state_root_duration_avg", + "reth_sync_block_validation_total_duration_avg", + "reth_sync_block_validation_trie_updates_sorted_size_avg", + "reth_sync_execution_accounts_updated_histogram_avg", + "reth_sync_execution_execution_duration", + "reth_sync_execution_execution_duration_avg", + "reth_sync_execution_storage_slots_updated_histogram_avg", + "reth_sync_execution_transaction_execution_histogram_avg", + "reth_sync_execution_transaction_wait_histogram_avg", + "reth_sync_state_provider_total_account_fetch_latency", + "reth_sync_state_provider_total_account_fetch_latency_avg", + "reth_sync_state_provider_total_code_fetch_latency", + "reth_sync_state_provider_total_code_fetch_latency_avg", + "reth_sync_state_provider_total_storage_fetch_latency", + "reth_sync_state_provider_total_storage_fetch_latency_avg", + "reth_transaction_pool_pending_pool_transactions", + "reth_transaction_pool_total_transactions", + "reth_tree_root_sparse_trie_cache_wait_duration_histogram_avg", + "reth_tree_root_sparse_trie_channel_wait_duration_histogram_avg", + "reth_tree_root_sparse_trie_final_update_duration_histogram", + "reth_tree_root_sparse_trie_final_update_duration_histogram_avg", + "reth_tree_root_sparse_trie_reveal_multiproof_duration_histogram_avg", + "reth_tree_root_sparse_trie_total_duration_histogram", + "reth_tree_root_sparse_trie_total_duration_histogram_avg", +]; + #[derive(Debug, Clone, Copy)] enum ScalarSample { Counter(f64), @@ -219,7 +270,7 @@ impl PrometheusBlockCollector { let current = Self::scrape(&client, &metrics_url).await?; let block_count = head - last_head; let mut metrics = current.delta_for_blocks(&previous, block_count); - metrics.retain(|name, _| Self::is_diagnostic_metric(name)); + metrics.retain(|name, _| Self::is_visualizer_metric(name)); metrics.insert( "benchmark/prometheus_blocks_per_scrape".to_string(), block_count as f64, @@ -269,32 +320,14 @@ impl PrometheusBlockCollector { Ok(PrometheusSnapshot::parse(&response.text().await?)) } - /// Returns whether a flattened metric belongs to the stable benchmark diagnostic set. - pub fn is_diagnostic_metric(name: &str) -> bool { - [ - "reth_base_builder_", - "reth_sync_execution_", - "reth_sync_block_validation_", - "reth_sync_state_provider_", - "reth_consensus_engine_beacon_block_insert_", - "reth_consensus_engine_beacon_new_payload_", - "reth_consensus_engine_beacon_backpressure_", - "reth_consensus_engine_beacon_failed_", - "reth_consensus_engine_persistence_save_blocks_", - "reth_storage_providers_database_save_blocks_", - "reth_tree_root_sparse_trie_", - "reth_parallel_sparse_trie_", - "reth_trie_proof_task_", - "reth_trie_cursor_overall_duration", - "reth_trie_hashed_cursor_overall_duration", - "reth_trie_leaves_added", - "reth_trie_branches_added", - "reth_transaction_pool_pending_pool_transactions", - "reth_transaction_pool_total_transactions", - "reth_db_freelist", - ] - .iter() - .any(|prefix| name.starts_with(prefix)) + /// Returns whether a flattened metric is rendered by the benchmark visualizer. + /// + /// This intentionally uses exact metric names rather than broad metric-family prefixes. The + /// latter pull in one sample for every flashblock, gas bucket, and label combination, even + /// though the report UI cannot display them. Keep this list in sync with the metrics rendered + /// by `base/benchmark`'s `ChartGrid`, including its compatibility aliases. + pub fn is_visualizer_metric(name: &str) -> bool { + VISUALIZER_METRICS.contains(&name) } } @@ -364,20 +397,29 @@ build_duration_count{stage="execution"} 4 } #[test] - fn selects_only_report_diagnostic_families() { - assert!(PrometheusBlockCollector::is_diagnostic_metric( + fn selects_only_metrics_rendered_by_the_visualizer() { + assert!(PrometheusBlockCollector::is_visualizer_metric( "reth_base_builder_total_block_built_duration_avg" )); - assert!(PrometheusBlockCollector::is_diagnostic_metric( + assert!(PrometheusBlockCollector::is_visualizer_metric( "reth_sync_execution_execution_duration" )); - assert!(PrometheusBlockCollector::is_diagnostic_metric( - "reth_consensus_engine_beacon_backpressure_stall_duration_avg" + assert!(PrometheusBlockCollector::is_visualizer_metric( + "reth_consensus_engine_beacon_new_payload_latency_avg" + )); + assert!(PrometheusBlockCollector::is_visualizer_metric( + "reth_tree_root_sparse_trie_final_update_duration_histogram" + )); + assert!(PrometheusBlockCollector::is_visualizer_metric( + "reth_op_rbuilder_flashblock_count" + )); + assert!(!PrometheusBlockCollector::is_visualizer_metric( + "reth_base_builder_flashblock_gas_headroom_flashblock_index_1_avg" )); - assert!(PrometheusBlockCollector::is_diagnostic_metric( - "reth_consensus_engine_beacon_failed_new_payload_response_deliveries" + assert!(!PrometheusBlockCollector::is_visualizer_metric( + "reth_consensus_engine_beacon_new_payload_thread_user_cpu_seconds_avg" )); - assert!(!PrometheusBlockCollector::is_diagnostic_metric( + assert!(!PrometheusBlockCollector::is_visualizer_metric( "reth_rpc_server_calls_started_total_method_eth_call" )); } From d8a2a42398cc6a3ace97771d85554e58a0319e36 Mon Sep 17 00:00:00 2001 From: William Law Date: Fri, 18 Sep 2026 18:01:28 +0000 Subject: [PATCH 059/299] feat(simulation): accept MeterBundleResponse on builder insert (3/n) (#4600) * feat(txpool): accept MeterBundleResponse on builder insert wire Carry metering as a first-class field on ValidatedTransaction so u128 timing survives JSON (flatten cannot). Builders apply it on insert; forwarder copies it from the pooled tx. Absent when None. Co-authored-by: Cursor * feat(builder): write insert metering into the builder cache base_insertValidatedTransaction is the write path now that base_setMeteringInformation is going away. On inbound metering, clone into MeteringProvider so payload selection can keep using get(tx_hash). Co-authored-by: Cursor * fix(txpool): clippy and call-site nits after 3/n rebase Co-authored-by: Cursor * fix(txpool): write metering cache only after pool accept Rejected inserts no longer pollute the builder cache. Fold BuilderApiExtensionArgs Debug into the derive. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- bin/base/src/commands/sequencer.rs | 6 +- bin/builder/src/main.rs | 6 +- crates/builder/core/src/extension.rs | 45 ++++++-- crates/builder/core/src/lib.rs | 3 +- crates/builder/core/src/shadow_validity.rs | 29 ++++- crates/builder/core/tests/ordering.rs | 23 ++-- crates/builder/core/tests/rpc.rs | 21 ++-- .../builder/core/tests/transaction_events.rs | 4 +- .../tx-forwarding/src/forwarder/request.rs | 1 + .../tx-forwarding/src/forwarder/task.rs | 1 + .../tx-forwarding/src/reader/task.rs | 8 +- crates/execution/tx-forwarding/src/service.rs | 7 +- crates/execution/txpool/src/builder/mod.rs | 2 +- crates/execution/txpool/src/builder/rpc.rs | 100 ++++++++++++++++-- crates/execution/txpool/src/lib.rs | 2 +- crates/execution/txpool/src/transaction.rs | 9 ++ crates/execution/txpool/src/wire.rs | 70 +++++++++++- etc/systems/tests/tx_forwarding.rs | 4 +- 18 files changed, 286 insertions(+), 55 deletions(-) diff --git a/bin/base/src/commands/sequencer.rs b/bin/base/src/commands/sequencer.rs index a969abeed4..971836d7ae 100644 --- a/bin/base/src/commands/sequencer.rs +++ b/bin/base/src/commands/sequencer.rs @@ -110,9 +110,11 @@ impl SequencerCommand { .with_cutover_enabled(payload_builder_cutover) .with_basic_only(basic_payload_builder), ); - runner.install_ext::(metering_provider); + runner.install_ext::(Arc::clone(&metering_provider)); runner.install_ext::(TxPoolRpcConfig { sequencer_rpc }); - runner.install_ext::(builder_api_config); + runner.install_ext::( + builder_api_config.with_metering_provider(metering_provider), + ); if builder_api_config.accept_experimental_validity_transactions { runner.install_ext::( SendRawTransactionValidityConfig { diff --git a/bin/builder/src/main.rs b/bin/builder/src/main.rs index 604fa208e2..9ad80145f3 100644 --- a/bin/builder/src/main.rs +++ b/bin/builder/src/main.rs @@ -66,9 +66,11 @@ fn main() { .with_cutover_enabled(payload_builder_cutover) .with_basic_only(basic_payload_builder), ); - runner.install_ext::(metering_provider); + runner.install_ext::(Arc::clone(&metering_provider)); runner.install_ext::(TxPoolRpcConfig::default()); - runner.install_ext::(builder_api_config); + runner.install_ext::( + builder_api_config.with_metering_provider(metering_provider), + ); if builder_api_config.accept_experimental_validity_transactions { runner.install_ext::( SendRawTransactionValidityConfig { diff --git a/crates/builder/core/src/extension.rs b/crates/builder/core/src/extension.rs index 779abf231c..46700098ff 100644 --- a/crates/builder/core/src/extension.rs +++ b/crates/builder/core/src/extension.rs @@ -1,10 +1,15 @@ //! Builder API RPC extension for registering the `base_insertValidatedTransaction` endpoint. +use std::sync::Arc; + use base_execution_txpool::BuilderApiServer; pub use base_execution_txpool::DEFAULT_MAX_VALIDITY_PREDICATES; use base_node_runner::{BaseNodeExtension, BaseRpcContext, FromExtensionConfig, NodeHooks}; -use crate::{ShadowValidityBuilderApi, ShadowValidityConfig, ShadowValidityConfigError}; +use crate::{ + NoopMeteringProvider, ShadowValidityBuilderApi, ShadowValidityConfig, + ShadowValidityConfigError, SharedMeteringProvider, +}; /// Builder RPC configuration for experimental validity-bearing transactions. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -45,6 +50,19 @@ impl BuilderApiExtensionConfig { self.shadow_validity = shadow_validity; Ok(self) } + + /// Pairs this validity config with a metering cache for insert. + pub fn with_metering_provider( + self, + metering_provider: SharedMeteringProvider, + ) -> BuilderApiExtensionArgs { + BuilderApiExtensionArgs { config: self, metering_provider } + } + + /// Pairs this validity config with a no-op metering cache. + pub fn with_noop_metering(self) -> BuilderApiExtensionArgs { + self.with_metering_provider(Arc::new(NoopMeteringProvider)) + } } impl Default for BuilderApiExtensionConfig { @@ -53,20 +71,35 @@ impl Default for BuilderApiExtensionConfig { } } +/// Install arguments for [`BuilderApiExtension`]. +#[derive(Debug, Clone)] +pub struct BuilderApiExtensionArgs { + /// Validity-extension RPC settings. + pub config: BuilderApiExtensionConfig, + /// Shared builder metering cache written on `insertValidatedTransaction`. + pub metering_provider: SharedMeteringProvider, +} + /// Extension that registers the Builder API RPC module (`base_insertValidatedTransaction`). /// /// Its configuration controls validity metadata acceptance, predicate limits, and shadow-only /// validity injection. Ordinary validated transactions remain available in all modes. -#[derive(Debug, Default)] +#[derive(Debug, Clone)] pub struct BuilderApiExtension { config: BuilderApiExtensionConfig, + metering_provider: SharedMeteringProvider, } impl BaseNodeExtension for BuilderApiExtension { fn apply(self: Box, builder: NodeHooks) -> NodeHooks { let config = self.config; + let metering_provider = self.metering_provider; builder.add_rpc_module(move |ctx: &mut BaseRpcContext<'_>| { - let api = ShadowValidityBuilderApi::new(ctx.pool().clone(), config); + let api = ShadowValidityBuilderApi::new( + ctx.pool().clone(), + config, + Arc::clone(&metering_provider), + ); ctx.modules.merge_configured(api.into_rpc())?; Ok(()) }) @@ -74,9 +107,9 @@ impl BaseNodeExtension for BuilderApiExtension { } impl FromExtensionConfig for BuilderApiExtension { - type Config = BuilderApiExtensionConfig; + type Config = BuilderApiExtensionArgs; - fn from_config(config: Self::Config) -> Self { - Self { config } + fn from_config(args: Self::Config) -> Self { + Self { config: args.config, metering_provider: args.metering_provider } } } diff --git a/crates/builder/core/src/lib.rs b/crates/builder/core/src/lib.rs index d738256102..c991070a05 100644 --- a/crates/builder/core/src/lib.rs +++ b/crates/builder/core/src/lib.rs @@ -50,7 +50,8 @@ pub use flashblocks::{ mod extension; pub use extension::{ - BuilderApiExtension, BuilderApiExtensionConfig, DEFAULT_MAX_VALIDITY_PREDICATES, + BuilderApiExtension, BuilderApiExtensionArgs, BuilderApiExtensionConfig, + DEFAULT_MAX_VALIDITY_PREDICATES, }; mod shadow_validity; diff --git a/crates/builder/core/src/shadow_validity.rs b/crates/builder/core/src/shadow_validity.rs index 42810830ee..0199b699ed 100644 --- a/crates/builder/core/src/shadow_validity.rs +++ b/crates/builder/core/src/shadow_validity.rs @@ -1,14 +1,17 @@ //! Shadow-builder validity predicate injection for forwarded transactions. -use alloy_primitives::U256; +use std::sync::Arc; + +use alloy_primitives::{TxHash, U256}; +use base_bundles::MeterBundleResponse; use base_execution_txpool::{ - BasePooledTransaction, BuilderApiImpl, BuilderApiServer, TransactionValidity, + BasePooledTransaction, BuilderApiImpl, BuilderApiServer, InsertMetering, TransactionValidity, ValidatedTransaction, ValidityOperator, ValidityPredicate, }; use jsonrpsee::core::RpcResult; use reth_transaction_pool::TransactionPool; -use crate::{BuilderApiExtensionConfig, BuilderMetrics}; +use crate::{BuilderApiExtensionConfig, BuilderMetrics, SharedMeteringProvider}; /// Number of basis points representing a 100% sampling rate. pub const MAX_SHADOW_VALIDITY_SAMPLE_RATE_BPS: u16 = 10_000; @@ -107,6 +110,16 @@ impl Default for ShadowValidityConfig { } } +/// Adapts [`SharedMeteringProvider`] to the insert-path metering sink. +#[derive(Debug, Clone)] +struct InsertMeteringAdapter(SharedMeteringProvider); + +impl InsertMetering for InsertMeteringAdapter { + fn insert_metering(&self, tx_hash: TxHash, metering: MeterBundleResponse) { + self.0.insert(tx_hash, metering); + } +} + /// Builder API that decorates sampled transactions before normal validated insertion. #[derive(Debug)] pub struct ShadowValidityBuilderApi

{ @@ -116,13 +129,18 @@ pub struct ShadowValidityBuilderApi

{ impl

ShadowValidityBuilderApi

{ /// Creates a builder API using validated configuration. - pub const fn new(pool: P, config: BuilderApiExtensionConfig) -> Self { + pub fn new( + pool: P, + config: BuilderApiExtensionConfig, + metering_provider: SharedMeteringProvider, + ) -> Self { Self { inner: BuilderApiImpl::with_extensions( pool, config.accept_experimental_validity_transactions, config.max_validity_predicates, - ), + ) + .with_metering_cache(Arc::new(InsertMeteringAdapter(metering_provider))), config: config.shadow_validity, } } @@ -176,6 +194,7 @@ mod tests { ValidatedTransaction { sender: Address::repeat_byte(0x11), raw, + metering: None, extensions: TransactionValidity::default(), } } diff --git a/crates/builder/core/tests/ordering.rs b/crates/builder/core/tests/ordering.rs index 50769443ba..1d93b2d444 100644 --- a/crates/builder/core/tests/ordering.rs +++ b/crates/builder/core/tests/ordering.rs @@ -95,10 +95,10 @@ async fn fee_priority_ordering() -> eyre::Result<()> { #[tokio::test] async fn predicates_delay_priority_without_blocking_nonce_descendants() -> eyre::Result<()> { let instance = LocalInstanceBuilder::new(BuilderConfig::for_tests()) - .install_ext::(BuilderApiExtensionConfig::new( - true, - DEFAULT_MAX_VALIDITY_PREDICATES, - )) + .install_ext::( + BuilderApiExtensionConfig::new(true, DEFAULT_MAX_VALIDITY_PREDICATES) + .with_noop_metering(), + ) .build() .await?; let driver = instance.driver().await?; @@ -117,6 +117,7 @@ async fn predicates_delay_priority_without_blocking_nonce_descendants() -> eyre: let validated_parent = ValidatedTransaction { sender: accounts[0].address(), raw: parent.encoded_2718().into(), + metering: None, extensions: TransactionValidity { validity: vec![ValidityPredicate::Balance { address: watched, @@ -184,10 +185,10 @@ async fn predicates_delay_priority_without_blocking_nonce_descendants() -> eyre: async fn predicate_eval_hard_cutoff_defers_without_evaluating() -> eyre::Result<()> { let instance = LocalInstanceBuilder::new(BuilderConfig::for_tests().with_predicate_eval_hard_cutoff_ms(0)) - .install_ext::(BuilderApiExtensionConfig::new( - true, - DEFAULT_MAX_VALIDITY_PREDICATES, - )) + .install_ext::( + BuilderApiExtensionConfig::new(true, DEFAULT_MAX_VALIDITY_PREDICATES) + .with_noop_metering(), + ) .build() .await?; let driver = instance.driver().await?; @@ -216,6 +217,7 @@ async fn predicate_eval_hard_cutoff_defers_without_evaluating() -> eyre::Result< (ValidatedTransaction { sender: accounts[0].address(), raw: first.encoded_2718().into(), + metering: None, extensions: TransactionValidity { validity: always_satisfied.clone() }, },), ) @@ -237,6 +239,7 @@ async fn predicate_eval_hard_cutoff_defers_without_evaluating() -> eyre::Result< (ValidatedTransaction { sender: accounts[1].address(), raw: deferred.encoded_2718().into(), + metering: None, extensions: TransactionValidity { validity: always_satisfied }, },), ) @@ -278,7 +281,8 @@ async fn shadow_validity_injection_preserves_forwarded_transaction() -> eyre::Re let shadow = ShadowValidityConfig::enabled(MAX_SHADOW_VALIDITY_SAMPLE_RATE_BPS).expect("valid rate"); let api_config = BuilderApiExtensionConfig::new(true, DEFAULT_MAX_VALIDITY_PREDICATES) - .with_shadow_validity(shadow)?; + .with_shadow_validity(shadow)? + .with_noop_metering(); let instance = LocalInstanceBuilder::new(BuilderConfig::for_tests()) .install_ext::(api_config) .build() @@ -301,6 +305,7 @@ async fn shadow_validity_injection_preserves_forwarded_transaction() -> eyre::Re let forwarded = ValidatedTransaction { sender: accounts[0].address(), raw: raw.clone().into(), + metering: None, extensions: TransactionValidity::default(), }; driver diff --git a/crates/builder/core/tests/rpc.rs b/crates/builder/core/tests/rpc.rs index 893abd0d77..c29fb70759 100644 --- a/crates/builder/core/tests/rpc.rs +++ b/crates/builder/core/tests/rpc.rs @@ -25,7 +25,8 @@ async fn setup( accept_validity: bool, max_validity_predicates: usize, ) -> eyre::Result<(TestHarness, RpcClient)> { - let config = BuilderApiExtensionConfig::new(accept_validity, max_validity_predicates); + let config = BuilderApiExtensionConfig::new(accept_validity, max_validity_predicates) + .with_noop_metering(); let harness = TestHarness::builder().with_ext::(config).build().await?; let client = harness.rpc_client()?; Ok((harness, client)) @@ -54,7 +55,8 @@ async fn setup_with_validity_ingress( accept_validity: bool, max_validity_predicates: usize, ) -> eyre::Result<(TestHarness, RpcClient)> { - let config = BuilderApiExtensionConfig::new(accept_validity, max_validity_predicates); + let config = BuilderApiExtensionConfig::new(accept_validity, max_validity_predicates) + .with_noop_metering(); let mut builder = TestHarness::builder().with_ext::(config); if accept_validity { builder = builder.with_ext::( @@ -114,8 +116,8 @@ async fn test_insert_validated_deposit_tx() -> eyre::Result<()> { let (_harness, client) = setup(false, DEFAULT_MAX_VALIDITY_PREDICATES).await?; let (sender, raw) = create_deposit_tx(); - let validated_tx = ValidatedTransaction { sender, raw, extensions: NoExtensions {} }; - + let validated_tx = + ValidatedTransaction { sender, raw, metering: None, extensions: NoExtensions {} }; let result: Result<(), _> = client.request("base_insertValidatedTransaction", (validated_tx,)).await; @@ -137,8 +139,8 @@ async fn test_insert_validated_eip1559_tx() -> eyre::Result<()> { let (harness, client) = setup(false, DEFAULT_MAX_VALIDITY_PREDICATES).await?; let (sender, raw) = create_eip1559_tx(harness.chain_id()); - let validated_tx = ValidatedTransaction { sender, raw, extensions: NoExtensions {} }; - + let validated_tx = + ValidatedTransaction { sender, raw, metering: None, extensions: NoExtensions {} }; // EIP-1559 transactions are supported by the pool let result: Result<(), _> = client.request("base_insertValidatedTransaction", (validated_tx,)).await; @@ -156,6 +158,7 @@ async fn test_insert_invalid_tx_fails() -> eyre::Result<()> { let validated_tx = ValidatedTransaction { sender: Address::repeat_byte(0x01), raw: Bytes::from(vec![0xFF, 0x01, 0x02, 0x03]), + metering: None, extensions: NoExtensions {}, }; @@ -184,7 +187,8 @@ async fn test_validity_transactions_require_explicit_opt_in() -> eyre::Result<() let (disabled_harness, disabled_client) = setup(false, DEFAULT_MAX_VALIDITY_PREDICATES).await?; let (sender, raw) = create_eip1559_tx(disabled_harness.chain_id()); - let disabled_tx = ValidatedTransaction { sender, raw, extensions: validity.clone() }; + let disabled_tx = + ValidatedTransaction { sender, raw, metering: None, extensions: validity.clone() }; let disabled: Result<(), _> = disabled_client.request("base_insertValidatedTransaction", (disabled_tx,)).await; assert!( @@ -196,7 +200,7 @@ async fn test_validity_transactions_require_explicit_opt_in() -> eyre::Result<() let (enabled_harness, enabled_client) = setup(true, DEFAULT_MAX_VALIDITY_PREDICATES).await?; let (sender, raw) = create_eip1559_tx(enabled_harness.chain_id()); - let enabled_tx = ValidatedTransaction { sender, raw, extensions: validity }; + let enabled_tx = ValidatedTransaction { sender, raw, metering: None, extensions: validity }; let enabled: Result<(), _> = enabled_client.request("base_insertValidatedTransaction", (enabled_tx,)).await; assert!(enabled.is_ok(), "enabled builder should accept validity: {enabled:?}"); @@ -217,6 +221,7 @@ async fn test_validity_transactions_enforce_configured_limit() -> eyre::Result<( let transaction = ValidatedTransaction { sender, raw, + metering: None, extensions: TransactionValidity { validity: vec![predicate; 2] }, }; diff --git a/crates/builder/core/tests/transaction_events.rs b/crates/builder/core/tests/transaction_events.rs index 9c4dcdb4c3..b4a90606ad 100644 --- a/crates/builder/core/tests/transaction_events.rs +++ b/crates/builder/core/tests/transaction_events.rs @@ -17,7 +17,7 @@ use base_observability_events::{TransactionEventCapture, TransactionEventType}; fn validity_instance() -> LocalInstanceBuilder { LocalInstanceBuilder::new(BuilderConfig::for_tests()).install_ext::( - BuilderApiExtensionConfig::new(true, DEFAULT_MAX_VALIDITY_PREDICATES), + BuilderApiExtensionConfig::new(true, DEFAULT_MAX_VALIDITY_PREDICATES).with_noop_metering(), ) } @@ -45,6 +45,7 @@ async fn recoverable_predicate_emits_builder_deferred() -> eyre::Result<()> { (ValidatedTransaction { sender: accounts[0].address(), raw: gated.encoded_2718().into(), + metering: None, extensions: TransactionValidity { validity: vec![ValidityPredicate::Balance { address: watched, @@ -134,6 +135,7 @@ async fn expired_position_predicate_emits_builder_expired() -> eyre::Result<()> (ValidatedTransaction { sender: accounts[0].address(), raw: expired.encoded_2718().into(), + metering: None, extensions: TransactionValidity { validity: vec![ValidityPredicate::BlockNumber { op: ValidityOperator::LessThanOrEqual, diff --git a/crates/execution/tx-forwarding/src/forwarder/request.rs b/crates/execution/tx-forwarding/src/forwarder/request.rs index 21ac43f6ac..75c51d572e 100644 --- a/crates/execution/tx-forwarding/src/forwarder/request.rs +++ b/crates/execution/tx-forwarding/src/forwarder/request.rs @@ -81,6 +81,7 @@ mod tests { transaction: ValidatedTransaction { sender: Address::repeat_byte(0x11), raw: Bytes::from_static(&[0x02, 0x03]), + metering: None, extensions: NoExtensions {}, }, tx_hash, diff --git a/crates/execution/tx-forwarding/src/forwarder/task.rs b/crates/execution/tx-forwarding/src/forwarder/task.rs index 3642665ec7..035f7467c3 100644 --- a/crates/execution/tx-forwarding/src/forwarder/task.rs +++ b/crates/execution/tx-forwarding/src/forwarder/task.rs @@ -444,6 +444,7 @@ mod tests { transaction: ValidatedTransaction { sender: Address::repeat_byte((nonce + 1) as u8), raw: Bytes::from(vec![nonce as u8]), + metering: None, extensions: E::default(), }, tx_hash: B256::with_last_byte(nonce as u8), diff --git a/crates/execution/tx-forwarding/src/reader/task.rs b/crates/execution/tx-forwarding/src/reader/task.rs index b762a03283..0291386b6a 100644 --- a/crates/execution/tx-forwarding/src/reader/task.rs +++ b/crates/execution/tx-forwarding/src/reader/task.rs @@ -2,7 +2,9 @@ use std::{fmt, sync::Arc}; use alloy_eips::Encodable2718; use alloy_primitives::{Bytes, TxHash}; -use base_execution_txpool::{NoExtensions, ValidatedTransaction, ValidatedTransactionExtensions}; +use base_execution_txpool::{ + BasePooledTx, NoExtensions, ValidatedTransaction, ValidatedTransactionExtensions, +}; use base_observability_events::{ TransactionEventProducer, TransactionEventType, transaction_event, }; @@ -37,7 +39,7 @@ pub(crate) struct DestinationReader { impl DestinationReader where P: TransactionPool + 'static, - P::Transaction: PoolTransaction, + P::Transaction: PoolTransaction + BasePooledTx, ::Consensus: Encodable2718, E: ValidatedTransactionExtensions, { @@ -150,6 +152,7 @@ where transaction: ValidatedTransaction { sender: *transaction.sender_ref(), raw: Bytes::from(consensus.inner().encoded_2718()), + metering: transaction.transaction.metering().cloned(), extensions: E::extract(transaction), }, tx_hash: *transaction.transaction.hash(), @@ -258,6 +261,7 @@ mod tests { assert_eq!(converted.tx_hash, *transaction.hash()); assert_eq!(converted.transaction.sender, *transaction.sender_ref()); + assert!(converted.transaction.metering.is_none()); assert!(!converted.transaction.raw.is_empty(), "the envelope must be encoded"); } diff --git a/crates/execution/tx-forwarding/src/service.rs b/crates/execution/tx-forwarding/src/service.rs index e5180f067a..f250f5dc62 100644 --- a/crates/execution/tx-forwarding/src/service.rs +++ b/crates/execution/tx-forwarding/src/service.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use alloy_eips::Encodable2718; -use base_execution_txpool::{NoExtensions, ValidatedTransactionExtensions}; +use base_execution_txpool::{BasePooledTx, NoExtensions, ValidatedTransactionExtensions}; use futures::{StreamExt, future::join_all, stream::FuturesUnordered}; use jsonrpsee::http_client::HttpClientBuilder; use reth_tasks::TaskExecutor; @@ -52,7 +52,7 @@ impl TxForwardingService { pub fn spawn

(self, pool: P, executor: &TaskExecutor) -> TxForwardingHandle where P: TransactionPool + Clone + Send + 'static, - P::Transaction: PoolTransaction, + P::Transaction: PoolTransaction + BasePooledTx, ::Consensus: Encodable2718, { self.spawn_with_extensions::(pool, executor) @@ -62,7 +62,7 @@ impl TxForwardingService { pub fn spawn_with_extensions(self, pool: P, executor: &TaskExecutor) -> TxForwardingHandle where P: TransactionPool + Clone + Send + 'static, - P::Transaction: PoolTransaction, + P::Transaction: PoolTransaction + BasePooledTx, ::Consensus: Encodable2718, E: ValidatedTransactionExtensions, { @@ -292,6 +292,7 @@ mod tests { transaction: ValidatedTransaction { sender: Address::repeat_byte(byte), raw: Bytes::from(vec![byte]), + metering: None, extensions: Default::default(), }, tx_hash: B256::repeat_byte(byte), diff --git a/crates/execution/txpool/src/builder/mod.rs b/crates/execution/txpool/src/builder/mod.rs index 9f0c5c0dec..13d892a23d 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}; +pub use rpc::{BuilderApiImpl, BuilderApiServer, InsertMetering}; 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 931a26052a..493569e1ef 100644 --- a/crates/execution/txpool/src/builder/rpc.rs +++ b/crates/execution/txpool/src/builder/rpc.rs @@ -1,8 +1,9 @@ -use std::{marker::PhantomData, time::Instant}; +use std::{marker::PhantomData, sync::Arc, time::Instant}; use alloy_consensus::transaction::Recovered; use alloy_eips::Decodable2718; use alloy_primitives::TxHash; +use base_bundles::MeterBundleResponse; use base_common_consensus::BaseTransactionSigned; use base_observability_events::{ TransactionEventProducer, TransactionEventType, transaction_event, @@ -22,6 +23,12 @@ use crate::{ ValidatedTransactionExtensions, }; +/// Writes inbound `insertValidatedTransaction` metering into the builder cache. +pub trait InsertMetering: core::fmt::Debug + Send + Sync + 'static { + /// Stores metering for `tx_hash`. + fn insert_metering(&self, tx_hash: TxHash, metering: MeterBundleResponse); +} + /// RPC interface for submitting pre-validated transactions to a block builder. /// /// `E` is the wire extension payload; see [`ValidatedTransactionExtensions`]. @@ -48,6 +55,7 @@ pub struct BuilderApiImpl { pool: P, accept_extensions: bool, max_extension_items: usize, + metering_cache: Option>, _extensions: PhantomData, } @@ -60,7 +68,13 @@ impl

BuilderApiImpl { /// call site (`E0282`), because type-parameter defaults do not participate /// in inference for associated-function calls. pub const fn new(pool: P) -> Self { - Self { pool, accept_extensions: false, max_extension_items: 0, _extensions: PhantomData } + Self { + pool, + accept_extensions: false, + max_extension_items: 0, + metering_cache: None, + _extensions: PhantomData, + } } } @@ -75,7 +89,20 @@ impl BuilderApiImpl { accept_extensions: bool, max_extension_items: usize, ) -> Self { - Self { pool, accept_extensions, max_extension_items, _extensions: PhantomData } + Self { + pool, + accept_extensions, + max_extension_items, + metering_cache: None, + _extensions: PhantomData, + } + } + + /// Writes inbound metering into the builder cache after the pool accepts the tx. + #[must_use] + pub fn with_metering_cache(mut self, cache: Arc) -> Self { + self.metering_cache = Some(cache); + self } } @@ -121,8 +148,11 @@ where let encoded_len = tx.raw.len(); let recovered = Recovered::new_unchecked(consensus_tx, sender); - let pool_tx = BasePooledTransaction::new(recovered, encoded_len); - + let mut pool_tx = BasePooledTransaction::new(recovered, encoded_len); + let metering = tx.metering; + if let Some(ref metering) = metering { + pool_tx = pool_tx.with_metering(metering.clone()); + } // Attach any extension data carried on the wire. This is a no-op for // `NoExtensions`, the default payload. let pool_tx = tx.extensions.apply(pool_tx).map_err(|e| { @@ -142,6 +172,11 @@ where match result { Ok(_) => { + if let Some(metering) = metering + && let Some(cache) = &self.metering_cache + { + cache.insert_metering(tx_hash, metering); + } self.emit_validated_insert_event( TransactionEventType::TxpoolValidatedInsertAccepted, tx_hash, @@ -197,9 +232,12 @@ impl BuilderApiImpl { #[cfg(test)] mod tests { + use std::sync::{Arc, Mutex}; + use alloy_consensus::TxEip1559; use alloy_eips::eip2718::Encodable2718; - use alloy_primitives::{Address, Bytes, Signature, TxKind, U256}; + use alloy_primitives::{Address, Bytes, Signature, TxHash, TxKind, U256}; + use base_bundles::MeterBundleResponse; use base_common_consensus::{BaseTransactionSigned, BaseTypedTransaction, TxDeposit}; use reth_transaction_pool::noop::NoopTransactionPool; @@ -257,7 +295,7 @@ mod tests { raw: Bytes, extensions: E, ) -> ValidatedTransaction { - ValidatedTransaction { sender, raw, extensions } + ValidatedTransaction { sender, raw, metering: None, extensions } } // ========================================================================== @@ -364,6 +402,54 @@ mod tests { assert_eq!(err.code(), ErrorCode::InternalError.code()); } + #[tokio::test] + async fn metering_does_not_require_extension_opt_in() { + let handler = handler(); + let (sender, raw) = create_eip1559_tx(); + let mut tx = validated_transaction(sender, raw, NoExtensions {}); + tx.metering = Some(MeterBundleResponse::default()); + + let err = handler.insert_validated_transaction(tx).await.unwrap_err(); + + assert_eq!( + err.code(), + ErrorCode::InternalError.code(), + "metering must not be gated on experimental validity extensions" + ); + } + + #[derive(Debug, Default)] + struct RecordingMetering { + inserted: Mutex>, + } + + impl InsertMetering for RecordingMetering { + fn insert_metering(&self, tx_hash: TxHash, metering: MeterBundleResponse) { + self.inserted.lock().expect("recording lock").push((tx_hash, metering)); + } + } + + #[tokio::test] + async fn rejected_insert_does_not_write_metering_to_the_builder_cache() { + let cache = Arc::new(RecordingMetering::default()); + let handler = handler().with_metering_cache(Arc::clone(&cache) as Arc); + let (sender, raw) = create_eip1559_tx(); + let mut tx = validated_transaction(sender, raw, NoExtensions {}); + tx.metering = Some(MeterBundleResponse { + total_gas_used: 21_000, + total_execution_time_us: 500, + ..MeterBundleResponse::default() + }); + + let err = handler.insert_validated_transaction(tx).await.unwrap_err(); + + assert_eq!(err.code(), ErrorCode::InternalError.code()); + assert!( + cache.inserted.lock().expect("recording lock").is_empty(), + "rejected pool inserts must not pollute the builder metering cache" + ); + } + #[test] fn both_monomorphizations_build_rpc_modules() { // The stock call-site shape must keep working untouched... diff --git a/crates/execution/txpool/src/lib.rs b/crates/execution/txpool/src/lib.rs index 70ab2b9298..10ad08a804 100644 --- a/crates/execution/txpool/src/lib.rs +++ b/crates/execution/txpool/src/lib.rs @@ -66,7 +66,7 @@ mod pool_error_label; pub use pool_error_label::PoolRejectionLabel; mod builder; -pub use builder::{BuilderApiImpl, BuilderApiMetrics, BuilderApiServer}; +pub use builder::{BuilderApiImpl, BuilderApiMetrics, BuilderApiServer, InsertMetering}; mod wire; pub use wire::{ diff --git a/crates/execution/txpool/src/transaction.rs b/crates/execution/txpool/src/transaction.rs index 1d4bee6762..1c4873e8af 100644 --- a/crates/execution/txpool/src/transaction.rs +++ b/crates/execution/txpool/src/transaction.rs @@ -443,6 +443,11 @@ pub trait BasePooledTx: PoolTransaction + DataAvailabilitySized { /// Defaults to a no-op for transaction types that do not carry a manifest. fn set_watch_manifest(&self, _watch_manifest: crate::WatchManifest) {} + /// Returns the attached `meter_bundle` result, if any. + fn metering(&self) -> Option<&MeterBundleResponse> { + None + } + /// Returns whether this transaction belongs in the EIP-8130 sidecar. fn is_eip8130_sidecar_transaction(&self) -> bool { self.eip8130_nonce_channel_key().is_some() || self.eip8130_replay_id().is_some() @@ -510,6 +515,10 @@ where fn set_limit_class(&self, limit_class: crate::LimitClass) { let _ = self.limit_class.set(limit_class); } + + fn metering(&self) -> Option<&MeterBundleResponse> { + self.metering.as_deref() + } } /// Trait for transactions that expose their received-at timestamp. diff --git a/crates/execution/txpool/src/wire.rs b/crates/execution/txpool/src/wire.rs index d57328a1e1..af25761476 100644 --- a/crates/execution/txpool/src/wire.rs +++ b/crates/execution/txpool/src/wire.rs @@ -1,6 +1,7 @@ use core::fmt::Debug; use alloy_primitives::{Address, Bytes}; +use base_bundles::MeterBundleResponse; use reth_transaction_pool::{PoolTransaction, ValidPoolTransaction}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -92,6 +93,13 @@ pub struct ValidatedTransaction { pub sender: Address, /// EIP-2718 encoded transaction bytes. pub raw: Bytes, + /// In-process `meter_bundle` result. Nested so `u128` timing fields do not go + /// through `#[serde(flatten)]`, which `serde_json` cannot represent. + /// + /// Omitted when `None`, so existing payloads stay byte-compatible until + /// inline simulation starts sending it. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub metering: Option, /// Extension fields, inlined into the top-level JSON object. /// /// Deliberately not `#[serde(default)]`: that would force an `E: Default` @@ -104,6 +112,7 @@ pub struct ValidatedTransaction { #[cfg(test)] mod tests { use alloy_primitives::U256; + use base_bundles::MeterBundleResponse; use super::*; use crate::{TransactionValidity, ValidityOperator, ValidityPredicate}; @@ -133,9 +142,12 @@ mod tests { #[test] fn no_extensions_encoding_matches_legacy_layout() { let legacy = LegacyValidatedTransaction { sender: sender(), raw: raw() }; - let current = - ValidatedTransaction { sender: sender(), raw: raw(), extensions: NoExtensions {} }; - + let current = ValidatedTransaction { + sender: sender(), + raw: raw(), + metering: None, + extensions: NoExtensions {}, + }; assert_eq!( serde_json::to_string(&legacy).unwrap(), serde_json::to_string(¤t).unwrap(), @@ -149,6 +161,7 @@ mod tests { let current = ValidatedTransaction { sender: sender(), raw: raw(), + metering: None, extensions: TransactionValidity::default(), }; @@ -167,6 +180,7 @@ mod tests { let tx = ValidatedTransaction { sender: sender(), raw: raw(), + metering: None, extensions: TransactionValidity { validity: vec![predicate.clone()] }, }; @@ -178,12 +192,55 @@ mod tests { let decoded: ValidatedTransaction = serde_json::from_value(value).unwrap(); assert_eq!(decoded.extensions.validity, vec![predicate]); + assert!(decoded.metering.is_none()); } #[test] - fn no_extensions_adds_no_json_fields() { - let tx = ValidatedTransaction { sender: sender(), raw: raw(), extensions: NoExtensions {} }; + fn metering_is_nested_and_round_trips() { + let metering = MeterBundleResponse { + total_gas_used: 21_000, + total_execution_time_us: 500, + ..MeterBundleResponse::default() + }; + let tx = ValidatedTransaction { + sender: sender(), + raw: raw(), + metering: Some(metering.clone()), + extensions: NoExtensions {}, + }; + let value = serde_json::to_value(&tx).unwrap(); + assert!(value.get("extensions").is_none()); + assert_eq!(value["metering"]["totalExecutionTimeUs"], 500); + + let decoded: ValidatedTransaction = serde_json::from_value(value).unwrap(); + assert_eq!(decoded.metering, Some(metering)); + } + + #[test] + fn default_reader_keeps_metering_when_present() { + let extended = ValidatedTransaction { + sender: sender(), + raw: raw(), + metering: Some(MeterBundleResponse::default()), + extensions: NoExtensions {}, + }; + let json = serde_json::to_string(&extended).unwrap(); + + let decoded: ValidatedTransaction = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.sender, sender()); + assert!(decoded.metering.is_some()); + assert_eq!(decoded.extensions, NoExtensions {}); + } + + #[test] + fn no_extensions_adds_no_json_fields() { + let tx = ValidatedTransaction { + sender: sender(), + raw: raw(), + metering: None, + extensions: NoExtensions {}, + }; let json = serde_json::to_string(&tx).unwrap(); assert!(!json.contains("extensions"), "flattened marker must not emit a key: {json}"); assert_eq!( @@ -197,6 +254,7 @@ mod tests { let tx = ValidatedTransaction { sender: sender(), raw: raw(), + metering: None, extensions: TestExtensions { extra: Some(9) }, }; @@ -209,6 +267,7 @@ mod tests { let extended = ValidatedTransaction { sender: sender(), raw: raw(), + metering: None, extensions: TestExtensions { extra: Some(9) }, }; let json = serde_json::to_string(&extended).unwrap(); @@ -233,6 +292,7 @@ mod tests { let tx = ValidatedTransaction { sender: sender(), raw: raw(), + metering: None, extensions: TestExtensions { extra: Some(u64::MAX) }, }; let json = serde_json::to_string(&tx).unwrap(); diff --git a/etc/systems/tests/tx_forwarding.rs b/etc/systems/tests/tx_forwarding.rs index 90cd623a2e..e28cccf45a 100644 --- a/etc/systems/tests/tx_forwarding.rs +++ b/etc/systems/tests/tx_forwarding.rs @@ -190,8 +190,8 @@ async fn test_insert_validated_transaction_single() -> Result<()> { create_signed_eip1559_tx(&signer, L2_CHAIN_ID, nonce, recipient)?; // Create the ValidatedTransaction payload - let validated_tx = ValidatedTransaction { sender, raw: raw_tx, extensions: NoExtensions {} }; - + let validated_tx = + ValidatedTransaction { sender, raw: raw_tx, metering: None, extensions: NoExtensions {} }; // Create RPC client for the builder let builder_rpc_url = system.l2_rpc_url()?; let rpc_client = RpcClient::builder().http(builder_rpc_url); From f94c83976ee0246238451493c4748cb81c1b891d Mon Sep 17 00:00:00 2001 From: xenoliss Date: Fri, 18 Sep 2026 20:34:44 +0000 Subject: [PATCH 060/299] fix(batcher): keep tracking in-flight submissions across pipeline resets (#5153) * fix(batcher): keep tracking in-flight submissions across pipeline resets * docs(batcher): state the stale-id rule once, on BatchPipeline::reset --- crates/batcher/core/README.md | 4 +- crates/batcher/core/src/driver.rs | 19 +++-- crates/batcher/core/src/submissions.rs | 13 ---- crates/batcher/core/src/test_utils/mod.rs | 5 +- .../batcher/core/src/test_utils/pipeline.rs | 4 +- .../batcher/core/src/test_utils/tx_manager.rs | 39 ++++++++++ crates/batcher/core/tests/pause_resume.rs | 6 +- crates/batcher/core/tests/reorg.rs | 71 +++++++++++++++++-- crates/batcher/encoder/src/encoder.rs | 4 +- crates/batcher/encoder/src/pipeline.rs | 5 +- 10 files changed, 128 insertions(+), 42 deletions(-) diff --git a/crates/batcher/core/README.md b/crates/batcher/core/README.md index 191fd08332..6580a3a2d2 100644 --- a/crates/batcher/core/README.md +++ b/crates/batcher/core/README.md @@ -23,8 +23,8 @@ blobs or calldata depending on the `DaType`, and hands the resulting `TxCandidat TxOutcome)` pair when the transaction settles. Confirmed receipts call `pipeline.confirm` and `pipeline.advance_l1_head`. Failed submissions are requeued. A `TxpoolBlocked` outcome sets a sticky flag that prevents further submissions until `recover_txpool` successfully cancels the -stuck transaction. On reorg, `SubmissionQueue::discard` drops all in-flight futures and releases -their permits so the freshly reset pipeline is not corrupted by stale completions. +stuck transaction. In-flight futures survive a pipeline reset and keep their permits until they +settle; the reset pipeline ignores the stale ids they report. `TxOutcome` represents the three terminal states of an L1 submission: `Confirmed { l1_block }`, `Failed`, and `TxpoolBlocked`. During normal operation, failed frames are requeued for retry; diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index e776184c4b..5aad3bea2a 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -206,8 +206,7 @@ where /// [`AdminCommand::Resume`] is received via the admin API. /// /// Equivalent to the batcher starting normally and immediately receiving - /// a pause command, but without discarding any in-flight submissions. - /// Use this when the `--stopped` flag is set at startup. + /// a pause command. Use this when the `--stopped` flag is set at startup. pub const fn with_stopped(mut self, stopped: bool) -> Self { self.stopped = stopped; self @@ -354,7 +353,6 @@ where /// Drop buffered pipeline state, recording why it was dropped. fn reset_pipeline(&mut self, reason: &'static str) { BatcherMetrics::pipeline_reset_total(reason).increment(1); - self.submissions.discard(); self.pipeline.reset(); self.discard_pending_flush_acks(); } @@ -435,9 +433,8 @@ where /// Ingest a new L2 block into the pipeline. /// /// If the pipeline signals a reorg via `add_block` (parent-hash mismatch), - /// discards in-flight submissions, resets the pipeline, and restarts - /// sequential catchup from `safe_head + 1`. The triggering block will be - /// re-delivered by the sequential poller. + /// resets the pipeline and restarts sequential catchup from `safe_head + 1`. + /// The triggering block will be re-delivered by the sequential poller. fn on_block(&mut self, block: Box) { let number = block.header.number; if self.safe_head.is_some_and(|safe_head| number <= safe_head.number) { @@ -477,11 +474,11 @@ where /// Derivation-status changes are also handled before unsafe blocks so pruning and /// recovery cannot be starved by sequential catchup. /// - /// [`AdminCommand::Pause`] immediately discards in-flight submissions and - /// resets the pipeline, then drops `Block` and `Flush` source events until - /// [`AdminCommand::Resume`] is received. Reorg events propagate regardless - /// of pause state. On resume the source is reset to catch up sequentially - /// from the last known safe L2 head. + /// [`AdminCommand::Pause`] immediately resets the pipeline, then drops + /// `Block` and `Flush` source events until [`AdminCommand::Resume`] is + /// received. Reorg events propagate regardless of pause state. On resume + /// the source is reset to catch up sequentially from the last known safe + /// L2 head. /// /// Non-fatal L1 head source errors loop internally to avoid polluting the /// return type with a no-op variant. diff --git a/crates/batcher/core/src/submissions.rs b/crates/batcher/core/src/submissions.rs index 6cad28fe11..97046673cc 100644 --- a/crates/batcher/core/src/submissions.rs +++ b/crates/batcher/core/src/submissions.rs @@ -325,19 +325,6 @@ impl SubmissionQueue { } } - /// Discard all in-flight futures, returning their semaphore permits. - /// - /// Used before resetting the pipeline so stale completions cannot modify - /// freshly rebuilt state. - pub fn discard(&mut self) { - let discarded = self.in_flight.len(); - if discarded > 0 { - warn!(discarded = %discarded, "discarding in-flight submissions before pipeline reset"); - BatcherMetrics::in_flight_submissions().set(0.0); - } - self.in_flight = FuturesUnordered::new(); - } - /// Returns a future for the next settled `(ids, outcome)` pair. /// /// Resolves immediately to `None` when in-flight is empty; safe to use as diff --git a/crates/batcher/core/src/test_utils/mod.rs b/crates/batcher/core/src/test_utils/mod.rs index 477148015f..9ac5ffd348 100644 --- a/crates/batcher/core/src/test_utils/mod.rs +++ b/crates/batcher/core/src/test_utils/mod.rs @@ -19,4 +19,7 @@ pub use builder::{DriverFixture, SubmissionStub}; #[cfg(any(test, feature = "test-utils"))] mod tx_manager; #[cfg(any(test, feature = "test-utils"))] -pub use tx_manager::{ImmediateConfirmTxManager, ImmediateFailTxManager, NeverConfirmTxManager}; +pub use tx_manager::{ + ImmediateConfirmTxManager, ImmediateFailTxManager, ManualConfirmTxManager, + NeverConfirmTxManager, +}; diff --git a/crates/batcher/core/src/test_utils/pipeline.rs b/crates/batcher/core/src/test_utils/pipeline.rs index de9c089e54..184261c679 100644 --- a/crates/batcher/core/src/test_utils/pipeline.rs +++ b/crates/batcher/core/src/test_utils/pipeline.rs @@ -149,8 +149,8 @@ impl BatchPipeline for TrackingPipeline { /// [`BatchPipeline`] that always returns [`ReorgError`] from `add_block`. /// -/// Used to verify the driver's reorg-on-add path: it must reset the pipeline and -/// discard in-flight submissions rather than propagating a fatal error. +/// Used to verify the driver's reorg-on-add path: it must reset the pipeline +/// rather than propagating a fatal error. #[derive(Debug)] pub struct ReorgPipeline { /// Shared recording state (only `resets` is incremented). diff --git a/crates/batcher/core/src/test_utils/tx_manager.rs b/crates/batcher/core/src/test_utils/tx_manager.rs index 29b829d6e0..7ddcca2a7a 100644 --- a/crates/batcher/core/src/test_utils/tx_manager.rs +++ b/crates/batcher/core/src/test_utils/tx_manager.rs @@ -1,5 +1,10 @@ //! Test [`TxManager`] implementations for controlling submission outcomes in driver tests. +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; + use alloy_consensus::{Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom}; use alloy_primitives::{Address, B256, Bloom}; use alloy_rpc_types_eth::TransactionReceipt; @@ -98,3 +103,37 @@ impl TxManager for NeverConfirmTxManager { Address::ZERO } } + +/// [`TxManager`] whose submissions stay in flight until the test confirms them. +/// +/// Hand-rolled because tests settle submissions while they are in flight, which `mockall` +/// expectations cannot express. Clones share the same in-flight queue: keep one in the test and +/// hand the other to the driver. +#[derive(Debug, Clone, Default)] +pub struct ManualConfirmTxManager { + in_flight: Arc>>>, +} + +impl ManualConfirmTxManager { + /// Confirm the oldest in-flight submission at `l1_block`. + pub fn confirm_next(&self, l1_block: u64) { + let tx = self.in_flight.lock().unwrap().pop_front().expect("a submission is in flight"); + let _ = tx.send(Ok(stub_receipt(l1_block))); + } +} + +impl TxManager for ManualConfirmTxManager { + async fn send(&self, _: TxCandidate) -> SendResponse { + unreachable!() + } + + fn send_async(&self, _: TxCandidate) -> impl std::future::Future + Send { + let (tx, rx) = oneshot::channel(); + self.in_flight.lock().unwrap().push_back(tx); + std::future::ready(SendHandle::new(rx)) + } + + fn sender_address(&self) -> Address { + Address::ZERO + } +} diff --git a/crates/batcher/core/tests/pause_resume.rs b/crates/batcher/core/tests/pause_resume.rs index 38bd1c7527..b823877bf5 100644 --- a/crates/batcher/core/tests/pause_resume.rs +++ b/crates/batcher/core/tests/pause_resume.rs @@ -27,9 +27,9 @@ use base_runtime::{ }; use tokio::sync::mpsc; -/// `AdminCommand::Pause` must immediately reset the pipeline and discard -/// in-flight submissions. This is verified by checking that `pipeline.reset()` -/// is called exactly once after the pause command is processed. +/// `AdminCommand::Pause` must immediately reset the pipeline. This is verified +/// by checking that `pipeline.reset()` is called exactly once after the pause +/// command is processed. #[test] fn test_pause_resets_pipeline() { Runner::start(Config::seeded(0), |ctx| async move { diff --git a/crates/batcher/core/tests/reorg.rs b/crates/batcher/core/tests/reorg.rs index 3f76eb0ec7..b860393b03 100644 --- a/crates/batcher/core/tests/reorg.rs +++ b/crates/batcher/core/tests/reorg.rs @@ -7,10 +7,11 @@ use std::{ use alloy_primitives::Address; use base_batcher_core::{ - BatchDriver, BatchDriverConfig, DaThrottle, NoopThrottleClient, ThrottleController, + AdminHandle, BatchDriver, BatchDriverConfig, DaThrottle, NoopThrottleClient, + ThrottleController, test_utils::{ - ImmediateConfirmTxManager, OneBlockSource, PendingL1HeadSource, Recorded, ReorgPipeline, - TrackingPipeline, + ImmediateConfirmTxManager, ManualConfirmTxManager, OneBlockSource, PendingL1HeadSource, + Recorded, ReorgPipeline, SubmissionStub, TrackingPipeline, }, }; use base_batcher_source::{ChannelBlockSource, L2BlockEvent}; @@ -20,8 +21,8 @@ use base_runtime::{ }; /// When `add_block` returns a `ReorgError`, the driver must reset the pipeline -/// and discard in-flight futures instead of propagating a fatal error. This -/// mirrors the `L2BlockEvent::Reorg` handling path. +/// instead of propagating a fatal error. This mirrors the `L2BlockEvent::Reorg` +/// handling path. #[test] fn test_add_block_reorg_resets_pipeline_instead_of_fatal_error() { Runner::start(Config::seeded(0), |ctx| async move { @@ -58,8 +59,8 @@ fn test_add_block_reorg_resets_pipeline_instead_of_fatal_error() { } /// When the source delivers `L2BlockEvent::Reorg`, the driver must reset the -/// pipeline and discard in-flight submissions. This is distinct from the -/// `add_block`-triggered reorg path tested above. +/// pipeline. This is distinct from the `add_block`-triggered reorg path tested +/// above. #[test] fn test_l2_reorg_event_resets_pipeline() { Runner::start(Config::seeded(0), |ctx| async move { @@ -95,3 +96,59 @@ fn test_l2_reorg_event_resets_pipeline() { ); }); } + +/// A submission in flight when the pipeline resets must stay tracked and settle normally +/// once its receipt arrives. +#[test] +fn test_reorg_keeps_tracking_in_flight_submissions() { + Runner::start(Config::seeded(0), |ctx| async move { + let recorded = Arc::new(Mutex::new(Recorded::default())); + let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + pipeline.submissions.push_back(SubmissionStub::stub()); + let tx_manager = ManualConfirmTxManager::default(); + let (source, source_tx) = ChannelBlockSource::new(); + let (admin_handle, admin_rx) = AdminHandle::channel(); + + let driver = BatchDriver::new_without_derivation_status( + ctx.clone(), + pipeline, + source, + tx_manager.clone(), + BatchDriverConfig { + inbox: Address::ZERO, + max_pending_transactions: 1, + drain_timeout: Duration::from_millis(10), + force_blobs_when_throttling: true, + }, + DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + PendingL1HeadSource, + ) + .with_admin_rx(admin_rx); + let handle = ctx.spawn(driver.run()); + + // Let the driver submit the stub, then reorg while it is in flight. + ctx.sleep(Duration::from_millis(10)).await; + source_tx.send(L2BlockEvent::Reorg).unwrap(); + ctx.sleep(Duration::from_millis(10)).await; + + assert_eq!(recorded.lock().unwrap().resets, 1); + assert_eq!( + admin_handle.get_status().await.unwrap().in_flight, + 1, + "the reset must not drop the in-flight submission" + ); + + tx_manager.confirm_next(7); + ctx.sleep(Duration::from_millis(10)).await; + + assert_eq!( + admin_handle.get_status().await.unwrap().in_flight, + 0, + "the receipt must settle the submission after the reset" + ); + assert_eq!(recorded.lock().unwrap().l1_heads, vec![7]); + + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + }); +} diff --git a/crates/batcher/encoder/src/encoder.rs b/crates/batcher/encoder/src/encoder.rs index 49e5628db4..9b09f94822 100644 --- a/crates/batcher/encoder/src/encoder.rs +++ b/crates/batcher/encoder/src/encoder.rs @@ -1078,7 +1078,7 @@ mod tests { let sub = encoder.next_submission().unwrap(); let stale_id = sub.id; // ID 0, now in-flight - // Simulate a reorg: driver calls reset() after clearing in_flight. + // Simulate a reorg: driver calls reset() while the submission is still in flight. encoder.reset(); assert_eq!(encoder.egress.pending_submission_count(), 0); // next_id is preserved across reset so post-reset IDs can never collide @@ -1131,7 +1131,7 @@ mod tests { let pre_reorg_sub = encoder.next_submission().unwrap(); assert_eq!(pre_reorg_sub.id.0, 0); - // Reorg: driver discards the future for pre_reorg_sub.id, then resets. + // Reorg: driver resets while pre_reorg_sub.id is still in flight. encoder.reset(); // Post-reorg: next_id must NOT have been reset to 0. diff --git a/crates/batcher/encoder/src/pipeline.rs b/crates/batcher/encoder/src/pipeline.rs index dcde30e0a0..d62e91c50c 100644 --- a/crates/batcher/encoder/src/pipeline.rs +++ b/crates/batcher/encoder/src/pipeline.rs @@ -98,7 +98,10 @@ pub trait BatchPipeline: Send { /// Does not advance the tracked L1 head. fn flush(&mut self) -> Result<(), StepError>; - /// Drop buffered encoding state. Discard in-flight tracking first. + /// Drop buffered encoding state. + /// + /// Ids issued before the reset are never reused; a later [`confirm`](Self::confirm) or + /// [`requeue`](Self::requeue) of one is ignored. fn reset(&mut self); /// Prune blocks at or below `safe_l2`. From ca69234452354853813cbf03238c559085080a18 Mon Sep 17 00:00:00 2001 From: refcell Date: Fri, 18 Sep 2026 21:44:29 +0000 Subject: [PATCH 061/299] fix(baseup): reject unsupported architectures (#5000) Co-authored-by: Claude --- baseup/baseup | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/baseup/baseup b/baseup/baseup index fb34a3f04c..6d4cb3cab9 100755 --- a/baseup/baseup +++ b/baseup/baseup @@ -663,8 +663,7 @@ detect_arch() { echo "arm64" ;; *) - warn "Unknown architecture: $arch, defaulting to amd64" - echo "amd64" + error "Unsupported architecture: $arch" ;; esac } From c8701553d151e3baf6bbe15712c8b95f2bfe7bbe Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Fri, 18 Sep 2026 22:42:43 +0000 Subject: [PATCH 062/299] feat(consensus): add --sequencer.isolated flag and config field (#5141) --- crates/consensus/cli/src/sequencer.rs | 59 ++++++++++++++++++- .../service/src/actors/sequencer/config.rs | 26 ++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/consensus/cli/src/sequencer.rs b/crates/consensus/cli/src/sequencer.rs index 573e8ab9eb..8554a622df 100644 --- a/crates/consensus/cli/src/sequencer.rs +++ b/crates/consensus/cli/src/sequencer.rs @@ -51,6 +51,15 @@ pub struct SequencerArgs { )] pub recover: bool, + /// Run the sequencer without canonical-chain ingress or payload publication. + #[arg( + long = "sequencer.isolated", + default_value = "false", + env = "BASE_NODE_SEQUENCER_ISOLATED", + conflicts_with_all = ["shadow_blocks_per_cycle", "conductor_rpc", "recover"] + )] + pub isolated: bool, + /// Number of private blocks to build before reconciling to canonical P2P payloads. /// /// Providing this value enables shadow sequencer mode. @@ -117,6 +126,7 @@ impl SequencerArgs { SequencerConfig { sequencer_stopped: self.stopped, sequencer_recovery_mode: self.recover, + isolated: self.isolated, shadow_blocks_per_cycle: self.shadow_blocks_per_cycle, shadow_funding: self.shadow_funding_address.map(|address| { ShadowFunding::new( @@ -144,7 +154,7 @@ mod tests { utils::{Unit, parse_ether}, }; use base_consensus_node::ShadowFunding; - use clap::Parser; + use clap::{Parser, error::ErrorKind}; use super::{SequencerArgs, SequencerConfig}; use crate::L1ClientArgs; @@ -204,6 +214,53 @@ mod tests { assert_eq!(args.config().shadow_blocks_per_cycle, NonZeroU64::new(12)); } + #[test] + fn parses_isolated_flag() { + let args = SequencerArgs::try_parse_from(["base-consensus", "--sequencer.isolated"]) + .expect("isolated flag should parse"); + + assert!(args.isolated); + assert!(args.config().isolated); + } + + #[test] + fn rejects_isolated_with_shadow_mode() { + let error = SequencerArgs::try_parse_from([ + "base-consensus", + "--sequencer.isolated", + "--sequencer.shadow-blocks-per-cycle", + "12", + ]) + .unwrap_err(); + + assert_eq!(error.kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn rejects_isolated_with_conductor_rpc() { + let error = SequencerArgs::try_parse_from([ + "base-consensus", + "--sequencer.isolated", + "--conductor.rpc", + "http://localhost:9090", + ]) + .unwrap_err(); + + assert_eq!(error.kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn rejects_isolated_with_recover() { + let error = SequencerArgs::try_parse_from([ + "base-consensus", + "--sequencer.isolated", + "--sequencer.recover", + ]) + .unwrap_err(); + + assert_eq!(error.kind(), ErrorKind::ArgumentConflict); + } + #[test] fn defaults_shadow_funding_to_ten_thousand_eth() { let funding_address = address!("1111111111111111111111111111111111111111"); diff --git a/crates/consensus/service/src/actors/sequencer/config.rs b/crates/consensus/service/src/actors/sequencer/config.rs index 2353f53154..42adf7eec3 100644 --- a/crates/consensus/service/src/actors/sequencer/config.rs +++ b/crates/consensus/service/src/actors/sequencer/config.rs @@ -20,6 +20,8 @@ pub struct SequencerConfig { pub sequencer_stopped: bool, /// Whether or not the sequencer is in recovery mode. pub sequencer_recovery_mode: bool, + /// Whether the sequencer runs without canonical-chain ingress or payload publication. + pub isolated: bool, /// Number of private blocks to build per cycle when running as a shadow sequencer. /// /// When [`None`], the node runs as a normal sequencer. @@ -60,6 +62,16 @@ impl SequencerConfig { pub const fn is_shadow_sequencer(&self) -> bool { self.shadow_blocks_per_cycle.is_some() } + + /// Returns whether the consensus network actor should be constructed. + pub const fn network_enabled(&self) -> bool { + !self.isolated + } + + /// Returns whether a derivation actor should be constructed. + pub const fn derivation_enabled(&self) -> bool { + !self.isolated + } } impl Default for SequencerConfig { @@ -67,6 +79,7 @@ impl Default for SequencerConfig { Self { sequencer_stopped: false, sequencer_recovery_mode: false, + isolated: false, shadow_blocks_per_cycle: None, shadow_funding: None, conductor_rpc_url: None, @@ -78,3 +91,16 @@ impl Default for SequencerConfig { } } } + +#[cfg(test)] +mod tests { + use super::SequencerConfig; + + #[test] + fn isolated_disables_network_and_derivation_actor_construction() { + let config = SequencerConfig { isolated: true, ..Default::default() }; + + assert!(!config.network_enabled()); + assert!(!config.derivation_enabled()); + } +} From f0868234031732c3b85cc9871181464efc40b0f0 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Fri, 18 Sep 2026 23:05:25 +0000 Subject: [PATCH 063/299] feat(payload): add hybrid predicate index (#5097) --- crates/builder/cli/src/args.rs | 24 + crates/builder/core/benches/tx_selection.rs | 182 ++++- crates/builder/core/src/config.rs | 9 +- .../builder/core/src/flashblocks/context.rs | 29 +- .../builder/multiplex/src/service_builder.rs | 3 + crates/execution/node/src/node.rs | 15 +- crates/execution/payload/src/builder.rs | 18 +- crates/execution/payload/src/config.rs | 10 +- crates/execution/payload/src/lib.rs | 3 +- crates/execution/payload/src/metrics.rs | 12 +- .../execution/payload/src/predicate_loads.rs | 2 +- crates/execution/payload/src/validity.rs | 744 ++++++++++++------ 12 files changed, 761 insertions(+), 290 deletions(-) diff --git a/crates/builder/cli/src/args.rs b/crates/builder/cli/src/args.rs index 038370fbaf..90dc9b841e 100644 --- a/crates/builder/cli/src/args.rs +++ b/crates/builder/cli/src/args.rs @@ -236,6 +236,14 @@ pub struct Args { #[arg(long = "builder.predicate-eval-hard-cutoff-ms", default_value = "10")] pub predicate_eval_hard_cutoff_ms: u64, + /// Parked predicate bucket depth at which state wakeups become threshold-aware. + #[arg( + long = "builder.predicate-bucket-ordered-threshold", + default_value = "32", + value_parser = clap::builder::RangedU64ValueParser::::new().range(1..) + )] + pub predicate_bucket_ordered_threshold: usize, + /// URL of the audit-archiver RPC endpoint for forwarding rejected transactions #[arg(long = "builder.audit-archiver-url", env = "BUILDER_AUDIT_ARCHIVER_URL")] pub audit_archiver_url: Option, @@ -346,6 +354,7 @@ impl Default for Args { max_uncompressed_block_size: None, metering_wait_duration_ms: None, predicate_eval_hard_cutoff_ms: 10, + predicate_bucket_ordered_threshold: 32, audit_archiver_url: None, rejected_tx_channel_size: 500, max_rejected_txs_per_block: 500, @@ -421,6 +430,7 @@ impl Args { max_uncompressed_block_size: self.max_uncompressed_block_size, metering_wait_duration: self.metering_wait_duration_ms.map(Duration::from_millis), predicate_eval_hard_cutoff: Duration::from_millis(self.predicate_eval_hard_cutoff_ms), + predicate_bucket_ordered_threshold: self.predicate_bucket_ordered_threshold, metering_provider, rejection_cache: RejectionCache::new( self.rejection_cache_max_capacity, @@ -699,6 +709,20 @@ mod tests { assert_eq!(config.predicate_eval_hard_cutoff, expected); } + #[test] + fn predicate_bucket_threshold_is_nonzero_and_propagated() { + let config = convert(Args { predicate_bucket_ordered_threshold: 64, ..Default::default() }); + assert_eq!(config.predicate_bucket_ordered_threshold, 64); + assert!( + CommandParser::try_parse_from([ + "builder", + "--builder.predicate-bucket-ordered-threshold", + "0", + ]) + .is_err() + ); + } + #[test] fn metering_store_ttl_propagates_to_store() { let args = Args { diff --git a/crates/builder/core/benches/tx_selection.rs b/crates/builder/core/benches/tx_selection.rs index 2b3b9165b7..066b59b4fb 100644 --- a/crates/builder/core/benches/tx_selection.rs +++ b/crates/builder/core/benches/tx_selection.rs @@ -244,10 +244,11 @@ fn run_predicate_selection(pool: &Pool, db: &mut InMemoryDB) -> usize { &context, ) .expect("in-memory reads cannot fail"); - if let Some(blocking_predicate) = blocking_predicate { + if let Some((blocking_predicate_index, _)) = blocking_predicate { let transaction_hash = *transaction.hash(); + let predicate = transaction.validity_predicates()[blocking_predicate_index].clone(); assert!(best.park_current()); - predicate_index.park(transaction_hash, transaction, blocking_predicate); + predicate_index.park(transaction_hash, transaction, predicate); continue; } @@ -311,12 +312,20 @@ fn predicate_index_benches(c: &mut Criterion) { for parked_transactions in [1_000, 10_000, 100_000] { for shared_state in [false, true] { let shared_address = address(TRANSACTION_COUNTS[TRANSACTION_COUNTS.len() - 1]); - let mut index = ParkedPredicateIndex::default(); + let mut index = ParkedPredicateIndex::new(if shared_state { 32 } else { usize::MAX }); for transaction_index in 0..parked_transactions { let transaction_hash: B256 = U256::from(transaction_index + 1).into(); let predicate_address = if shared_state { shared_address } else { address(transaction_index) }; - index.park(transaction_hash, (), ValidityPredicateKey::Balance(predicate_address)); + index.park( + transaction_hash, + (), + ValidityPredicate::Balance { + address: predicate_address, + op: ValidityOperator::GreaterThanOrEqual, + value: U256::MAX, + }, + ); } let changed_address = if shared_state { shared_address } else { address(0) }; @@ -337,5 +346,168 @@ fn predicate_index_benches(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, selection_benches, predicate_benches, predicate_index_benches); +criterion_group!( + benches, + selection_benches, + predicate_benches, + predicate_index_benches, + predicate_index_threshold_benches, + predicate_index_lifecycle_benches, + predicate_index_population_benches, + predicate_index_crossing_benches +); criterion_main!(benches); + +// Kept separate so `cargo bench -p base-builder-core --bench tx_selection -- +// predicate_index_threshold --sample-size 10` completes quickly. +fn predicate_index_threshold_benches(c: &mut Criterion) { + let mut group = c.benchmark_group("tx_selection/predicate_index_threshold"); + group.sample_size(10); + let watched_address = address(999_999); + + for (name, threshold, parked_transactions) in [ + ("flat/shallow", usize::MAX, 16), + ("hybrid/shallow", 32, 16), + ("ordered/shallow", 1, 16), + ("flat/hot_deep", usize::MAX, 2_048), + ("hybrid/hot_deep", 32, 2_048), + ("ordered/hot_deep", 1, 2_048), + ] { + let mut index = ParkedPredicateIndex::new(threshold); + for transaction_index in 0..parked_transactions { + let transaction_hash: B256 = U256::from(transaction_index + 1).into(); + index.park( + transaction_hash, + (), + ValidityPredicate::Balance { + address: watched_address, + op: ValidityOperator::GreaterThanOrEqual, + value: U256::MAX, + }, + ); + } + let mut changed_account = Account::default(); + changed_account.info.balance = U256::ONE; + let changed_state = EvmState::from_iter([(watched_address, changed_account)]); + group.bench_function(name, |b| { + b.iter(|| { + let effects = index.affected_by_state(&changed_state); + let expected = usize::from(parked_transactions < threshold) * parked_transactions; + assert_eq!(effects.affected_transactions.len(), expected); + black_box(effects); + }); + }); + } + group.finish(); +} + +// Kept separate so `cargo bench -p base-builder-core --bench tx_selection -- +// predicate_index_population --sample-size 10` completes quickly. +fn predicate_index_population_benches(c: &mut Criterion) { + let mut group = c.benchmark_group("tx_selection/predicate_index_population"); + group.sample_size(10); + + for parked_transactions in [100, 500, 1_000] { + group.bench_function(format!("distinct_shallow/entries={parked_transactions}"), |b| { + b.iter(|| { + let mut index = ParkedPredicateIndex::new(32); + for transaction_index in 0..parked_transactions { + let transaction_hash: B256 = U256::from(transaction_index + 1).into(); + index.park( + transaction_hash, + (), + ValidityPredicate::Balance { + address: address(transaction_index), + op: ValidityOperator::GreaterThanOrEqual, + value: U256::MAX, + }, + ); + } + assert!(!index.is_empty()); + black_box(index); + }); + }); + } + group.finish(); +} + +// Kept separate so `cargo bench -p base-builder-core --bench tx_selection -- +// predicate_index_crossing --sample-size 10` completes quickly. +fn predicate_index_crossing_benches(c: &mut Criterion) { + const PARKED_TRANSACTIONS: usize = 2_048; + + let mut group = c.benchmark_group("tx_selection/predicate_index_crossing"); + group.sample_size(10); + let watched_address = address(777_777); + let mut changed_account = Account::default(); + changed_account.info.balance = U256::from(PARKED_TRANSACTIONS); + let changed_state = EvmState::from_iter([(watched_address, changed_account)]); + + for (name, threshold) in [ + ("flat/all_thresholds", usize::MAX), + ("hybrid/all_thresholds", 32), + ("ordered/all_thresholds", 1), + ] { + let mut index = ParkedPredicateIndex::new(threshold); + for transaction_index in 0..PARKED_TRANSACTIONS { + let transaction_hash: B256 = U256::from(transaction_index + 1).into(); + index.park( + transaction_hash, + (), + ValidityPredicate::Balance { + address: watched_address, + op: ValidityOperator::GreaterThanOrEqual, + value: U256::from(transaction_index + 1), + }, + ); + } + group.bench_function(name, |b| { + b.iter(|| { + let effects = index.affected_by_state(&changed_state); + assert_eq!(effects.affected_transactions.len(), PARKED_TRANSACTIONS); + black_box(effects); + }); + }); + } + group.finish(); +} + +// Includes bucket construction and parking so tiny-bucket conversion overhead is visible. +fn predicate_index_lifecycle_benches(c: &mut Criterion) { + let mut group = c.benchmark_group("tx_selection/predicate_index_lifecycle"); + group.sample_size(10); + let watched_address = address(888_888); + let mut changed_account = Account::default(); + changed_account.info.balance = U256::ONE; + let changed_state = EvmState::from_iter([(watched_address, changed_account)]); + + for (name, threshold, parked_transactions) in [ + ("flat/one", usize::MAX, 1), + ("ordered/one", 1, 1), + ("flat/four", usize::MAX, 4), + ("ordered/four", 1, 4), + ] { + group.bench_function(name, |b| { + b.iter(|| { + let mut index = ParkedPredicateIndex::new(threshold); + for transaction_index in 0..parked_transactions { + let transaction_hash: B256 = U256::from(transaction_index + 1).into(); + index.park( + transaction_hash, + (), + ValidityPredicate::Balance { + address: watched_address, + op: ValidityOperator::GreaterThanOrEqual, + value: U256::MAX, + }, + ); + } + let effects = index.affected_by_state(&changed_state); + let expected = usize::from(parked_transactions < threshold) * parked_transactions; + assert_eq!(effects.affected_transactions.len(), expected); + black_box(effects); + }); + }); + } + group.finish(); +} diff --git a/crates/builder/core/src/config.rs b/crates/builder/core/src/config.rs index b61be4253e..830dd78521 100644 --- a/crates/builder/core/src/config.rs +++ b/crates/builder/core/src/config.rs @@ -6,7 +6,10 @@ use core::{ }; use std::sync::Arc; -use base_execution_payload_builder::config::{BaseDAConfig, GasLimitConfig}; +use base_execution_payload_builder::{ + DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, + config::{BaseDAConfig, GasLimitConfig}, +}; use crate::{ExecutionMeteringMode, NoopMeteringProvider, RejectionCache, SharedMeteringProvider}; @@ -64,6 +67,8 @@ pub struct BuilderConfig { /// `base_builder_predicate_eval_duration_per_block` metric's P99 SLO. pub predicate_eval_hard_cutoff: Duration, + /// Number of parked predicates that converts one state bucket to ordered wakeups. + pub predicate_bucket_ordered_threshold: usize, /// Resource metering provider pub metering_provider: SharedMeteringProvider, @@ -123,6 +128,7 @@ impl core::fmt::Debug for BuilderConfig { .field("max_uncompressed_block_size", &self.max_uncompressed_block_size) .field("metering_wait_duration", &self.metering_wait_duration) .field("predicate_eval_hard_cutoff", &self.predicate_eval_hard_cutoff) + .field("predicate_bucket_ordered_threshold", &self.predicate_bucket_ordered_threshold) .field("metering_provider", &self.metering_provider) .field("rejection_cache_size", &self.rejection_cache.entry_count()) .field("audit_archiver_url", &self.audit_archiver_url) @@ -151,6 +157,7 @@ impl Default for BuilderConfig { max_uncompressed_block_size: None, metering_wait_duration: None, predicate_eval_hard_cutoff: Duration::from_millis(10), + predicate_bucket_ordered_threshold: DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, metering_provider: Arc::new(NoopMeteringProvider), rejection_cache: RejectionCache::default(), audit_archiver_url: None, diff --git a/crates/builder/core/src/flashblocks/context.rs b/crates/builder/core/src/flashblocks/context.rs index 0c987345d3..b1aeb07688 100644 --- a/crates/builder/core/src/flashblocks/context.rs +++ b/crates/builder/core/src/flashblocks/context.rs @@ -846,7 +846,8 @@ impl BasePayloadBuilderCtx { let block_number = as_u64_saturated!(self.evm_env.block_env.number); let block_timestamp = self.attributes().timestamp(); let payload_id = self.payload_id().to_string(); - let mut predicate_index = ParkedPredicateIndex::default(); + let mut predicate_index = + ParkedPredicateIndex::new(self.builder_config.predicate_bucket_ordered_threshold); let predicate_context = PredicateContext { block_number, flashblock_index: self.flashblock_index() }; @@ -920,9 +921,13 @@ impl BasePayloadBuilderCtx { ) }) { Ok(ValidityPredicateEvaluation::Matched) => None, - Ok(ValidityPredicateEvaluation::Unsatisfied { blocker, expired }) => { + Ok(ValidityPredicateEvaluation::Unsatisfied { + blocker, + blocker_index, + expired, + }) => { predicate_expired = expired; - Some(blocker) + Some((blocker, blocker_index)) } Err(error) => { warn!( @@ -990,7 +995,7 @@ impl BasePayloadBuilderCtx { // Recoverable state mismatch: park under the current blocker to retry at a // later position or flashblock, or reject if the iterator cannot park it. self.emit_considered(&cx, tx_hash, ordering_position); - let blocking_predicate = blocking_predicate + let (_, blocker_index) = blocking_predicate .expect("unsatisfied, non-terminal predicate implies a blocking key"); if self.defer_or_reject_current( best_txs, @@ -999,7 +1004,8 @@ impl BasePayloadBuilderCtx { &tx, ordering_position, ) { - predicate_index.park(tx_hash, tx, blocking_predicate); + let predicate = tx.validity_predicates()[blocker_index].clone(); + predicate_index.park(tx_hash, tx, predicate); } } continue; @@ -1540,9 +1546,11 @@ impl BasePayloadBuilderCtx { ) }) { Ok(ValidityPredicateEvaluation::Matched) => None, - Ok(ValidityPredicateEvaluation::Unsatisfied { blocker, .. }) => { - Some(blocker) - } + Ok(ValidityPredicateEvaluation::Unsatisfied { + blocker, + blocker_index, + .. + }) => Some((blocker, blocker_index)), Err(error) => { warn!( target: "payload_builder", @@ -1565,8 +1573,9 @@ impl BasePayloadBuilderCtx { if predicate_read_failed { predicate_index.remove(*parked_hash); best_txs.discard_parked(*parked_hash); - } else if let Some(blocking_predicate) = blocking_predicate { - predicate_index.reindex(*parked_hash, blocking_predicate); + } else if let Some((_, blocker_index)) = blocking_predicate { + let predicate = parked_transaction.validity_predicates()[blocker_index].clone(); + predicate_index.reindex(*parked_hash, predicate); } else { predicate_index.remove(*parked_hash); best_txs.promote(*parked_hash); diff --git a/crates/builder/multiplex/src/service_builder.rs b/crates/builder/multiplex/src/service_builder.rs index 27eb56fc14..506ac6b765 100644 --- a/crates/builder/multiplex/src/service_builder.rs +++ b/crates/builder/multiplex/src/service_builder.rs @@ -70,6 +70,9 @@ impl MultiplexingServiceBuilder { gas_limit_config: self.builder_config.gas_limit_config.clone(), manifest_precheck_enabled: self.builder_config.manifest_precheck_enabled, predicate_eval_hard_cutoff: self.builder_config.predicate_eval_hard_cutoff, + predicate_bucket_ordered_threshold: self + .builder_config + .predicate_bucket_ordered_threshold, resource_metering: ResourceMeteringConfig { provider: Arc::clone(&self.builder_config.metering_provider), ..ResourceMeteringConfig::default() diff --git a/crates/execution/node/src/node.rs b/crates/execution/node/src/node.rs index 0babdc6069..d0b988ff24 100644 --- a/crates/execution/node/src/node.rs +++ b/crates/execution/node/src/node.rs @@ -17,7 +17,8 @@ use base_execution_chainspec::BaseChainSpec; use base_execution_consensus::BaseBeaconConsensus; use base_execution_evm::{BaseEvmConfig, BaseRethReceiptBuilder}; use base_execution_payload_builder::{ - Attributes, BaseBuiltPayload, BasePayloadBuilderAttributes, PayloadPrimitives, RejectionCache, + Attributes, BaseBuiltPayload, BasePayloadBuilderAttributes, + DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, PayloadPrimitives, RejectionCache, builder::BasePayloadTransactions, config::{BaseBuilderConfig, BaseDAConfig, GasLimitConfig, ResourceMeteringConfig}, }; @@ -1046,6 +1047,8 @@ pub struct BasePayloadBuilder { pub manifest_precheck_enabled: bool, /// Hard cutoff on cumulative validity-predicate evaluation time per payload build. pub predicate_eval_hard_cutoff: Duration, + /// Number of parked predicates that converts one state bucket to ordered wakeups. + pub predicate_bucket_ordered_threshold: usize, /// Resource metering by opcode for native payload admission. pub resource_metering: ResourceMeteringConfig, /// Shared, cross-job cache of permanently rejected transaction hashes. @@ -1060,6 +1063,7 @@ impl Default for BasePayloadBuilder { gas_limit_config: GasLimitConfig::default(), manifest_precheck_enabled: true, predicate_eval_hard_cutoff: Duration::from_millis(10), + predicate_bucket_ordered_threshold: DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, resource_metering: ResourceMeteringConfig::default(), rejection_cache: RejectionCache::default(), } @@ -1075,6 +1079,7 @@ impl BasePayloadBuilder { gas_limit_config: GasLimitConfig::default(), manifest_precheck_enabled: true, predicate_eval_hard_cutoff: Duration::from_millis(10), + predicate_bucket_ordered_threshold: DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, resource_metering: ResourceMeteringConfig::default(), rejection_cache: RejectionCache::default(), } @@ -1104,6 +1109,12 @@ impl BasePayloadBuilder { self } + /// Configures the predicate bucket ordered conversion threshold. + pub const fn with_predicate_bucket_ordered_threshold(mut self, threshold: usize) -> Self { + self.predicate_bucket_ordered_threshold = threshold; + self + } + /// Configure resource metering by opcode for the native payload builder. pub fn with_resource_metering(mut self, resource_metering: ResourceMeteringConfig) -> Self { self.resource_metering = resource_metering; @@ -1127,6 +1138,7 @@ impl BasePayloadBuilder { gas_limit_config: self.gas_limit_config, manifest_precheck_enabled: self.manifest_precheck_enabled, predicate_eval_hard_cutoff: self.predicate_eval_hard_cutoff, + predicate_bucket_ordered_threshold: self.predicate_bucket_ordered_threshold, resource_metering: self.resource_metering, rejection_cache: self.rejection_cache, } @@ -1177,6 +1189,7 @@ where gas_limit_config: self.gas_limit_config, manifest_precheck_enabled: self.manifest_precheck_enabled, predicate_eval_hard_cutoff: self.predicate_eval_hard_cutoff, + predicate_bucket_ordered_threshold: self.predicate_bucket_ordered_threshold, resource_metering: self.resource_metering, rejection_cache: self.rejection_cache, state_provider_metrics: ctx.config().engine.state_provider_metrics, diff --git a/crates/execution/payload/src/builder.rs b/crates/execution/payload/src/builder.rs index ade957b307..426356aaf7 100644 --- a/crates/execution/payload/src/builder.rs +++ b/crates/execution/payload/src/builder.rs @@ -943,7 +943,8 @@ where let block_number = builder.evm_mut().block().number().try_into().expect("block number must fit in u64"); let predicate_context = PredicateContext { block_number, flashblock_index: 0 }; - let mut predicate_index = ParkedPredicateIndex::default(); + let mut predicate_index = + ParkedPredicateIndex::new(self.builder_config.predicate_bucket_ordered_threshold); let mut predicate_loads = PredicateLoadTracker::default(); let mut predicate_eval_duration = None; let mut predicate_bucket_wakeups = 0; @@ -1121,7 +1122,11 @@ where } continue; } - Ok(ValidityPredicateEvaluation::Unsatisfied { blocker, expired: false }) => { + Ok(ValidityPredicateEvaluation::Unsatisfied { + blocker, + blocker_index, + expired: false, + }) => { ValidityMetrics::validity_predicate_evaluations_total("not_satisfied") .increment(1); trace!( @@ -1141,7 +1146,8 @@ where "defer_detail" => "a validity predicate is not satisfied by the current build state", } ); - predicate_index.park(tx_hash, tx, blocker); + let predicate = tx.validity_predicates()[blocker_index].clone(); + predicate_index.park(tx_hash, tx, predicate); } else { emit_native_validity_event!( self, @@ -1434,12 +1440,14 @@ where predicate_index.remove(parked_hash); best_txs.promote(parked_hash); } - Ok(ValidityPredicateEvaluation::Unsatisfied { blocker, .. }) => { + Ok(ValidityPredicateEvaluation::Unsatisfied { blocker_index, .. }) => { ValidityMetrics::validity_predicate_evaluations_total( "rescan_not_satisfied", ) .increment(1); - predicate_index.reindex(parked_hash, blocker); + let predicate = + parked_transaction.validity_predicates()[blocker_index].clone(); + predicate_index.reindex(parked_hash, predicate); } Err(error) => { ValidityMetrics::validity_predicate_evaluations_total("rescan_read_error") diff --git a/crates/execution/payload/src/config.rs b/crates/execution/payload/src/config.rs index 8ba82b325b..a0a0296dd3 100644 --- a/crates/execution/payload/src/config.rs +++ b/crates/execution/payload/src/config.rs @@ -17,9 +17,9 @@ use revm::state::EvmState; use tracing::{debug, warn}; use crate::{ - MeteringProvider, NoopMeteringProvider, RejectionCache, ResourceMeteringError, - ResourceMeteringMetrics, ResourceMeteringSchedule, ResourceMeteringUsage, ResourceSample, - ResourceThrottlingDecision, SharedMeteringProvider, + DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, MeteringProvider, NoopMeteringProvider, + RejectionCache, ResourceMeteringError, ResourceMeteringMetrics, ResourceMeteringSchedule, + ResourceMeteringUsage, ResourceSample, ResourceThrottlingDecision, SharedMeteringProvider, }; /// Settings for the Base payload builder. @@ -34,6 +34,8 @@ pub struct BaseBuilderConfig { pub manifest_precheck_enabled: bool, /// Hard cutoff on cumulative validity-predicate evaluation time per payload build. pub predicate_eval_hard_cutoff: Duration, + /// Number of parked predicates that converts one state bucket to ordered wakeups. + pub predicate_bucket_ordered_threshold: usize, /// Resource-unit metering used to throttle transactions in the native /// payload builder. pub resource_metering: ResourceMeteringConfig, @@ -60,6 +62,7 @@ impl Default for BaseBuilderConfig { gas_limit_config: GasLimitConfig::default(), manifest_precheck_enabled: true, predicate_eval_hard_cutoff: Duration::from_millis(10), + predicate_bucket_ordered_threshold: DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, resource_metering: ResourceMeteringConfig::default(), rejection_cache: RejectionCache::default(), state_provider_metrics: false, @@ -79,6 +82,7 @@ impl BaseBuilderConfig { gas_limit_config, manifest_precheck_enabled, predicate_eval_hard_cutoff: Duration::from_millis(10), + predicate_bucket_ordered_threshold: DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, resource_metering: ResourceMeteringConfig::default(), rejection_cache: RejectionCache::default(), state_provider_metrics: false, diff --git a/crates/execution/payload/src/lib.rs b/crates/execution/payload/src/lib.rs index bb3d39309b..399c140945 100644 --- a/crates/execution/payload/src/lib.rs +++ b/crates/execution/payload/src/lib.rs @@ -53,7 +53,8 @@ pub use types::BasePayloadTypes; mod validity; pub use validity::{ - ParkedPredicateIndex, StateChangeEffects, ValidityPredicateEvaluation, ValidityPredicateKey, + DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, ParkedPredicateIndex, StateChangeEffects, + ValidityPredicateEvaluation, ValidityPredicateKey, }; pub mod validator; diff --git a/crates/execution/payload/src/metrics.rs b/crates/execution/payload/src/metrics.rs index 1092fc133b..a0cd1ac843 100644 --- a/crates/execution/payload/src/metrics.rs +++ b/crates/execution/payload/src/metrics.rs @@ -167,10 +167,10 @@ mod tests { use std::time::Duration; use alloy_primitives::{Address, B256, U256}; + use base_execution_txpool::{ValidityOperator, ValidityPredicate}; use metrics_exporter_prometheus::PrometheusBuilder; use super::*; - use crate::ValidityPredicateKey; #[test] fn records_predicate_metrics() { @@ -184,7 +184,15 @@ mod tests { tracker.record_slot(account, slot); let mut index = ParkedPredicateIndex::default(); - index.park(B256::with_last_byte(1), (), ValidityPredicateKey::Balance(account)); + index.park( + B256::with_last_byte(1), + (), + ValidityPredicate::Balance { + address: account, + op: ValidityOperator::Equal, + value: U256::ZERO, + }, + ); metrics::with_local_recorder(&recorder, || { ValidityMetrics::record_predicate_eval_duration(Duration::from_millis(500)); diff --git a/crates/execution/payload/src/predicate_loads.rs b/crates/execution/payload/src/predicate_loads.rs index a1b4070eb6..b212ce0695 100644 --- a/crates/execution/payload/src/predicate_loads.rs +++ b/crates/execution/payload/src/predicate_loads.rs @@ -223,7 +223,7 @@ mod tests { assert_eq!( ValidityPredicateKey::first_unsatisfied(&predicates, &mut recorder, &context()) .unwrap(), - Some(ValidityPredicateKey::Balance(address)) + Some((0, ValidityPredicateKey::Balance(address))) ); } diff --git a/crates/execution/payload/src/validity.rs b/crates/execution/payload/src/validity.rs index b10c99cfa3..5bcc9e7d18 100644 --- a/crates/execution/payload/src/validity.rs +++ b/crates/execution/payload/src/validity.rs @@ -1,12 +1,18 @@ //! Validity-predicate evaluation and indexing for payload transactions. +use core::ops::Bound::{Excluded, Included}; +use std::collections::BTreeMap; + use alloy_primitives::{ Address, TxHash, U256, - map::{HashMap, HashSet}, + map::{B256Map, B256Set, HashMap, U256Map}, }; -use base_execution_txpool::{PredicateContext, ValidityPredicate}; +use base_execution_txpool::{PredicateContext, ValidityOperator, ValidityPredicate}; use revm::{Database, state::EvmState}; +/// The number of parked predicates at which a flat bucket becomes ordered by default. +pub const DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD: usize = 32; + /// Location that currently blocks a parked validity predicate. /// /// State keys ([`Self::Balance`], [`Self::Storage`]) are woken by @@ -36,19 +42,16 @@ impl ValidityPredicateKey { } } - /// Returns the first predicate that does not hold against `db` and `context`. - /// - /// `Ok(None)` means every predicate matches. `Err` means a predicate's state could not be - /// read; callers must treat that as an inability to verify rather than a successful match. + /// Returns the first predicate that does not hold against `db` and `context`, with its index. pub fn first_unsatisfied( predicates: &[ValidityPredicate], db: &mut DB, context: &PredicateContext, - ) -> Result, DB::Error> { - for predicate in predicates { + ) -> Result, DB::Error> { + for (index, predicate) in predicates.iter().enumerate() { match predicate.matches(db, context) { Ok(true) => {} - Ok(false) => return Ok(Some(Self::for_predicate(predicate))), + Ok(false) => return Ok(Some((index, Self::for_predicate(predicate)))), Err(error) => return Err(error), } } @@ -65,6 +68,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, /// Whether the predicate batch can never be satisfied at a later build position. expired: bool, }, @@ -72,19 +77,19 @@ pub enum ValidityPredicateEvaluation { impl ValidityPredicateEvaluation { /// Evaluates predicates against the current state and build position. - /// - /// Returns a database error when a state-reading predicate cannot be verified. pub fn evaluate( predicates: &[ValidityPredicate], db: &mut DB, context: &PredicateContext, ) -> Result { - let Some(blocker) = ValidityPredicateKey::first_unsatisfied(predicates, db, context)? + let Some((blocker_index, 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), }) } @@ -92,104 +97,134 @@ impl ValidityPredicateEvaluation { /// Predicate-parked transactions indexed by one currently unsatisfied state location. /// -/// A parked transaction only needs one blocker in the index. When that location changes, callers -/// re-evaluate all of the transaction's predicates and either promote it or replace its blocker. +/// Buckets start as compact hash sets. Once a state bucket reaches `ordered_threshold`, it is +/// converted for the rest of the build into an ordered representation. The latter wakes only +/// false predicates whose comparison can become true for the observed old/new state transition. #[derive(Debug)] pub struct ParkedPredicateIndex { - blockers: HashMap>, - transactions: HashMap, + blockers: HashMap, + transactions: B256Map>, + ordered_threshold: usize, +} + +#[derive(Debug)] +struct ParkedTransaction { + transaction: T, + blocker: ValidityPredicateKey, + predicate: ValidityPredicate, +} + +#[derive(Debug)] +enum PredicateBucket { + Flat(B256Set), + Ordered(OrderedPredicateBucket), +} + +#[derive(Debug, Default)] +struct OrderedPredicateBucket { + thresholds: U256Map>, + equal: U256Map>, + not_equal: U256Map, + never: B256Set, +} + +#[derive(Debug, Clone, Copy)] +enum OrderedPredicateKind { + Threshold(U256), + Equal(U256), + NotEqual, + Never, } impl Default for ParkedPredicateIndex { fn default() -> Self { - Self { blockers: HashMap::default(), transactions: HashMap::default() } + Self::new(DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD) } } impl ParkedPredicateIndex { + /// Creates an index that converts state buckets at `ordered_threshold` entries. + /// + /// A zero threshold is treated as one, so every state bucket uses the ordered form. + pub fn new(ordered_threshold: usize) -> Self { + Self { + blockers: HashMap::default(), + transactions: B256Map::default(), + ordered_threshold: ordered_threshold.max(1), + } + } + /// Returns whether no transactions are indexed. pub fn is_empty(&self) -> bool { self.transactions.is_empty() } - /// Adds a parked transaction under one currently unsatisfied predicate key. - pub fn park( - &mut self, - transaction_hash: TxHash, - transaction: T, - blocker: ValidityPredicateKey, - ) { + /// Adds a parked transaction under its currently unsatisfied predicate. + pub fn park(&mut self, transaction_hash: TxHash, transaction: T, predicate: ValidityPredicate) { self.remove(transaction_hash); - self.transactions.insert(transaction_hash, (transaction, blocker)); - self.blockers.entry(blocker).or_default().insert(transaction_hash); + let blocker = ValidityPredicateKey::for_predicate(&predicate); + self.transactions + .insert(transaction_hash, ParkedTransaction { transaction, blocker, predicate }); + self.insert_into_bucket(transaction_hash); } /// Returns an indexed parked transaction. pub fn transaction(&self, transaction_hash: TxHash) -> Option<&T> { - self.transactions.get(&transaction_hash).map(|(transaction, _)| transaction) + self.transactions.get(&transaction_hash).map(|entry| &entry.transaction) } - /// Replaces a parked transaction's currently unsatisfied predicate key. - pub fn reindex(&mut self, transaction_hash: TxHash, blocker: ValidityPredicateKey) -> bool { - let Some((_, previous)) = self.transactions.get_mut(&transaction_hash) else { + /// Replaces a parked transaction's currently unsatisfied predicate. + pub fn reindex(&mut self, transaction_hash: TxHash, predicate: ValidityPredicate) -> bool { + let Some(previous) = self.transactions.get(&transaction_hash) else { return false; }; - if *previous == blocker { - return true; - } - - let previous = core::mem::replace(previous, blocker); - if let Some(hashes) = self.blockers.get_mut(&previous) { - hashes.remove(&transaction_hash); - if hashes.is_empty() { - self.blockers.remove(&previous); - } - } - self.blockers.entry(blocker).or_default().insert(transaction_hash); + let old_blocker = previous.blocker; + let old_predicate = previous.predicate.clone(); + self.remove_from_bucket(transaction_hash, old_blocker, &old_predicate); + let blocker = ValidityPredicateKey::for_predicate(&predicate); + let entry = self.transactions.get_mut(&transaction_hash).expect("entry was checked above"); + entry.blocker = blocker; + entry.predicate = predicate; + self.insert_into_bucket(transaction_hash); true } /// Removes and returns an indexed parked transaction. pub fn remove(&mut self, transaction_hash: TxHash) -> Option { - let (transaction, blocker) = self.transactions.remove(&transaction_hash)?; - if let Some(hashes) = self.blockers.get_mut(&blocker) { - hashes.remove(&transaction_hash); - if hashes.is_empty() { - self.blockers.remove(&blocker); - } - } - Some(transaction) + let entry = self.transactions.remove(&transaction_hash)?; + self.remove_from_bucket(transaction_hash, entry.blocker, &entry.predicate); + Some(entry.transaction) } /// Returns parked transactions and index-bucket wakeups triggered by `state`. pub fn affected_by_state(&self, state: &EvmState) -> StateChangeEffects { let mut effects = StateChangeEffects::default(); for (address, account) in state { - if account.info.balance != account.original_info().balance - && let Some(hashes) = self.blockers.get(&ValidityPredicateKey::Balance(*address)) - { - effects.affected_transactions.extend(hashes.iter().copied()); - effects.woken_buckets += 1; + if account.info.balance != account.original_info().balance { + self.wake_bucket( + &mut effects, + ValidityPredicateKey::Balance(*address), + account.original_info().balance, + account.info.balance, + ); } - - // Selfdestruct can clear slots that were not loaded during this execution. Wake every - // storage blocker for the account so those predicates are re-read from committed state. if account.is_selfdestructed() { - for (key, hashes) in &self.blockers { + for (key, bucket) in &self.blockers { if matches!(key, ValidityPredicateKey::Storage(key_address, _) if key_address == address) { - effects.affected_transactions.extend(hashes.iter().copied()); + bucket.extend_all(&mut effects.affected_transactions); effects.woken_buckets += 1; } } } else { for (slot, value) in &account.storage { - if value.is_changed() - && let Some(hashes) = - self.blockers.get(&ValidityPredicateKey::Storage(*address, *slot)) - { - effects.affected_transactions.extend(hashes.iter().copied()); - effects.woken_buckets += 1; + if value.is_changed() { + self.wake_bucket( + &mut effects, + ValidityPredicateKey::Storage(*address, *slot), + value.original_value(), + value.present_value(), + ); } } } @@ -199,16 +234,265 @@ impl ParkedPredicateIndex { /// Returns the number of parked transactions blocked on each distinct index bucket. pub fn bucket_depths(&self) -> impl Iterator + '_ { - self.blockers.values().map(HashSet::len) + self.blockers.values().map(PredicateBucket::len) + } + + fn insert_into_bucket(&mut self, hash: TxHash) { + let entry = self.transactions.get(&hash).expect("inserted transaction must exist"); + let blocker = entry.blocker; + let predicate = &entry.predicate; + let bucket = self + .blockers + .entry(blocker) + .or_insert_with(|| PredicateBucket::Flat(B256Set::default())); + bucket.insert(hash, predicate); + if bucket.len() >= self.ordered_threshold && bucket.is_flat() && blocker.is_state() { + let hashes = bucket.take_flat().expect("bucket was checked as flat"); + let mut ordered = OrderedPredicateBucket::default(); + for hash in hashes { + let predicate = + &self.transactions.get(&hash).expect("bucket entry must exist").predicate; + ordered.insert(hash, predicate); + } + *bucket = PredicateBucket::Ordered(ordered); + } + } + + fn remove_from_bucket( + &mut self, + hash: TxHash, + blocker: ValidityPredicateKey, + predicate: &ValidityPredicate, + ) { + let Some(bucket) = self.blockers.get_mut(&blocker) else { return }; + bucket.remove(hash, predicate); + if bucket.is_empty() { + self.blockers.remove(&blocker); + } + } + + fn wake_bucket( + &self, + effects: &mut StateChangeEffects, + key: ValidityPredicateKey, + old: U256, + new: U256, + ) { + let Some(bucket) = self.blockers.get(&key) else { return }; + bucket.extend_affected(old, new, &mut effects.affected_transactions); + effects.woken_buckets += 1; + } +} + +impl ValidityPredicateKey { + const fn is_state(self) -> bool { + matches!(self, Self::Balance(_) | Self::Storage(_, _)) + } +} + +impl PredicateBucket { + fn insert(&mut self, hash: TxHash, predicate: &ValidityPredicate) { + match self { + Self::Flat(hashes) => { + hashes.insert(hash); + } + Self::Ordered(bucket) => bucket.insert(hash, predicate), + } + } + + fn remove(&mut self, hash: TxHash, predicate: &ValidityPredicate) { + match self { + Self::Flat(hashes) => { + hashes.remove(&hash); + } + Self::Ordered(bucket) => bucket.remove(hash, predicate), + } + } + + fn len(&self) -> usize { + match self { + Self::Flat(hashes) => hashes.len(), + Self::Ordered(bucket) => bucket.len(), + } + } + + fn is_empty(&self) -> bool { + self.len() == 0 + } + const fn is_flat(&self) -> bool { + matches!(self, Self::Flat(_)) + } + fn take_flat(&mut self) -> Option { + match self { + Self::Flat(hashes) => Some(core::mem::take(hashes)), + Self::Ordered(_) => None, + } + } + + fn extend_all(&self, target: &mut Vec) { + match self { + Self::Flat(hashes) => target.extend(hashes.iter().copied()), + Self::Ordered(bucket) => bucket.extend_all(target), + } + } + + fn extend_affected(&self, old: U256, new: U256, target: &mut Vec) { + match self { + Self::Flat(hashes) => target.extend(hashes.iter().copied()), + Self::Ordered(bucket) => bucket.extend_affected(old, new, target), + } + } +} + +impl OrderedPredicateBucket { + fn insert(&mut self, hash: TxHash, predicate: &ValidityPredicate) { + let Some((mask, kind)) = ordered_predicate(predicate) else { return }; + match kind { + OrderedPredicateKind::Threshold(value) => { + self.thresholds.entry(mask).or_default().entry(value).or_default().insert(hash); + } + OrderedPredicateKind::Equal(value) => { + self.equal.entry(mask).or_default().entry(value).or_default().insert(hash); + } + OrderedPredicateKind::NotEqual => { + self.not_equal.entry(mask).or_default().insert(hash); + } + OrderedPredicateKind::Never => { + self.never.insert(hash); + } + } + } + + fn remove(&mut self, hash: TxHash, predicate: &ValidityPredicate) { + let Some((mask, kind)) = ordered_predicate(predicate) else { return }; + match kind { + OrderedPredicateKind::Threshold(value) => { + remove_hash(&mut self.thresholds, mask, Some(value), hash) + } + OrderedPredicateKind::Equal(value) => { + remove_hash(&mut self.equal, mask, Some(value), hash) + } + OrderedPredicateKind::NotEqual => remove_hash(&mut self.not_equal, mask, None, hash), + OrderedPredicateKind::Never => { + self.never.remove(&hash); + } + } + } + + fn len(&self) -> usize { + self.thresholds.values().flat_map(BTreeMap::values).map(B256Set::len).sum::() + + self.equal.values().flat_map(U256Map::values).map(B256Set::len).sum::() + + self.not_equal.values().map(B256Set::len).sum::() + + self.never.len() + } + + fn extend_all(&self, target: &mut Vec) { + target.extend( + self.thresholds + .values() + .flat_map(BTreeMap::values) + .chain(self.equal.values().flat_map(U256Map::values)) + .chain(self.not_equal.values()) + .chain(core::iter::once(&self.never)) + .flat_map(|hashes| hashes.iter().copied()), + ); + } + + fn extend_affected(&self, old: U256, new: U256, target: &mut Vec) { + for (mask, thresholds) in &self.thresholds { + let old = old & *mask; + let new = new & *mask; + if old != new { + let (lower, upper) = if old < new { (old, new) } else { (new, old) }; + for hashes in + thresholds.range((Excluded(lower), Included(upper))).map(|(_, hashes)| hashes) + { + target.extend(hashes.iter().copied()); + } + } + } + for (mask, equal) in &self.equal { + if let Some(hashes) = equal.get(&(new & *mask)) { + target.extend(hashes.iter().copied()); + } + } + for (mask, hashes) in &self.not_equal { + if (old & *mask) != (new & *mask) { + target.extend(hashes.iter().copied()); + } + } + } +} + +fn ordered_predicate(predicate: &ValidityPredicate) -> Option<(U256, OrderedPredicateKind)> { + let (mask, op, value) = match predicate { + ValidityPredicate::Balance { op, value, .. } => (U256::MAX, *op, *value), + ValidityPredicate::Storage { mask, op, value, .. } => (*mask, *op, *value), + ValidityPredicate::BlockNumber { .. } | ValidityPredicate::FlashblockIndex { .. } => { + return None; + } + }; + let kind = match op { + ValidityOperator::LessThan | ValidityOperator::GreaterThanOrEqual => { + OrderedPredicateKind::Threshold(value) + } + ValidityOperator::LessThanOrEqual | ValidityOperator::GreaterThan => value + .checked_add(U256::ONE) + .map_or(OrderedPredicateKind::Never, OrderedPredicateKind::Threshold), + ValidityOperator::Equal => OrderedPredicateKind::Equal(value), + ValidityOperator::NotEqual => OrderedPredicateKind::NotEqual, + }; + Some((mask, kind)) +} + +fn remove_hash(map: &mut U256Map, mask: U256, value: Option, hash: TxHash) +where + M: BucketMap, +{ + let remove_mask = map.get_mut(&mask).is_some_and(|bucket| bucket.remove_hash(value, hash)); + if remove_mask { + map.remove(&mask); + } +} + +trait BucketMap { + fn remove_hash(&mut self, value: Option, hash: TxHash) -> bool; +} + +impl BucketMap for BTreeMap { + fn remove_hash(&mut self, value: Option, hash: TxHash) -> bool { + let value = value.expect("threshold index has a value"); + if let Some(hashes) = self.get_mut(&value) { + hashes.remove(&hash); + if hashes.is_empty() { + self.remove(&value); + } + } + self.is_empty() + } +} + +impl BucketMap for U256Map { + fn remove_hash(&mut self, value: Option, hash: TxHash) -> bool { + let value = value.expect("point index has a value"); + if let Some(hashes) = self.get_mut(&value) { + hashes.remove(&hash); + if hashes.is_empty() { + self.remove(&value); + } + } + self.is_empty() + } +} + +impl BucketMap for B256Set { + fn remove_hash(&mut self, _: Option, hash: TxHash) -> bool { + self.remove(&hash); + self.is_empty() } } /// Parked transactions and index-bucket wakeups triggered by one state change. -/// -/// A bucket is "woken" when the watched [`ValidityPredicateKey`] it indexes actually changed, -/// counted once per bucket regardless of how many parked transactions block on it. A wakeup -/// means the bucket's parked transactions are due for re-evaluation — it does not mean their -/// predicates became satisfied; that outcome is determined separately by the rescan. #[derive(Debug, Default, PartialEq, Eq)] pub struct StateChangeEffects { /// Parked transactions whose blocking predicate may have changed and need re-evaluation. @@ -219,245 +503,183 @@ pub struct StateChangeEffects { #[cfg(test)] mod tests { - use alloy_primitives::{Address, B256, U256, map::HashSet}; - use base_execution_txpool::{PredicateContext, ValidityOperator, ValidityPredicate}; - use revm::{ - database::InMemoryDB, - state::{Account, EvmState, EvmStorageSlot}, - }; + use alloy_primitives::{Address, B256, U256, map::B256Set}; + use base_execution_txpool::{ValidityOperator, ValidityPredicate}; + use revm::state::{Account, EvmState, EvmStorageSlot}; - use super::{ParkedPredicateIndex, StateChangeEffects, ValidityPredicateKey}; + use super::{ParkedPredicateIndex, StateChangeEffects}; - fn balance_predicate(address: Address, value: U256) -> ValidityPredicate { - ValidityPredicate::Balance { address, op: ValidityOperator::Equal, value } + fn balance(address: Address, op: ValidityOperator, value: u64) -> ValidityPredicate { + ValidityPredicate::Balance { address, op, value: U256::from(value) } } - fn test_context() -> PredicateContext { - PredicateContext { block_number: 0, flashblock_index: 0 } + fn storage( + address: Address, + mask: U256, + op: ValidityOperator, + value: u64, + ) -> ValidityPredicate { + ValidityPredicate::Storage { + address, + slot: U256::from(7), + mask, + op, + value: U256::from(value), + } } - #[test] - fn indexes_only_transactions_affected_by_changed_state() { - let balance_address = Address::with_last_byte(1); - let storage_address = Address::with_last_byte(2); - let balance_hash = B256::with_last_byte(1); - let storage_hash = B256::with_last_byte(2); - let mut index = ParkedPredicateIndex::default(); - index.park(balance_hash, 1, ValidityPredicateKey::Balance(balance_address)); - index.park(storage_hash, 2, ValidityPredicateKey::Storage(storage_address, U256::from(7))); - - let mut state = EvmState::default(); + fn changed_balance(address: Address, old: u64, new: u64) -> EvmState { let mut account = Account::default(); - account.info.balance = U256::ONE; - state.insert(balance_address, account); - - assert_eq!( - index.affected_by_state(&state), - StateChangeEffects { affected_transactions: vec![balance_hash], woken_buckets: 1 } - ); + account.info.balance = U256::from(new); + account.original_info_mut().balance = U256::from(old); + EvmState::from_iter([(address, account)]) + } - assert!( - index.reindex( - balance_hash, - ValidityPredicateKey::Storage(storage_address, U256::from(8)) - ) - ); + fn changed_storage(address: Address, old: u64, new: u64) -> EvmState { let mut account = Account::default(); account.storage.insert( - U256::from(8), - EvmStorageSlot::new_changed(U256::ZERO, U256::ONE, Default::default()), - ); - state.clear(); - state.insert(storage_address, account); - - assert_eq!( - index.affected_by_state(&state), - StateChangeEffects { affected_transactions: vec![balance_hash], woken_buckets: 1 } + U256::from(7), + EvmStorageSlot::new_changed(U256::from(old), U256::from(new), Default::default()), ); - assert_eq!(index.remove(balance_hash), Some(1)); - assert_eq!(index.transaction(storage_hash), Some(&2)); + EvmState::from_iter([(address, account)]) } #[test] - fn selfdestruct_wakes_unloaded_storage_blockers() { + fn flat_bucket_wakes_every_rider() { let address = Address::with_last_byte(1); - let hash = B256::with_last_byte(1); - let mut index = ParkedPredicateIndex::default(); - index.park(hash, (), ValidityPredicateKey::Storage(address, U256::from(7))); - - let mut account = Account::default(); - account.mark_selfdestruct(); - let mut state = EvmState::default(); - state.insert(address, account); - + let hashes = [B256::with_last_byte(1), B256::with_last_byte(2)]; + let mut index = ParkedPredicateIndex::new(3); + for hash in hashes { + index.park(hash, (), balance(address, ValidityOperator::GreaterThan, 10)); + } + let effects = index.affected_by_state(&changed_balance(address, 1, 2)); + assert_eq!(effects.woken_buckets, 1); assert_eq!( - index.affected_by_state(&state), - StateChangeEffects { affected_transactions: vec![hash], woken_buckets: 1 } + effects.affected_transactions.into_iter().collect::(), + B256Set::from_iter(hashes) ); } #[test] - fn selfdestruct_wakes_every_matching_storage_bucket() { + fn ordered_bucket_skips_uncrossed_thresholds_and_wakes_crossed_ones() { let address = Address::with_last_byte(1); - let first_hash = B256::with_last_byte(1); - let second_hash = B256::with_last_byte(2); - let mut index = ParkedPredicateIndex::default(); - index.park(first_hash, (), ValidityPredicateKey::Storage(address, U256::from(7))); - index.park(second_hash, (), ValidityPredicateKey::Storage(address, U256::from(8))); - - let mut account = Account::default(); - account.mark_selfdestruct(); - let mut state = EvmState::default(); - state.insert(address, account); - - let effects = index.affected_by_state(&state); - assert_eq!(effects.woken_buckets, 2); - assert_eq!( - effects.affected_transactions.into_iter().collect::>(), - HashSet::from_iter([first_hash, second_hash]) - ); - } - - #[test] - fn distinct_accounts_each_wake_their_own_bucket() { - let balance_address = Address::with_last_byte(1); - let storage_address = Address::with_last_byte(2); - let balance_hash = B256::with_last_byte(1); - let storage_hash = B256::with_last_byte(2); - let mut index = ParkedPredicateIndex::default(); - index.park(balance_hash, (), ValidityPredicateKey::Balance(balance_address)); - index.park(storage_hash, (), ValidityPredicateKey::Storage(storage_address, U256::from(7))); - - let mut state = EvmState::default(); - let mut changed_balance = Account::default(); - changed_balance.info.balance = U256::ONE; - state.insert(balance_address, changed_balance); - let mut changed_storage = Account::default(); - changed_storage.storage.insert( - U256::from(7), - EvmStorageSlot::new_changed(U256::ZERO, U256::ONE, Default::default()), + let first = B256::with_last_byte(1); + let second = B256::with_last_byte(2); + let mut index = ParkedPredicateIndex::new(2); + index.park(first, (), balance(address, ValidityOperator::GreaterThanOrEqual, 10)); + index.park(second, (), balance(address, ValidityOperator::GreaterThanOrEqual, 20)); + assert!( + index + .affected_by_state(&changed_balance(address, 1, 9)) + .affected_transactions + .is_empty() ); - state.insert(storage_address, changed_storage); - - let effects = index.affected_by_state(&state); - assert_eq!(effects.woken_buckets, 2); assert_eq!( - effects.affected_transactions.into_iter().collect::>(), - HashSet::from_iter([balance_hash, storage_hash]) + index.affected_by_state(&changed_balance(address, 9, 12)).affected_transactions, + vec![first] ); - } - - #[test] - fn shared_bucket_wakes_once_but_affects_every_rider() { - let shared_address = Address::with_last_byte(1); - let first_hash = B256::with_last_byte(1); - let second_hash = B256::with_last_byte(2); - let mut index = ParkedPredicateIndex::default(); - index.park(first_hash, (), ValidityPredicateKey::Balance(shared_address)); - index.park(second_hash, (), ValidityPredicateKey::Balance(shared_address)); - - let mut state = EvmState::default(); - let mut account = Account::default(); - account.info.balance = U256::ONE; - state.insert(shared_address, account); - - let effects = index.affected_by_state(&state); - assert_eq!(effects.woken_buckets, 1); assert_eq!( - effects.affected_transactions.into_iter().collect::>(), - HashSet::from_iter([first_hash, second_hash]) + index.affected_by_state(&changed_balance(address, 12, 25)).affected_transactions, + vec![second] ); } #[test] - fn bucket_depths_reflects_parked_transactions_per_bucket() { - let shared_address = Address::with_last_byte(1); - let unique_address = Address::with_last_byte(2); - let mut index = ParkedPredicateIndex::default(); - index.park(B256::with_last_byte(1), (), ValidityPredicateKey::Balance(shared_address)); - index.park(B256::with_last_byte(2), (), ValidityPredicateKey::Balance(shared_address)); - index.park(B256::with_last_byte(3), (), ValidityPredicateKey::Balance(unique_address)); - - let mut depths: Vec = index.bucket_depths().collect(); - depths.sort_unstable(); - assert_eq!(depths, vec![1, 2]); + fn ordered_bucket_wakes_each_threshold_operator_at_its_boundary() { + let address = Address::with_last_byte(1); + for (hash, op, old, new) in [ + (B256::with_last_byte(1), ValidityOperator::LessThan, 10, 9), + (B256::with_last_byte(2), ValidityOperator::LessThanOrEqual, 11, 10), + (B256::with_last_byte(3), ValidityOperator::GreaterThan, 10, 11), + ] { + let mut index = ParkedPredicateIndex::new(1); + index.park(hash, (), balance(address, op, 10)); + assert_eq!( + index.affected_by_state(&changed_balance(address, old, new)).affected_transactions, + vec![hash] + ); + } } #[test] - fn first_unsatisfied_returns_first_failing_predicate() { - let mut db = InMemoryDB::default(); - let passing = balance_predicate(Address::with_last_byte(1), U256::ZERO); - let failing = balance_predicate(Address::with_last_byte(2), U256::ONE); - - let predicates = [passing, failing]; - let context = test_context(); - assert_eq!(ValidityPredicateKey::first_unsatisfied(&[], &mut db, &context).unwrap(), None); + fn ordered_bucket_wakes_masked_storage_point_operators() { + let address = Address::with_last_byte(1); + let equal = B256::with_last_byte(1); + let not_equal = B256::with_last_byte(2); + let mask = U256::from(0xff); + + let mut index = ParkedPredicateIndex::new(1); + index.park(equal, (), storage(address, mask, ValidityOperator::Equal, 10)); assert_eq!( - ValidityPredicateKey::first_unsatisfied(&predicates[..1], &mut db, &context).unwrap(), - None + index.affected_by_state(&changed_storage(address, 0x109, 0x20a)).affected_transactions, + vec![equal] ); + + let mut index = ParkedPredicateIndex::new(1); + index.park(not_equal, (), storage(address, mask, ValidityOperator::NotEqual, 10)); assert_eq!( - ValidityPredicateKey::first_unsatisfied(&predicates, &mut db, &context).unwrap(), - Some(ValidityPredicateKey::Balance(Address::with_last_byte(2))) + index.affected_by_state(&changed_storage(address, 0x10a, 0x20b)).affected_transactions, + vec![not_equal] ); } #[test] - fn evaluation_classifies_expired_position_predicates() { - let mut db = InMemoryDB::default(); - let context = PredicateContext { block_number: 2, flashblock_index: 1 }; - let predicates = - [ValidityPredicate::BlockNumber { op: ValidityOperator::Equal, value: U256::from(1) }]; - + fn reindex_and_remove_update_ordered_buckets() { + let address = Address::with_last_byte(1); + let hash = B256::with_last_byte(1); + let mut index = ParkedPredicateIndex::new(1); + index.park(hash, 7, balance(address, ValidityOperator::GreaterThan, 10)); + assert!(index.reindex(hash, balance(address, ValidityOperator::GreaterThan, 20))); + assert!( + index + .affected_by_state(&changed_balance(address, 9, 11)) + .affected_transactions + .is_empty() + ); + assert_eq!(index.remove(hash), Some(7)); assert_eq!( - super::ValidityPredicateEvaluation::evaluate(&predicates, &mut db, &context).unwrap(), - super::ValidityPredicateEvaluation::Unsatisfied { - blocker: ValidityPredicateKey::BlockNumber, - expired: true, - } + index.affected_by_state(&changed_balance(address, 19, 21)), + StateChangeEffects::default() ); } #[test] - fn first_unsatisfied_indexes_context_predicates() { - let mut db = InMemoryDB::default(); - let context = PredicateContext { block_number: 1, flashblock_index: 0 }; - let block_number = - ValidityPredicate::BlockNumber { op: ValidityOperator::Equal, value: U256::from(2) }; - let flashblock_index = ValidityPredicate::FlashblockIndex { - op: ValidityOperator::Equal, - value: U256::from(1), - }; - - assert_eq!( - ValidityPredicateKey::first_unsatisfied(&[block_number], &mut db, &context).unwrap(), - Some(ValidityPredicateKey::BlockNumber) - ); - assert_eq!( - ValidityPredicateKey::first_unsatisfied(&[flashblock_index], &mut db, &context) - .unwrap(), - Some(ValidityPredicateKey::FlashblockIndex) + fn ordered_bucket_retains_never_satisfied_predicates() { + let address = Address::with_last_byte(1); + let hash = B256::with_last_byte(1); + let mut index = ParkedPredicateIndex::new(1); + index.park( + hash, + 7, + ValidityPredicate::Balance { + address, + op: ValidityOperator::GreaterThan, + value: U256::MAX, + }, ); - - let passing = - ValidityPredicate::BlockNumber { op: ValidityOperator::Equal, value: U256::from(1) }; - assert_eq!( - ValidityPredicateKey::first_unsatisfied(&[passing], &mut db, &context).unwrap(), - None + assert_eq!(index.bucket_depths().collect::>(), vec![1]); + assert!( + index + .affected_by_state(&changed_balance(address, 0, 1)) + .affected_transactions + .is_empty() ); + assert_eq!(index.remove(hash), Some(7)); + assert!(index.is_empty()); } #[test] - fn context_blockers_are_not_woken_by_state() { + fn selfdestruct_conservatively_wakes_ordered_storage_buckets() { + let address = Address::with_last_byte(1); let hash = B256::with_last_byte(1); - let mut index = ParkedPredicateIndex::default(); - index.park(hash, (), ValidityPredicateKey::BlockNumber); - - let mut state = EvmState::default(); + let mut index = ParkedPredicateIndex::new(1); + index.park(hash, (), storage(address, U256::MAX, ValidityOperator::GreaterThan, 10)); let mut account = Account::default(); - account.info.balance = U256::ONE; - state.insert(Address::with_last_byte(1), account); - - assert_eq!(index.affected_by_state(&state), StateChangeEffects::default()); + account.mark_selfdestruct(); + let effects = index.affected_by_state(&EvmState::from_iter([(address, account)])); + assert_eq!( + effects, + StateChangeEffects { affected_transactions: vec![hash], woken_buckets: 1 } + ); } } From aaf93684773af23c1c504f6b840acc588af698c2 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Mon, 21 Sep 2026 14:23:31 +0000 Subject: [PATCH 064/299] fix: remove unused find_l2_block_by_timestampe (#5195) --- crates/proof/zk/witness/src/fetcher.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/proof/zk/witness/src/fetcher.rs b/crates/proof/zk/witness/src/fetcher.rs index 767e24dc14..cf3f6f38b1 100644 --- a/crates/proof/zk/witness/src/fetcher.rs +++ b/crates/proof/zk/witness/src/fetcher.rs @@ -321,11 +321,6 @@ impl OPSuccinctDataFetcher { self.find_block_by_timestamp(&self.l1_provider, target_timestamp).await } - /// Finds the L2 block at the provided timestamp. - pub async fn find_l2_block_by_timestamp(&self, target_timestamp: u64) -> Result<(B256, u64)> { - self.find_block_by_timestamp(&self.l2_provider, target_timestamp).await - } - /// Finds the block at the provided timestamp, using the provided provider. async fn find_block_by_timestamp( &self, From 6a3a32ecbae0abc37bac888f0b9ab2197273dd1d Mon Sep 17 00:00:00 2001 From: Haardik Date: Mon, 21 Sep 2026 15:24:01 +0000 Subject: [PATCH 065/299] chore(common): schedule Mainnet Cobalt upgrade (#5196) Amp-Thread-ID: https://ampcode.com/threads/T-01a0c47c-4416-719a-ad1d-b041cade22be Co-authored-by: Amp --- 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 | 9 ++++++--- crates/execution/chainspec/src/spec.rs | 4 ++-- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/common/chains/src/chain.rs b/crates/common/chains/src/chain.rs index cd624718d9..956a9ddb31 100644 --- a/crates/common/chains/src/chain.rs +++ b/crates/common/chains/src/chain.rs @@ -175,7 +175,10 @@ mod tests { base_mainnet_forks[Beryl], ForkCondition::Timestamp(ChainConfig::mainnet().beryl_timestamp.unwrap()) ); - assert_eq!(base_mainnet_forks[Cobalt], ForkCondition::Never); + assert_eq!( + base_mainnet_forks[Cobalt], + ForkCondition::Timestamp(ChainConfig::mainnet().cobalt_timestamp.unwrap()) + ); assert_eq!(base_mainnet_forks[Denim], ForkCondition::Never); assert_eq!(base_mainnet_forks[Zenith], ForkCondition::Never); } @@ -317,6 +320,12 @@ mod tests { #[test] fn is_cobalt_active_at_timestamp() { + let base_mainnet_forks = ChainUpgrades::mainnet(); + assert!(!base_mainnet_forks.is_cobalt_active_at_timestamp(0)); + assert!(!base_mainnet_forks.is_cobalt_active_at_timestamp(1_790_791_199)); + assert!(base_mainnet_forks.is_cobalt_active_at_timestamp(1_790_791_200)); + assert!(base_mainnet_forks.is_cobalt_active_at_timestamp(u64::MAX)); + let base_sepolia_forks = ChainUpgrades::sepolia(); assert!(!base_sepolia_forks.is_cobalt_active_at_timestamp(0)); assert!(!base_sepolia_forks.is_cobalt_active_at_timestamp(1_790_186_399)); diff --git a/crates/common/chains/src/config.rs b/crates/common/chains/src/config.rs index 927fd8a65c..99126440c5 100644 --- a/crates/common/chains/src/config.rs +++ b/crates/common/chains/src/config.rs @@ -471,7 +471,7 @@ const MAINNET: ChainConfig = ChainConfig { jovian_timestamp: 1_764_691_201, azul_timestamp: Some(1_779_991_200), beryl_timestamp: Some(1_782_410_400), - cobalt_timestamp: None, + cobalt_timestamp: Some(1_790_791_200), denim_timestamp: None, genesis_l1_hash: b256!("5c13d307623a926cd31415036c8b7fa14572f9dac64528e857a470511fc30771"), diff --git a/crates/common/chains/src/upgrade.rs b/crates/common/chains/src/upgrade.rs index df4ea5dd0b..a3b7ef117b 100644 --- a/crates/common/chains/src/upgrade.rs +++ b/crates/common/chains/src/upgrade.rs @@ -325,6 +325,11 @@ mod tests { ChainConfig::mainnet().beryl_timestamp.unwrap(), BaseUpgrade::Beryl, ), + ( + Chain::base_mainnet(), + ChainConfig::mainnet().cobalt_timestamp.unwrap(), + BaseUpgrade::Cobalt, + ), ( 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 ab2134ac7c..441145d345 100644 --- a/crates/common/genesis/src/chain/upgrade.rs +++ b/crates/common/genesis/src/chain/upgrade.rs @@ -667,7 +667,7 @@ impl UpgradeConfig { base: BaseUpgradeConfig { azul: Some(1_779_991_200), beryl: Some(1_782_410_400), - cobalt: None, + cobalt: Some(1_790_791_200), denim: None, zenith: None, }, @@ -1109,7 +1109,7 @@ mod runtime_tests { assert_eq!( BaseUpgrade::from_chain_and_timestamp(CHAIN_ID, u64::MAX), - Some(BaseUpgrade::Azul), + Some(BaseUpgrade::Cobalt), ); RuntimeUpgradeRegistry::clear_chain(CHAIN_ID); @@ -1127,7 +1127,10 @@ 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::Beryl)); + assert_eq!( + BaseUpgrade::from_chain_and_timestamp(8453, u64::MAX), + Some(BaseUpgrade::Cobalt) + ); assert_eq!( BaseUpgrade::from_chain_and_timestamp(84532, u64::MAX), Some(BaseUpgrade::Cobalt) diff --git a/crates/execution/chainspec/src/spec.rs b/crates/execution/chainspec/src/spec.rs index 3608b638ed..e7916ebdc9 100644 --- a/crates/execution/chainspec/src/spec.rs +++ b/crates/execution/chainspec/src/spec.rs @@ -1321,7 +1321,7 @@ mod tests { fn latest_base_mainnet_fork_id() { let base_mainnet_spec = BaseChainSpec::mainnet(); assert_eq!( - base_mainnet_spec.hardfork_fork_id(BaseUpgrade::Beryl).unwrap(), + base_mainnet_spec.hardfork_fork_id(BaseUpgrade::Cobalt).unwrap(), base_mainnet_spec.latest_fork_id() ) } @@ -1331,7 +1331,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::Beryl).unwrap(), + base_mainnet_spec.hardfork_fork_id(BaseUpgrade::Cobalt).unwrap(), base_mainnet.latest_fork_id() ) } From 0a8b484f471b839bc4a1a3f45153c44fb12cc458 Mon Sep 17 00:00:00 2001 From: refcell Date: Mon, 21 Sep 2026 15:25:51 +0000 Subject: [PATCH 066/299] fix(cli): restore unified OTLP tracing (#5172) Amp-Thread-ID: https://ampcode.com/threads/T-01a0b70a-a4d2-7134-8238-e5dab4d6dbf7 Co-authored-by: Amp --- Cargo.lock | 1 + bin/base/Cargo.toml | 5 +- bin/base/README.md | 17 ++++ bin/base/src/cli.rs | 196 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 213 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5610b23bf3..36f194a77f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2931,6 +2931,7 @@ dependencies = [ "reth-cli-commands", "reth-cli-runner", "reth-cli-util", + "reth-node-core", "rstest", "serde", "serde_json", diff --git a/bin/base/Cargo.toml b/bin/base/Cargo.toml index 3a4956a9fd..977130f844 100644 --- a/bin/base/Cargo.toml +++ b/bin/base/Cargo.toml @@ -18,7 +18,6 @@ workspace = true [dependencies] # workspace base-reth-cli.workspace = true -base-cli-utils.workspace = true base-node-core.workspace = true base-txpool-rpc.workspace = true base-batcher-cli.workspace = true @@ -35,11 +34,13 @@ base-builder-multiplex.workspace = true base-execution-chainspec.workspace = true base-execution-consensus.workspace = true base-shadow-indexer.workspace = true +base-cli-utils = { workspace = true, features = ["otlp"] } # alloy alloy-chains = { workspace = true, features = ["std"] } # reth +reth-node-core.workspace = true reth-cli-runner.workspace = true reth-cli-commands.workspace = true reth-cli-util = { workspace = true, optional = true } @@ -63,7 +64,7 @@ tempfile.workspace = true serde_json.workspace = true base-common-genesis.workspace = true figment = { workspace = true, features = ["test"] } -axum = { workspace = true, features = ["tokio", "http1"] } +axum = { workspace = true, features = ["tokio", "http1", "http2"] } tokio = { workspace = true, features = ["net", "time", "macros", "process", "rt-multi-thread"] } [features] diff --git a/bin/base/README.md b/bin/base/README.md index 82b924a08b..22739a2951 100644 --- a/bin/base/README.md +++ b/bin/base/README.md @@ -2,6 +2,23 @@ Unified Base node binary. +## OTLP Tracing + +Unified commands, including `rpc`, `follow`, and `sequencer`, support the same +OTLP span-export options as the standalone nodes. Set +`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL` (`http` or +`grpc`), and `OTEL_SERVICE_NAME`, or use their CLI equivalents: + +```text +base rpc --tracing-otlp=http://localhost:4317 --tracing-otlp-protocol=grpc --tracing-otlp.filter=info,reth=debug +``` + +Tracing flags work before or after the subcommand. `--tracing-otlp.filter` +controls exported spans independently of stdout/file logging verbosity, and +`--tracing-otlp.sample-ratio` controls sampling. Export is disabled unless an +endpoint is configured; HTTP endpoints use `/v1/traces` and gRPC endpoints use +the collector address without that path. + ## `base batcher` `base batcher` submits L2 batch data to L1. It uses the shared logging and metrics diff --git a/bin/base/src/cli.rs b/bin/base/src/cli.rs index 9fc9cb5c0f..852c76cc5a 100644 --- a/bin/base/src/cli.rs +++ b/bin/base/src/cli.rs @@ -1,6 +1,7 @@ use base_cli_utils::{LogConfig, MetricsConfig}; use clap::Parser; use eyre::WrapErr; +use reth_node_core::args::TraceArgs; use crate::{ commands::BaseCommand, @@ -32,6 +33,10 @@ pub(crate) struct BaseCli { #[command(flatten)] pub(crate) logging: LogArgs, + /// `OpenTelemetry` tracing export configuration. + #[command(flatten)] + pub traces: TraceArgs, + /// Metrics configuration. #[command(flatten)] pub(crate) metrics: MetricsArgs, @@ -44,9 +49,23 @@ pub(crate) struct BaseCli { impl BaseCli { /// Runs the selected command with shared process initialization. pub(crate) fn run(self) -> eyre::Result<()> { - LogConfig::from(self.logging) - .init_tracing_subscriber() - .wrap_err("failed to initialize tracing")?; + // Tonic captures the runtime during OTLP initialization. Keep it alive while the + // command runs, but leave its context before commands enter their own runtimes. + let tracing_runtime = self + .traces + .otlp + .as_ref() + .map(|_| { + tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build() + }) + .transpose() + .wrap_err("failed to create tracing runtime")?; + { + let _guard = tracing_runtime.as_ref().map(tokio::runtime::Runtime::enter); + LogConfig::from(self.logging) + .init_with_trace_args(&self.traces, &[]) + .wrap_err("failed to initialize tracing")?; + } let metrics_enabled = self.metrics.enabled; MetricsConfig::from(self.metrics) @@ -62,12 +81,181 @@ impl BaseCli { #[cfg(test)] mod tests { - use std::ffi::OsStr; + use std::{ffi::OsStr, process::Command, thread, time::Duration}; + use axum::{Router, body::Bytes, routing::post}; use clap::{CommandFactory, Parser}; + use tokio::{net::TcpListener, sync::mpsc}; use super::*; + #[test] + fn parses_shared_otlp_configuration() { + for flavor in ["rpc", "follow", "sequencer"] { + for before_subcommand in [false, true] { + let mut args = vec!["base"]; + if !before_subcommand { + args.push(flavor); + } + args.extend([ + "--tracing-otlp=http://localhost:4317", + "--tracing-otlp-protocol", + "grpc", + "--tracing-otlp.filter", + "warn,base_otlp_test=debug", + "--tracing-otlp.service-name", + "unified-test", + "--tracing-otlp.sample-ratio", + "0.25", + ]); + if before_subcommand { + args.push(flavor); + } + args.extend([ + "--l1-eth-rpc", + "http://localhost:8545", + "--l1-beacon", + "http://localhost:5052", + ]); + if flavor == "follow" { + args.extend(["--source-l2-rpc", "http://localhost:9545"]); + } else if flavor == "sequencer" { + args.extend(["--p2p.sequencer.key.path", "/tmp/sequencer-key"]); + } + let cli = BaseCli::try_parse_from(args).unwrap(); + assert_eq!(cli.traces.otlp.unwrap().as_str(), "http://localhost:4317/"); + assert_eq!(cli.traces.service_name, "unified-test"); + assert_eq!(cli.traces.sample_ratio, Some(0.25)); + assert_eq!(cli.traces.otlp_filter.to_string(), "base_otlp_test=debug,warn"); + } + } + } + + #[test] + fn exports_otlp_spans_with_independent_filter() { + // Each exporter needs its own process because the tracing subscriber is global. + for protocol in ["http", "grpc"] { + let mut command = Command::new(std::env::current_exe().unwrap()); + for (key, _) in std::env::vars() { + if key.starts_with("OTEL_") || key.starts_with("BASE_") || key == "RUST_LOG" { + command.env_remove(key); + } + } + let output = command + .args(["--exact", "cli::tests::exports_otlp_child", "--ignored", "--nocapture"]) + .env("BASE_OTLP_TEST_PROTOCOL", protocol) + .env("BASE_NODE_METRICS_ENABLED", "false") + .env("OTEL_BSP_SCHEDULE_DELAY", "50") + .env("OTEL_SERVICE_NAME", "unified-otlp-test") + .output() + .unwrap(); + assert!( + output.status.success(), + "{protocol} export failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + } + + #[tokio::test] + #[ignore = "spawned by exports_otlp_spans_with_independent_filter"] + async fn exports_otlp_child() { + let protocol = std::env::var("BASE_OTLP_TEST_PROTOCOL").unwrap(); + let collector = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", collector.local_addr().unwrap()); + let (sender, mut requests) = mpsc::unbounded_channel(); + let grpc = protocol == "grpc"; + let path = if grpc { + "/opentelemetry.proto.collector.trace.v1.TraceService/Export" + } else { + "/v1/traces" + }; + let router = Router::new().route( + path, + post(move |body: Bytes| { + let sender = sender.clone(); + async move { + sender.send(body).unwrap(); + let content_type = + if grpc { "application/grpc" } else { "application/x-protobuf" }; + // An empty ExportTraceServiceResponse, with a gRPC message envelope if needed. + ( + [("content-type", content_type), ("grpc-status", "0")], + if grpc { vec![0_u8; 5] } else { Vec::new() }, + ) + } + }), + ); + tokio::spawn(async move { axum::serve(collector, router).await.unwrap() }); + + // Keep the unified RPC command running without any external network dependency. + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream_url = format!("http://{}", upstream.local_addr().unwrap()); + let dir = tempfile::tempdir().unwrap(); + let otlp_arg = format!("--tracing-otlp={endpoint}"); + let cli = BaseCli::try_parse_from([ + "base", + "--chain", + "dev", + "rpc", + "--datadir", + dir.path().to_str().unwrap(), + "--ipcpath", + dir.path().join("rpc.ipc").to_str().unwrap(), + "--port", + "0", + "--authrpc.port", + "0", + "--rpc.port", + "0", + "--p2p.listen.tcp", + "0", + "--p2p.listen.udp", + "0", + "--disable-discovery", + "--l1-eth-rpc", + &upstream_url, + "--l1-beacon", + &upstream_url, + "-q", + &otlp_arg, + "--tracing-otlp-protocol", + &protocol, + "--tracing-otlp.filter", + "off,base_otlp_test=debug", + ]) + .unwrap(); + let node = + thread::Builder::new().stack_size(32 * 1024 * 1024).spawn(move || cli.run()).unwrap(); + let received = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node.is_finished() { + panic!("node exited before OTLP export: {:?}", node.join().unwrap()); + } + { + let _span = + tracing::debug_span!(target: "base_otlp_test", "included_span").entered(); + } + { + let _span = + tracing::error_span!(target: "excluded_target", "excluded_span").entered(); + } + tokio::select! { + Some(body) = requests.recv() => break body, + _ = tokio::time::sleep(Duration::from_millis(50)) => {} + } + } + }) + .await + .expect("timed out waiting for OTLP export"); + // Protobuf string fields retain their UTF-8 bytes in both transport encodings. + let payload = String::from_utf8_lossy(&received); + assert!(payload.contains("included_span"), "missing enabled debug span: {payload}"); + assert!(payload.contains("unified-otlp-test"), "missing service name: {payload}"); + assert!(!payload.contains("excluded_span"), "OTLP filter was ignored: {payload}"); + } + #[test] fn parses_batcher_configuration() { let cli = BaseCli::try_parse_from([ From 5c626d715b678dc378408097a6adbfbb263d1a7e Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Mon, 21 Sep 2026 17:27:33 +0000 Subject: [PATCH 067/299] chore(bench): remove snapshot gas limit tag (#5200) --- etc/systems/src/benchmark_report.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/etc/systems/src/benchmark_report.rs b/etc/systems/src/benchmark_report.rs index 5693c1ffac..161dd5e1e7 100644 --- a/etc/systems/src/benchmark_report.rs +++ b/etc/systems/src/benchmark_report.rs @@ -209,14 +209,12 @@ impl SnapshotBenchmarkReportConfig { let validator_gas_per_second = result.validator_blocks.iter().map(|block| block.gas_used).sum::() as f64 / (result.validator_blocks.len() as f64 * block_seconds); - let gas_limit = result.blocks.first().map(|block| block.gas_limit).unwrap_or_default(); let transaction_payload = Self::transaction_payload(result); let test_config = BTreeMap::from([ ("BenchmarkRun".to_string(), serde_json::Value::String(self.benchmark_run.clone())), ("Scenario".to_string(), serde_json::Value::String(self.scenario.clone())), ("ChainId".to_string(), result.chain_id.into()), ("BlockTimeMilliseconds".to_string(), result.block_interval_ms.into()), - ("GasLimit".to_string(), gas_limit.into()), ("NodeType".to_string(), serde_json::Value::String("base-reth-node".to_string())), ("TransactionPayload".to_string(), serde_json::Value::String(transaction_payload)), ("ClientVersion".to_string(), serde_json::Value::String(self.client_version.clone())), @@ -406,7 +404,7 @@ transactions: assert_eq!(metadata["runs"][0]["outputDir"], output_name); assert_eq!(metadata["runs"][0]["testConfig"]["BenchmarkRun"], "snapshot-throughput"); assert_eq!(metadata["runs"][0]["testConfig"]["Scenario"], "blake2f-200ms"); - assert_eq!(metadata["runs"][0]["testConfig"]["GasLimit"], 400_000_000); + assert!(metadata["runs"][0]["testConfig"].get("GasLimit").is_none()); assert_eq!(metadata["runs"][0]["testConfig"]["TransactionPayload"], "blake2f"); assert_eq!( metadata["runs"][0]["result"]["artifacts"]["loadTestResult"], From 1734b76fb988cb29250e2e2bc22ef296993d0457 Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Mon, 21 Sep 2026 17:50:35 +0000 Subject: [PATCH 068/299] feat(cli): fail closed on a signing key in isolated sequencer mode (#5142) --- crates/consensus/cli/src/node.rs | 58 +++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/crates/consensus/cli/src/node.rs b/crates/consensus/cli/src/node.rs index ae62b0c122..a03b201dcf 100644 --- a/crates/consensus/cli/src/node.rs +++ b/crates/consensus/cli/src/node.rs @@ -358,16 +358,18 @@ impl ConsensusNodeArgs { Ok(config) } - /// Validates that a non-shadow sequencer has a signing key configured. + /// Validates the signing-key requirements for the configured sequencer mode. pub fn validate_sequencer_key(&self) -> eyre::Result<()> { - if self.config.node_mode.is_sequencer() - && !self.config.sequencer_flags.config().is_shadow_sequencer() - { + if self.config.node_mode.is_sequencer() { + let sequencer = self.config.sequencer_flags.config(); let signer = &self.config.p2p_flags.signer; - if signer.sequencer_key.is_none() - && signer.sequencer_key_path.is_none() - && signer.endpoint.is_none() - { + let has_signing_key = signer.sequencer_key.is_some() + || signer.sequencer_key_path.is_some() + || signer.endpoint.is_some(); + if sequencer.isolated && has_signing_key { + eyre::bail!("isolated sequencer must not configure a signing key"); + } + if !sequencer.isolated && !sequencer.is_shadow_sequencer() && !has_signing_key { eyre::bail!( "sequencer mode requires a signing key; \ provide --p2p.sequencer.key, --p2p.sequencer.key.path, \ @@ -994,6 +996,46 @@ mod tests { assert!(args.validate_sequencer_key().is_ok()); } + #[test] + fn isolated_sequencer_does_not_require_signing_key() { + let args = ConsensusNodeArgs::new( + ConsensusChainArgs { l2_chain_id: Chain::from(8453_u64) }, + ConsensusNodeConfigArgs { + node_mode: NodeMode::Sequencer, + sequencer_flags: SequencerArgs { isolated: true, ..SequencerArgs::default() }, + ..default_node_config_args() + }, + ); + + assert!(args.validate_sequencer_key().is_ok()); + } + + #[rstest] + #[case::raw_key(SignerArgs { sequencer_key: Some(B256::ZERO), ..Default::default() })] + #[case::key_path(SignerArgs { + sequencer_key_path: Some(PathBuf::from("/tmp/key.hex")), + ..Default::default() + })] + #[case::remote_endpoint(SignerArgs { + endpoint: Some(Url::parse("http://localhost:8080").unwrap()), + ..Default::default() + })] + fn isolated_sequencer_rejects_signing_key(#[case] signer: SignerArgs) { + let args = ConsensusNodeArgs::new( + ConsensusChainArgs { l2_chain_id: Chain::from(8453_u64) }, + ConsensusNodeConfigArgs { + node_mode: NodeMode::Sequencer, + p2p_flags: P2PArgs { signer, ..P2PArgs::default() }, + sequencer_flags: SequencerArgs { isolated: true, ..SequencerArgs::default() }, + ..default_node_config_args() + }, + ); + + let error = args.validate_sequencer_key().unwrap_err(); + + assert_eq!(error.to_string(), "isolated sequencer must not configure a signing key"); + } + #[test] fn shadow_funding_is_rejected_outside_shadow_sequencer_mode() { let args = ConsensusNodeArgs::new( From 4878643243b8e444e1210cc8b2bfd9ef4ad9b66d Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Mon, 21 Sep 2026 18:57:40 +0000 Subject: [PATCH 069/299] ci(bench): add opt-in fresh-devnet advisory workflow (#5132) * ci(bench): add opt-in fresh-devnet advisory workflow * ci(bench): ignore unrelated label changes --- .depot/workflows/bench-fresh-devnet.yml | 181 ++++++++++++++++++++ etc/scripts/ci/render_fresh_devnet_bench.py | 145 ++++++++++++++++ etc/systems/README.md | 3 + 3 files changed, 329 insertions(+) create mode 100644 .depot/workflows/bench-fresh-devnet.yml create mode 100755 etc/scripts/ci/render_fresh_devnet_bench.py diff --git a/.depot/workflows/bench-fresh-devnet.yml b/.depot/workflows/bench-fresh-devnet.yml new file mode 100644 index 0000000000..a2a394a1fb --- /dev/null +++ b/.depot/workflows/bench-fresh-devnet.yml @@ -0,0 +1,181 @@ +name: Fresh devnet benchmarks (advisory) + +on: + pull_request: + # `labeled` starts the opt-in suite and `unlabeled` cancels an in-progress + # suite when its opt-in label is removed. + types: [opened, reopened, synchronize, labeled, unlabeled] + workflow_dispatch: + +concurrency: + # Only `bench:tps` label changes belong to the PR's benchmark concurrency group. Other label + # changes are skipped and receive a per-run group, so they cannot interrupt an active benchmark. + group: >- + ${{ github.workflow }}-${{ + github.event_name == 'pull_request' && + ( + ( + github.event.action != 'labeled' && + github.event.action != 'unlabeled' + ) || + github.event.label.name == 'bench:tps' + ) && + format('pr-{0}', github.event.pull_request.number) || + github.run_id + }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTC_WRAPPER: "sccache" + BENCHMARK_OUTPUT: ${{ runner.temp }}/fresh-devnet-benchmark + BENCHMARK_VISUALIZER_REF: "582d987fd86a8ac5575d44bc53229297071a2db8" + +permissions: + contents: read + pull-requests: write + +jobs: + benchmark: + name: Fresh devnet workload suite + # The suite is deliberately opt-in: it is expensive and starts services. Keep its write-capable + # token away from untrusted fork code; contributors can run it from a trusted base/base branch + # after applying the `bench:tps` label. + if: > + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'bench:tps') && + ((github.event.action != 'labeled' && github.event.action != 'unlabeled') || + github.event.label.name == 'bench:tps')) + # Depot's largest x86 runner: 64 vCPUs, 256 GB memory, and a 32 GB RAM disk accelerator. + runs-on: depot-ubuntu-24.04-64 + timeout-minutes: 90 + env: + BENCHMARK_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout benchmark target + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 1 + + - name: Setup Rust toolchain, cache, and dependencies + uses: ./.depot/actions/setup + with: + native-deps: "true" + foundry: "true" + + - name: Build system-test contracts + run: just build::contracts + + - name: Pre-pull devnet images + run: just devnet pull-images + + - name: Build base-bench + run: cargo build --locked --release -p base-system-tests --bin base-bench + + - name: Run fresh-devnet workload suite + continue-on-error: true + shell: bash + run: | + set -euo pipefail + target/release/base-bench local \ + --workload-config etc/benchmarks/fresh-devnet.yml \ + --output-dir "$BENCHMARK_OUTPUT" \ + --client-version "base/${BENCHMARK_SHA}" + + # This must run after a failed suite too: base-bench persists any completed workload's + # sidecar and the suite manifest before returning a nonzero exit code. + - name: Render benchmark results + if: always() + id: render + shell: bash + env: + WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + python3 etc/scripts/ci/render_fresh_devnet_bench.py \ + --results-dir "$BENCHMARK_OUTPUT" \ + --output "$RUNNER_TEMP/fresh-devnet-bench-comment.md" \ + --run-url "$WORKFLOW_RUN_URL" \ + --commit "$BENCHMARK_SHA" + cat "$RUNNER_TEMP/fresh-devnet-bench-comment.md" >> "$GITHUB_STEP_SUMMARY" + if [ -f "$BENCHMARK_OUTPUT/metadata.json" ]; then + echo "has-results=true" >> "$GITHUB_OUTPUT" + else + echo "has-results=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout benchmark visualizer + if: always() && steps.render.outputs.has-results == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: base/benchmark + ref: ${{ env.BENCHMARK_VISUALIZER_REF }} + path: benchmark-visualizer + fetch-depth: 1 + + - name: Setup Node.js for benchmark visualizer + if: always() && steps.render.outputs.has-results == 'true' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Build benchmark visualizer + if: always() && steps.render.outputs.has-results == 'true' + continue-on-error: true + shell: bash + run: | + set -euo pipefail + rm -rf benchmark-visualizer/output + ln -s "$BENCHMARK_OUTPUT" benchmark-visualizer/output + corepack enable + yarn --cwd benchmark-visualizer install --immutable + yarn --cwd benchmark-visualizer build + + - name: Upload raw benchmark results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fresh-devnet-benchmark-results + path: ${{ env.BENCHMARK_OUTPUT }} + if-no-files-found: warn + retention-days: 14 + + - name: Upload benchmark visualizer + if: always() && steps.render.outputs.has-results == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fresh-devnet-benchmark-visualizer + path: benchmark-visualizer/dist + if-no-files-found: warn + retention-days: 14 + + - name: Create or update benchmark results comment + if: > + always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + COMMENT_PATH: ${{ runner.temp }}/fresh-devnet-bench-comment.md + run: | + set -euo pipefail + marker='' + existing_id=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | startswith(\"${marker}\")) | .id" \ + | head -1) + + if [ -n "$existing_id" ]; then + gh api "repos/${REPO}/issues/comments/${existing_id}" \ + -X PATCH -F body=@"$COMMENT_PATH" > /dev/null + else + gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + -F body=@"$COMMENT_PATH" > /dev/null + fi diff --git a/etc/scripts/ci/render_fresh_devnet_bench.py b/etc/scripts/ci/render_fresh_devnet_bench.py new file mode 100755 index 0000000000..cb053889f4 --- /dev/null +++ b/etc/scripts/ci/render_fresh_devnet_bench.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Render a single PR comment from a fresh-devnet base-bench workload suite.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +MARKER = "" + + +def markdown_cell(value: object) -> str: + """Format untrusted JSON text safely inside a one-line Markdown table cell.""" + return str(value).replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ") + + +def load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as file: + value = json.load(file) + if not isinstance(value, dict): + raise ValueError(f"expected a JSON object in {path}") + return value + + +def number(value: object, precision: int = 0) -> str: + if not isinstance(value, (int, float)): + return "—" + return f"{value:,.{precision}f}" + + +def duration(value: object) -> str: + if not isinstance(value, dict): + return "—" + seconds = value.get("secs") + nanoseconds = value.get("nanos") + if not isinstance(seconds, int) or not isinstance(nanoseconds, int): + return "—" + milliseconds = seconds * 1_000 + nanoseconds / 1_000_000 + if milliseconds < 1: + return f"{milliseconds * 1_000:.0f} µs" + return f"{milliseconds:.1f} ms" + + +def summary_values(results_dir: Path, output_dir: str) -> tuple[str, str, str, str]: + summary_path = results_dir / output_dir / "load-test-result.json" + if not summary_path.is_file(): + return "—", "—", "—", "—" + try: + summary = load_json(summary_path) + except (OSError, ValueError, json.JSONDecodeError): + return "—", "—", "—", "—" + throughput = summary.get("throughput", {}) + block_latency = summary.get("block_latency", {}) + if not isinstance(throughput, dict) or not isinstance(block_latency, dict): + return "—", "—", "—", "—" + return ( + number(throughput.get("total_confirmed")), + number(throughput.get("tps"), 1), + number(throughput.get("gps"), 0), + duration(block_latency.get("p50")), + ) + + +def render_comment(results_dir: Path, run_url: str, commit: str) -> str: + suite_path = results_dir / "suite-results.json" + if not suite_path.is_file(): + return "\n".join( + [ + MARKER, + "## Fresh-devnet `base-bench` results", + "", + f"Commit `{markdown_cell(commit)}` · [workflow run]({run_url})", + "", + "❌ The suite did not produce a results manifest. The build or devnet setup failed " + "before the workloads could begin; see the workflow run for details.", + "", + ] + ) + suite = load_json(suite_path) + runs = suite.get("runs") + if not isinstance(runs, list): + raise ValueError("suite-results.json is missing its runs array") + + lines = [ + MARKER, + "## Fresh-devnet `base-bench` results", + "", + f"Commit `{markdown_cell(commit)}` · [workflow run]({run_url})", + "", + "Every workload ran against its own newly initialized, empty Base devnet. " + "B-20 workloads enable Beryl at genesis. These are advisory measurements, not merge gates.", + "", + "| Workload | Payload | Status | Confirmed | TPS | Gas/s | p50 block latency |", + "| --- | --- | --- | ---: | ---: | ---: | ---: |", + ] + failures: list[tuple[str, str]] = [] + for run in runs: + if not isinstance(run, dict): + raise ValueError("suite-results.json contains a non-object run") + workload = markdown_cell(run.get("workload", "unknown")) + payload = markdown_cell(run.get("transaction_payload", "unknown")) + output_dir = run.get("output_dir") + if not isinstance(output_dir, str): + output_dir = "" + confirmed, tps, gps, latency = summary_values(results_dir, output_dir) + success = run.get("success") is True + status = "✅ passed" if success else "❌ failed" + lines.append( + f"| `{workload}` | `{payload}` | {status} | {confirmed} | {tps} | {gps} | {latency} |" + ) + error = run.get("error") + if not success and isinstance(error, str) and error: + failures.append((workload, error)) + + lines.extend( + [ + "", + "Artifacts: `fresh-devnet-benchmark-results` contains the raw load-test sidecars; " + "`fresh-devnet-benchmark-visualizer` contains a static [base/benchmark](https://github.com/base/benchmark) report.", + ] + ) + if failures: + lines.extend(["", "

Failed workload details", ""]) + for workload, error in failures: + lines.append(f"- `{workload}`: `{markdown_cell(error)[:1_000]}`") + lines.extend(["", "
"]) + return "\n".join(lines) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--results-dir", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--run-url", required=True) + parser.add_argument("--commit", required=True) + args = parser.parse_args() + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(render_comment(args.results_dir, args.run_url, args.commit), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/etc/systems/README.md b/etc/systems/README.md index ee48793f9f..27d1f5a6ea 100644 --- a/etc/systems/README.md +++ b/etc/systems/README.md @@ -168,6 +168,9 @@ results/fresh-devnet/ `metadata.json` and the load-test sidecars are directly consumable by the static visualizer in `base/benchmark`; link this output directory to that repository's ignored `output/` directory and run its normal production build. +The opt-in Depot workflow runs this suite for trusted `base/base` pull requests +with the `bench:tps` label. It publishes raw and visualizer artifacts and updates +one advisory PR comment with the workload summaries. Swap workloads can opt into fresh-devnet contract provisioning with `deploy_devnet_swap_harness: true` on a workload entry. That mode deploys a fresh devnet USDC token plus Uniswap/Aerodrome router shims for each workload, From e4da73939fc1502a92f189cd83676c1d167cae7f Mon Sep 17 00:00:00 2001 From: refcell Date: Mon, 21 Sep 2026 19:27:47 +0000 Subject: [PATCH 070/299] fix(ci): Remove Superseded Review Summary Comments (#5206) * fix(ci): remove superseded review summary comments Amp-Thread-ID: https://ampcode.com/threads/T-01a0c490-b863-70b8-8840-755728aa8a51 Co-authored-by: Amp * fix(ci): attempt every stale review summary deletion Amp-Thread-ID: https://ampcode.com/threads/T-01a0c490-b863-70b8-8840-755728aa8a51 Co-authored-by: Amp * fix(ci): clear stale summaries after clean reviews Amp-Thread-ID: https://ampcode.com/threads/T-01a0c564-f4f3-73ab-98d4-131818c60ac3 Co-authored-by: Amp --------- Co-authored-by: Amp --- .github/workflows/claude-review.yml | 61 ++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 51907b2172..e5ec0ea1f7 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -40,6 +40,20 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 + + - name: Record pre-review comment boundary + id: review-comments + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + shell: bash + run: | + set -euo pipefail + latest_comment_id="$(gh api --paginate --slurp "repos/$GH_REPO/issues/$PR_NUMBER/comments" | + jq '[.[][] | .id] | max // 0')" + echo "latest_comment_id=$latest_comment_id" >> "$GITHUB_OUTPUT" + - name: Run Claude Code id: claude-review uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29 @@ -101,12 +115,47 @@ jobs: A Critical finding must explain the triggering input/state/config, affected code path, propagated error or panic, why block production or validator progression can halt/stall, and the missing mitigation or test. COMMENT MANAGEMENT: - Before posting your review summary, delete ONLY your own previous summary comments from earlier runs, identified by the leading marker ``. - Do NOT use `gh pr comment --delete-last`: it deletes the most recent github-actions[bot] top-level comment regardless of which workflow created it, so it wipes unrelated bot comments (e.g. the benchmark results comment) that also post as github-actions[bot]. - 1. List your prior summary comment ids: gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments --jq '.[] | select(.body | startswith("")) | .id' - 2. Delete each id individually: gh api -X DELETE repos/${{ github.repository }}/issues/comments/ - 3. Then post your new summary using: gh pr comment ${{ github.event.pull_request.number }} --body "\n\n" + If you have findings, post one summary using `gh pr comment ${{ github.event.pull_request.number }} --body ...`. + Start the body with the exact marker ``, followed by a blank line and your summary. + Do not edit or delete existing top-level comments. A dedicated workflow step removes older marked summaries after you finish. claude_args: | --model claude-opus-4-6-default - --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git ls-files docs),Bash(git show HEAD:docs/:*),Bash(cat /home/runner/.claude/projects/:*),Bash(gh pr comment ${{ github.event.pull_request.number }} --body:*),Bash(gh pr comment ${{ github.event.pull_request.number }} --edit-last --body:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments:*),Bash(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments:*),Bash(gh api -X DELETE repos/${{ github.repository }}/issues/comments/:*)" + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git ls-files docs),Bash(git show HEAD:docs/:*),Bash(cat /home/runner/.claude/projects/:*),Bash(gh pr comment ${{ github.event.pull_request.number }} --body:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments:*),Bash(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments:*)" + + - name: Remove superseded review summaries + # A review can post its summary before a later action error. + if: ${{ !cancelled() && steps.review-comments.outcome == 'success' }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PRE_REVIEW_COMMENT_ID: ${{ steps.review-comments.outputs.latest_comment_id }} + REVIEW_OUTCOME: ${{ steps.claude-review.outcome }} + shell: bash + run: | + set -euo pipefail + stale_comments="$(mktemp)" + trap 'rm -f "$stale_comments"' EXIT + + # Fetch every page before deleting anything. Keep the newest summary + # unless a successful review posted none, making all old summaries stale. + # Failed reviews without a replacement retain the latest prior summary. + # Never touch human comments or comments owned by other workflows. + gh api --paginate --slurp "repos/$GH_REPO/issues/$PR_NUMBER/comments" | + jq -r --argjson boundary "$PRE_REVIEW_COMMENT_ID" --arg outcome "$REVIEW_OUTCOME" '[.[][] + | select(.user.login == "github-actions[bot]" and .user.type == "Bot") + | select((.body // "") | startswith(""))] + | sort_by(.id) + | if $outcome == "success" and ((.[-1].id // 0) <= $boundary) + then .[] else .[:-1][] end + | .id' > "$stale_comments" + + cleanup_failed=0 + while IFS= read -r comment_id; do + if ! gh api --method DELETE "repos/$GH_REPO/issues/comments/$comment_id"; then + echo "::warning::Failed to delete stale review summary $comment_id" + cleanup_failed=1 + fi + done < "$stale_comments" + exit "$cleanup_failed" From 659901ad11b80dc42d5d9acdd476acabaf4b4f7a Mon Sep 17 00:00:00 2001 From: refcell Date: Mon, 21 Sep 2026 19:46:12 +0000 Subject: [PATCH 071/299] feat(cli): Default Mainnet, Sepolia, and Zeronet Upgrade Contracts (#5201) * feat(cli): default Base Sepolia upgrade signal contract Amp-Thread-ID: https://ampcode.com/threads/T-01a0c4ed-8f12-71bf-a2c5-016bb097c68c Co-authored-by: Amp * fix(cli): correct upgrade contract defaults for all Base networks Amp-Thread-ID: https://ampcode.com/threads/T-01a0c4fd-f5c4-709c-b924-c5204da3865b Co-authored-by: Amp * fix(cli): update mainnet upgrade contract address Amp-Thread-ID: https://ampcode.com/threads/T-01a0c4fd-f5c4-709c-b924-c5204da3865b Co-authored-by: Amp * fix(cli): shut down the OTLP test node before process exit Amp-Thread-ID: https://ampcode.com/threads/T-01a0c490-b863-70b8-8840-755728aa8a51 Co-authored-by: Amp --------- Co-authored-by: Amp --- bin/base/src/cli.rs | 61 +++++++++++-- bin/base/src/commands/rpc.rs | 5 ++ bin/base/src/commands/sequencer.rs | 1 + crates/consensus/cli/src/node.rs | 4 +- crates/utilities/upgrade-signal/README.md | 15 +++- .../upgrade-signal/src/config/args.rs | 89 ++++++++++++++++++- 6 files changed, 166 insertions(+), 9 deletions(-) diff --git a/bin/base/src/cli.rs b/bin/base/src/cli.rs index 852c76cc5a..77ef3ae42b 100644 --- a/bin/base/src/cli.rs +++ b/bin/base/src/cli.rs @@ -81,11 +81,20 @@ impl BaseCli { #[cfg(test)] mod tests { - use std::{ffi::OsStr, process::Command, thread, time::Duration}; + use std::{ffi::OsStr, process::Command}; + #[cfg(unix)] + use std::{thread, time::Duration}; + #[cfg(unix)] use axum::{Router, body::Bytes, routing::post}; use clap::{CommandFactory, Parser}; - use tokio::{net::TcpListener, sync::mpsc}; + #[cfg(unix)] + use tokio::{ + net::TcpListener, + process::Command as AsyncCommand, + signal::unix::{SignalKind, signal}, + sync::{mpsc, oneshot}, + }; use super::*; @@ -131,6 +140,7 @@ mod tests { } } + #[cfg(unix)] #[test] fn exports_otlp_spans_with_independent_filter() { // Each exporter needs its own process because the tracing subscriber is global. @@ -158,10 +168,14 @@ mod tests { } } + #[cfg(unix)] #[tokio::test] #[ignore = "spawned by exports_otlp_spans_with_independent_filter"] async fn exports_otlp_child() { let protocol = std::env::var("BASE_OTLP_TEST_PROTOCOL").unwrap(); + // Install before node startup so an early shutdown request cannot terminate the + // process before the CLI has registered its own SIGTERM listener. + let _sigterm = signal(SignalKind::terminate()).unwrap(); let collector = TcpListener::bind("127.0.0.1:0").await.unwrap(); let endpoint = format!("http://{}", collector.local_addr().unwrap()); let (sender, mut requests) = mpsc::unbounded_channel(); @@ -187,7 +201,15 @@ mod tests { } }), ); - tokio::spawn(async move { axum::serve(collector, router).await.unwrap() }); + let (stop_collector, collector_shutdown) = oneshot::channel(); + let collector = tokio::spawn(async move { + axum::serve(collector, router) + .with_graceful_shutdown(async { + let _ = collector_shutdown.await; + }) + .await + .unwrap(); + }); // Keep the unified RPC command running without any external network dependency. let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -231,7 +253,7 @@ mod tests { let received = tokio::time::timeout(Duration::from_secs(30), async { loop { if node.is_finished() { - panic!("node exited before OTLP export: {:?}", node.join().unwrap()); + break None; } { let _span = @@ -242,13 +264,40 @@ mod tests { tracing::error_span!(target: "excluded_target", "excluded_span").entered(); } tokio::select! { - Some(body) = requests.recv() => break body, + Some(body) = requests.recv() => break Some(body), _ = tokio::time::sleep(Duration::from_millis(50)) => {} } } }) + .await; + + // Export may complete before the CLI starts listening for shutdown. Retry the signal + // until it exits, keeping the collector, upstream and data directory alive throughout. + // Never let the test process exit with a detached node using RocksDB's global mutexes. + tokio::time::timeout(Duration::from_secs(30), async { + while !node.is_finished() { + let status = AsyncCommand::new("kill") + .args(["-TERM", &std::process::id().to_string()]) + .status() + .await + .unwrap(); + assert!(status.success(), "failed to request CLI shutdown"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) .await - .expect("timed out waiting for OTLP export"); + .expect("timed out waiting for CLI shutdown"); + let node_result = node.join(); + stop_collector.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(5), collector) + .await + .expect("timed out waiting for collector shutdown") + .unwrap(); + node_result.expect("CLI thread panicked").expect("CLI shutdown failed"); + let received = received + .expect("timed out waiting for OTLP export") + .expect("node exited before OTLP export"); + // Protobuf string fields retain their UTF-8 bytes in both transport encodings. let payload = String::from_utf8_lossy(&received); assert!(payload.contains("included_span"), "missing enabled debug span: {payload}"); diff --git a/bin/base/src/commands/rpc.rs b/bin/base/src/commands/rpc.rs index 333d08cfc1..1dba4e6e50 100644 --- a/bin/base/src/commands/rpc.rs +++ b/bin/base/src/commands/rpc.rs @@ -54,6 +54,11 @@ impl RpcCommand { let consensus_chain = resolved_chain.consensus_chain_args(); let mut execution = execution; let mut consensus_config: ConsensusNodeConfigArgs = consensus.into(); + execution + .standard + .rollup_args + .upgrade_signal + .apply_chain_default(execution_chain.chain().id()); execution .standard .rollup_args diff --git a/bin/base/src/commands/sequencer.rs b/bin/base/src/commands/sequencer.rs index 971836d7ae..536e200f59 100644 --- a/bin/base/src/commands/sequencer.rs +++ b/bin/base/src/commands/sequencer.rs @@ -57,6 +57,7 @@ impl SequencerCommand { }; let consensus_chain = resolved_chain.consensus_chain_args(); let mut consensus_config: ConsensusNodeConfigArgs = consensus.into(); + builder.rollup_args.upgrade_signal.apply_chain_default(execution_chain.chain().id()); builder .rollup_args .upgrade_signal_l1_rpc diff --git a/crates/consensus/cli/src/node.rs b/crates/consensus/cli/src/node.rs index a03b201dcf..c6ffbeec01 100644 --- a/crates/consensus/cli/src/node.rs +++ b/crates/consensus/cli/src/node.rs @@ -466,7 +466,9 @@ impl ConsensusNodeArgs { "overriding the L1 data-availability batcher sender filter" ); } - let upgrade_signal_config = self.config.upgrade_signal.config(); + let mut upgrade_signal = self.config.upgrade_signal.clone(); + upgrade_signal.apply_chain_default(cfg.l2_chain_id.id()); + let upgrade_signal_config = upgrade_signal.config(); let upgrade_signal_l1_rpc = overrides.upgrade_signal_l1_rpc.clone(); if let Some(signal_config) = &upgrade_signal_config && startup_mode.reads_and_applies() diff --git a/crates/utilities/upgrade-signal/README.md b/crates/utilities/upgrade-signal/README.md index ec15c1c0ef..1ed4801703 100644 --- a/crates/utilities/upgrade-signal/README.md +++ b/crates/utilities/upgrade-signal/README.md @@ -19,10 +19,23 @@ The shared CLI flags are: | Flag | Env var | Default | Description | | ---- | ------- | ------- | ----------- | -| `--upgrade-signal.contract
` | `BASE_NODE_UPGRADE_SIGNAL_CONTRACT` | unset | Enables L1 schedule reads from the `ProtocolVersions` contract or proxy. The full contract-backed upgrade schedule is always read; application depends on the selected mode. | +| `--upgrade-signal.contract
` | `BASE_NODE_UPGRADE_SIGNAL_CONTRACT` | chain-dependent (see below) | Enables L1 schedule reads from the `ProtocolVersions` contract or proxy. The full contract-backed upgrade schedule is always read; application depends on the selected mode. | | `--upgrade-signal.mode ` | `BASE_NODE_UPGRADE_SIGNAL_MODE` | `metrics-only` | Selects whether reads are observation-only, startup-applied, or runtime-applied. | | `--upgrade-signal.l1-block-tag ` | `BASE_NODE_UPGRADE_SIGNAL_L1_BLOCK_TAG` | `finalized` | Selects the L1 block tag used for contract calls. Also selects the interval between live contract reads: `finalized` 15m, `safe` 6m24s, `latest` 12s. | +`base-consensus`, `base rpc`, and `base sequencer` use the following network defaults: + +| Network | L2 chain ID | Contract address | +| ------- | ----------- | ---------------- | +| Base mainnet | `8453` | `0x7480Afc8D99a5c645c247dB5A1e4a4f440e6e095` | +| Base Sepolia | `84532` | `0x15B721B12CF3b0C4400c8a2935A0Ae391c2eF65b` | +| Base zeronet | `763360` | `0x30e172aaC675c9fe5A64792F92C9fD4d3E7cA9Da` | + +Explicit CLI or environment contract addresses take precedence. Other chains and standalone +execution nodes have no default contract. These defaults are specific to the dynamic upgrade +reader, not the legacy protocol-versions address in the rollup +config. The mode remains `metrics-only`; select `startup-apply` or `runtime-admin` to apply upgrades. + Execution-side readers also need `--upgrade-signal.l1-rpc` or `BASE_NODE_UPGRADE_SIGNAL_L1_RPC`. Integrated `base rpc` and `base sequencer` commands derive this from their consensus `--l1-eth-rpc` by default so execution and consensus read the same L1 source diff --git a/crates/utilities/upgrade-signal/src/config/args.rs b/crates/utilities/upgrade-signal/src/config/args.rs index 938e0b9f11..f87728a428 100644 --- a/crates/utilities/upgrade-signal/src/config/args.rs +++ b/crates/utilities/upgrade-signal/src/config/args.rs @@ -1,4 +1,4 @@ -use alloy_primitives::Address; +use alloy_primitives::{Address, address}; use base_common_genesis::UpgradeActivationSink; use url::Url; @@ -8,6 +8,8 @@ use super::{UpgradeSignalBlockTag, UpgradeSignalConfig, UpgradeSignalDefaults, U #[derive(Debug, Clone, Default, PartialEq, Eq, clap::Args)] pub struct UpgradeSignalArgs { /// L1 upgrade signal contract or proxy address. + /// Defaults to the network's contract for Base mainnet, Sepolia, and zeronet on + /// base-consensus and unified base nodes. #[arg(long = "upgrade-signal.contract", env = "BASE_NODE_UPGRADE_SIGNAL_CONTRACT")] pub contract_address: Option
, @@ -32,6 +34,18 @@ pub struct UpgradeSignalArgs { } impl UpgradeSignalArgs { + /// Defaults the contract for the selected L2 chain, preserving explicit CLI/env overrides. + /// Base mainnet, Sepolia, and zeronet have defaults; other chains remain opt-in. + pub fn apply_chain_default(&mut self, chain_id: u64) { + let contract = match chain_id { + 8453 => address!("7480Afc8D99a5c645c247dB5A1e4a4f440e6e095"), + 84532 => address!("15B721B12CF3b0C4400c8a2935A0Ae391c2eF65b"), + 763360 => address!("30e172aaC675c9fe5A64792F92C9fD4d3E7cA9Da"), + _ => return, + }; + self.contract_address.get_or_insert(contract); + } + /// Builds a schedule read configuration if the upgrade signal is enabled. pub fn config(&self) -> Option { let contract_address = self.contract_address?; @@ -150,9 +164,82 @@ impl UpgradeSignalL1RpcArgs { #[cfg(test)] mod tests { use alloy_primitives::address; + use rstest::rstest; use super::*; + #[rstest] + #[case(8453, address!("7480Afc8D99a5c645c247dB5A1e4a4f440e6e095"))] + #[case(84532, address!("15B721B12CF3b0C4400c8a2935A0Ae391c2eF65b"))] + #[case(763360, address!("30e172aaC675c9fe5A64792F92C9fD4d3E7cA9Da"))] + fn defaults_chain_contract_without_changing_mode( + #[case] chain_id: u64, + #[case] contract: Address, + ) { + let mut args = UpgradeSignalArgs::default(); + + args.apply_chain_default(chain_id); + + let config = args.config().unwrap(); + assert_eq!(config.contract_address, contract); + assert_eq!(config.mode, UpgradeSignalMode::MetricsOnly); + assert_eq!(config.l1_block_tag, UpgradeSignalBlockTag::Finalized); + } + + #[rstest] + #[case(1)] + #[case(11155111)] + #[case(560048)] + #[case(12345)] + fn other_chains_remain_opt_in(#[case] chain_id: u64) { + let mut args = UpgradeSignalArgs::default(); + + args.apply_chain_default(chain_id); + + assert_eq!(args.config(), None); + } + + #[rstest] + #[case(84532)] + #[case(8453)] + #[case(763360)] + #[case(12345)] + fn chain_default_preserves_explicit_contract(#[case] chain_id: u64) { + let contract = address!("0000000000000000000000000000000000000001"); + let mut args = UpgradeSignalArgs { contract_address: Some(contract), ..Default::default() }; + + args.apply_chain_default(chain_id); + + assert_eq!(args.config().unwrap().contract_address, contract); + } + + #[rstest] + #[case(8453, address!("7480Afc8D99a5c645c247dB5A1e4a4f440e6e095"))] + #[case(84532, address!("15B721B12CF3b0C4400c8a2935A0Ae391c2eF65b"))] + #[case(763360, address!("30e172aaC675c9fe5A64792F92C9fD4d3E7cA9Da"))] + fn chain_default_is_used_for_startup( + #[case] chain_id: u64, + #[case] contract: Address, + #[values(UpgradeSignalMode::StartupApply, UpgradeSignalMode::RuntimeAdmin)] + mode: UpgradeSignalMode, + ) { + let mut args = UpgradeSignalArgs { + mode, + l1_block_tag: UpgradeSignalBlockTag::Safe, + ..Default::default() + }; + let l1_rpc_args = UpgradeSignalL1RpcArgs { + upgrade_signal_l1_rpc: Some(Url::parse("http://l1:8545").unwrap()), + }; + + args.apply_chain_default(chain_id); + + let config = args.startup_config(&l1_rpc_args).unwrap().unwrap(); + assert_eq!(config.signal_config.contract_address, contract); + assert_eq!(config.signal_config.mode, mode); + assert_eq!(config.signal_config.l1_block_tag, UpgradeSignalBlockTag::Safe); + } + #[test] fn disabled_when_no_contract() { let args = UpgradeSignalArgs::default(); From 9115793ff051284f94cf149df4d46e05000d93fb Mon Sep 17 00:00:00 2001 From: Haardik Date: Mon, 21 Sep 2026 20:20:29 +0000 Subject: [PATCH 072/299] fix(consensus): retry sequencer EL sync probe (#5207) * fix(consensus): retry sequencer EL sync probe Recover derivation when the EL becomes ready after a Syncing bootstrap response without resetting while snap sync is incomplete. BASE-676: https://linear.app/coinbase/issue/BASE-676/recover-sequencer-derivation-when-el-becomes-ready-after-bootstrap Amp-Thread-ID: https://ampcode.com/threads/T-01a0c567-7237-729e-a0ee-93d7c65f38e4 Co-authored-by: Amp * fix(consensus): preserve engine wait metric Stop receive-wait timing before running the periodic EL readiness probe. Amp-Thread-ID: https://ampcode.com/threads/T-01a0c567-7237-729e-a0ee-93d7c65f38e4 Co-authored-by: Amp --------- Co-authored-by: Amp --- .../actors/engine/engine_request_processor.rs | 26 +++++++ .../sequencer/engine_request_coordinator.rs | 35 ++++++++-- .../service/src/test_utils/builder.rs | 20 +++++- .../service/src/test_utils/invariant_tests.rs | 67 +++++++++++++++++++ 4 files changed, 142 insertions(+), 6 deletions(-) diff --git a/crates/consensus/service/src/actors/engine/engine_request_processor.rs b/crates/consensus/service/src/actors/engine/engine_request_processor.rs index e420c64393..58106c5641 100644 --- a/crates/consensus/service/src/actors/engine/engine_request_processor.rs +++ b/crates/consensus/service/src/actors/engine/engine_request_processor.rs @@ -673,6 +673,32 @@ where } } } + + /// Re-probes the execution layer while a sequencer is waiting for EL sync to complete. + pub async fn probe_sequencer_el_sync(&mut self, active_sequencer: bool) { + if self.engine.state().el_sync_finished { + return; + } + + let head = match self.client.l2_block_info_by_label(BlockNumberOrTag::Latest).await { + Ok(Some(head)) => head, + Ok(None) => { + debug!(target: "engine", "Sequencer EL sync probe skipped: latest head unavailable"); + return; + } + Err(err) => { + warn!(target: "engine", error = %err, "Sequencer EL sync probe failed to query latest head"); + return; + } + }; + let at_genesis = head.block_info.hash == self.rollup.genesis.l2.hash; + + if active_sequencer { + self.bootstrap_active_sequencer(Some(head), at_genesis).await; + } else { + self.bootstrap_conductor_follower(Some(head)).await; + } + } } #[cfg(test)] diff --git a/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs b/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs index 2510f36742..c0cb2b156f 100644 --- a/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs +++ b/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs @@ -1,6 +1,9 @@ //! Sequencer ownership and serialized routing of engine requests. -use std::{sync::Arc, time::Instant}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; use alloy_eips::BlockNumberOrTag; use base_consensus_engine::{ @@ -11,6 +14,7 @@ use opentelemetry::context::FutureExt as OtelFutureExt; use tokio::{ sync::{mpsc, watch}, task::JoinHandle, + time::{self, Instant as TokioInstant, MissedTickBehavior}, }; use tracing::{debug, error, info, warn}; @@ -23,6 +27,7 @@ use crate::{ }; const MAX_SEQUENCER_EXTERNAL_UNSAFE_GAP: u64 = 300; +const SEQUENCER_EL_SYNC_PROBE_INTERVAL: Duration = Duration::from_secs(5); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BootstrapRole { @@ -225,6 +230,12 @@ where } } + let mut el_sync_probe_interval = time::interval_at( + TokioInstant::now() + SEQUENCER_EL_SYNC_PROBE_INTERVAL, + SEQUENCER_EL_SYNC_PROBE_INTERVAL, + ); + el_sync_probe_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { // Full processor iteration window: drain + recv wait + request handling. // Bounds the worst-case channel wait — any request arriving during this @@ -264,10 +275,26 @@ where }); // Wait for the next processing request. - let recv_result = base_metrics::time!( - EngineMetrics::engine_processor_recv_wait_duration_seconds(), - { request_channel.recv().await } + let recv_wait_timer = base_metrics::timed!( + EngineMetrics::engine_processor_recv_wait_duration_seconds() ); + let recv_result = if self.processor.engine_state().el_sync_finished { + request_channel.recv().await + } else { + tokio::select! { + request = request_channel.recv() => request, + _ = el_sync_probe_interval.tick() => { + drop(recv_wait_timer); + let active_sequencer = self.resolve_bootstrap_role().await + == BootstrapRole::ActiveSequencer; + self.processor + .probe_sequencer_el_sync(active_sequencer) + .await; + continue; + } + } + }; + drop(recv_wait_timer); let Some(request) = recv_result else { error!(target: "engine", "Engine processing request receiver closed unexpectedly"); return Err(EngineError::ChannelClosed); diff --git a/crates/consensus/service/src/test_utils/builder.rs b/crates/consensus/service/src/test_utils/builder.rs index 7bc603ecd1..a49d482af9 100644 --- a/crates/consensus/service/src/test_utils/builder.rs +++ b/crates/consensus/service/src/test_utils/builder.rs @@ -149,7 +149,9 @@ pub struct HarnessBuilder { scripted_el_responses: Vec, l1_chain: Vec, initial_safedb: Vec, + initial_l2_head: L2BlockInfo, reset_recovery_support: bool, + sequencer_stopped: bool, } impl Default for HarnessBuilder { @@ -159,7 +161,9 @@ impl Default for HarnessBuilder { scripted_el_responses: Vec::new(), l1_chain: Vec::new(), initial_safedb: Vec::new(), + initial_l2_head: L2BlockInfo::default(), reset_recovery_support: false, + sequencer_stopped: false, } } } @@ -200,6 +204,18 @@ impl HarnessBuilder { self } + /// Sets the execution layer's latest L2 head returned during engine bootstrap. + pub const fn with_initial_l2_head(mut self, head: L2BlockInfo) -> Self { + self.initial_l2_head = head; + self + } + + /// Configures a sequencer as stopped, which follows the conductor-follower bootstrap path. + pub const fn with_sequencer_stopped(mut self, stopped: bool) -> Self { + self.sequencer_stopped = stopped; + self + } + /// Configures the harness so that engine `reset()` can succeed against the fakes. /// /// Reset flows through `find_starting_forkchoice_with_checkpoint_reader`, which fetches @@ -243,7 +259,7 @@ impl HarnessBuilder { fake_engine_handle.push_scripted_fcu_v3(self.scripted_el_responses); fake_engine_client - .set_l2_block_info_by_label(BlockNumberOrTag::Latest, L2BlockInfo::default()); + .set_l2_block_info_by_label(BlockNumberOrTag::Latest, self.initial_l2_head); if self.reset_recovery_support { fake_engine_handle.set_l2_block_by_label(BlockNumberOrTag::Latest, Default::default()); } @@ -277,7 +293,7 @@ impl HarnessBuilder { engine_processor, false, None, - false, + self.sequencer_stopped, unsafe_head_tx, ) .start(engine_actor_request_rx) diff --git a/crates/consensus/service/src/test_utils/invariant_tests.rs b/crates/consensus/service/src/test_utils/invariant_tests.rs index 81bde3b38e..d837e1c163 100644 --- a/crates/consensus/service/src/test_utils/invariant_tests.rs +++ b/crates/consensus/service/src/test_utils/invariant_tests.rs @@ -222,6 +222,73 @@ async fn l5_no_cross_actor_deadlock() { assert!(safe_number >= 3, "L5 violated: safe-head did not reach block 3 after 200 ticks"); } +#[tokio::test(start_paused = true)] +async fn sequencer_recovers_when_el_becomes_ready_after_bootstrap_probe() { + let mut driver = Driver::new(); + let initial_head = L2BlockInfo { + block_info: block(100, hash_for(99), hash_for(100), 100), + ..Default::default() + }; + let node_id = driver + .spawn_node( + NodeMode::Sequencer, + NodeConfig { + builder: HarnessBuilder::new() + .with_reset_recovery_support() + .with_sequencer_stopped(true) + .with_initial_l2_head(initial_head) + .with_scripted_el_responses( + std::iter::once(syncing_fcu()).chain((0..16).map(|_| valid_fcu())), + ), + }, + ) + .await; + + driver.tick(20).await; + assert!( + !driver.harness(node_id).latest_engine_state().el_sync_finished, + "bootstrap Syncing response must leave EL sync incomplete" + ); + + let l1_head = block(1, B256::ZERO, hash_for(1), 1); + driver + .harness(node_id) + .derivation_request_sender() + .send(crate::DerivationActorRequest::ProcessL1HeadUpdateRequest(Box::new(l1_head))) + .await + .expect("derivation actor must accept the L1 head"); + driver.tick(100).await; + + assert_eq!( + driver.harness(node_id).derivation_state().await, + crate::DerivationState::AwaitingELSyncCompletion, + "L1 activity alone must not bypass the EL sync gate" + ); + assert_eq!( + driver.harness(node_id).latest_safe_head_number().await, + 0, + "derivation must not advance before the EL confirms readiness" + ); + + tokio::time::advance(std::time::Duration::from_secs(5)).await; + for _ in 0..200 { + if driver.harness(node_id).latest_engine_state().el_sync_finished { + break; + } + driver.tick(1).await; + } + + assert!( + driver.harness(node_id).latest_engine_state().el_sync_finished, + "sequencer did not automatically re-probe after the EL became ready" + ); + assert_eq!( + driver.harness(node_id).derivation_state().await, + crate::DerivationState::AwaitingL1Data, + "sync completion must automatically unblock derivation and attempt catch-up" + ); +} + #[tokio::test(start_paused = true)] async fn e2e_invalid_fcu_reset_and_recovery() { // Regression: 2026-06-25 mainnet incident. From 55ce9c88c925f969e68763780deef53c040042b8 Mon Sep 17 00:00:00 2001 From: roger-bai-coinbase Date: Mon, 21 Sep 2026 20:22:24 +0000 Subject: [PATCH 073/299] add cobalt reports (#5211) --- audits/cobalt/cobalt-b20.pdf | Bin 0 -> 144541 bytes audits/cobalt/cobalt-dynamic-upgrades.pdf | Bin 0 -> 252706 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 audits/cobalt/cobalt-b20.pdf create mode 100644 audits/cobalt/cobalt-dynamic-upgrades.pdf diff --git a/audits/cobalt/cobalt-b20.pdf b/audits/cobalt/cobalt-b20.pdf new file mode 100644 index 0000000000000000000000000000000000000000..af312714b8eb288875d461d2232ff934d85cd8f4 GIT binary patch literal 144541 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
X($r?Kn}KQgf6s2}(}JR;T^3+)D3KpbZU- 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 u&reqcR&u$C0CJI(Y74Bh{n`0kwTlmk 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 z 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 gv;OPX+1!T2sX1mPH%*@PeKi}KkAH3-|KjzJ> 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

U6MU#0FR&J1mGbFacdrx`zD@z+R*Ib+P{Fshp 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ƚVdum0`X8E*BR_UnYGt_E61z9fjYC65NqyyB2f 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%0A&I|I!gD1Vz)hvgr>*{~pe;GDoMJjw|rZ+B_zk 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 z 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 LE8#1XIQl2W*Exme7+j<+&N2 zzJPX`Y5@U7TlN~;&73`arw|$d1Zh-&3{@z0-8YQ?!`L~-h!#X!x_#TWZQHhO+qP}n zwrv}?ZQHiH=T2r`-kZ!zW`0#w&YwySa%z2huXTv19dzk{#v4I)Z}k-$Do>q3o-sNH zk|~+<1cXyW7b5BoZpWYwjj&Isi9RfzK*$;?HWH<4IwA9r8d!`)T!Tyy6Cb@`P9aB` za8Y7WIEV_Yjli(`eHxXov$H5PqLZU$?HtEnPA)DfK6!DP z^icG0_#p5e|CRmK{S~+(hgF`^!f8{yl25a&YT0%!ep>yw0bC)Ds@0JRS&H-(OR&G}4Zg*ROVu?Hi?L&OY+|uQyTb%!QMHpPflm zZ?qgOfpT=1H@Y@4?1&B~#h5}HlNO_xm_my(ezHW=qM%t=lcq+E;U-xJ{ZOAn9hY*7 za@5@;IkG{-HpwoHOF6phxF_17?+e+8o7zn`tU*|-KxYa)2fc{S2>N_TeF%NlZA=d| zYuYtK!BKdf{lR*+3Q*C43wHH4x4uMUH>B!2%vwb|Pl;mU1(SbPis#eIRC}Sn?>pz3 zx9Ex{n`47(0cCeOVFhK!Wj}g}&9(5;CPiv_qestYXM20ur(~*Us^oIw=3~)JE)R%Z zr=KP4eLZ(EbP=KKeCb_DM~~VqT!FAH>he~a)9G2z&m?`P4DOsLR@7{t@fr`0#n@u%>)mQz2kx zK_;^Xk8C*GkWGDtYX1~_xavUAdYGG_PXjJ)0IoS`xPe1^fL#Bwy*m5g?*~S+UbTG| zcbuKTwtaMO5WFEW`#`5@HG9;Vfj@Up+M#~^b$4=aFiZu1wjJO->)C+q!NCNOx&fW` zWXuDK<{)qRvAUt!`akLMcK)Ghg61KIHze=Tj7ukVNV0v{Hz3ab-F zT&KNV?5a1Rw(V#?usC-t)ggBJFxg?tOE7c?F!iv#Bd6z9Z?G#vuWy7tz*u`g)}eX& zNMGPuduHB2*7Yc~gCy>NVS_>TAxL+(^+~fcw)KO!_4^zNpm0v=yn!H}^8I=95V#^R zy2x$)zRn?Ib1=EWa_r$dcg!FCOlMGG_VnCQzISM`J45K;vje;I=(%I^XT$K$1r_ds zTmGsI2x)+7+G3liL<-uHNhD7q+@cX@^^Kcp*rC=IB$WkYK; zhWDQTjJptVoy58`6g5u^Wyd!!;V(x%@1=H?TIs@$d_GTP(8g!243_J00;dQ@A<_J#~ zNI9dB7=kG6fxgK{3qk1Sztv+{nsZ$JLwCj8nqI~H*@6eaTVb=E6C!#cBuH(%_5JJm zns^1V@`iyEJZok${s1L;~cg_932%G3+JA!1Ms}2RP+PnI=ebJoG@T3s%mM4 zt}9ttm1)KqD0@muc0M!t;zG?^3bmVtjO#kKQ*ct2%+d4CsKZlX2z7IBZC24RFDdHS zoO4)Jrw=R3IrINkYEeND9>nduUnE0T19ef zdaLhbp`$c0zE}Pct$%jbTMxVwS zEBkOO#0YDWoo>;A=4bodLwg(Nj6?sK&+}JC#p|?}SJrH6+oew{ZUfWns&jriqDQoN1Dt zM*;oz_vhpDszrk*MWJr5(X;qz3%Iuu7)rY+dK&5KTDb}dyGonds%u&c&r~xzWvNLn zzQxCXc{_Exx{u?t;T^s`MK`v;Res&wttHP17jaWBZBK8iTc0$QuO$z$-s?bqVY4>C zU9(bvP|;9o#4j^9$;1G^@ToLHvmHNyvxE!# zKc|U&cE8emj6{l6*Xeh%Y*s#y*7t&ULv~s@)EUA$GQb0?Zr7d3!5)O;Wo&YqN?Y05 z%5-YQBxPd`4dD13hF4>^Va7dS)-`*KHkB9W4Tm?s^TcZ0MAe~mLh2$W8cdc`U^ zgErN|kK_S2gMyfUHiOr6E}NsT8qS!{OxcJx(Uv7fFU|8Nbt97DiRn$tR8)BAo~!`H z4XQSEm}o1j8U)gsnvdE`%Q9qX%#yFXgnG7Km5r-CdFdf9WfU6>71y7cck4eF{d+g+ z$x9w|?|6W%{>6?es)44ju`2l=xa8V@&!4?0u z`>T3bIYb`QADHz1bff1OBB@oEZ`K=_>nKuZmR{}fjCg8h4qiIi^gW~vf|A_AJvC+C zJY89|B{#lx{#7^`?9{}o%i209Gcjy_b~gPeKG?1HeOD~h@uRn^rkZjv$ik`RZEUm1 zm~AZWiMHdsY5b`020QsoeeeE!Q`RME+EUO=+h=-Dkl@8|DT$MN$QZtyw9nK0zC*ZF zcJZmD5ovh^^T(eVjgSOe(z9bHJ3BsOTNnY~M%6p# zfX^x(6^^_n%sxs-FFuOO_w~KFZSr~&E$Y>KX1qIRI2(dd-;|A^1s=NuS)whDZtZcv9k*20``u2O&cx^9N zdQwhV@mnX|EJOn5xaFVFx2a@K05XS4zrZM7YO{s8%njEJk+_Va>eY9f%YpT9=3Cp9 z-ZwbV75|BfUb+Bx_g(XACfMWH`uQA{vNduo^MYEN!F#dmVv<*c z?T{?xEFsJ#IfExjk|tsp#!k+AWcdAyBJ+kPnSJ;hiQ!%38iHxxNn09>>GR_acTyD{ z`pDKT`H9Ue$V}rf2cpY7D*zSSrTnMneC!aUfSAkNEPR*>b_0c&`#gX9V?GEUsg27# z&bOKD;;q%LtOq}WXhHUqpB&n4uD4sTaS?hvsHq@F)ZWa;wKH$po2+-VPT?mf(Aqpy zbSD9*Jd87a;+_Leh`H|{8 zcbHeC2%{Er*rw)5VuoMpq}Cu=hyNzwO7-1Knl?SZAatCxsv1=*OI0_~x?suVH#Jsy z+bSkh9%xkL1dz&?vMH*)UV~7E%9H6mk6gi~yWG+5A*@cHWhrn>qhaYsTs+~>pb^Z4 zZ%ZX^#!%BT_v$d?$KyD?Ex;;^AMBIq6+oR{pVU1yk1Wk<`FynnHs3E(1X4z=QKdr5 ziJn*rxBfDEu0?<=N(>Jxiq^rBi{ipEoU-XTT zBrU5$diaptZ>navfTw}ynpQU{`eKQu^f@RO^^Xhjq$O)=jCJ4fK0OIC+X8DhV1{=_ zGt*en!}Ye)Nj=CGGQJMjb*N{{rR=X|p>2<*brf5 z;9tuOg!($rzbKy~op#CHCi5=np(k-(6snaiV%I%;hH0RSfUq&h9|sxN*AU(0riJ@M zAzMjprlzbNoUgzwCEVn84f+F75HCTzT(@evcg3LRqPXi{R#~E|F* zU&HYerTdPqBOlncxQ5tD6Ff#kicye*ndE~c2ifxP3Q(9sObSrV!HFq=YX%X{@sNYr z@JAFegA7PP(jjyg-ys%wMcyEMl$@az>4BLeR2rilk1E8%LS!Ga>jidWd;FVV zfHO@wY=E#hElCSZAO^$UclOh?S8PHxVZHSddM|BUg=s0Z0lwT|N!(^_Rm_-n)OmrH zzAh{*j(!3QA5uE~-wzEd{eSJHkT$V3b2ewdXJBSv$EW*WD@J@44o0^BSTX$*x9CJI ztes69|BcoL&L+YpMs~&~|8X6km7V>+ZMleX231bB+N9mCK+MAC?+m@Uv5ij-z|aRV z4KEOgze$D{E*6MSMNSb_gDG%;CVJ5T`_ zy2h`xM-NoKP1&>32}E9+^3)TP02q31SF8> zHGv08DFY4zWbV-h01^ZMRFD82M3Y-$^n0k2O$sYF3|Uf#p6eU4RS}Ur~GHv z%$*-A3%-=zrw54rk_`a(51i)e9Rq->h5uQ?1pz+uhnIy{TN3t)hw2Bng$u#|`dJPX z+JFeKPdYk%y}#cF?(lpV;K4TG@BrYI4@T;TKY+Nu3-t%&0|a&!sEywpe*;$s9F>)y z-Ss7E2Oj+MZ=?CxwDMlyf00!7L4k$10m}CQh+u5|bFn~PxHLZ9fZ+CKw*f%jKzsQ$^5Eb7S_QTnA)%{5++0HX;d4RwX3L+$F@zneAh5#XfxxPREUmOqF0tB{u@G&R-6y<3H{k&u0LHI$)X-SD;LHM@- z;q|=8e)6tkzPIrE0)hCp$w34FZSC3ofgwSN!7l&=z7atFo$Uw!g2T{=$@l#J{MaCX zg9G%-dx7-B9)O1+d@bS}hq8aQDonlle*jSjkl#Q6`1ASwdNhg7LqUgiynQGASoM8b zN@l_>!F2c${m4lxAlyKK!y^LUK}G^7IHRNjgcs-r{%jxfz5R~ z|6%6`c*6p|c=#FJ{|-aN_9GsGczXrcVUL`G2N>g9Y6FS{xK@A*__YK4qI~bW|1nGZ zMSK4J z3Qi0I`imgqpbKcgn}27=x1@w}0R?y|gzzHU7xDB;E6=2zPan>^h4k?l6;ufX^h*`0 znA5{_QFRcpv33hTjzl6bTzCA_OhBIS{fSLU@5TOBh6ySn42Ta07mo)R2~REs0*~*X zNP~Xx8FdTb%ZrX3Kna*n{|69z4;O@Qm6{rQPfTUNCwtG{3@se^z$|@FkLaq0n_J%O zZ3KGhtM6LxB8=ag;045K(cY`#PRwQBE`bHInYSB@XkkrEWBgBGH<1XddpK^&eOOh6 zXvgeni*Ch?3sm_Aw`X0|S*&*h^COFXPj172+*@{8NRYLf4pMEvElOzRT6e7}J1tX`s@dzzG-KidWs2900DCt_QEZ+(?rdXycrlip7ufTo$A>uh%Hj3)}8* zLz|YRvGw-|;EIPK3Kl)3>QbsAeW_P(P|=;KfzbN&(b2l#l5sW{dR(kG1Xpj|xLtVO z&a?EVNxr&t4xVIhlTP-IU?e2m+%@FNGS{JfPVwB}&+1<3^778RD+UkiQZHiHD`Dh+ zZd8{nxntfVN10O%8Gkd^i*PfR1ow^LH{Wx%LAvw@7Rp9?#^oANbad>H7kJD$_-ey- zbU#!9M&zso$0`_Ez@kWU+XU!I`*d6OpvbHKL+oLsicqbK*xjb76*K zk?bzujxwpb6X)tVhoNW0zZ0at;jxmAy^D|UQGE9;fbg&kMATr6^Ej)-x-WP1+Cz3V zyQ2g!`JxRe(Q}A{ycrlYQ?1@D#5M7HaQymmH8_#oEG6oTZWM1taLY?emV?w1KL|!R z?4m9ZrxIhJ5ABC@VA{!k2fnf<#bYo>E%G$8cb+6Bvj#Xl3c^{^5HbSop(vNlmbyCR z9K{5YE}HP*#_L03>%r^wCOzG@DoNG*iQ!$JV4`hB=CBdaGFd}Ne`j4tx^zo;5>*}$ zEy8HT@NiQ}VDhly;OBZ({z^pW5v33pd=)$H2{LgiZR_PRSI|iF>t7!kdegeZwh3R{ z-u?A~ITzFMP@$%h)h6cuX(Zc8YNLqBtvo8MF ztPB3CM{bpnP$skyTJBzVaI6fwjV_1w`MdEq99!L$d-Bgx20SC_8r$2R1w$^N(aj4a znB3Oh&dy83r_sowZ5IlRkG`$qdf`u_&vbyDVViN;Lo{SZD}yDbSPk2UYJ4hV=7YjT zkiE*vkR6-%Mga2**N^Fl8@_QipZfjxfxR_tX%qZOFykR{spHSlaOK1hk4=tS%5#Fw zKBdaWfe7^V23ayTC9MByE5x>8#Va|0=*!B`@tLpPCgr{eG%9B;rK z$x~;SbBj)th(7H=OtVjDa!-n3uQ0mKY0OtI0P{1M)Lt4X)WwFe zZc?(DMc#t;gzmbMU504gCr^VePf#k%7eumo)1pqZ!--pEoaLUSYVS51{DHHqbRjM- z^073m!Po1RlS^b+cF(NR3_M&=PNyUn0h|K@7{HGO!Yk`YL~OV^3kSI=r@X#n0mo>F z#&8~vIfOx_4;nLi4NqA(;RLgGtd~7QVP5OCP~ zH6^gcC2#X7TIfS0qnY#AHUlr3$V1!ycdR1 zMH)twS?p_TnDRmC{twVv4SU=z5QO}EFZk(Vo5(4k-UHz>Q!N&o)oNU7*hLC#)qKlP^BA z+q<;ItCnjdI40rbvXM8n%lZ0%;n>w&_jxwmr~~J5`3K0b(#L~P;k!n`NA;K zR7?$66TDtwcZnv?_}uo*!hpO<`-f6bF|BGObD^IP$5BQpDZJn5c(!>iS+)_`cM&eo zf1e$;0`s>M771xPA-}|dhgD>Z!dNPnwx5A*ks5Ibg)6w2ZCL|gQn{Yw39Pyh^cij7 zva#jfxB}{uE`{8;~xaJ98jfqP0c=kFU6^1SScD6KDpoOqj*Q?)^p*Ps+wlk1nZ@cr3PVeSR+EFhDd^xk1A1}u@%w6L%!UF_whN} z=LCuIGezyMPOWF4)@*-rkHlbN1|l^{e-v_k-&27{dy9n+h3FdQG%uW*&a!3Ned`aR zmm?JYW_PlC$ao&rtliJ@1E<1uq$=&}<>Pxpy6d`>y(U8wj+GA@z=b~P4?N0ZPlR>9 ze*QZDZc8hX0onXLU?&(BzYT(L-lTN-9Bf`g-(Z{FHE6DwTR>fAu^nYyxE0af5GOkw z=I{n=&JGThUuZZ@(Akrsi$_40p}_5eLr}*LK|8D9NvbaEkmhb1QnMTDFq;rG!iWqM zs^Fr2WBlWFuM`9s)-LoGsXKF(u@1V^k}$|H*84P>T|#??5ePLJs};?Fn#6c!6KR4# z2)`%}C3LYw6<2)~*@Hr>o0xT$==3|oeHnZlq_&H{ji|9Ovwz@$Mj|=sdYfq(xMR^0 zNJ%CYF1_RJ-ZIcjEbGVAf2!RRkM9q$W4Bk)yGNTg3b8kajs=cf3#l#t{7hyrAG=9= zdKWfDZ4l{~he#b4N)lPmswGz(Kt=!B5;0c8^%eRPS@2ERj@n|gf=0?tb(NmjD>(@d zznr|UsXmry%-l-(N-}Y@r(jv(i$|p(JDK(unk#hd9DrfKi|JABqL8{#J4A4}!&XNv zA$#lsYiFFZgs%T=qp(pX~vl01i0VCEI5N1a?rgbz7 zeLI?MTNS5fvrfh;9P}9Md_5&jV3$HoKtlmC#BVwC!mx#L-|Xzz0J(8Qt@<*KBY7jw z!5lryazTVuAlYk%GEL1C*E_a3zpJ-9BCqqR(hJqDGLv~Eio5w*rYYXQwEV(pH3ewL zIa>w?I-d+^Lj4bQL4lIRUF>_}DyAOs+B4nSFkaZ%vn( z2Cb<-d;72MKuNy$Z)#s_$P}s?Sp#&7n~jWtH_Oz@X`A=w4+-73PMIUI<=Q9dG&k1V zN4uUbGkz)FU?P?WM!ZfuH|Y=Xj%TPkV^WF7wie(s(E}%rcr6R!v3mJ=zfzZ!Cp-WC zw~nCEK+m79spVPSinexpFzPzKnng?$p;7z?~$LGv!SNA-wH6kUjnYc~w4}*Fdv#*@lbYE>E)4mrzD$J$v zOcs;FRL>w!NfXiN=5PylWE8{o*z5B5O}vaKacw-VS~d#}#(B)n0Gj2zStV}VVoXxI z79}f5@t~lE#PY-SO-9LVvTlEepI-j6RguB695?;)?}@jn9>pwNZ0CTrT&o>L^PCpv z-1wuP4^gb*?rOM+az3rBQ18yTV6NClH{Pl^l?}1!lX_%VZg1UIz|qR>(_i|+pmh#9 z5X%7S8mVWzSBQNcGO+69cHftfpYr4q3RRP5)t!?`r*P-NAGn?4i%!q}bjOn++d{5T z_PH${PO^C%JZimUz_XgzQ4mORu3Sl)A_=%l^fWumVJ)l7_+l%;&^LIYD%nN&ocoWg zMbPUX94*VAj(Hk0UhVq^dHhwwz6Qz7+BTz&FdOhvaPgvc0)WlCRtz`hXZ?w9)owjQ z+o#b?F$VC(bk>vD!LX^=tN3|R8Wf07^oD_z9lV|mv*4T+c_#>b>M&Y#>w*$mApx;SI6LTo(e)(-T{p;l z&$jWev!gEXCIR0w1|8WrX`>cp*A6Wp3*FM}&tyrk>5l7i`7@5L+?96TAYID&=>{8; zx2_kux4rF@>EN&@!ZfeF_k}mHv#n<0Uqhd@DMsV?Zyx6eE0mqYQz=fSDG6=ibfYQ{ z;*ZeBKP`pBE6A*8B!NcU^LsH7l^lT6H^z$=HX_|7j80>2e_1++-?Kj{n5S>W40b11 zDAtO79u4h{xA}_U-3%)$N<)}+n65-*V*h$mf!GWO@GfYpA!{1weza<1WV$G1;#6K* zMXB4AZHs4t6MCZXx)q&kXo>RNa-_4HL_@psu=XCFv?EL8h?(c^3UdCszQY%jNAh9$ zyd7%wmUEebA!*bNWOHs0oVrt~-X2^5-^~n-Y_{KmC}#sfP|YRGKVmxSYfqZCMXNa)=sdVOqEou?GYx=1;Df$8g)~BT+o`P zM8tlFzfy0+bju>X7U8{SB2spY=3KVDCh5L=u zt?nNlfkyA~f<;B-fBax_u-J-sa=ePmHsP)TY~>M$sUO zpiczz7}L(=X+MBS-h1?%$>3@}fj>OzTGr!w2LaSqBnO%H*>Roi>J#wkjl+yT{}G=n^YoS zVQ1JrsZFiiGJb`I-Xu=MDA`i#E=8wDd?@tzElIN^r0^36|2)Y$0dpS6rM200&x|3g zCjPFbXIa?2qW&{r&(mwqv}QCd?@hTE^W)%gs&B2f9DCJ@-34vIfs*zd?fN?Kuyz-q z0-IZPFjuKInraltopR~BLszjQeZKbSs%87E|8-{fHOnzoL9~J zpf3QX>dpNCPJVGxXM{@1e39_9mfS;X?WJmbiQ0g`lpIE=w(fGq2V|sc*Dc$UujoGm zT+7czoBJ*~g|XrPhfbD7LX7Q~v5@my_HXsQfvZxH)y?y@4nc3=bKFp2(`4iK*j0?P zSVa$sOm|XBjZpGs_Hl?=*(?R9umnf~4j##$j0EH9+Uz++7e$wL97Ud+dbzBy6XD8` zt}!<+^tak08*ifn=sW38fbIDggY%+{(rfo$nljR=hw4hCy#uNa@;0dNWtrgUT1A-_TdyG*21<)~W3;PrV#R!;ZN)aX z;AJ~4mdU|6Qv#P0PUe=8zMb~*jvT-eS4tg4fbP)><|L*OE7ci<-9oPpGZwnnC*QN$ zyfWYpti;}bIe7DoD)O=@6wTU}qAzbF9BCWzKd)b-!xRa%DJt3s18J;Ch~G@XiW+V8`H3vADr7Dx_muAX&SQN>2O65ejWY1N7xr43DIm*UlUs&MDR19pE*fLGWDVxl;B2HxP z1kF=->?8>9%8>O>!9|2q$_X2NG@pqui8M^-3I|#B+TcW$WpAklf%p&UMMRDzY=cgL z8%OLNidMC#?b{4Q?M_L0x;8}bO+jxhJJGqC9S?LFKtz|=W{+!HjSKUxeT$UFyKw)?Q1Lu^9utKJv! z-fp;ZoA?_D%qK`GuZ-CL5oLh^z&kLwnhwcwO`vI@|L$yY=j(pzPwljc@-bQ<6D9o` zhE}`u4YLmXNa{Ua3+eKrGhH0p_jtUK6+pMTFT@!#Qy8(z-UOSZ!au#7kI8TdSknEz%wRYQ z_!Yn-iN2=W;2MWvhh?DKAr4A-+itYbnbP-#4E?F06HFYk-<@Qv-j9c#bd!u6Q4eU> zigYeoE@3f^F*7-nuI`*1XhDTQC(fuvh}@w;peBgZ>iVKP+)c*bx?ir>(`xX@v=A{( z@HRXjqL%Geuqj9`1hS@60HL$=go;b4W8iHyR7e|qY<~J|F5}XIXFNfy7egjr%o|M8 zUV@LvWol#n$sl#u$SjuUAr_y>+H!Z-n`hPSc~|13mtaz25<0Hu+Wka5?ebO#AcghP zGH)h&|Fqi{4uC8)!Th`{%E%gg1G3Zvju@AzTHqBkO`OF9eM~9hePZDb#mWJzObjPn zAw0elAB$!aF9-A_QXG%y5=(U8#X^Ryp{r|TnPw>JO_C@a?|&_5ycANqij=we`2{$j zvw8pT2;2W;W_E^_klft=LCO9RHYSe$gRuR}3j5!@?f*^KIGFwmzWvX-@fcT7Wo4UH zR&lqYWNcy(?9c+CWH7)9fle?}e}aFMtt&Y(IT^7SOQe%TSWr_yl2e)ABH3=~)FbcK zN9MJL!tDySofqwH&Z4N@6`db4x;$?cspnfF- zxs8mFwc)O=qWSbU#eFYj!OlW!`f=N0cXDPp3g&dlyLwPs#E`b1Q6O7GB6on0=NAF; zN*_>xED1kGO+xwr4B^S`?d9zL_Mq@PtS^Cm3zwhm_`X~rz8MYBKwsK(wEi@yAcO!G zQT;y&A00#72mqtgzzBPK+60>7Vlj(Q(Y0tRG5 zNHMvv%6~&K34f#;Nr7UY?0@X)Fu_7|U_Fv?RQG`d3-!a#@VFLqK+j|RFYy^zU;Lb( z+^FhVvg65vVK;EzF9U*N0{MTY!f-gT#IA%!a5n3AX+c~|e{|F^OoBpxF?kIb8~{#E zz#a)g^jS!$Nx|MA49bvW?|x|P_~D}kjp_*iQjGxgqg;u;msJrE_~EfaUOpD7tK**Z z-fg(Iw(5sowZPU6{ObJVIDz(K>IuH-V_G|KZsgH+JQay%4@l}+rQ6%La8vZRuqZ9-SY=Lq&W9Q-{7`@B$cT1Kbuo$P6hz+$G$56!ow7kQqRaif3Q5rd++RLx^dIG;5bVy4iu|M?PMT*@GE zjIW+mpw@Ydj|tya%_KasR^&wxFi7ux=wLr5jA+(Oj;tMBq6 z+>$neDvoem+j*pac$QI)p_=u_&lVFi9vSDeEi~&kbhTHy837ttun7m zhT~QX>$p;H`CRgJBDNk869&OXBEr-u0uFL!Y>tfxl!VXFlR(Vg0rDDID6_lK+3M^7&1 zduOxAB>rIQxr?xpLykn|G;Kuf=VYSqAWElw0 z4HqqyoP`$gP2yjL4S2<6dz-^7Y@V6l+(!Glw9AEg+&K1yBTYvpscb~3`&HR%085OQ zM>TNL7%pP}%HSbUTU0&zn;Q?Fa$7o*y2kLjrQw1n=c66R;7t%d3HE`_iKcc4h8H|j zoSac6-i$v@4^0r2Sb9goq?eX1BZVT)j@H|@eCbR}%TZW9xWK9UQ;%us?q4Rin_zBp z0Lk+|;9E`tFBgM?Dy0 zXBnPOOUF;$K=ydpZ1(Ny_z2gak+}wi{{^fuz^gnRh2+`#F80soza|;U7gXEFgRwgUk>I6s^Z357D>51tpdO6 z1G0<$R!5GtF0I_bW^Fe*rmNVpc}aGx-o>=cBI83XN%C2r;l!`duof9rw=hv6PKG+T zAZKfa@en?+Q#0C-?z^QL{h?{;tjUlZHq4D4mb0>}+nbhy>Jt-!p5%O8s|sh=ea)o} zL+PFcz;R_u?3*m)U{ zpOom0`8UoPDU!rrY{Chpyrn#GgG>dyO}=`S1AQ@1Q&8zq=oQmJTzgw-9Fd^?0hEpt z|762HJeN?@c^yA~*p1Y)VlLJ+)*Jesv0S5F6yv6RUIP)vi|*~HOul~_=9M$5c5f1B zY)j9Ly-xHZ_ zt4r@yJm%^l+vRse_qub>GYI*0P+Y!d9meYPbR<8%LbyaLr~dWmMDk6;q9duvi0HoOag5G=FH*p31uAY@2ZlCAhTGev zy>3}z?=tF-lMGf#+N!!BqjY^979K)d$*XVOtT7U{0YzOdvXY?ovPuB1=C3-5n~2A1 zA$_e(2^kZ#d#$Qc-E-Y+OfqsWaqq!`Pr7NzLrFem}d(LmrZ)%eNkJa9yQuu1*G7vcB?K(I)9haK*`sReZKc#B5|T9}d;WtXE6||) z%y`7~5=;RUskeF`R#~4$`o85La@>75?+GY|_=Ld-Wp{Q@aA8#y0)c-7&fIuWR?Vo$Xmy7rXQTSOhF4$A>KaK7T?}k?att&cv_mInW zg-UD+h?el&ZAPs=U}Z5LkCX+Sl&ZF*D%ZOhTdudM?Oe(bVA#$KqZcj939+N$Loy3? zxf8Tw7&WeBu8Q*Vsb^R|_=}-n8JG7}oRx_6URQepc`voL%*VcLx@33lyVe@1MJ_3b zFq1?Lh*amK5T#fra=bGQ27t}R5?s1jiCJR|i#;1vrU6&ZfTEo9tv{)J%a7k+L^m)x zt}B5Ka40+N^mN|$utRs14Z^E^ZWp-fM6+`wu(JE?JxB2=)DuRD5oTih#4;~Y53e%5 zV*-qfXP=v3;si;!b~M={4dHx14!#R<7l0$uO)Dah-BPWP3kp*+^H5Pv)lg_zF)#_s zpSJo4g{aq9zNwiZ6T8UJXrXYXJzd7@y5l>1t>XE)p^cKWGd|?PuH!u?_K4!Nhd8OZMV)znb@J!YM;`h=x#eK&YMsn?&1@Q*mPlnU~Q?S zJ0u{;h{)b>d;qyoH2R*(AmUy!xDZ%sF)?MaF=UmjIfrPPs{+wO zHW-+AdcNAN(HOsjf~i}xw99b>S51Y+SEVmVag|PpH=46jj8Yr!-?lr`bG z%c-ZU@eOn?2geg|R98J;5K% zy`SD|KvM>8*lBQNzQ0dOM8s%ywno(<+G-6>uR@t{nLQS%w=o)+y^N$qY$SVV93<_O0td*WjBxN zq*~Ac!kCq6$;%%V>~#q~5QbanX85>DW3kO3^%Jf09yQ<<%|uH4e6;aalEE)mQtjB zb0So*ZU)p6+-48bBfw>>nx^*V4W(#2qE5*upRu#eo6%^t+`Mb08I0aOh)_Jh>TqqZ zeK=n~_jfqvkK>=vN-3(MH3m`qmG-9j@GT}tdAw6f)DLG2z?w%88neJ%#gCy`&y3*D zHP`P(ruM?Jxpn&(Y?b}BNy50t|E8z9kR97z$v_ya(pw(1jq83}i$)B_>F7Ij1Fub8 z+C8cTuOw?G15$SP# zSiq=0;cHdPM_DPI!Y;!UdCMGYgMh<%KFtS>e6F>dgV^egY3vIkx|gEuC1_i&t{ia( zhoEd7HR&IR+$Pa-_c*-V;wI^{N-&cVuRtNS4sP*}*@DTDxr6fIp}P;7Zj=~~^fk$~ zmU2^v=|NmkxS4~#1c*~T9Dq#4!c7URblxyxVTgZmej{d9=tH4EK1!<_aaFD~#e9N3 zmQT-Z1}xzGLUp5JB5qiK{!(pZpGSs`3@92|Z8~nSUdfMzDNt5H81l8;I_+$ica}O9 zc=T{C*mFLQs`$=BkCwh6IuEY0Y^+Re~aEf78qnVhh7p8>ShU7yTJEP#PS5tMBl~#feij!)vv0_vrbur&w(ixO%uVCYmbewxK(! z@Vgz4WzTsktE)nL-K9Xobs1!LsLwt4)pRy0)O+?Eb>9u z8J}^tkfwe9l%DC|JzLqwemls)+PsU(sq@hW*Kxg8#KKH$Dt#EMnT4YTkMmqT>M8iv zVZM5r9p74U^!C}PzKK)Mm=5#M6Rm{?t9*;GN?U|qO^H-IN|y7;uf@#E!|iTrTa0g^ zJk3jwh-Nejn)}xFcSyGKdmj8{-hM2_3ZM8S=b=!5Q@KHG`GWA#Npq;(wv&{o;&v0U z3!En1^EK`<<@xK5lcbkd`_^MDVsoTjhGv(T9yes1OKNOe3BKAB#4gxYbN$+tH$X=f zQ<$%zJLHi2=}$97ywL1EODkHmKLp9QAdV=B+llEDu*<6l*tM`qX|_wjFheu0IV+mg zMAvAgM_07rI=U8d`=U@AO+9dPkQih(zhV>x(?D~J;&~Xh9pJiImA6cr+bxu7#+eG8 zf2#Scix=bWP+79Fd%)_(VxE?BSyNvvQ#nYtorx@qULkwFJ>j-P568xL^?=buqDnYy z&s;-~@+li+N^EMpJZ=7IJXhW)sX$FVzK6h5q5$hyX6`P~q{8MNdb2)+xrMs`A+<0o z>xxh-SUJKYXtdkQ>ahy4QubjoT=nwiyMsllGCNt7*fl#YwyZ>)UD#kf1J`jOc?N)Z z3*{8=-*n;o?^NWb>*6=*8^?b9xHlCOWGmXBuquyu;s-a~!{83P#B8(Ya9z9#jK?2uoVK_^UpC56y@bMfv{UOYL{Tjlme=Po(Tq^_#ZI5OsB}ZLeD+1vYMYc{-o zI7~Q_JrOPO_PO^B$a-PsE!)-prer6Q}ZIW$rq= zA}b=ZcCFfLxx^amcwIV=YpKllWGG=Nd$6-*YcRDCJ!;!R2eou4eH_M%uL)pyn&6FD#|>#wA)@+4G~Dw;Y=7 zrIWe)UOMq;#83JlY*u=|OIj9FMHR+RZ4@Z`Xxlq(h-aZmzCWI4Jpgx(*}#q>4~?gH z96Anmc3Dh}3nn&a5wV(aymmO)|0VSFjPJ~Eq3`oNw<)-({jTyH7o|xEn55C@Q1=(h zH?s$-LbsF0jq1mRH7Uz0XCuzCK9DDR)2T3_cni1y5vZ%?|7enW3kUeDMX7?_Ul2ew z%@wHDi>StJk)GV;MhQYc&M_4;(o2H1d;u>N_r zdH*GP`c;8Q+8O6Y%y|r?P7XLg<4MK8=`em+?6F!G)ViqA>eJPFZkkJTv*~C=X2Z=W zJiqz35oJ_DY(I(hQD)wyak7#Uv6`g=ibs2p(Fi|g6hfaoRaGq{&FZT2j;x@m@}5+l z;YUh2DPi35imiYA{2IF^M6TGWjR-epC4Ltd45nbOv|QsdFpGM^~k`pBx}dsInPc{OYL?W6XlQUH`3eAYUL;ItDGHRLU;J53m@ z@eItdTv)=zUiNh!&ne57Qc26FQ99akk=T-RG1C&`)ZlY##8)TZ&_1yyEAu5L(6E87 zQ@|A$`KnnGwWqzEGxq`0#BtWp?Yge;A{m!ApQxJtQu2KSYBz<KD%yKcEcht>@%iF3;*u{L&Vl@@U{MEM>nIWgc2 z(E{Gp%AsduX1Q)_y$Y2U-~4$l=YtU}_E*M=oj5>T~j=D&x@T*qlUC>M$@6nU3q z4v8fGjEo`F+KHR0&Sp|e>4>r$Zqh~P=#MQ6$mg`=iVYaaOp3g(Eb=0G*(!aD-LuR} zu}cWYi6RRR2DjEb;;X=O>v`kaOU>IAG3SqaTr&@EB)Uq2-QQ#~-2iHRyW^4~J9FzS z)MObK548L|`IvHG)0tg5By11pnjiEKSUx|ZJ~LM8lDtP2fuZVKXohn+#{%MKsYI=V zhE`sa%SbK5y!79;?@CJa9s_sgwwtD!Du=&+RvH8fz z7cnxc)}dVf7N=sQ9`BPjfj-_z8j-JZ;C`d#;85>&`Eu59^>Mtqw>;Rte49mixJ&3> z6f{;mfJV@YnSrd4R{w4Y*Z7n+>zoBRS1nne2pvdEv`;=ezndsLQ;pzoIp%_90`n}@ z{063&DS&*D)r}sJi+(Z7sgT>L>gRadpXK0Fu)QC~@gds%D`e};a%yD`B(O3juzw$y zST-y6x1aipy!yd6(Cdle(|?D?G5;^nI0nZ5JsQXM@5z+^j>ds0DO%3c0M3;&A;bU# zEW`lh<`Zx5c9R1h z1AdQ74cfvE>misWe0&3z=LNYX{wj69#y~ zZ!cp;>0^kHW{V6bbNs*e1ZG$6bKl4xo1@pe7{0<`1|lGIZ20_%I-;2B%-3 zRdx%wHCMQPWd9&?e*Qb(1@Dw^sszAiu5=4qIFU|4c{kt*ggw7@0e|$1&H?Rp*yI6_ zLEb_BwCT}?&9LCeKSHnZyy$jEKsb>14s3O>_ zG$TfY71Xcrxt2`<2-Hl9#i{clYnmx~0Uc!L7-Cua1%jVa z@>R>;XAb}_CZnMt0Se#>)YEy9IpAB!I@sRl%h}sK0SxNHQ)Q;=2jmaD0k;c4%2nc3 zBL~L@5Mm$l{`z4%+^wplM+XpGGyLmfCkh;Sk#`lsq@_QbIrsJVAG97wp&6Xt0B_Ep z$Co(#I&@59Am4BwuD*lHP7%zunBgD#AGq6>>t?im(J9D$QZf=rc&ZTWei0Q+J=gyi zkPDU@(DelRjF*Et^aCCF%;jTi?>2Y)D1_|oCJ_O@xrTf*3rN@n==Dvog$eMxfMC7( zZvOHO`Per4rX2s4eEsgkcXmZf-nQx8{`&5R5hl>O@%;rZ;WCU+BmgI!0<+*NNWJ4r zq7DZu*h2WdULF*PSR{ykC??nPTMZIC?#)nCZxKPqE(IUZ^!hGXgDkl;Tg_=OWPy5cF)Hhdc<{z@eC(9Qiyg2|eP@q9Ye{hU=KRy5+ zy}pFL9i8q&7kNYW(JaPwLZ(P)lU0>${DfkAVPVzrnZRvbC+jkI$u zquO!1R-zlw=vOFgN;gjMWFU$X7{J=HiqwAh_|}GrpQom}XV0$2612vb-#1CM5&a3x zT-8h`Eobct&m^W1YzLJjvcq)9xuYOYO6JBzC!muIsQ3-y=%5qsJ`5`W2+4-~Ktb3? zGhoP+O|HG^CResl@_RW1%eVD7v!RV6<&&u0MdS&j5X?y+{C*FS%=a#@vx$v_Zyz8oCC^3m6*Jzwi|rCz zr5R!V!l;@{o%(MMI^OMe9o|ckqZ+c9t5<~;DCO4RdM{NDAM#F@^GQbo^R5u7(a0g; z>7*D@45-k`j2^5K9BTz5=}z0^N2*Y~!!mX^lL?5I8-v~|iv_o0U9@*I-1a-ay}nKwh*2pg_c901){5T^YRq30v&B!txrp}#h?<62uNz9Gou#e z(-lp6N*GzARbplnJLEaVGr_8_StcOnMv|}?J4|`v)332DsAzw8!xrsLd^@2~MRj++ zkoOD^L7;j$Tsw!Bo(F+@oxQuY7a;9}{G_ys1d#Nr4$;Nj%{vnqNRUusl9Kd7S#jG& z%Jyk{r?@qlO*E)0t6nKkWx+vO7__mmVTd|KeOjgByGk6)a|q-7VYZi)NbYDGQEIMS zIOIZJFzG$hX(e|KSvh^LTaST~K@zN$Q*G@l+<)SWUwSUaz8j3bh(RK{3)Y=dQ6*^< z5U>#Qylfy?I&thzhfJ|BaepMi3SSYD` zbeE-X+wEzHrxzU3rt@KQ@6ftOAwnj?2v+ow7pB4Qz-8M>)|c^gVO@uc#&F<1VU5#$ z9YVw}aXTz&9gD-FAWIpu5=5Muw->Y+9yoA8=)vz2$Kp5VMRg9pK5T~(T&@gmdDDs? z0c5zeXt&*o%@w)&rbF6>ftuUG^7>@Y!5pwl@iiq2wefi{9c)oMGu+{>R^=oeN9MU^ z{JuOD*|L?rF&^xIx!yUNNvG#zKbe4frLoLyk(rNK4uvo-TIWK8OY2Z|mFN=|vqZVB zhc+`x-w&8y8f2h@E+JdWjgKj5KkhvT!{0By%A5`<0LxK>w4-Ai1u%rs>N{~@z@kxSC4Pa?3Ckv~G{8#^CR7rKQ2mS7z(iZW zUH?MFAUY%!%3!dIw-w+zPO2Oorzb4@FyOyRwZR^^h5h$*IQkvl~w$v4}E_W@iDz@r@AEklKxOny`w!}ccC%pHBzG;yr zMJ%{{78f|EVL@YR1|{Tc)5>y_iPUT5wOi>sbyrt(RWfXL$$!GH8uAJQdw%q}C`VVy z{I}tcikAKF>egLKZdJQ+QtJB$?F0er^`oF^p0fr(fe0!*x#$#%gSF47u%!n2T?G9f z!QDodx>U|ruMkz@R9gjHoUq`U_I32}F21u&*Y{qN42H|?+`KKl=*ihT%G)THe;a?Q4*(xuuOClV|81VoU@Ty+zVf24BbZu zjB%`V?+&NIqIs6ws$y+pfxLAfqE_zMQ!Yythr;PRJC38?ENaFUm>Tfd&l&9=#bPn7 z+k0EEs7=FC*7^D0@rFbBy3$7q%!S&m0J1uKYJI9?gLQ~2+Rp*ch=yFY;Uhj4S-@EZ z)Y~kQp|~fH1vV!mhicQcj5TT6hHJJ)5_?OH6`kq)(Z<*+t}OG%~e_k^GHkqe)yo26n%pIUnU+C14PN}~$ZRF+8GEQIAGpPD1v3N1(5 z9A^yJM1gAGnGA3%ElQ`d;N%(?Btzu7>=U2HXpqF&Mbi7~f#wCj&0*1Kjc4u>B4*MC zqH>)(shTeEu9D%a2f4W(EHU~pO~J`cC++OTpQI}kT6DeabS0Oa-Gna9f)#X4-TT->{ZTLCT)Joz%j~+fJ8E(vz>%s}g)-%%_5OVg=<0R$T z`O|R_thER?*^hU&G*HkZg&I66RL6BKN5UR5uf)9EvngQ%+{C3c&=;SxO&4@f;FlZe zcvYW3iyhE%ZyLqG5Mj8Rh+r{`9UoOcY%~Kkapd@sIlH+?yE)_gfVYGVuLP$6 zb8zqAZ$;e30LrN(5BG)hQZg|a7`Fs5KK75Vb`SR$S8-^z8UQHGq+W|ial%Anu{gJD zYfLbL)AGwG3lEHsz#>XmXECIB0trfj<4k4s#l=$5<4YN(ufpZlvX|fTal2=crkcfu zX&Q}DeDS=iJTW8j!`S5rNXuMT=B2$jaP_STm)>%W?7Ipr`EF8`hq;5!FZ~yi9;I6iytNhM|GY}JFi0ME^1o%r@m zK(Zwf_D89x?qM!2Y*8kMN)FYaEYfDW$6*$GygqBU z&rMA^?`Y(G>ao5!XkPbTD3(j5H6On!3+M!*sJIz~1X3_NB zFDMo4Clnhj3zsQ-Mh*YO`IDi>-{;OlI>W>>{r)6%cd1dNx&Uc-usOe%k3~wYDyD>n zp-SDytVs3L^}__hy0I^wd6>>LWhoYKj&;*8(6>;{P$S1#I6W`z_i0{i)>Li+8G%cc zquRtm!da2t$~@n;x34>MuU?Va`MPFzmwFwlcNU>vQsIKpLAH7$O!+Cw89S3*vzk@I zdAuS^k?g$Er{k?MtQnhZCa!DBts$xPWMP1h`S5+aCmcSTS>GOvQF4*$ zJg2ppwAEF;I31%TDjp4pJ;MDp-{LI__uqvmWCOP#uug+`|+@1Wjm8GQQ zmd4}twCG7n9-;6yv+>w?uJQQT`d7QlH9RmTclC#7|GbY8X_x^q^0{%eu&(-23wSFv zGRys(L72V78UGlc^^@IUqZLm&)C+ak_nv|N;E=8V(_AAl4Fb)+qH&}V0!gL1B6Z2$ zL4OGtbZ4%QG$iGKh)O>BFt;>(W#Oy-c{-^D&=)*V&t!Y)HaF<>0mafdcH29@OY0Vu zZ^)&=Xq_Z`rV;priAKTocmNuf!7|ZD#sWj-79Q(H995&^S+9`gOobA7lCmrF4Q)oeka^y%d-*QexMQ|N*D z$1U#(#-raDyTj+!{~KT ze&s(_jYaoJ3!g-jv{sLXuF!W4v^*ZvEbjEiPfv-|v{Q!8UlQD$eBv$6km8iF>U&ox zQA21htRU@|2yN62GkeyhpP8Bf%s#3T5%K((W}^vTf+|+O+1D^>A4d*WUOwk#KJyiK zGEPhDg*l@(S9t9I_=>27-MTmOK@GbVC@s*(+-n;CWcQSMz-rZ;wnw$Ey18MWQ>zn< ze8Hi&_9(wLju;OjdejcrTY(BjbeqXeVC?}Zax(mAe+1$Gtg>keOr?QLdb}N~ zo;A200TTU0-R}(_sBphV=-8(!x@9cpYmHnF>WJ$F;c?J%w~>&l=98zBNFa>=+{rBX zWKTKIsp2sVw|lad(J^|8P+~k{7#GRUY_&sep3x7hT@+pUVM?%JmoFrbnZ0ra^_w|F zcjCTgW~qe6dy*jO7vE)Iplk1CdE=qu!^1HK%fjs2I_x zY3d8(`HLBgEkI_6XY!WLWjp^_To!+bwIrx!*8NogFb~H0>wHFp^e^WWM3;Hf=02mU z?aT$)$HrGfooLEV|nRQ`_nWh`HHsL5A;a)gkLX$3H* zq~p{jI@2ysf%`#!nmGA}CYkfXM&s@?AZqxfi%b7rwvO<_DfJaCzt33zu z=6!pv@gn=?%{?GB{45`r8vu0c`+Er4{;|iK2!JDbeZSLN;wQcy!H$^i-gl9kI!MM?sB3qDLUf=R|tJtybeJQhuKP)-hL9M9jS$Kas zy7FiyH&OANU%|R8=}}YAVWVhp+6m=w}p58^JYk2@(pRL5;c z>bc5H#eFv}0jsxRsD771M!TGTyB&}6q`y2RVP|-_=m}3-J2a^4d_l>((+53gUUAqO zRvEPX(d@-ENmvY&&Q6|JEwS!o?b>j1wDo@71u4`e_PmxVw4^r;_mGtxRJ=+jZDfM7 zxi}S&K&W86%eF$5;^GGlNZjk4vfB!+;apWK#p zTcI5oYoOC`lr<>x|4#qlQ!ZIFcYBT|73V=ab}H0XWXZ#*z?oVv#&x$K(_xo$jS}w?)a`#iXK{;B59~&LDxs-Md7x^omAWPG(=o zQ+HCso*}=n*0g)>iEKY(XBm|DfvZK)KvVhtVgjfrkJUR7avJa3GarC4r3h!KZ@f#h z)`ZQ4QLUaP5q5#8yU1nT+UBcV@xCl>-p+6%#1^WldRW;iBtmNJUr;x z2X0q*og->n@w`9Cb*H*E%y1Bqkv4~CPsgc&EGzUw8v{d(44-|hDdTWn}8hTs5O1N zL@Xjj&G#M4W3Y>%Q_-JN+hf!$xKMzY@xj4AB}D*#;znS1S^chHY&y4Eh0@hm=mYM| zUm5bhVr$HF|BkIGx!e7)LdqIgC^}i=(aPY_)BTG%(!tRQkNt;q@_!_eWMpJ#{!fO| z{{+=0HMJEn*-^cZRh<^Qh?s?4&o$R13X^(b=!no!<5DLS7LsZ)Z=!0+*C$Bc&!4fJ zo7*o}27u!adK(J5eiFJlo&_y(f5c`74pjpUSDPRb(F`Kg ziDR1qa;QfxDnyvZU&YZ3fgnvk>{2abUPdJ`fcH zY#1nsd`i>}42zY}0S&r@9YbzplMHbyJk^pOh%itc8sJXiT(uGZD;zcXU=Y7Sx#O8q zI5j^1Qec|oTN((p-^eqF&QYa*_(stE@X_P~cGr&P+Npt3!ZLQ40MbUV`Hg#M;e=4v zKrrYWe5Sx0~O7=CU-OU_elXBpgaH(1V1pXkZ&6-ZfU2GPo6O2Z2B)_zC zV#tK=It3WJ)J*LYang|FuiH1L2*rm<+lJ|M-y1y8vkPa=r6~!#>x3h7a1vJ2rwoh^ z!E*NRf^(UlSMd6FIb*sL&A(|;)TEhein};?@_?*gIQzbsHr-#SV-c;N%Dm_6yToN>)?AH+q~}yoLE~2Z18lmU0a7_ooAxqsGJxr zVY?O$5>fJ?Zmy5CcrKp8k+KT2rbpZg;$zExJ!w#UBi|{4=@M6uD}tyhjZ+p%hX!90 z(;8tv6niJx&3yx$CWBN{3|DY=y@ zaFp*}>H#oS7Bt1Ku_sF4`EXlY0@t+B*rFRrl!rK*lx2tCgR^BOf;>00&uSYYm?>N?VxdtMGh8Z2qM1Y>Sk5on$d>0t2Buv0 zhQ-;21m1Im2Y>DXAXY}oNja(QM=f&4O-C1o6vm~6^!;de&MkY zzNK-zUft*}*Ko(t$T(a0Og^`UtMMfMTz0Yv8>a9J6%srgKRoIM$40=*CPM>H4!E6! zSwI5g&(H8&k)lZ0EZiJhcV2?jyz9eiDl17DcIQjVy_(YSzk#&@FOI287lK?Zj1^aA zUE$LRq%RoK7Dh^8JNm4I8e+S|AL}O0Yd;mMHXob2I%Q_Mk_f*ZgT{=|{q=|uSN)Pw z&jtm?fuJ(XTS*`|=exVFjC?cU!q8UoHO$jSzv+H2M1uO^E&9UqFJDC9gM`Cw;o!Gd zO(}+L-Q0sln8fpqqW)+H;`vJe10*BoIf^RjBn~$sj?ox+mc|W}s|Vlq^($ zFD?l$J3;BIAdj?}{0vXg#?8L|V};dy(M7sTD(j^JQ@%u{#^I8av?k;-Vm^(k^Y$K! z5uxGMNRAL=a9p195_M+7Sd*}!=4I93cJo+n?s8wl*kp&ZCDl~{2Krieeagf1iC4ZS z62|B1QrgYEkS6Y>9>3!nCCR4!w+RPE@?&$RWrw@tgZ{DEaj{vZtw{guQ}}ESSOEXA zL)=N+oiO%rQ>zO&sr%kd;P7Uu6Z(MKxC8vMiN$!E>9Scu5?V5r`d)h;Kx<+TVc+|!nm zO&WP6eQuEOzH3=Y>^725dm8!iJn$ITZ=HxdTy`3o_xRHn#XF;7Ru0-MlGSSH6s6&K zkBj1rv&qSvmLou6SLAsZ^^xGY_-(W87iVkZYec%CGs?%clay z47m-?nIp=ERrZEe_Xd1Q>-@=TKFp1NXsd~~HNEm^IExzP&;!t(CV@R-Jx3FWm5eQ> z6&31Uz?F;?|H;GXUrnBWNKiDQ?52jP;WWDRPcVbKb`du|0W&f&ooGhTAA|f<7fof} zJwf>2!GFn5Mt;`huuV&ryTmk51{{R-oF;=T*1w>1LgW=G!%Ci7+bn@MNWP8I!ObO6 zsj#-yP}{8~MOmdR2hC5eoVTt%lhXRKbD4u6|316SVf+%Y@4Pfe>u-aC%387=o3BFk z2(5oK_}N~IGB2^AYENvn_iZl?U)KNf;~~5lK=4fTd1B=6gW$N8fb;cFlhk6I7Dg^R za8<0{Wq}n9X%aoC$7V((^O@N#CDM?JFkmh(-fh9xz-PzEme*Oi`-gI}>!j6kByk`0 zKK=Djep0@*iIb@rjJkGXLafFEQD2JXy5Eao=~14U6`>y9(MIdYw{k{T5eg{>Ts?xY z#mfz?PxQYNa;3gaQszNGH~Z8OhBL_{wUhTuQaQJRCa0fc>+UlCy7MvpZf+*V(}@zD zH@G`~9&h>VF!x}zP1CU*V^WpuoM75ym~TjDndDS4=w<3mOD#%{HcexK-g5{PspF*T|bA&BDH)jLrfH$8~L}^)%PY+i0n7Fz_5~m37oVH!{h#rf?g`$*dL4SV{1cj9Bdo@^u4A18C*_jv z_ae9lMUh`uhiVP)Gb^3GT~Mq?NFl%c%^04z-1iU za*Ib2%2|r4{pV*CnJQ=a^D~Rmz}6H`C?!tLF_q${tNZ%|GN!hhR%XuiU}>*qv_7ByKr2emuT+i`7Qoz*9FAFA9B8Fdynqm(-)u&Ryk z%W}}%OF7*`S!k7gQNMW=4$1yI0e~N-|No>X;dgd2vvnXNr&Tg{vNHbpRdhD6FgA4h zsR=q5>pT7QN$D#YtKqqtJDK5`{e*Z94#pwi($M|%{Od2wZEb{pWCQTXgg6-J7+C1o>DlQR=@{r3HGV9lZH@l_ok|Y+ zc6P=_KcS<(mE%urDW@c?MkC^EWhJ0*WAl$cB{OqJyr1unUon9n&s2CS#tx1@4)Olu z6n-pzPF>2}#Tf6$H6yK%v4Oe14LSKwsQFJc`wvG83*EmD$Nw9{GW{!V{U?NF{6`Yu zf6_sqXZ^9@;lcZl&wqxn-fG&m8!Yf%&(%AIT0ONd(tyGKIsE>4<{C_witxyF)W9Kc zUPIz&MBiPU$c6jKSNJ#PEm&7mu;S!e4ombjRQ17MVndC38J;A6^1IW1ocW3)h>Fw+B<)(9#PBJs%dl5+UctkkE>#8jO_8 zba!?YwfWVVo&}b`AMiD3vn_Az^Yv=#i=|V9cl_an)de!yKR>+789C~H@x^nP$D~5z z9V=;~)iGE&rS*Y`_DrriQMYrZLSwV8W#42qLGrk?%8*VyFJ$HQV(f}l?5Kh3!ByIF zF&%<5*=~P`cueBtj%1}`xm;GoS-y2D8P7QDF8OdpP6ztS*q+{OABv&<_h!p zNzs*z=H}LiR6lO6;=c*vt!-hyZP%h%=z^BuQaaO>Td!&Cf~M3|I#z{Wr^(!89eJDz zqkLIb*12Prc>dS;F3k*;jp-)!zw z=F0d}v!$7@j%fXhhi!%bnc(?P*!eK&NOAws!&l*+`8(LIvjy(CT^bbN`Kx$ zwyc~h*Ya2;6R!l6T$iFcs9Rt?&NGS|zk_$&W$)AqN^ZR=l1xNH@1BUPss~Z3?OBW4 zqp;qTI3BNSp}e=6Ga0LZVz>-ANT%S`EjrX*I}vcuL>-p!NH;3|+W@g=fE2f`WK&;! zWP9k=MduxEl(o#!qsrPu42R?hbfa{1s1n^c8uV${jVtn$+Uf4=*73^pIDDG6l#S$e zf6jEVeP_A%NIs)I-<~0f0812(@aKj`ml@lay3IdMon4pAVnV!2;^#R#Z;7`p%kqaFbA$zQv6 zX9p5)y9cIcy@Zy!CEnE$CREP2Tko&@R6!&Yw^k1q$VS48IV87?Rr>9V%ok4wWXc ziO5f}kQyaOv#{JOH?I75-x4JXivD{dH-bLY8lrG-mqCxp1qJeYEv5VJkpu{v#;pQq zXys_Rnw6}4?n+)Bm6}1i3Q59B*|@Z`NUipMxLUy8MrwXn5!8_?!}3 zh*M~uo^nx+oSj$oG*&h`dvy1X2S}k)YI}e}n^jVPLgG0}LY?xR+?dL!VCmRjND1Yn z{@layAqz6))8bquY^XvnVanS&IeM}jl)xbf?Itum1pyI#zH_rRFfFVTI#S|+#7H0%9K6HwGdlV?e32M>?Tm= z*hSFJH1D>g-!RN`tS}yv^uk1E<1AtIaZb-YAQx*bfflpHI;!sOS-U+}{wNAB_w=s| z0+ITZW3V*_6+;-aPd1xb%1sfMMfS!tUp0XS+BSje<2Eykou*APk4g<$!Njd!iU{P& zoHv1zYm)v`nEq3Aw~`p2FD~I3pR*#>-7J}i{3tP#yFYjmlA>shZ!VE_v>St=U{Osh zk$I7@E-(I8*31XZPgm!UJn8q^M_sSwZtRS_rz2~#Dl7GMgVkl~bGl?MZ#`x|H|W01 zg16m!4-Lhpzg=Lw<(wU;7DRGAIZ~DF?0eTQpi}fZ|8~PZ`O)-yJMLHYI`A!wlV%#f z#4SE`e@2WsEax>dNcv9W`?wA%-hIlpR|^1o$@Tq_PHk^rk1qavY0byYw%Oj^zCRpJ ze)r7t#n#yZ(HEP1?56nwYcp#8Z$!0O=-L16_TfL)OEm>!6FgdR8zW;kJk6h8mLENB zZ9G~DZ zepY4?A!ZRaAvzIOR%T`a1|}9ZW+6s;CIJDS|BcCyr~h%eW@2af@0M%qBuOh}W#r&z zj^uT)LcZJtQ*+7F{TTsrWXK=|vckdyKx=Yq_s3qLzd)I6^7%4MBqUcUF)3ydv#s?qPMMh2+Bc3!_mlk2}ar}wYEw=K8f>zazy>3E^vdSj{nIMmqf z&&z2Q8sKSK2r$OvV)L(ys{@wLfX-l$kLLhhvaP|6Nyr7c09tbzGpj|qpju-Z!>9$a z_%|iip;ic|3#<#Y!d8f;i>ynjgR$gH<}l0I06z{+kHJ_Y70&o24K?5izH2c+~u5+$v;heo8_FP$+=ZFrdDHI<4WUc2DzYUD0#jMAFI@3)qs%ZZG>sa zSI()$IE0-EZNiZrPYC2TTc1%IbwE22u@ss|yasunX`8Xj-2i&Zf6N)p(y;@u28RI~ z2jjaZ%AY#_oI7L{Vwul$6M-@18NK&HJdL31TrwMO_0=Vv5pMt4jt08{?u z&>28TI~_q|y#|&GAe$@@G=AYbh$(>cdP4%>d=#_oJw|f^Z>N5adYO-bf`tTD6NUYK;koZ3BG{n1;R z2iQ!KeOOI~aV$E3pU-zR1VEd(nEPOOBF)tKnw-ya^pyA&oiBN8_!s*KX%F&m#&gZe z^{GH{I|TUP52_Yua6umwsr1OMD$XhZz&KhzbiV!3>eNYAv$3Cxxd99bbR{ZcNNl|O z;7D7_#P>U|uf}S1_n6u57-5=F|8EOM`NUSnvKe*Hwip4ymFPzFqF+6|fzrU|-#`Ra z!_HN(?XzD*1c^AIH4CRK>!$0g@T|4)QK1oO{1KhDx z*Q#2u+qK^{50C-QhG4hhy5U;!=rNwJy)-=H;u&!b1k$8k2uL2*9RcI4AV zMq|#ehtbEs-im*}yxY{wJ)uNW2p)_LS2oGs;X8z%yzdCh;^A2; Date: Mon, 21 Sep 2026 20:48:58 +0000 Subject: [PATCH 074/299] docs: clarify BaseTime availability during block execution (#5205) * docs: clarify BaseTime availability during block execution Amp-Thread-ID: https://ampcode.com/threads/T-01a0c4ff-4775-7305-bde6-a73fbcdb43c1 Co-authored-by: Amp * docs: quote BaseTime in Rustdoc Amp-Thread-ID: https://ampcode.com/threads/T-01a0c4ff-4775-7305-bde6-a73fbcdb43c1 Co-authored-by: Amp --------- Co-authored-by: Amp --- crates/consensus/derive/src/attributes/stateful.rs | 2 ++ crates/consensus/protocol/src/base_time.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/consensus/derive/src/attributes/stateful.rs b/crates/consensus/derive/src/attributes/stateful.rs index 9cbea9551b..df2cba5b13 100644 --- a/crates/consensus/derive/src/attributes/stateful.rs +++ b/crates/consensus/derive/src/attributes/stateful.rs @@ -223,6 +223,8 @@ where + deposit_transactions.len() + upgrade_transactions.len(), ); + // L1-info remains tx[0]; BaseTime updates at tx[1] after Denim, before user execution. + // The L1-info execution path must not depend on current-block BaseTime millis. txs.push(encoded_l1_info_tx.into()); if base_time_active { diff --git a/crates/consensus/protocol/src/base_time.rs b/crates/consensus/protocol/src/base_time.rs index 74f016b302..0428c8879d 100644 --- a/crates/consensus/protocol/src/base_time.rs +++ b/crates/consensus/protocol/src/base_time.rs @@ -12,6 +12,18 @@ use base_common_genesis::RollupConfig; use crate::REGOLITH_SYSTEM_TX_GAS; /// Versioned calldata for the `BaseTime` metadata deposit. +/// +/// # Execution ordering +/// +/// From Denim onward, this deposit occupies `tx[1]`, immediately after the +/// L1-info deposit and before user deposits and sequencer transactions. +/// Its execution makes the current block's millisecond component available +/// to subsequent transactions. +/// +/// `BaseTime` is not guaranteed to reflect the current block during `tx[0]`. +/// Before this update, `timestampMs()` combines the current block's seconds +/// with the previous stored millisecond component. For consecutive 200ms +/// blocks, this is 200ms behind within a second and 800ms ahead at rollover. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct BaseTimeUpdateTx { From 77764412318292de8950553c18fff2e254df39f2 Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Mon, 21 Sep 2026 20:51:50 +0000 Subject: [PATCH 075/299] feat(consensus): encode private gossip capability (#5143) --- .../service/src/actors/network/gossip.rs | 102 ++++++++++++++++-- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/crates/consensus/service/src/actors/network/gossip.rs b/crates/consensus/service/src/actors/network/gossip.rs index 6a661ff35e..bbdbee951f 100644 --- a/crates/consensus/service/src/actors/network/gossip.rs +++ b/crates/consensus/service/src/actors/network/gossip.rs @@ -2,7 +2,6 @@ use std::fmt::Debug; use async_trait::async_trait; use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; -use derive_more::Constructor; use thiserror::Error; use tokio::sync::mpsc; @@ -10,6 +9,11 @@ use tokio::sync::mpsc; #[cfg_attr(test, mockall::automock)] #[async_trait] pub trait UnsafePayloadGossipClient: Send + Sync + Debug { + /// Returns whether payloads should be sealed privately without commit or gossip. + fn seals_privately(&self) -> bool { + false + } + /// This is a fire-and-forget function that schedules the provided /// [`BaseExecutionPayloadEnvelope`] to be gossiped. The implementation should return as /// quickly as possible and offers no guarantees that the payload actually was gossiped @@ -30,22 +34,100 @@ pub enum UnsafePayloadGossipClientError { /// Queued implementation of [`UnsafePayloadGossipClient`] that handles requests by sending them /// to a handler via the contained sender. -#[derive(Debug, Clone, Constructor)] -pub struct QueuedUnsafePayloadGossipClient { - /// Queue used to relay unsafe payloads to gossip. - request_tx: mpsc::Sender, +#[derive(Debug, Clone)] +pub enum QueuedUnsafePayloadGossipClient { + /// Payloads are relayed to the network actor for gossip. + Network { + /// Queue used to relay unsafe payloads to gossip. + request_tx: mpsc::Sender, + }, + /// Payloads are sealed privately without a network actor. + Private, +} + +impl QueuedUnsafePayloadGossipClient { + /// Creates a payload gossip client backed by the network actor. + pub const fn new(request_tx: mpsc::Sender) -> Self { + Self::Network { request_tx } + } + + /// Creates a private-sealing capability without a network actor. + pub const fn private() -> Self { + Self::Private + } } #[async_trait] impl UnsafePayloadGossipClient for QueuedUnsafePayloadGossipClient { + fn seals_privately(&self) -> bool { + match self { + Self::Network { .. } => false, + Self::Private => true, + } + } + async fn schedule_execution_payload_gossip( &self, payload: BaseExecutionPayloadEnvelope, ) -> Result<(), UnsafePayloadGossipClientError> { - self.request_tx.send(payload).await.map_err(|send_error| { - let err = UnsafePayloadGossipClientError::RequestError("request channel closed".to_string()); - error!(target: "gossip_client", payload = ?send_error.0, ?err, "failed to request to gossip payload."); - err - }) + match self { + Self::Network { request_tx } => request_tx.send(payload).await.map_err(|send_error| { + let err = UnsafePayloadGossipClientError::RequestError( + "request channel closed".to_string(), + ); + error!(target: "gossip_client", payload = ?send_error.0, ?err, "failed to request to gossip payload."); + err + }), + Self::Private => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, Bloom, U256}; + use alloy_rpc_types_engine::ExecutionPayloadV1; + use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; + + use super::{QueuedUnsafePayloadGossipClient, UnsafePayloadGossipClient}; + + fn dummy_envelope() -> BaseExecutionPayloadEnvelope { + BaseExecutionPayloadEnvelope { + execution_payload: BaseExecutionPayload::V1(ExecutionPayloadV1 { + parent_hash: B256::ZERO, + fee_recipient: Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::ZERO, + prev_randao: B256::ZERO, + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: 1, + extra_data: Default::default(), + base_fee_per_gas: U256::ZERO, + block_hash: B256::ZERO, + transactions: vec![], + }), + parent_beacon_block_root: None, + } + } + + #[test] + fn private_client_seals_privately() { + let client = QueuedUnsafePayloadGossipClient::Private; + + let result = client.seals_privately(); + + assert!(result); + } + + #[tokio::test] + async fn private_client_gossip_is_a_no_op() { + let client = QueuedUnsafePayloadGossipClient::Private; + + let result = client.schedule_execution_payload_gossip(dummy_envelope()).await; + + assert!(result.is_ok()); } } From 8c639786d21805a92b9ea242d660c7d3b1a6a1d9 Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Mon, 21 Sep 2026 21:43:18 +0000 Subject: [PATCH 076/299] feat(consensus): add disabled-mode derivation and L1 watcher clients (#5144) Introduce Enabled/Disabled variants for QueuedEngineDerivationClient and QueuedL1WatcherDerivationClient. Both types now expose new() and disabled() constructors and use a consistent Enabled { tx }/Disabled enum internally, making the disabled state explicit in the type system. --- .../service/src/actors/engine/client.rs | 117 +++++++++++++----- .../service/src/actors/l1_watcher/client.rs | 94 ++++++++++---- crates/consensus/service/src/service/node.rs | 2 +- 3 files changed, 155 insertions(+), 58 deletions(-) diff --git a/crates/consensus/service/src/actors/engine/client.rs b/crates/consensus/service/src/actors/engine/client.rs index a795fa1373..8d66c85c23 100644 --- a/crates/consensus/service/src/actors/engine/client.rs +++ b/crates/consensus/service/src/actors/engine/client.rs @@ -3,7 +3,6 @@ use std::fmt::Debug; use async_trait::async_trait; use base_consensus_derive::Signal; use base_protocol::L2BlockInfo; -use derive_more::Constructor; use tokio::sync::mpsc; use crate::{DerivationActorRequest, DerivationClientError, DerivationClientResult}; @@ -27,53 +26,105 @@ pub trait EngineDerivationClient: Debug + Send + Sync { } /// Client to use to send messages to the [`crate::DerivationActor`]'s inbound channel. -#[derive(Constructor, Debug)] -pub struct QueuedEngineDerivationClient { - /// A channel to use to send the [`DerivationActorRequest`]s to the [`crate::DerivationActor`]. - pub derivation_actor_request_tx: mpsc::Sender, +#[derive(Debug)] +pub enum QueuedEngineDerivationClient { + /// Requests are sent to a derivation actor. + Enabled { + /// Channel used to send [`DerivationActorRequest`]s to the derivation actor. + derivation_actor_request_tx: mpsc::Sender, + }, + /// Requests are accepted without a derivation actor. + Disabled, +} + +impl QueuedEngineDerivationClient { + /// Creates an enabled derivation client. + pub const fn new(derivation_actor_request_tx: mpsc::Sender) -> Self { + Self::Enabled { derivation_actor_request_tx } + } + + /// Creates a disabled derivation client for a node without a derivation actor. + pub const fn disabled() -> Self { + Self::Disabled + } } #[async_trait] impl EngineDerivationClient for QueuedEngineDerivationClient { async fn notify_sync_completed(&self, safe_head: L2BlockInfo) -> DerivationClientResult<()> { - info!(target: "engine", "Sending sync completed to derivation actor"); - - self.derivation_actor_request_tx - .send(DerivationActorRequest::ProcessEngineSyncCompletionRequest(Box::new(safe_head))) - .await - .map_err(|_| { - DerivationClientError::RequestError("request channel closed.".to_string()) - })?; - - Ok(()) + match self { + Self::Enabled { derivation_actor_request_tx } => { + info!(target: "engine", "Sending sync completed to derivation actor"); + derivation_actor_request_tx + .send(DerivationActorRequest::ProcessEngineSyncCompletionRequest(Box::new( + safe_head, + ))) + .await + .map_err(|_| { + DerivationClientError::RequestError("request channel closed.".to_string()) + }) + } + Self::Disabled => { + info!(target: "engine", "Ignoring sync completed notification: derivation client disabled"); + Ok(()) + } + } } async fn send_new_engine_safe_head( &self, safe_head: L2BlockInfo, ) -> DerivationClientResult<()> { - info!(target: "engine", safe_head = ?safe_head, "Sending new safe head to derivation actor"); - - self.derivation_actor_request_tx - .send(DerivationActorRequest::ProcessEngineSafeHeadUpdateRequest(Box::new(safe_head))) - .await - .map_err(|_| { - DerivationClientError::RequestError("request channel closed.".to_string()) - })?; - - Ok(()) + match self { + Self::Enabled { derivation_actor_request_tx } => { + info!(target: "engine", safe_head = ?safe_head, "Sending new safe head to derivation actor"); + derivation_actor_request_tx + .send(DerivationActorRequest::ProcessEngineSafeHeadUpdateRequest(Box::new( + safe_head, + ))) + .await + .map_err(|_| { + DerivationClientError::RequestError("request channel closed.".to_string()) + }) + } + Self::Disabled => { + info!(target: "engine", safe_head = ?safe_head, "Ignoring new engine safe head: derivation client disabled"); + Ok(()) + } + } } async fn send_signal(&self, signal: Signal) -> DerivationClientResult<()> { - info!(target: "engine", signal = ?signal, "Sending signal to derivation actor"); + match self { + Self::Enabled { derivation_actor_request_tx } => { + info!(target: "engine", signal = ?signal, "Sending signal to derivation actor"); + derivation_actor_request_tx + .send(DerivationActorRequest::ProcessEngineSignalRequest(Box::new(signal))) + .await + .map_err(|_| { + DerivationClientError::RequestError("request channel closed.".to_string()) + }) + } + Self::Disabled => { + info!(target: "engine", signal = ?signal, "Ignoring signal: derivation client disabled"); + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use base_protocol::L2BlockInfo; + + use super::{EngineDerivationClient, QueuedEngineDerivationClient}; + + #[tokio::test] + async fn disabled_client_accepts_sync_completion_without_an_actor() { + let client = QueuedEngineDerivationClient::disabled(); - self.derivation_actor_request_tx - .send(DerivationActorRequest::ProcessEngineSignalRequest(Box::new(signal))) - .await - .map_err(|_| { - DerivationClientError::RequestError("request channel closed.".to_string()) - })?; + let result = client.notify_sync_completed(L2BlockInfo::default()).await; - Ok(()) + assert!(result.is_ok()); } } diff --git a/crates/consensus/service/src/actors/l1_watcher/client.rs b/crates/consensus/service/src/actors/l1_watcher/client.rs index ebfcaba4f6..23157da8e1 100644 --- a/crates/consensus/service/src/actors/l1_watcher/client.rs +++ b/crates/consensus/service/src/actors/l1_watcher/client.rs @@ -2,7 +2,6 @@ use std::fmt::Debug; use async_trait::async_trait; use base_protocol::BlockInfo; -use derive_more::Constructor; use tokio::sync::mpsc; use crate::{DerivationActorRequest, DerivationClientError, DerivationClientResult}; @@ -23,36 +22,83 @@ pub trait L1WatcherDerivationClient: Debug + Send + Sync { } /// Client to use to send messages to the [`crate::DerivationActor`]'s inbound channel. -#[derive(Constructor, Debug)] -pub struct QueuedL1WatcherDerivationClient { - /// A channel to use to send the [`DerivationActorRequest`]s to the [`crate::DerivationActor`]. - pub derivation_actor_request_tx: mpsc::Sender, +#[derive(Debug)] +pub enum QueuedL1WatcherDerivationClient { + /// Requests are sent to a derivation actor. + Enabled { + /// Channel used to send [`DerivationActorRequest`]s to the derivation actor. + derivation_actor_request_tx: mpsc::Sender, + }, + /// Requests are accepted without a derivation actor. + Disabled, +} + +impl QueuedL1WatcherDerivationClient { + /// Creates an enabled derivation client. + pub const fn new(derivation_actor_request_tx: mpsc::Sender) -> Self { + Self::Enabled { derivation_actor_request_tx } + } + + /// Creates a disabled derivation client for a node without a derivation actor. + pub const fn disabled() -> Self { + Self::Disabled + } } #[async_trait] impl L1WatcherDerivationClient for QueuedL1WatcherDerivationClient { async fn send_finalized_l1_block(&self, block: BlockInfo) -> DerivationClientResult<()> { - trace!(target: "l1_watcher", ?block, "Sending finalized l1 block to derivation actor."); - let _ = self - .derivation_actor_request_tx - .send(DerivationActorRequest::ProcessFinalizedL1Block(Box::new(block))) - .await - .map_err(|_| { - DerivationClientError::RequestError("request channel closed.".to_string()) - })?; - - Ok(()) + match self { + Self::Enabled { derivation_actor_request_tx } => { + trace!(target: "l1_watcher", ?block, "Sending finalized l1 block to derivation actor."); + derivation_actor_request_tx + .send(DerivationActorRequest::ProcessFinalizedL1Block(Box::new(block))) + .await + .map_err(|_| { + DerivationClientError::RequestError("request channel closed.".to_string()) + }) + } + Self::Disabled => Ok(()), + } } async fn send_new_l1_head(&self, block: BlockInfo) -> DerivationClientResult<()> { - trace!(target: "l1_watcher", ?block, "Sending new l1 head to derivation actor."); - self.derivation_actor_request_tx - .send(DerivationActorRequest::ProcessL1HeadUpdateRequest(Box::new(block))) - .await - .map_err(|_| { - DerivationClientError::RequestError("request channel closed.".to_string()) - })?; - - Ok(()) + match self { + Self::Enabled { derivation_actor_request_tx } => { + trace!(target: "l1_watcher", ?block, "Sending new l1 head to derivation actor."); + derivation_actor_request_tx + .send(DerivationActorRequest::ProcessL1HeadUpdateRequest(Box::new(block))) + .await + .map_err(|_| { + DerivationClientError::RequestError("request channel closed.".to_string()) + }) + } + Self::Disabled => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use base_protocol::BlockInfo; + + use super::{L1WatcherDerivationClient, QueuedL1WatcherDerivationClient}; + + #[tokio::test] + async fn disabled_client_accepts_finalized_block_without_an_actor() { + let client = QueuedL1WatcherDerivationClient::Disabled; + + let result = client.send_finalized_l1_block(BlockInfo::default()).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn disabled_client_accepts_new_head_without_an_actor() { + let client = QueuedL1WatcherDerivationClient::Disabled; + + let result = client.send_new_l1_head(BlockInfo::default()).await; + + assert!(result.is_ok()); } } diff --git a/crates/consensus/service/src/service/node.rs b/crates/consensus/service/src/service/node.rs index c53d145860..d55c2ac130 100644 --- a/crates/consensus/service/src/service/node.rs +++ b/crates/consensus/service/src/service/node.rs @@ -557,7 +557,7 @@ impl RollupNode { Arc::clone(&self.config), AlloyL1BlockFetcher(self.l1_config.engine_provider.clone()), l1_head_updates_tx.clone(), - QueuedL1WatcherDerivationClient { derivation_actor_request_tx }, + QueuedL1WatcherDerivationClient::new(derivation_actor_request_tx), Some(signer), cancellation.clone(), head_stream, From 72fb45f84bcc1d6e64a929d47eb538bb5e9edc37 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Mon, 21 Sep 2026 21:48:44 +0000 Subject: [PATCH 077/299] feat(precompiles): reject the token as a credit recipient (#5150) * feat(precompiles): reject B20-prefix recipients at Denim Co-authored-by: Codex * test(precompiles): cover B20-prefix credit recipients Co-authored-by: Codex * style(precompiles): apply nightly formatting Co-authored-by: Codex * fix(precompiles): reject only the token as recipient Co-authored-by: OpenCode * style(precompiles): apply nightly formatting Co-authored-by: OpenCode * refactor(precompiles): rename credit recipient module Co-authored-by: OpenCode --------- Co-authored-by: Codex Co-authored-by: Stephan Cilliers Co-authored-by: OpenCode --- .../precompiles/src/b20_asset/logic/v3.rs | 130 +++++++++++++++--- .../src/b20_stablecoin/logic/v3.rs | 113 ++++++++++++--- crates/common/precompiles/src/common/mod.rs | 3 +- .../src/common/ops/credit_recipient.rs | 80 +++++++++++ .../common/precompiles/src/common/ops/mod.rs | 3 + crates/common/precompiles/src/lib.rs | 6 +- .../precompiles/tests/b20_asset_v3_golden.rs | 100 ++++++++++++++ .../tests/b20_stablecoin_v3_golden.rs | 82 +++++++++++ 8 files changed, 472 insertions(+), 45 deletions(-) create mode 100644 crates/common/precompiles/src/common/ops/credit_recipient.rs diff --git a/crates/common/precompiles/src/b20_asset/logic/v3.rs b/crates/common/precompiles/src/b20_asset/logic/v3.rs index b867ef0ffb..89d3a00ce3 100644 --- a/crates/common/precompiles/src/b20_asset/logic/v3.rs +++ b/crates/common/precompiles/src/b20_asset/logic/v3.rs @@ -15,9 +15,9 @@ use alloy_sol_types::{SolEvent, SolValue}; use base_precompile_storage::{BasePrecompileError, Result}; use crate::{ - Asset, AssetAccounting, B20_MAX_SUPPLY_CAP, B20AssetStorage, B20AssetToken, B20Guards, - B20PausableFeature, B20PolicyType, B20TokenRole, Eip712Domain, IB20, IB20Asset, NonZeroAddress, - PermitArgs, PolicyAccounting, Token, TransferPolicyIds, + Asset, AssetAccounting, B20_MAX_SUPPLY_CAP, B20AssetStorage, B20AssetToken, B20CreditRecipient, + B20Guards, B20PausableFeature, B20PolicyType, B20TokenRole, Eip712Domain, IB20, IB20Asset, + NonZeroAddress, PermitArgs, PolicyAccounting, Token, TransferPolicyIds, }; /// `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")` @@ -34,14 +34,14 @@ pub struct AssetV3; /// One token movement: `caller` sends `amount` from `from` to `to`. /// -/// `from` / `to` are [`NonZeroAddress`]: callers validate zero addresses (and choose the typed -/// revert) before any policy SLOAD. `policies` carries all three transfer policy ids pre-read from -/// their shared slot; `Some` enforces them (unprivileged path), `None` skips them (factory-privileged -/// path). +/// `from` is [`NonZeroAddress`] and `to` is [`B20CreditRecipient`], so callers validate their +/// asymmetric sender/receiver rules (and choose the typed revert) before any policy SLOAD. +/// `policies` carries all three transfer policy ids pre-read from their shared slot; `Some` +/// enforces them (unprivileged path), `None` skips them (factory-privileged path). struct TokenTransfer<'a> { caller: Address, from: NonZeroAddress, - to: NonZeroAddress, + to: B20CreditRecipient, amount: U256, policies: Option<&'a TransferPolicyIds>, } @@ -255,7 +255,7 @@ impl Asset for AssetV3 { privileged: bool, ) -> Result<()> { B20Guards::ensure_not_paused(token, IB20::PausableFeature::TRANSFER)?; - let to = NonZeroAddress::new(to) + let to = B20CreditRecipient::new(to, token.token_address()) .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; let from = NonZeroAddress::new(caller) .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: caller }))?; @@ -281,7 +281,7 @@ impl Asset for AssetV3 { ) -> Result<()> { B20Guards::ensure_not_paused(token, IB20::PausableFeature::TRANSFER)?; // Validate before allowance / transfer-policy-id SLOADs. - let to = NonZeroAddress::new(to) + let to = B20CreditRecipient::new(to, token.token_address()) .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; let from = NonZeroAddress::new(from) .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: from }))?; @@ -350,9 +350,8 @@ impl Asset for AssetV3 { if !privileged { B20Guards::ensure_token_role(token, caller, B20TokenRole::Mint)?; } - if to == Address::ZERO { - return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); - } + B20CreditRecipient::new(to, token.token_address()) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; B20Guards::ensure_policy_type(token, B20PolicyType::MintReceiver, to)?; let supply = token.accounting().total_supply()?; let cap = token.accounting().supply_cap()?; @@ -411,11 +410,10 @@ impl Asset for AssetV3 { ) -> Result<()> { B20Guards::ensure_not_paused(token, IB20::PausableFeature::SEIZE)?; B20Guards::ensure_token_role(token, caller, B20TokenRole::Seize)?; - // `to != 0` guards against a disguised burn; `from != 0` guards against a disguised mint - // (`Transfer(0x0, to, ...)`), matching `transfer_inner`. - if to == Address::ZERO { - return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); - } + // A valid recipient guards against a disguised burn or a balance stranded on this token; + // `from != 0` guards against a disguised mint, matching `transfer_inner`. + B20CreditRecipient::new(to, token.token_address()) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; if from == Address::ZERO { return Err(BasePrecompileError::revert(IB20::InvalidSender { sender: from })); } @@ -1078,8 +1076,9 @@ mod tests { use crate::{ Asset, AssetAccounting, AssetV3, B20_MAX_SUPPLY_CAP, B20AssetStorage, B20AssetToken, - B20PolicyType, B20TokenRole, IB20, IB20Asset, PackedPolicy, PermitArgs, PolicyAccounting, - PolicyRegistryStorage, PolicyVersion, Token, TokenAccounting, TransferPolicyIds, + B20PolicyType, B20TokenRole, B20Variant, IB20, IB20Asset, PackedPolicy, PermitArgs, + PolicyAccounting, PolicyRegistryStorage, PolicyVersion, Token, TokenAccounting, + TransferPolicyIds, }; // --- Self-contained in-memory fakes (no dependency on `common::test_utils`, so shared test @@ -1450,6 +1449,11 @@ mod tests { tok.accounting_mut().set_total_supply(supply + amount).unwrap(); } + /// Returns a B-20 address other than [`TOKEN`]. + fn other_b20_address() -> Address { + B20Variant::compute_address_for_discriminant(ALICE, u8::MAX, B256::repeat_byte(0x42)).0 + } + fn last_event_sig(tok: &Tok) -> B256 { tok.accounting().events.last().unwrap().topics()[0] } @@ -1525,6 +1529,28 @@ mod tests { ); } + #[test] + fn transfer_reverts_on_token_receiver() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(10u64)); + + let err = LOGIC.transfer(&mut tok, ALICE, TOKEN, U256::from(1u64), true).unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(10u64)); + } + + #[test] + fn transfer_allows_another_b20_receiver() { + let mut tok = token(); + let receiver = other_b20_address(); + fund(&mut tok, ALICE, U256::from(10u64)); + + LOGIC.transfer(&mut tok, ALICE, receiver, U256::from(1u64), true).unwrap(); + + assert_eq!(tok.accounting().balance_of(receiver).unwrap(), U256::ONE); + } + #[test] fn transfer_reverts_when_paused() { let mut tok = token(); @@ -1560,6 +1586,17 @@ mod tests { assert_eq!(tok.accounting().allowance(ALICE, BOB).unwrap(), U256::MAX); } + #[test] + fn transfer_from_reverts_on_token_receiver_before_allowance() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(10u64)); + + let err = + LOGIC.transfer_from(&mut tok, BOB, ALICE, TOKEN, U256::from(1u64), true).unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + } + // --- approve --- #[test] @@ -1607,6 +1644,16 @@ mod tests { ); } + #[test] + fn mint_reverts_on_token_receiver() { + let mut tok = token(); + + let err = LOGIC.mint(&mut tok, ADMIN, TOKEN, U256::from(1u64), true).unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + assert_eq!(tok.accounting().total_supply().unwrap(), U256::ZERO); + } + // --- burn --- #[test] @@ -1703,6 +1750,31 @@ mod tests { ); } + #[test] + fn seize_reverts_on_token_receiver_before_sender_validation() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + + let err = LOGIC + .seize_with_memo(&mut tok, ADMIN, Address::ZERO, TOKEN, U256::from(1u64), MEMO) + .unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + } + + #[test] + fn seize_can_recover_a_balance_from_the_token_address() { + let mut tok = token(); + fund(&mut tok, TOKEN, U256::from(10u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + + LOGIC.seize_with_memo(&mut tok, ADMIN, TOKEN, BOB, U256::from(10u64), MEMO).unwrap(); + + assert_eq!(tok.accounting().balance_of(TOKEN).unwrap(), U256::ZERO); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(10u64)); + } + #[test] fn seize_reverts_on_zero_from() { let mut tok = token(); @@ -2152,6 +2224,24 @@ mod tests { ); } + #[test] + fn batch_mint_reverts_on_the_token_recipient() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Mint.id(), ALICE); + + let err = LOGIC + .batch_mint( + &mut tok, + ALICE, + vec![BOB, TOKEN], + vec![U256::from(1u64), U256::from(1u64)], + false, + ) + .unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + } + /// PAUSE fires before the role check. #[test] fn batch_mint_paused_gets_pause_error() { diff --git a/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs b/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs index d43d6bdb74..87fbc309f2 100644 --- a/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs +++ b/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs @@ -11,9 +11,9 @@ use alloy_sol_types::{SolEvent, SolValue}; use base_precompile_storage::{BasePrecompileError, Result}; use crate::{ - B20_MAX_SUPPLY_CAP, B20Guards, B20PausableFeature, B20PolicyType, B20StablecoinToken, - B20TokenRole, Eip712Domain, IB20, NonZeroAddress, PermitArgs, PolicyAccounting, Stablecoin, - StablecoinAccounting, Token, TransferPolicyIds, + B20_MAX_SUPPLY_CAP, B20CreditRecipient, B20Guards, B20PausableFeature, B20PolicyType, + B20StablecoinToken, B20TokenRole, Eip712Domain, IB20, NonZeroAddress, PermitArgs, + PolicyAccounting, Stablecoin, StablecoinAccounting, Token, TransferPolicyIds, }; /// `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")` @@ -30,14 +30,14 @@ pub struct StablecoinV3; /// One token movement: `caller` sends `amount` from `from` to `to`. /// -/// `from` / `to` are [`NonZeroAddress`]: callers validate zero addresses (and choose the typed -/// revert) before any policy SLOAD. `policies` carries all three transfer policy ids pre-read from -/// their shared slot; `Some` enforces them (unprivileged path), `None` skips them (factory-privileged -/// path). +/// `from` is [`NonZeroAddress`] and `to` is [`B20CreditRecipient`], so callers validate their +/// asymmetric sender/receiver rules (and choose the typed revert) before any policy SLOAD. +/// `policies` carries all three transfer policy ids pre-read from their shared slot; `Some` +/// enforces them (unprivileged path), `None` skips them (factory-privileged path). struct TokenTransfer<'a> { caller: Address, from: NonZeroAddress, - to: NonZeroAddress, + to: B20CreditRecipient, amount: U256, policies: Option<&'a TransferPolicyIds>, } @@ -212,7 +212,7 @@ impl Stablecoin for Stableco privileged: bool, ) -> Result<()> { B20Guards::ensure_not_paused(token, IB20::PausableFeature::TRANSFER)?; - let to = NonZeroAddress::new(to) + let to = B20CreditRecipient::new(to, token.token_address()) .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; let from = NonZeroAddress::new(caller) .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: caller }))?; @@ -238,7 +238,7 @@ impl Stablecoin for Stableco ) -> Result<()> { B20Guards::ensure_not_paused(token, IB20::PausableFeature::TRANSFER)?; // Validate before allowance / transfer-policy-id SLOADs. - let to = NonZeroAddress::new(to) + let to = B20CreditRecipient::new(to, token.token_address()) .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; let from = NonZeroAddress::new(from) .map_err(|_| BasePrecompileError::revert(IB20::InvalidSender { sender: from }))?; @@ -307,9 +307,8 @@ impl Stablecoin for Stableco if !privileged { B20Guards::ensure_token_role(token, caller, B20TokenRole::Mint)?; } - if to == Address::ZERO { - return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); - } + B20CreditRecipient::new(to, token.token_address()) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; B20Guards::ensure_policy_type(token, B20PolicyType::MintReceiver, to)?; let supply = token.accounting().total_supply()?; let cap = token.accounting().supply_cap()?; @@ -373,11 +372,10 @@ impl Stablecoin for Stableco ) -> Result<()> { B20Guards::ensure_not_paused(token, IB20::PausableFeature::SEIZE)?; B20Guards::ensure_token_role(token, caller, B20TokenRole::Seize)?; - // `to != 0` guards against a disguised burn; `from != 0` guards against a disguised mint - // (`Transfer(0x0, to, ...)`), matching `transfer_inner`. - if to == Address::ZERO { - return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); - } + // A valid recipient guards against a disguised burn or a balance stranded on this token; + // `from != 0` guards against a disguised mint, matching `transfer_inner`. + B20CreditRecipient::new(to, token.token_address()) + .map_err(|_| BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to }))?; if from == Address::ZERO { return Err(BasePrecompileError::revert(IB20::InvalidSender { sender: from })); } @@ -772,9 +770,9 @@ mod tests { use k256::ecdsa::SigningKey; use crate::{ - B20_MAX_SUPPLY_CAP, B20PolicyType, B20StablecoinToken, B20TokenRole, IB20, PackedPolicy, - PermitArgs, PolicyAccounting, PolicyRegistryStorage, PolicyVersion, Stablecoin, - StablecoinAccounting, StablecoinV3, Token, TokenAccounting, TransferPolicyIds, + B20_MAX_SUPPLY_CAP, B20PolicyType, B20StablecoinToken, B20TokenRole, B20Variant, IB20, + PackedPolicy, PermitArgs, PolicyAccounting, PolicyRegistryStorage, PolicyVersion, + Stablecoin, StablecoinAccounting, StablecoinV3, Token, TokenAccounting, TransferPolicyIds, }; // --- Self-contained in-memory fakes (no dependency on `common::test_utils`, so shared test @@ -1094,6 +1092,11 @@ mod tests { tok.accounting_mut().set_total_supply(supply + amount).unwrap(); } + /// Returns a B-20 address other than [`TOKEN`]. + fn other_b20_address() -> Address { + B20Variant::compute_address_for_discriminant(ALICE, u8::MAX, B256::repeat_byte(0x42)).0 + } + fn last_event_sig(tok: &Tok) -> B256 { tok.accounting().events.last().unwrap().topics()[0] } @@ -1150,6 +1153,28 @@ mod tests { ); } + #[test] + fn transfer_reverts_on_token_receiver() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(10u64)); + + let err = LOGIC.transfer(&mut tok, ALICE, TOKEN, U256::from(1u64), true).unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + assert_eq!(tok.accounting().balance_of(ALICE).unwrap(), U256::from(10u64)); + } + + #[test] + fn transfer_allows_another_b20_receiver() { + let mut tok = token(); + let receiver = other_b20_address(); + fund(&mut tok, ALICE, U256::from(10u64)); + + LOGIC.transfer(&mut tok, ALICE, receiver, U256::from(1u64), true).unwrap(); + + assert_eq!(tok.accounting().balance_of(receiver).unwrap(), U256::ONE); + } + #[test] fn transfer_reverts_on_insufficient_balance() { let mut tok = token(); @@ -1217,6 +1242,17 @@ mod tests { assert_eq!(tok.accounting().allowance(ALICE, BOB).unwrap(), U256::MAX); } + #[test] + fn transfer_from_reverts_on_token_receiver_before_allowance() { + let mut tok = token(); + fund(&mut tok, ALICE, U256::from(10u64)); + + let err = + LOGIC.transfer_from(&mut tok, BOB, ALICE, TOKEN, U256::from(1u64), true).unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + } + #[test] fn transfer_from_reverts_on_insufficient_allowance() { let mut tok = token(); @@ -1293,6 +1329,16 @@ mod tests { ); } + #[test] + fn mint_reverts_on_token_receiver() { + let mut tok = token(); + + let err = LOGIC.mint(&mut tok, ADMIN, TOKEN, U256::from(1u64), true).unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + assert_eq!(tok.accounting().total_supply().unwrap(), U256::ZERO); + } + // --- burn / burn_blocked --- #[test] @@ -1399,6 +1445,31 @@ mod tests { ); } + #[test] + fn seize_reverts_on_token_receiver_before_sender_validation() { + let mut tok = token(); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + + let err = LOGIC + .seize_with_memo(&mut tok, ADMIN, Address::ZERO, TOKEN, U256::from(1u64), MEMO) + .unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + } + + #[test] + fn seize_can_recover_a_balance_from_the_token_address() { + let mut tok = token(); + fund(&mut tok, TOKEN, U256::from(10u64)); + make_seizable(&mut tok); + grant(&mut tok, B20TokenRole::Seize.id(), ADMIN); + + LOGIC.seize_with_memo(&mut tok, ADMIN, TOKEN, BOB, U256::from(10u64), MEMO).unwrap(); + + assert_eq!(tok.accounting().balance_of(TOKEN).unwrap(), U256::ZERO); + assert_eq!(tok.accounting().balance_of(BOB).unwrap(), U256::from(10u64)); + } + #[test] fn seize_reverts_on_zero_from() { let mut tok = token(); diff --git a/crates/common/precompiles/src/common/mod.rs b/crates/common/precompiles/src/common/mod.rs index 04031221a6..5657f4cedb 100644 --- a/crates/common/precompiles/src/common/mod.rs +++ b/crates/common/precompiles/src/common/mod.rs @@ -13,7 +13,8 @@ pub use core_storage::B20CoreStorage; mod ops; pub use ops::{ - B20Guards, B20TokenRole, Eip712Domain, NonZeroAddress, PermitArgs, ZeroAddressError, + B20CreditRecipient, B20CreditRecipientError, B20Guards, B20TokenRole, Eip712Domain, + NonZeroAddress, PermitArgs, ZeroAddressError, }; mod pausable_feature; diff --git a/crates/common/precompiles/src/common/ops/credit_recipient.rs b/crates/common/precompiles/src/common/ops/credit_recipient.rs new file mode 100644 index 0000000000..61f63efb08 --- /dev/null +++ b/crates/common/precompiles/src/common/ops/credit_recipient.rs @@ -0,0 +1,80 @@ +//! Valid B-20 balance-credit recipients. + +use alloy_primitives::Address; + +use crate::NonZeroAddress; + +/// Error returned when an address cannot receive a B-20 balance credit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct B20CreditRecipientError; + +/// An address that can receive a B-20 balance credit. +/// +/// The address is neither zero nor the token contract being credited. Policy authorization is +/// deliberately outside this type's invariant because it is operation-specific and can still +/// reject an otherwise valid credit recipient. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct B20CreditRecipient(NonZeroAddress); + +impl B20CreditRecipient { + /// Returns a valid B-20 credit recipient, or [`B20CreditRecipientError`] when `address` is zero + /// or is the token contract being credited. + pub fn new(address: Address, token_address: Address) -> Result { + if address == Address::ZERO || address == token_address { + return Err(B20CreditRecipientError); + } + NonZeroAddress::new(address).map(Self).map_err(|_| B20CreditRecipientError) + } + + /// Returns the wrapped address. + pub const fn get(self) -> Address { + self.0.get() + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256}; + + use crate::{B20CreditRecipient, B20CreditRecipientError, B20Variant}; + + #[test] + fn new_accepts_an_ordinary_address() { + let address = Address::with_last_byte(1); + assert_eq!( + B20CreditRecipient::new(address, Address::with_last_byte(2)).unwrap().get(), + address + ); + } + + #[test] + fn new_rejects_the_zero_address() { + assert_eq!( + B20CreditRecipient::new(Address::ZERO, Address::with_last_byte(1)), + Err(B20CreditRecipientError) + ); + } + + #[test] + fn new_rejects_the_token_address() { + let token_address = Address::with_last_byte(1); + assert_eq!( + B20CreditRecipient::new(token_address, token_address), + Err(B20CreditRecipientError) + ); + } + + #[test] + fn new_accepts_a_different_b20_address() { + let address = B20Variant::compute_address_for_discriminant( + Address::repeat_byte(0x11), + u8::MAX, + B256::ZERO, + ) + .0; + assert_eq!( + B20CreditRecipient::new(address, Address::repeat_byte(0x22)).unwrap().get(), + address + ); + } +} diff --git a/crates/common/precompiles/src/common/ops/mod.rs b/crates/common/precompiles/src/common/ops/mod.rs index b7be5eb711..242b915dfe 100644 --- a/crates/common/precompiles/src/common/ops/mod.rs +++ b/crates/common/precompiles/src/common/ops/mod.rs @@ -7,6 +7,9 @@ mod guards; pub use guards::B20Guards; +mod credit_recipient; +pub use credit_recipient::{B20CreditRecipient, B20CreditRecipientError}; + mod non_zero_address; pub use non_zero_address::{NonZeroAddress, ZeroAddressError}; diff --git a/crates/common/precompiles/src/lib.rs b/crates/common/precompiles/src/lib.rs index b94cd7021e..575c364b0e 100644 --- a/crates/common/precompiles/src/lib.rs +++ b/crates/common/precompiles/src/lib.rs @@ -43,9 +43,9 @@ pub use common::{ AbiFingerprint, FakePolicyAccounting, InMemoryTokenAccounting, TestStablecoinToken, }; pub use common::{ - B20_MAX_SUPPLY_CAP, B20Abi, B20CoreStorage, B20Guards, B20PausableFeature, B20PolicyType, - B20TokenRole, Eip712Domain, IB20, IB20V1, IB20V2, NonZeroAddress, PermitArgs, Token, - TokenAccounting, TransferPolicyIds, ZeroAddressError, + B20_MAX_SUPPLY_CAP, B20Abi, B20CoreStorage, B20CreditRecipient, B20CreditRecipientError, + B20Guards, B20PausableFeature, B20PolicyType, B20TokenRole, Eip712Domain, IB20, IB20V1, IB20V2, + NonZeroAddress, PermitArgs, Token, TokenAccounting, TransferPolicyIds, ZeroAddressError, }; mod observer; diff --git a/crates/common/precompiles/tests/b20_asset_v3_golden.rs b/crates/common/precompiles/tests/b20_asset_v3_golden.rs index 88e64bd6df..661bac33dc 100644 --- a/crates/common/precompiles/tests/b20_asset_v3_golden.rs +++ b/crates/common/precompiles/tests/b20_asset_v3_golden.rs @@ -489,6 +489,21 @@ fn golden_transfer_reverts_zero_receiver() { assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); } +#[test] +fn golden_transfer_reverts_token_receiver() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(10))); + let err = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: TOKEN, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + read(&mut s, |t| assert_eq!(t.balance_of(ALICE).unwrap(), u(10))); +} + #[test] fn golden_transfer_reverts_zero_sender() { let mut s = fresh(); @@ -773,6 +788,19 @@ fn golden_transfer_from_reverts_zero_receiver() { assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); } +#[test] +fn golden_transfer_from_reverts_token_receiver_before_sender_validation() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: Address::ZERO, to: TOKEN, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); +} + #[test] fn golden_transfer_from_reverts_zero_sender() { let mut s = fresh(); @@ -917,6 +945,19 @@ fn golden_mint_reverts_zero_receiver() { assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); } +#[test] +fn golden_mint_reverts_token_receiver() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::mintCall { to: TOKEN, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); +} + #[test] fn golden_mint_with_memo() { let mut s = fresh(); @@ -1162,6 +1203,42 @@ fn golden_seize_reverts_zero_receiver() { assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); } +#[test] +fn golden_seize_reverts_token_receiver_before_sender_validation() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Seize.id(), ADMIN)); + let err = op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::seizeWithMemoCall { from: Address::ZERO, to: TOKEN, amount: u(1), memo: MEMO } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); +} + +#[test] +fn golden_seize_recovers_a_balance_from_token_source() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, TOKEN, u(10)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: TOKEN, to: BOB, amount: u(10), memo: MEMO }.abi_encode(), + ) + .unwrap(); + read(&mut s, |t| { + assert_eq!(t.balance_of(TOKEN).unwrap(), U256::ZERO); + assert_eq!(t.balance_of(BOB).unwrap(), u(10)); + }); +} + #[test] fn golden_seize_reverts_zero_from() { let mut s = fresh(); @@ -2249,6 +2326,23 @@ fn golden_batch_mint_reverts_length_mismatch() { ); } +#[test] +fn golden_batch_mint_reverts_token_recipient() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Mint.id(), ALICE)); + let mut policy = FakePolicyAccounting::new(); + policy.allow(0, BOB); + let err = op( + &mut s, + ALICE, + policy, + IB20Asset::batchMintCall { recipients: vec![BOB, TOKEN], amounts: vec![u(1), u(1)] } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); +} + #[test] fn golden_batch_mint_reverts_empty() { let mut s = fresh(); @@ -3677,6 +3771,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Asset::IB20AssetCall golden_transfer_unprivileged_blocked_sender_reverts, golden_transfer_unprivileged_enforces_executor_policy, golden_transfer_reverts_zero_receiver, + golden_transfer_reverts_token_receiver, golden_transfer_unprivileged_zero_receiver_storage_access, golden_transfer_reverts_insufficient_balance, golden_transfer_reverts_when_paused, @@ -3689,6 +3784,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Asset::IB20AssetCall golden_transfer_from_unprivileged_enforces_executor_policy, golden_transfer_from_self_unprivileged_enforces_executor_policy, golden_transfer_from_reverts_zero_receiver, + golden_transfer_from_reverts_token_receiver_before_sender_validation, golden_transfer_from_reverts_zero_sender, ]), C::approve(_) => covered(&[ @@ -3711,6 +3807,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Asset::IB20AssetCall golden_mint_unprivileged_requires_role_and_policy, golden_mint_reverts_over_supply_cap, golden_mint_reverts_zero_receiver, + golden_mint_reverts_token_receiver, ]), C::mintWithMemo(_) => covered(&[golden_mint_with_memo]), C::burn(_) => covered(&[ @@ -3729,6 +3826,8 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Asset::IB20AssetCall golden_seize_moves_balance_emits_transfer_memo_seized, golden_seize_reverts_missing_role, golden_seize_reverts_zero_receiver, + golden_seize_reverts_token_receiver_before_sender_validation, + golden_seize_recovers_a_balance_from_token_source, golden_seize_reverts_account_not_seizable, golden_seize_reverts_insufficient_balance, golden_seize_reverts_when_paused, @@ -3863,6 +3962,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Asset::IB20AssetCall SC::batchMint(_) => covered(&[ golden_batch_mint, golden_batch_mint_reverts_length_mismatch, + golden_batch_mint_reverts_token_recipient, golden_batch_mint_reverts_empty, golden_batch_mint_reverts_when_paused, golden_batch_mint_unprivileged_requires_role, diff --git a/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs b/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs index fcee612d8b..f2b1b280d6 100644 --- a/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs +++ b/crates/common/precompiles/tests/b20_stablecoin_v3_golden.rs @@ -459,6 +459,21 @@ fn golden_transfer_reverts_zero_receiver() { assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); } +#[test] +fn golden_transfer_reverts_token_receiver() { + let mut s = fresh(); + seed(&mut s, |t| fund(t, ALICE, u(10))); + let err = op_privileged( + &mut s, + ALICE, + FakePolicyAccounting::new(), + IB20::transferCall { to: TOKEN, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); + read(&mut s, |t| assert_eq!(t.balance_of(ALICE).unwrap(), u(10))); +} + #[test] fn golden_transfer_reverts_zero_sender() { let mut s = fresh(); @@ -743,6 +758,19 @@ fn golden_transfer_from_reverts_zero_receiver() { assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); } +#[test] +fn golden_transfer_from_reverts_token_receiver_before_sender_validation() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + BOB, + FakePolicyAccounting::new(), + IB20::transferFromCall { from: Address::ZERO, to: TOKEN, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); +} + #[test] fn golden_transfer_from_reverts_zero_sender() { let mut s = fresh(); @@ -887,6 +915,19 @@ fn golden_mint_reverts_zero_receiver() { assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); } +#[test] +fn golden_mint_reverts_token_receiver() { + let mut s = fresh(); + let err = op_privileged( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::mintCall { to: TOKEN, amount: u(1) }.abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); +} + #[test] fn golden_mint_with_memo() { let mut s = fresh(); @@ -1133,6 +1174,42 @@ fn golden_seize_reverts_zero_receiver() { assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO })); } +#[test] +fn golden_seize_reverts_token_receiver_before_sender_validation() { + let mut s = fresh(); + seed(&mut s, |t| give_role(t, B20TokenRole::Seize.id(), ADMIN)); + let err = op( + &mut s, + ADMIN, + FakePolicyAccounting::new(), + IB20::seizeWithMemoCall { from: Address::ZERO, to: TOKEN, amount: u(1), memo: MEMO } + .abi_encode(), + ) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20::InvalidReceiver { receiver: TOKEN })); +} + +#[test] +fn golden_seize_recovers_a_balance_from_token_source() { + let mut s = fresh(); + seed(&mut s, |t| { + fund(t, TOKEN, u(10)); + give_role(t, B20TokenRole::Seize.id(), ADMIN); + make_seizable(t); + }); + op( + &mut s, + ADMIN, + seize_receiver_policy(BOB), + IB20::seizeWithMemoCall { from: TOKEN, to: BOB, amount: u(10), memo: MEMO }.abi_encode(), + ) + .unwrap(); + read(&mut s, |t| { + assert_eq!(t.balance_of(TOKEN).unwrap(), U256::ZERO); + assert_eq!(t.balance_of(BOB).unwrap(), u(10)); + }); +} + #[test] fn golden_seize_reverts_account_not_seizable() { let mut s = fresh(); @@ -2539,6 +2616,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Stablecoin::IB20Stab golden_transfer_unprivileged_blocked_sender_reverts, golden_transfer_unprivileged_enforces_executor_policy, golden_transfer_reverts_zero_receiver, + golden_transfer_reverts_token_receiver, golden_transfer_unprivileged_zero_receiver_storage_access, golden_transfer_reverts_insufficient_balance, golden_transfer_reverts_when_paused, @@ -2551,6 +2629,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Stablecoin::IB20Stab golden_transfer_from_unprivileged_enforces_executor_policy, golden_transfer_from_self_unprivileged_enforces_executor_policy, golden_transfer_from_reverts_zero_receiver, + golden_transfer_from_reverts_token_receiver_before_sender_validation, golden_transfer_from_reverts_zero_sender, ]), C::approve(_) => covered(&[ @@ -2573,6 +2652,7 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Stablecoin::IB20Stab golden_mint_unprivileged_requires_role_and_policy, golden_mint_reverts_over_supply_cap, golden_mint_reverts_zero_receiver, + golden_mint_reverts_token_receiver, ]), C::mintWithMemo(_) => covered(&[golden_mint_with_memo]), C::burn(_) => covered(&[ @@ -2591,6 +2671,8 @@ fn v2_op_coverage_checklist(call: IB20::IB20Calls, ext: IB20Stablecoin::IB20Stab golden_seize_moves_balance_emits_transfer_memo_seized, golden_seize_reverts_missing_role, golden_seize_reverts_zero_receiver, + golden_seize_reverts_token_receiver_before_sender_validation, + golden_seize_recovers_a_balance_from_token_source, golden_seize_reverts_account_not_seizable, golden_seize_reverts_insufficient_balance, golden_seize_reverts_when_paused, From 671067396cd62459544ce09ece8f39b6d3511972 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Mon, 21 Sep 2026 21:53:59 +0000 Subject: [PATCH 078/299] chore(ingress-rpc): remove service (#5180) * chore(ingress-rpc): remove service Delete the Tips ingress-rpc service (bin/ingress-rpc and crates/infra/ingress-rpc) and all of its build and devnet wiring: workspace deps, Docker bake targets and Dockerfile stages, the compose overlay service, proxyd ingress forwarding, scrape job, dashboard panels, and docs. The transaction-event vocabulary (ingress-rpc producer and ingress lifecycle event types) plus audit retention mappings are intentionally kept so historical audit-archiver rows still parse. Devnet overlay naming (just devnet ingress, L2_INGRESS_RPC_URL) is unchanged; that URL now refers to the proxyd entrypoint. * chore(devnet): rename ingress stack to tx-observability Follow-up to the ingress-rpc removal: the remaining devnet stack is the transaction-observability pipeline plus the proxyd entrypoint, so replace the stale ingress naming with what things are now. - docker-compose.ingress.yml -> docker-compose.tx-observability.yml - just devnet ingress[-down/-logs] -> just devnet tx-observability[-down/-logs] - bake group ingress / INGRESS_TARGETS -> tx-observability / TX_OBSERVABILITY_TARGETS - L2_INGRESS_HTTP_PORT / L2_INGRESS_RPC_URL -> L2_PROXYD_HTTP_PORT / L2_PROXYD_RPC_URL - local-devnet-ingress config name, x-ingress-healthcheck anchor, and docs/comments updated to match; validity-forwarding 'ingress nodes' reworded to 'mempool nodes'. Generic transaction-admission 'ingress' terminology in txpool, builder, consensus, and systems tests is intentionally untouched. --- Cargo.lock | 81 -- Cargo.toml | 2 - bin/audit-archiver/README.md | 2 +- bin/ingress-rpc/Cargo.toml | 31 - bin/ingress-rpc/README.md | 5 - bin/ingress-rpc/src/main.rs | 127 ---- .../common/observability-events/src/event.rs | 4 +- crates/infra/ingress-rpc/Cargo.toml | 57 -- crates/infra/ingress-rpc/README.md | 30 - crates/infra/ingress-rpc/src/health.rs | 38 - crates/infra/ingress-rpc/src/lib.rs | 522 ------------- crates/infra/ingress-rpc/src/metrics.rs | 28 - crates/infra/ingress-rpc/src/service.rs | 719 ------------------ crates/infra/ingress-rpc/src/validation.rs | 112 --- docs/transaction-events/README.md | 22 +- .../tips-parity-checklist.md | 3 - etc/docker/Dockerfile.rust-services | 13 - etc/docker/Justfile | 24 +- etc/docker/README.md | 6 +- etc/docker/devnet-env | 12 +- etc/docker/docker-bake.hcl | 19 +- ...ml => docker-compose.tx-observability.yml} | 49 +- etc/docker/proxyd/proxyd.toml | 9 +- .../devnet/grafana/dashboards/base-mev.json | 378 --------- etc/scripts/devnet/prometheus.yml | 5 - etc/scripts/devnet/smoke.sh | 11 +- etc/scripts/devnet/status.sh | 7 +- .../devnet/transaction-events-smoke.sh | 9 +- 28 files changed, 57 insertions(+), 2268 deletions(-) delete mode 100644 bin/ingress-rpc/Cargo.toml delete mode 100644 bin/ingress-rpc/README.md delete mode 100644 bin/ingress-rpc/src/main.rs delete mode 100644 crates/infra/ingress-rpc/Cargo.toml delete mode 100644 crates/infra/ingress-rpc/README.md delete mode 100644 crates/infra/ingress-rpc/src/health.rs delete mode 100644 crates/infra/ingress-rpc/src/lib.rs delete mode 100644 crates/infra/ingress-rpc/src/metrics.rs delete mode 100644 crates/infra/ingress-rpc/src/service.rs delete mode 100644 crates/infra/ingress-rpc/src/validation.rs rename etc/docker/{docker-compose.ingress.yml => docker-compose.tx-observability.yml} (73%) diff --git a/Cargo.lock b/Cargo.lock index 36f194a77f..f2d5480163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11427,64 +11427,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "ingress-rpc" -version = "0.0.0" -dependencies = [ - "alloy-provider", - "anyhow", - "audit-archiver-lib", - "base-bundles", - "base-cli-utils", - "base-common-network", - "base-observability-events", - "clap", - "dotenvy", - "ingress-rpc-lib", - "jsonrpsee", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "ingress-rpc-lib" -version = "0.0.0" -dependencies = [ - "alloy-consensus 2.4.1", - "alloy-eips 2.4.1", - "alloy-network 2.4.1", - "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth 2.4.1", - "alloy-signer 2.4.1", - "alloy-signer-local 2.4.1", - "anyhow", - "async-trait", - "audit-archiver-lib", - "axum 0.8.9", - "base-bundles", - "base-common-consensus", - "base-common-evm", - "base-common-network", - "base-execution-evm", - "base-metrics", - "base-observability-events", - "clap", - "jsonrpsee", - "metrics", - "moka", - "reth-rpc-eth-types", - "reth-rpc-server-types", - "serde", - "serde_json", - "tokio", - "tracing", - "url", - "uuid", - "wiremock", -] - [[package]] name = "inlinable_string" version = "0.1.15" @@ -24736,29 +24678,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wiremock" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" -dependencies = [ - "assert-json-diff", - "base64 0.22.1", - "deadpool", - "futures", - "http 1.4.2", - "http-body-util", - "hyper 1.11.0", - "hyper-util", - "log", - "once_cell", - "regex", - "serde", - "serde_json", - "tokio", - "url", -] - [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index b6b86bff2f..dd8fbbe895 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -229,7 +229,6 @@ base-sidecrush = { path = "crates/infra/sidecrush" } audit-archiver-lib = { path = "crates/infra/audit" } base-snark-e2e = { path = "crates/infra/snark-e2e" } base-load-tests = { path = "crates/infra/load-tests" } -ingress-rpc-lib = { path = "crates/infra/ingress-rpc" } base-snapshotter = { path = "crates/infra/snapshotter" } websocket-proxy = { path = "crates/infra/websocket-proxy" } base-telemetry-service = { path = "crates/infra/telemetry" } @@ -542,7 +541,6 @@ proptest = "1.10.0" similar-asserts = "1" iai-callgrind = "0.16.1" arbitrary = { version = "1", default-features = false } -wiremock = { version = "0.6.2", default-features = false } testcontainers = { version = "0.25", default-features = false } testcontainers-modules = { version = "0.13", default-features = false } diff --git a/bin/audit-archiver/README.md b/bin/audit-archiver/README.md index 147f572bea..1f4d3f7d86 100644 --- a/bin/audit-archiver/README.md +++ b/bin/audit-archiver/README.md @@ -26,7 +26,7 @@ event journal. It is not a stdout/stderr log ingestion endpoint. To verify the local devnet path end-to-end: ```bash -just devnet ingress +just devnet tx-observability just devnet tx-observability-smoke ``` diff --git a/bin/ingress-rpc/Cargo.toml b/bin/ingress-rpc/Cargo.toml deleted file mode 100644 index 550f22a451..0000000000 --- a/bin/ingress-rpc/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "ingress-rpc" -version.workspace = true -rust-version.workspace = true -license.workspace = true -homepage.workspace = true -repository.workspace = true -edition.workspace = true - -[[bin]] -name = "ingress-rpc" -path = "src/main.rs" - -[lints] -workspace = true - -[dependencies] -clap.workspace = true -serde.workspace = true -tokio.workspace = true -anyhow.workspace = true -dotenvy.workspace = true -jsonrpsee.workspace = true -base-bundles.workspace = true -alloy-provider.workspace = true -base-cli-utils.workspace = true -ingress-rpc-lib.workspace = true -base-observability-events.workspace = true -base-common-network.workspace = true -audit-archiver-lib.workspace = true -tracing = { workspace = true, features = ["std"] } diff --git a/bin/ingress-rpc/README.md b/bin/ingress-rpc/README.md deleted file mode 100644 index 30993638ed..0000000000 --- a/bin/ingress-rpc/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# `ingress-rpc` - -JSON-RPC ingress server for the Base Stack. - -Receives transactions and bundles from external clients, submits them to the mempool, and forwards metering responses back to connected builder nodes. diff --git a/bin/ingress-rpc/src/main.rs b/bin/ingress-rpc/src/main.rs deleted file mode 100644 index 8c685c94bd..0000000000 --- a/bin/ingress-rpc/src/main.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Ingress RPC binary entry point. - -use std::time::Duration; - -use alloy_provider::RootProvider; -use audit_archiver_lib::{AuditConnector, BundleEvent, RpcBundleEventPublisher}; -use base_cli_utils::LogConfig; -use base_common_network::Base; -use base_observability_events::GlobalTransactionEventWriter; -use clap::Parser; -use ingress_rpc_lib::{ - BuilderConnector, Config, HealthServer, IngressApiServer, IngressService, - MeteringForwardMessage, -}; -use jsonrpsee::server::Server; -use tokio::sync::{broadcast, mpsc}; -use tracing::{info, warn}; - -base_cli_utils::define_log_args!("TIPS_INGRESS"); -base_cli_utils::define_metrics_args!("TIPS_INGRESS", 9002); - -/// CLI entry point for the tips ingress RPC service. -#[derive(Parser, Debug, Clone)] -#[command(author, version, about, long_about = None)] -struct Cli { - /// Service configuration. - #[command(flatten)] - config: Config, - /// Logging configuration. - #[command(flatten)] - log: LogArgs, - /// Metrics configuration. - #[command(flatten)] - metrics: MetricsArgs, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - dotenvy::dotenv().ok(); - - let cli = Cli::parse(); - let config = cli.config.clone(); - - LogConfig::from(cli.log).init_tracing_subscriber().expect("Failed to initialize tracing"); - - let metrics_addr = cli.metrics.addr; - let metrics_port = cli.metrics.port; - base_cli_utils::MetricsConfig::from(cli.metrics) - .init() - .expect("Failed to install Prometheus exporter"); - - info!( - message = "Starting ingress service", - address = %config.address, - port = config.port, - simulation_rpc = %config.simulation_rpc, - metrics_addr = %metrics_addr, - metrics_port = metrics_port, - health_check_address = %config.health_check_addr, - ); - - if config.deprecated_mempool_url.is_some() { - warn!( - env = "TIPS_INGRESS_RPC_MEMPOOL", - "Deprecated ingress mempool forwarding config is set and will be ignored" - ); - } - if config.deprecated_raw_tx_forward_rpc.is_some() { - warn!( - env = "TIPS_INGRESS_RAW_TX_FORWARD_RPC", - "Deprecated ingress raw transaction forwarder config is set and will be ignored" - ); - } - - let simulation_provider = RootProvider::::new_http(config.simulation_rpc.clone()); - - GlobalTransactionEventWriter::init(Some(config.transaction_event_writer_config())) - .map_err(|err| anyhow::anyhow!("{err:#}"))?; - - let audit_publisher = RpcBundleEventPublisher::new( - config.audit_rpc_url.as_str(), - Duration::from_secs(config.audit_rpc_timeout_secs), - )?; - let (audit_tx, audit_rx) = mpsc::channel::(config.audit_channel_capacity); - AuditConnector::connect_batched( - audit_rx, - audit_publisher, - config.audit_batch_max_size, - Duration::from_millis(config.audit_batch_max_wait_ms), - ); - - let (builder_tx, _) = - broadcast::channel::(config.max_buffered_meter_bundle_responses); - info!( - builder_rpcs = ?config.builder_rpcs, - send_to_builder = config.send_to_builder, - "Configuring builder connectors" - ); - config.builder_rpcs.iter().enumerate().for_each(|(destination_index, builder_rpc)| { - let metering_rx = builder_tx.subscribe(); - BuilderConnector::connect(metering_rx, builder_rpc.clone(), destination_index); - }); - - let health_check_addr = config.health_check_addr; - let (bound_health_addr, health_handle) = HealthServer::bind(health_check_addr).await?; - info!( - message = "Health check server started", - address = %bound_health_addr - ); - - let bind_addr = format!("{}:{}", config.address, config.port); - let service = IngressService::new(simulation_provider, audit_tx, builder_tx, cli.config); - - let server = Server::builder().build(&bind_addr).await?; - let addr = server.local_addr()?; - let handle = server.start(service.into_rpc()); - - info!( - message = "Ingress RPC server started", - address = %addr - ); - - handle.stopped().await; - health_handle.abort(); - - Ok(()) -} diff --git a/crates/common/observability-events/src/event.rs b/crates/common/observability-events/src/event.rs index cfa2ed4926..1bcd878933 100644 --- a/crates/common/observability-events/src/event.rs +++ b/crates/common/observability-events/src/event.rs @@ -29,7 +29,7 @@ pub enum TransactionEventProducer { /// Base builder payload loop. #[serde(rename = "base-builder")] BaseBuilder, - /// Ingress RPC/audit clients. + /// Retired ingress-rpc producer. Retained so historical events remain readable. #[serde(rename = "ingress-rpc")] IngressRpc, /// base-routing proxyd. @@ -73,6 +73,8 @@ pub enum TransactionEventType { /// A proxyd backend returned an error or failed before responding. #[serde(rename = "PROXY_BACKEND_FAILURE")] ProxyBackendFailure, + // Retired ingress-rpc lifecycle vocabulary. No active producer emits these + // variants; they are retained so historical audit-archiver rows still parse. /// Proxyd attempted to forward a transaction to ingress-rpc. #[serde(rename = "PROXY_INGRESS_RPC_ATTEMPT")] ProxyIngressRpcAttempt, diff --git a/crates/infra/ingress-rpc/Cargo.toml b/crates/infra/ingress-rpc/Cargo.toml deleted file mode 100644 index 5a7eb4f7d5..0000000000 --- a/crates/infra/ingress-rpc/Cargo.toml +++ /dev/null @@ -1,57 +0,0 @@ -[package] -name = "ingress-rpc-lib" -version.workspace = true -rust-version.workspace = true -license.workspace = true -homepage.workspace = true -repository.workspace = true -edition.workspace = true - -[lints] -workspace = true - -[dependencies] -url.workspace = true -alloy-eips.workspace = true -base-common-evm.workspace = true -async-trait.workspace = true -base-bundles.workspace = true -base-observability-events.workspace = true -alloy-rpc-types-eth.workspace = true -alloy-signer-local.workspace = true -base-execution-evm.workspace = true -base-common-network.workspace = true -audit-archiver-lib.workspace = true -reth-rpc-eth-types.workspace = true -reth-rpc-server-types.workspace = true -uuid = { workspace = true, features = ["v5"] } -metrics = { workspace = true, optional = true } -anyhow = { workspace = true, features = ["std"] } -tokio = { workspace = true, features = ["full"] } -tracing = { workspace = true, features = ["std"] } -moka = { workspace = true, features = ["future"] } -serde_json = { workspace = true, features = ["std"] } -serde = { workspace = true, features = ["derive", "std"] } -alloy-consensus = { workspace = true, features = ["std"] } -base-metrics = { workspace = true, features = ["metrics"] } -alloy-provider = { workspace = true, features = ["reqwest"] } -clap = { workspace = true, features = ["std", "derive", "env"] } -jsonrpsee = { workspace = true, features = ["server", "macros"] } -axum = { workspace = true, features = ["tokio", "http1", "json"] } -alloy-primitives = { workspace = true, features = ["map-foldhash", "serde"] } -base-common-consensus = { workspace = true, features = ["std", "k256", "serde"] } - -[dev-dependencies] -wiremock.workspace = true -alloy-signer.workspace = true -alloy-network.workspace = true -base-bundles = { workspace = true, features = ["test-utils"] } - -[features] -default = [ "metrics" ] -metrics = [ - "base-common-evm/metrics", - "base-metrics/metrics", - "base-observability-events/metrics", - "dep:metrics", -] diff --git a/crates/infra/ingress-rpc/README.md b/crates/infra/ingress-rpc/README.md deleted file mode 100644 index e974f01f0c..0000000000 --- a/crates/infra/ingress-rpc/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# `ingress-rpc-lib` - -Ingress RPC library. - -## Overview - -Handles incoming transaction submission for the Base block builder pipeline. -`IngressService` exposes a JSON-RPC endpoint that meters transactions via -`base_meterBundle` and emits audit events. Also provides `HealthServer` -for liveness checks and `Metrics` for request tracking. - -## Usage - -Add the dependency to your `Cargo.toml`: - -```toml -[dependencies] -ingress-rpc-lib = { workspace = true } -``` - -```rust,ignore -use ingress_rpc_lib::{IngressService, Config}; - -let service = IngressService::new(Config::from_env()?); -service.run().await?; -``` - -## License - -Licensed under the [MIT License](https://github.com/base/base/blob/main/LICENSE). diff --git a/crates/infra/ingress-rpc/src/health.rs b/crates/infra/ingress-rpc/src/health.rs deleted file mode 100644 index 28a7e1cf9f..0000000000 --- a/crates/infra/ingress-rpc/src/health.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::net::SocketAddr; - -use axum::{Router, http::StatusCode, response::IntoResponse, routing::get}; -use tracing::info; - -/// Health check handler that always returns 200 OK -async fn health() -> impl IntoResponse { - StatusCode::OK -} - -/// Health check server. -#[derive(Debug)] -pub struct HealthServer; - -impl HealthServer { - /// Bind and start the health check server on the specified address. - /// Returns a handle that can be awaited to run the server. - pub async fn bind( - addr: SocketAddr, - ) -> anyhow::Result<(SocketAddr, tokio::task::JoinHandle>)> { - let app = Router::new().route("/health", get(health)); - - let listener = tokio::net::TcpListener::bind(addr).await?; - let bound_addr = listener.local_addr()?; - - info!( - message = "Health check server bound successfully", - address = %bound_addr - ); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await?; - Ok(()) - }); - - Ok((bound_addr, handle)) - } -} diff --git a/crates/infra/ingress-rpc/src/lib.rs b/crates/infra/ingress-rpc/src/lib.rs deleted file mode 100644 index 8e91ad86c6..0000000000 --- a/crates/infra/ingress-rpc/src/lib.rs +++ /dev/null @@ -1,522 +0,0 @@ -#![doc = include_str!("../README.md")] - -/// Health check HTTP server. -mod health; -pub use health::HealthServer; - -/// Prometheus metrics for the ingress RPC service. -mod metrics; -pub use metrics::Metrics; - -/// Core RPC service implementation. -mod service; -pub use service::{IngressApiServer, IngressService}; - -/// Transaction validation implementation. -mod validation; -use std::{ - net::{IpAddr, SocketAddr}, - path::PathBuf, - sync::Arc, -}; - -use alloy_primitives::TxHash; -use alloy_provider::{Provider, RootProvider}; -use base_bundles::MeterBundleResponse; -use base_common_network::Base; -use base_observability_events::{ - DEFAULT_MAX_FILE_BYTES, DEFAULT_MAX_FILES, DEFAULT_QUEUE_CAPACITY, TransactionEventProducer, - TransactionEventType, TransactionEventWriterConfig, transaction_event, -}; -use clap::Args; -use serde_json::{Map, json}; -use tokio::{ - sync::{Semaphore, broadcast}, - task::JoinSet, -}; -use tracing::{debug, error, info, warn}; -use url::Url; -pub use validation::{AccountInfo, AccountInfoLookup, L1BlockInfoLookup}; - -/// Metering response plus the transaction hashes from the original ingress -/// request, used to keep builder-send journal events transaction-scoped even -/// when the simulator returns an empty result set. -#[derive(Debug, Clone)] -pub struct MeteringForwardMessage { - /// Transaction hashes from the original ingress request. - pub tx_hashes: Vec, - /// Metering response returned by the simulation RPC. - pub response: MeterBundleResponse, -} - -/// Configuration for the tips ingress RPC service. -#[derive(Args, Debug, Clone)] -pub struct Config { - /// Address to bind the RPC server to - #[arg(long, env = "TIPS_INGRESS_ADDRESS", default_value = "0.0.0.0")] - pub address: IpAddr, - - /// Port to bind the RPC server to - #[arg(long, env = "TIPS_INGRESS_PORT", default_value = "8080")] - pub port: u16, - - /// Deprecated. Ingress no longer proxies transactions to the mempool. - #[arg(long = "mempool-url", env = "TIPS_INGRESS_RPC_MEMPOOL", hide = true)] - pub deprecated_mempool_url: Option, - - /// URL of the audit-archiver RPC endpoint that receives bundle events via - /// `base_persistBatchedBundleEvent`. - #[arg(long, env = "TIPS_INGRESS_AUDIT_RPC_URL")] - pub audit_rpc_url: Url, - - /// Per-request timeout for audit RPC calls, in seconds. - #[arg(long, env = "TIPS_INGRESS_AUDIT_RPC_TIMEOUT_SECS", default_value = "2")] - pub audit_rpc_timeout_secs: u64, - - /// Flush the audit batch when it reaches this many events. - #[arg(long, env = "TIPS_INGRESS_AUDIT_BATCH_MAX_SIZE", default_value = "50")] - pub audit_batch_max_size: usize, - - /// Maximum time (ms) the first event in a batch waits before forced flush. - #[arg(long, env = "TIPS_INGRESS_AUDIT_BATCH_MAX_WAIT_MS", default_value = "25")] - pub audit_batch_max_wait_ms: u64, - - /// URL of the simulation RPC service for bundle metering - #[arg(long, env = "TIPS_INGRESS_RPC_SIMULATION")] - pub simulation_rpc: Url, - - /// Configurable block time in milliseconds (default: 2000 milliseconds) - #[arg(long, env = "TIPS_INGRESS_BLOCK_TIME_MILLISECONDS", default_value = "2000")] - pub block_time_milliseconds: u64, - - /// Timeout for bundle metering in milliseconds (default: 2000 milliseconds) - #[arg(long, env = "TIPS_INGRESS_METER_BUNDLE_TIMEOUT_MS", default_value = "2000")] - pub meter_bundle_timeout_ms: u64, - - /// URLs of the builder RPC service for setting metering information - #[arg(long, env = "TIPS_INGRESS_BUILDER_RPCS", value_delimiter = ',')] - pub builder_rpcs: Vec, - - /// Maximum number of `MeterBundleResponse`s to buffer in memory - #[arg(long, env = "TIPS_INGRESS_MAX_BUFFERED_METER_BUNDLE_RESPONSES", default_value = "100")] - pub max_buffered_meter_bundle_responses: usize, - - /// Address to bind the health check server to - #[arg(long, env = "TIPS_INGRESS_HEALTH_CHECK_ADDR", default_value = "0.0.0.0:8081")] - pub health_check_addr: SocketAddr, - - /// chain id - #[arg(long, env = "TIPS_INGRESS_CHAIN_ID", default_value = "11")] - pub chain_id: u64, - - /// Deprecated. Ingress no longer forwards raw transactions to another RPC endpoint. - #[arg(long = "raw-tx-forward-rpc", env = "TIPS_INGRESS_RAW_TX_FORWARD_RPC", hide = true)] - pub deprecated_raw_tx_forward_rpc: Option, - - /// TTL for bundle cache in seconds - #[arg(long, env = "TIPS_INGRESS_BUNDLE_CACHE_TTL", default_value = "20")] - pub bundle_cache_ttl: u64, - - /// Capacity of the bounded audit event channel. - /// - /// When the channel is full, new audit events are dropped to avoid blocking - /// the RPC handler. - #[arg(long, env = "TIPS_INGRESS_AUDIT_CHANNEL_CAPACITY", default_value = "512")] - pub audit_channel_capacity: usize, - - /// Enable sending to builder - #[arg(long, env = "TIPS_INGRESS_SEND_TO_BUILDER", default_value = "false")] - pub send_to_builder: bool, - - /// Enables transaction observability JSONL journal writes. - #[arg(long, env = "TIPS_INGRESS_TRANSACTION_EVENTS_ENABLED", default_value = "false")] - pub transaction_events_enabled: bool, - - /// Dedicated JSONL file path tailed by the transaction-events sidecar. - #[arg( - long, - env = "TIPS_INGRESS_TRANSACTION_EVENTS_FILE_PATH", - default_value = "/var/log/base/transaction-events.jsonl" - )] - pub transaction_events_file_path: PathBuf, - - /// Bounded in-process queue capacity for journal writes. - #[arg( - long, - env = "TIPS_INGRESS_TRANSACTION_EVENTS_QUEUE_CAPACITY", - default_value_t = DEFAULT_QUEUE_CAPACITY - )] - pub transaction_events_queue_capacity: usize, - - /// Maximum size of an active transaction event JSONL segment. - #[arg( - long, - env = "TIPS_INGRESS_TRANSACTION_EVENTS_MAX_FILE_BYTES", - default_value_t = DEFAULT_MAX_FILE_BYTES - )] - pub transaction_events_max_file_bytes: u64, - - /// Maximum number of transaction event JSONL segments to retain, including the active file. - #[arg( - long, - env = "TIPS_INGRESS_TRANSACTION_EVENTS_MAX_FILES", - default_value_t = DEFAULT_MAX_FILES - )] - pub transaction_events_max_files: usize, - - /// Fail service initialization if the journal file cannot be opened. - #[arg(long, env = "TIPS_INGRESS_TRANSACTION_EVENTS_REQUIRED", default_value = "false")] - pub transaction_events_required: bool, - - /// Network label written into transaction observability events. - #[arg(long, env = "TIPS_INGRESS_TRANSACTION_EVENTS_NETWORK", default_value = "base-mainnet")] - pub transaction_events_network: String, -} - -impl Config { - /// Builds the shared JSONL writer config for ingress transaction events. - pub fn transaction_event_writer_config(&self) -> TransactionEventWriterConfig { - TransactionEventWriterConfig { - enabled: self.transaction_events_enabled, - file_path: self.transaction_events_file_path.clone(), - queue_capacity: self.transaction_events_queue_capacity, - max_file_bytes: self.transaction_events_max_file_bytes, - max_files: self.transaction_events_max_files, - required: self.transaction_events_required, - producer: TransactionEventProducer::IngressRpc, - network: self.transaction_events_network.clone(), - } - } -} - -/// Maximum number of concurrent RPC calls per builder URL. -const MAX_CONCURRENT_RPCS: usize = 64; - -/// Connects ingress metering data to builder RPCs. -#[derive(Debug)] -pub struct BuilderConnector; - -impl BuilderConnector { - /// Spawns a background task that forwards metering data to the builder RPC. - /// - /// RPC calls are dispatched concurrently (up to [`MAX_CONCURRENT_RPCS`]) so - /// that slow responses don't block the recv loop and risk broadcast channel - /// lag. - pub fn connect( - metering_rx: broadcast::Receiver, - builder_rpc: Url, - destination_index: usize, - ) { - let rpc_url = builder_rpc.clone(); - let builder: RootProvider = RootProvider::new_http(builder_rpc); - - tokio::spawn(async move { - let mut event_rx = metering_rx; - let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_RPCS)); - let mut join_set = JoinSet::new(); - info!(url = %rpc_url, "BuilderConnector started, waiting for metering data"); - loop { - // Drain completed tasks to observe panics / errors. - while let Some(result) = join_set.try_join_next() { - if let Err(e) = result { - error!(url = %rpc_url, error = %e, "RPC forwarding task failed"); - } - } - - match event_rx.recv().await { - Ok(message) => { - let event = message.response; - if event.results.is_empty() { - for tx_hash in &message.tx_hashes { - emit_metering_send_event( - TransactionEventType::IngressMeteringSendDropped, - *tx_hash, - event.bundle_hash, - destination_index, - Map::from_iter([( - "drop_reason".to_string(), - json!("empty_results"), - )]), - ); - } - warn!( - url = %rpc_url, - hash = %event.bundle_hash, - "Received metering information with no transactions" - ); - continue; - } - - let tx_hash = event.results[0].tx_hash; - let bundle_hash = event.bundle_hash; - let Ok(permit) = Arc::clone(&semaphore).acquire_owned().await else { - break; - }; - let builder = builder.clone(); - let url = rpc_url.clone(); - join_set.spawn(async move { - emit_metering_send_event( - TransactionEventType::IngressMeteringSendAttempt, - tx_hash, - bundle_hash, - destination_index, - Map::new(), - ); - match builder - .client() - .request::<(TxHash, MeterBundleResponse), ()>( - "base_setMeteringInformation", - (tx_hash, event), - ) - .await - { - Ok(()) => { - emit_metering_send_event( - TransactionEventType::IngressMeteringSendSuccess, - tx_hash, - bundle_hash, - destination_index, - Map::new(), - ); - debug!( - url = %url, - tx_hash = %tx_hash, - "Forwarded metering information" - ); - } - Err(e) => { - emit_metering_send_event( - TransactionEventType::IngressMeteringSendFailure, - tx_hash, - bundle_hash, - destination_index, - Map::from_iter([( - "error".to_string(), - json!(e.to_string()), - )]), - ); - error!( - url = %url, - error = %e, - tx_hash = %tx_hash, - "Failed to set metering information" - ); - } - } - drop(permit); - }); - } - Err(broadcast::error::RecvError::Lagged(n)) => { - warn!( - url = %rpc_url, - skipped = n, - "BuilderConnector lagged behind, skipped messages" - ); - } - Err(broadcast::error::RecvError::Closed) => { - info!(url = %rpc_url, "BuilderConnector channel closed, shutting down"); - break; - } - } - } - - // Drain remaining in-flight tasks on shutdown. - while let Some(result) = join_set.join_next().await { - if let Err(e) = result { - error!(url = %rpc_url, error = %e, "RPC forwarding task failed during shutdown"); - } - } - }); - } -} - -fn emit_metering_send_event( - event_type: TransactionEventType, - tx_hash: TxHash, - bundle_hash: alloy_primitives::B256, - destination_index: usize, - mut data: Map, -) { - data.entry("bundle_hash".to_string()).or_insert_with(|| json!(bundle_hash.to_string())); - data.entry("target".to_string()).or_insert_with(|| json!("builder_metering")); - data.entry("rpc_method".to_string()).or_insert_with(|| json!("base_setMeteringInformation")); - data.entry("destination_index".to_string()).or_insert_with(|| json!(destination_index)); - - if let Err(err) = transaction_event!( - producer: TransactionEventProducer::IngressRpc, - event_type: event_type, - tx_hash: tx_hash, - id: { - "destination_index" => destination_index, - "bundle_hash" => bundle_hash.to_string(), - }, - data: data, - ) { - debug!(error = %err, event_type = %event_type, "transaction event not written"); - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use alloy_primitives::{Address, TxHash, U256}; - use base_bundles::{MeterBundleResponse, TransactionResult}; - use tokio::sync::broadcast; - use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; - - use super::{BuilderConnector, MeteringForwardMessage}; - - fn response_with_results() -> MeterBundleResponse { - MeterBundleResponse { - results: vec![TransactionResult { - coinbase_diff: U256::ZERO, - eth_sent_to_coinbase: U256::ZERO, - from_address: Address::ZERO, - gas_fees: U256::ZERO, - gas_price: U256::ZERO, - gas_used: 21000, - to_address: Some(Address::ZERO), - tx_hash: TxHash::ZERO, - value: U256::ZERO, - execution_time_us: 500, - opcode_gas: vec![], - }], - ..Default::default() - } - } - - fn forwarding_message(response: MeterBundleResponse) -> MeteringForwardMessage { - let tx_hashes = if response.results.is_empty() { - vec![TxHash::ZERO] - } else { - response.results.iter().map(|result| result.tx_hash).collect() - }; - MeteringForwardMessage { tx_hashes, response } - } - - fn jsonrpc_ok() -> ResponseTemplate { - ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "result": null - })) - } - - #[tokio::test] - async fn test_builder_connector_survives_lagged_receiver() { - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .respond_with(jsonrpc_ok()) - .expect(1..) - .mount(&mock_server) - .await; - - // Create a tiny broadcast channel so it's easy to overflow. - let (tx, rx) = broadcast::channel::(2); - - // Overflow the buffer before the connector starts reading. - // The receiver will get RecvError::Lagged on its first recv(). - let event = forwarding_message(response_with_results()); - for _ in 0..5 { - tx.send(event.clone()).unwrap(); - } - - // Start the connector with the already-lagged receiver. - BuilderConnector::connect(rx, mock_server.uri().parse().unwrap(), 0); - - // Give the connector time to hit Lagged and recover. - tokio::time::sleep(Duration::from_millis(50)).await; - - // Send a new message after recovery — this must be forwarded. - // send() fails with SendError when there are zero receivers, - // which is exactly what happened with the old buggy code: the - // connector task exited on Lagged, dropping the only receiver. - assert!( - tx.send(event).is_ok(), - "connector task died — receiver was dropped after Lagged error" - ); - - // Wait for the RPC call to complete. - tokio::time::sleep(Duration::from_millis(200)).await; - - // wiremock verifies expect(1..) — at least one call was made, - // proving the connector survived the Lagged error. - } - - #[tokio::test] - async fn test_builder_connector_forwards_metering_data() { - let mock_server = MockServer::start().await; - - Mock::given(method("POST")).respond_with(jsonrpc_ok()).expect(1).mount(&mock_server).await; - - let (tx, rx) = broadcast::channel::(16); - BuilderConnector::connect(rx, mock_server.uri().parse().unwrap(), 0); - - tx.send(forwarding_message(response_with_results())).unwrap(); - - tokio::time::sleep(Duration::from_millis(200)).await; - // wiremock verifies exactly 1 call was made. - } - - #[tokio::test] - async fn test_builder_connector_skips_empty_results() { - let mock_server = MockServer::start().await; - - Mock::given(method("POST")).respond_with(jsonrpc_ok()).expect(0).mount(&mock_server).await; - - let (tx, rx) = broadcast::channel::(16); - BuilderConnector::connect(rx, mock_server.uri().parse().unwrap(), 0); - - // Default response has empty results — should be skipped. - tx.send(forwarding_message(MeterBundleResponse::default())).unwrap(); - - tokio::time::sleep(Duration::from_millis(200)).await; - // wiremock verifies 0 calls were made. - } - - #[tokio::test] - async fn test_builder_connector_forwards_concurrently() { - let mock_server = MockServer::start().await; - - // Each response takes 200ms. Sequential forwarding would need >=1000ms - // for 5 messages. Concurrent forwarding completes in ~200ms; we allow - // a generous 2s budget so CI load doesn't cause flaky failures. - Mock::given(method("POST")) - .respond_with(jsonrpc_ok().set_delay(Duration::from_millis(200))) - .expect(5) - .mount(&mock_server) - .await; - - let (tx, rx) = broadcast::channel::(16); - BuilderConnector::connect(rx, mock_server.uri().parse().unwrap(), 0); - - for _ in 0..5 { - tx.send(forwarding_message(response_with_results())).unwrap(); - } - - // 2s is generous for concurrent (~200ms) but well under sequential (>=1s). - tokio::time::sleep(Duration::from_millis(2000)).await; - - // wiremock verifies exactly 5 calls were made within the time window. - } - - #[tokio::test] - async fn test_builder_connector_shuts_down_on_channel_close() { - let mock_server = MockServer::start().await; - - Mock::given(method("POST")).respond_with(jsonrpc_ok()).expect(1).mount(&mock_server).await; - - let (tx, rx) = broadcast::channel::(16); - BuilderConnector::connect(rx, mock_server.uri().parse().unwrap(), 0); - - // Send one message, then close the channel. - tx.send(forwarding_message(response_with_results())).unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - drop(tx); - - // The task should exit gracefully without panic. - tokio::time::sleep(Duration::from_millis(100)).await; - } -} diff --git a/crates/infra/ingress-rpc/src/metrics.rs b/crates/infra/ingress-rpc/src/metrics.rs deleted file mode 100644 index 5799da7f89..0000000000 --- a/crates/infra/ingress-rpc/src/metrics.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Prometheus metrics for the tips ingress RPC service. - -base_metrics::define_metrics! { - tips_ingress_rpc - #[describe("Number of valid transactions received")] - transactions_received: counter, - #[describe("Number of valid bundles parsed")] - bundles_parsed: counter, - #[describe("Number of bundles simulated")] - successful_simulations: counter, - #[describe("Number of bundles that failed simulation")] - failed_simulations: counter, - #[describe("Duration of validate_tx")] - validate_tx_duration: histogram, - #[describe("Duration of meter_bundle")] - meter_bundle_duration: histogram, - #[describe("Duration of send_raw_transaction")] - send_raw_transaction_duration: histogram, - #[describe("Number of bundles that exceeded the metering time")] - bundles_exceeded_metering_time: counter, - #[describe("Size of buffered meter bundle responses")] - buffered_meter_bundle_responses_size: gauge, - #[describe("Number of audit events dropped because the channel was full")] - audit_channel_full: counter, - #[describe("RPC call latency")] - #[label(rpc)] - rpc_latency: histogram, -} diff --git a/crates/infra/ingress-rpc/src/service.rs b/crates/infra/ingress-rpc/src/service.rs deleted file mode 100644 index 0a356b51ba..0000000000 --- a/crates/infra/ingress-rpc/src/service.rs +++ /dev/null @@ -1,719 +0,0 @@ -use std::sync::Arc; - -use alloy_consensus::transaction::{Recovered, SignerRecoverable}; -use alloy_eips::{BlockId, BlockNumberOrTag}; -use alloy_primitives::{B256, Bytes}; -use alloy_provider::{Provider, RootProvider, network::eip2718::Decodable2718}; -use alloy_rpc_types_eth::error::EthRpcErrorCode; -use audit_archiver_lib::BundleEvent; -use base_bundles::{AcceptedBundle, Bundle, BundleExtensions, MeterBundleResponse, ParsedBundle}; -use base_common_consensus::{BaseTxEnvelope, EIP8130_REJECTION_MSG}; -use base_common_network::Base; -use base_observability_events::{ - TransactionEventProducer, TransactionEventType, transaction_event, -}; -use jsonrpsee::{ - core::{RpcResult, async_trait}, - proc_macros::rpc, - types::ErrorObjectOwned, -}; -use moka::future::Cache; -use reth_rpc_eth_types::EthApiError; -use reth_rpc_server_types::result::rpc_err; -use tokio::{ - sync::{OnceCell, broadcast, mpsc}, - time::{Duration, Instant, timeout}, -}; -use tracing::{debug, info, warn}; - -use crate::{Config, MeteringForwardMessage, metrics::Metrics}; - -#[rpc(server, namespace = "eth")] -pub trait IngressApi { - /// Handler for: `eth_sendRawTransaction` - #[method(name = "sendRawTransaction")] - async fn send_raw_transaction(&self, tx: Bytes) -> RpcResult; -} - -/// Core ingress RPC service that handles transaction submission. -pub struct IngressService { - simulation_provider: Arc>, - audit_channel: mpsc::Sender, - block_time_milliseconds: u64, - meter_bundle_timeout_ms: u64, - builder_tx: broadcast::Sender, - bundle_cache: Cache, - send_to_builder: bool, - zenith_timestamp: OnceCell>, -} - -impl std::fmt::Debug for IngressService { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("IngressService").finish_non_exhaustive() - } -} - -impl IngressService { - /// Creates a new ingress service with the given providers and configuration. - pub fn new( - simulation_provider: RootProvider, - audit_channel: mpsc::Sender, - builder_tx: broadcast::Sender, - config: Config, - ) -> Self { - let simulation_provider = Arc::new(simulation_provider); - - // A TTL cache to deduplicate bundles with the same Bundle ID - let bundle_cache = - Cache::builder().time_to_live(Duration::from_secs(config.bundle_cache_ttl)).build(); - Self { - simulation_provider, - audit_channel, - block_time_milliseconds: config.block_time_milliseconds, - meter_bundle_timeout_ms: config.meter_bundle_timeout_ms, - builder_tx, - bundle_cache, - send_to_builder: config.send_to_builder, - zenith_timestamp: OnceCell::new(), - } - } -} - -#[async_trait] -impl IngressApiServer for IngressService { - async fn send_raw_transaction(&self, data: Bytes) -> RpcResult { - let start = Instant::now(); - let transaction = self.get_tx(&data).await?; - let tx_hash = transaction.tx_hash(); - - Metrics::transactions_received().increment(1); - Self::emit_transaction_event(TransactionEventType::IngressReceived, tx_hash, None); - - let bundle = Bundle { txs: vec![data.clone()] }; - - let parsed_bundle: ParsedBundle = bundle - .clone() - .try_into() - .map_err(|e: String| EthApiError::InvalidParams(e).into_rpc_err())?; - - let bundle_hash = &parsed_bundle.bundle_hash(); - - if self.bundle_cache.get(bundle_hash).await.is_some() { - debug!( - message = "Duplicate bundle detected, skipping", - bundle_hash = %bundle_hash, - transaction_hash = %transaction.tx_hash(), - ); - } else { - self.bundle_cache.insert(*bundle_hash, ()).await; - Metrics::bundles_parsed().increment(1); - - Self::emit_transaction_event( - TransactionEventType::SimulationStarted, - tx_hash, - Some(*bundle_hash), - ); - let simulation_start = Instant::now(); - let (meter_bundle_response, simulation_accepted): (Option, bool) = - match self.meter_bundle(&bundle, bundle_hash).await { - Ok(response) => { - Self::emit_simulation_event( - TransactionEventType::SimulationSucceeded, - tx_hash, - *bundle_hash, - simulation_start.elapsed(), - &response, - ); - info!(message = "Metering succeeded for raw transaction", bundle_hash = %bundle_hash, response = ?response); - (Some(response), true) - } - Err(e) => { - Self::emit_simulation_failed_event( - tx_hash, - *bundle_hash, - simulation_start.elapsed(), - e.error.to_string(), - e.metering.as_ref(), - ); - warn!( - bundle_hash = %bundle_hash, - error = %e.error, - "Metering failed for raw transaction" - ); - (e.metering, false) - } - }; - - if let Some(meter_info) = meter_bundle_response.as_ref() { - if simulation_accepted { - Metrics::successful_simulations().increment(1); - } else { - Metrics::failed_simulations().increment(1); - } - - if self.send_to_builder && simulation_accepted { - // Update the current size of the `builder_tx` channel captured right before sending to the builder - Metrics::buffered_meter_bundle_responses_size() - .set(self.builder_tx.len() as f64); - let message = MeteringForwardMessage { - tx_hashes: vec![tx_hash], - response: meter_info.clone(), - }; - match self.builder_tx.send(message) { - Ok(n) => debug!( - receivers = n, - bundle_hash = %bundle_hash, - "Broadcast metering data to builder connectors" - ), - Err(e) => warn!( - bundle_hash = %bundle_hash, - error = %e, - "No active receivers for metering broadcast" - ), - } - } - } else { - Metrics::failed_simulations().increment(1); - } - - let accepted_bundle = - AcceptedBundle::new(parsed_bundle, meter_bundle_response.unwrap_or_default()); - - info!( - message = "processed transaction", - bundle_hash = %bundle_hash, - transaction_hash = %transaction.tx_hash(), - ); - - self.send_audit_event(&accepted_bundle, accepted_bundle.bundle_hash()); - } - - Metrics::send_raw_transaction_duration().record(start.elapsed().as_secs_f64()); - - Ok(transaction.tx_hash()) - } -} - -impl IngressService { - async fn get_tx(&self, data: &Bytes) -> RpcResult> { - if data.is_empty() { - return Err(EthApiError::EmptyRawTransactionData.into_rpc_err()); - } - - let envelope = BaseTxEnvelope::decode_2718_exact(data.iter().as_slice()) - .map_err(|_| EthApiError::FailedToDecodeSignedTransaction.into_rpc_err())?; - self.ensure_zenith_active_for_eip8130(&envelope).await?; - - let transaction = envelope - .try_into_recovered() - .map_err(|_| EthApiError::FailedToDecodeSignedTransaction.into_rpc_err())?; - Ok(transaction) - } - - async fn ensure_zenith_active_for_eip8130(&self, envelope: &BaseTxEnvelope) -> RpcResult<()> { - if !envelope.is_eip8130() { - return Ok(()); - } - - // Zenith is intentionally absent from canonical chain schedules. Read the simulation - // node's genesis config so only an explicitly configured devnet/test chain can open this - // gate; never infer Zenith from a chain ID or the L1 runtime upgrade registry. - let zenith_timestamp = match self - .zenith_timestamp - .get_or_try_init(|| async { - let chain_config: Result = - self.simulation_provider.client().request_noparams("debug_chainConfig").await; - chain_config.map(|config| { - config - .get("base") - .and_then(|base| base.get("zenith")) - .and_then(|timestamp| timestamp.as_u64()) - }) - }) - .await - { - Ok(timestamp) => timestamp, - Err(error) => { - warn!(error = %error, "failed to fetch genesis config for EIP-8130 Zenith gate"); - return Err(Self::eip8130_pre_zenith_error()); - } - }; - let Some(zenith_timestamp) = zenith_timestamp else { - return Err(Self::eip8130_pre_zenith_error()); - }; - if *zenith_timestamp == 0 { - return Ok(()); - } - - let block = self - .simulation_provider - .get_block(BlockId::Number(BlockNumberOrTag::Latest)) - .await - .map_err(|error| { - warn!(error = %error, "failed to fetch latest block for EIP-8130 Zenith gate"); - EthApiError::InternalEthError.into_rpc_err() - })? - .ok_or_else(|| { - warn!("latest block missing for EIP-8130 Zenith gate"); - EthApiError::InternalEthError.into_rpc_err() - })?; - - if block.header.timestamp < *zenith_timestamp { - return Err(Self::eip8130_pre_zenith_error()); - } - Ok(()) - } - - fn eip8130_pre_zenith_error() -> jsonrpsee::types::ErrorObjectOwned { - rpc_err(EthRpcErrorCode::TransactionRejected.code(), EIP8130_REJECTION_MSG, None) - } - - /// `meter_bundle` is used to determine how long a bundle will take to execute. A bundle that - /// is within `block_time_milliseconds` will return the `MeterBundleResponse` that can be passed along - /// to the builder. - async fn meter_bundle( - &self, - bundle: &Bundle, - bundle_hash: &B256, - ) -> Result { - let start = Instant::now(); - let timeout_duration = Duration::from_millis(self.meter_bundle_timeout_ms); - - // The future we await has the nested type: - // Result< - // RpcResult, // 1. The inner operation's result - // tokio::time::error::Elapsed // 2. The outer timeout's result - // > - let res: MeterBundleResponse = timeout( - timeout_duration, - self.simulation_provider.client().request("base_meterBundle", (bundle,)), - ) - .await - .map_err(|_| { - warn!(message = "Timed out on requesting metering", bundle_hash = %bundle_hash); - MeterBundleFailure::without_response( - EthApiError::InvalidParams("Timeout on requesting metering".into()).into_rpc_err(), - ) - })? - .map_err(|e| { - MeterBundleFailure::without_response( - EthApiError::InvalidParams(e.to_string()).into_rpc_err(), - ) - })?; - - Metrics::rpc_latency("base_meterBundle").record(start.elapsed().as_secs_f64()); - - // we can save some builder payload building computation by not including bundles - // that we know will take longer than the block time to execute - let total_execution_time = (res.total_execution_time_us / 1_000) as u64; - if total_execution_time > self.block_time_milliseconds { - Metrics::bundles_exceeded_metering_time().increment(1); - return Err(MeterBundleFailure::with_response( - EthApiError::InvalidParams("Bundle simulation took too long".into()).into_rpc_err(), - res, - )); - } - Ok(res) - } - - /// Helper method to send audit event for a bundle. - /// - /// Uses `try_send` on the bounded channel to avoid blocking the RPC handler. - /// If the channel is full, the event is dropped and a warning is logged. - fn send_audit_event(&self, accepted_bundle: &AcceptedBundle, bundle_hash: B256) { - let audit_event = BundleEvent::Received { - bundle_id: *accepted_bundle.uuid(), - bundle: Box::new(accepted_bundle.clone()), - }; - match self.audit_channel.try_send(audit_event) { - Ok(()) => {} - Err(mpsc::error::TrySendError::Full(_)) => { - Metrics::audit_channel_full().increment(1); - warn!( - message = "audit channel full, dropping event", - bundle_hash = %bundle_hash, - ); - } - Err(mpsc::error::TrySendError::Closed(_)) => { - warn!( - message = "audit channel closed", - bundle_hash = %bundle_hash, - ); - } - } - } - - fn emit_transaction_event( - event_type: TransactionEventType, - tx_hash: B256, - bundle_hash: Option, - ) { - Self::emit_transaction_event_with_data( - event_type, - tx_hash, - bundle_hash, - serde_json::Map::new(), - ); - } - - fn emit_simulation_event( - event_type: TransactionEventType, - tx_hash: B256, - bundle_hash: B256, - duration: Duration, - metering: &MeterBundleResponse, - ) { - let mut data = metering_summary_data(bundle_hash, metering); - data.insert( - "simulation_duration_ms".to_string(), - serde_json::json!(duration.as_secs_f64() * 1000.0), - ); - - Self::emit_transaction_event_with_data(event_type, tx_hash, Some(bundle_hash), data); - } - - fn emit_simulation_failed_event( - tx_hash: B256, - bundle_hash: B256, - duration: Duration, - reason: String, - metering: Option<&MeterBundleResponse>, - ) { - let mut data = metering.map_or_else( - || { - serde_json::Map::from_iter([( - "bundle_hash".to_string(), - serde_json::json!(bundle_hash.to_string()), - )]) - }, - |metering| metering_summary_data(bundle_hash, metering), - ); - data.extend([ - ( - "simulation_duration_ms".to_string(), - serde_json::json!(duration.as_secs_f64() * 1000.0), - ), - ("rejection_reason".to_string(), serde_json::json!(reason)), - ("rejection_code".to_string(), serde_json::json!("simulation_error")), - ]); - Self::emit_transaction_event_with_data( - TransactionEventType::SimulationFailed, - tx_hash, - Some(bundle_hash), - data, - ); - } - - fn emit_transaction_event_with_data( - event_type: TransactionEventType, - tx_hash: B256, - bundle_hash: Option, - mut data: serde_json::Map, - ) { - if let Some(bundle_hash) = bundle_hash { - data.entry("bundle_hash".to_string()) - .or_insert_with(|| serde_json::json!(bundle_hash.to_string())); - } - - if let Err(err) = transaction_event!( - producer: TransactionEventProducer::IngressRpc, - event_type: event_type, - tx_hash: tx_hash, - data: data, - ) { - debug!(error = %err, event_type = %event_type, tx_hash = %tx_hash, "transaction event not written"); - } - } -} - -fn metering_summary_data( - bundle_hash: B256, - metering: &MeterBundleResponse, -) -> serde_json::Map { - let mut data = serde_json::Map::from_iter([( - "bundle_hash".to_string(), - serde_json::json!(bundle_hash.to_string()), - )]); - if let Ok(metering_response) = serde_json::to_value(metering) { - data.insert("meter_bundle_response".to_string(), metering_response); - } - data -} - -#[derive(Debug)] -struct MeterBundleFailure { - error: ErrorObjectOwned, - metering: Option, -} - -impl MeterBundleFailure { - const fn without_response(error: ErrorObjectOwned) -> Self { - Self { error, metering: None } - } - - const fn with_response(error: ErrorObjectOwned, metering: MeterBundleResponse) -> Self { - Self { error, metering: Some(metering) } - } -} - -#[cfg(test)] -mod tests { - use std::{ - net::{IpAddr, SocketAddr}, - str::FromStr, - }; - - use alloy_eips::eip2718::Encodable2718; - use alloy_primitives::U256; - use alloy_provider::RootProvider; - use alloy_signer::SignerSync; - use alloy_signer_local::PrivateKeySigner; - use base_bundles::test_utils::create_test_meter_bundle_response; - use base_common_consensus::{BasePooledTransaction, Eip8130Signed, TxEip8130}; - use base_observability_events::{DEFAULT_MAX_FILE_BYTES, DEFAULT_MAX_FILES}; - use tokio::sync::{broadcast, mpsc}; - use url::Url; - use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; - - use super::*; - use crate::Config; - - fn create_test_config(mock_server: &MockServer) -> Config { - Config { - address: IpAddr::from([127, 0, 0, 1]), - port: 8080, - deprecated_mempool_url: None, - simulation_rpc: mock_server.uri().parse().unwrap(), - block_time_milliseconds: 1000, - meter_bundle_timeout_ms: 5000, - builder_rpcs: vec![], - max_buffered_meter_bundle_responses: 100, - health_check_addr: SocketAddr::from(([127, 0, 0, 1], 8081)), - chain_id: 11, - deprecated_raw_tx_forward_rpc: None, - bundle_cache_ttl: 20, - audit_channel_capacity: 512, - send_to_builder: false, - audit_batch_max_size: 100, - audit_batch_max_wait_ms: 1000, - audit_rpc_timeout_secs: 5, - audit_rpc_url: Url::parse("http://localhost:9000").unwrap(), - transaction_events_enabled: false, - transaction_events_file_path: "/tmp/transaction-events.jsonl".into(), - transaction_events_queue_capacity: 1024, - transaction_events_max_file_bytes: DEFAULT_MAX_FILE_BYTES, - transaction_events_max_files: DEFAULT_MAX_FILES, - transaction_events_required: false, - transaction_events_network: "base-mainnet".to_string(), - } - } - - fn signed_eip8130() -> Bytes { - let signer = PrivateKeySigner::random(); - let tx = TxEip8130 { - chain_id: 11, - sender: None, - nonce_key: U256::ZERO, - nonce_sequence: 0, - valid_after: 0, - valid_before: 0, - max_priority_fee_per_gas: 0, - max_fee_per_gas: 1, - gas_limit: 50_000, - account_changes: Vec::new(), - calls: Vec::new(), - metadata: Bytes::new(), - payer: None, - }; - let signature = signer.sign_hash_sync(&tx.sender_signature_hash()).expect("test signer"); - let mut sender_auth = [0u8; 65]; - sender_auth[..32].copy_from_slice(&signature.r().to_be_bytes::<32>()); - sender_auth[32..64].copy_from_slice(&signature.s().to_be_bytes::<32>()); - sender_auth[64] = 27 + u8::from(signature.v()); - BasePooledTransaction::Eip8130(Eip8130Signed::new( - tx, - Bytes::from(sender_auth.to_vec()), - Bytes::new(), - )) - .encoded_2718() - .into() - } - - #[tokio::test] - async fn eip8130_gate_reads_zenith_from_genesis_chain_config() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "result": { "base": { "zenith": 0 } }, - }))) - .expect(1) - .mount(&mock_server) - .await; - - let config = create_test_config(&mock_server); - let simulation_provider = RootProvider::new_http(mock_server.uri().parse().unwrap()); - let (audit_tx, _audit_rx) = mpsc::channel(1); - let (builder_tx, _builder_rx) = broadcast::channel(1); - let service = IngressService::new(simulation_provider, audit_tx, builder_tx, config); - - let transaction = signed_eip8130(); - service.get_tx(&transaction).await.expect("Zenith genesis config should open the gate"); - service.get_tx(&transaction).await.expect("Zenith genesis config should open the gate"); - } - - #[tokio::test] - async fn eip8130_gate_rejects_genesis_without_zenith() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "result": { "base": { "cobalt": 0 } }, - }))) - .expect(1) - .mount(&mock_server) - .await; - - let config = create_test_config(&mock_server); - let simulation_provider = RootProvider::new_http(mock_server.uri().parse().unwrap()); - let (audit_tx, _audit_rx) = mpsc::channel(1); - let (builder_tx, _builder_rx) = broadcast::channel(1); - let service = IngressService::new(simulation_provider, audit_tx, builder_tx, config); - - let error = service - .get_tx(&signed_eip8130()) - .await - .expect_err("genesis without Zenith must keep the EIP-8130 gate closed"); - assert_eq!(error.code(), EthRpcErrorCode::TransactionRejected.code()); - assert_eq!(error.message(), EIP8130_REJECTION_MSG); - } - - #[tokio::test] - async fn test_timeout_logic() { - let timeout_duration = Duration::from_millis(100); - - // Test a future that takes longer than the timeout - let slow_future = async { - tokio::time::sleep(Duration::from_millis(200)).await; - Ok::(create_test_meter_bundle_response()) - }; - - let result = timeout(timeout_duration, slow_future) - .await - .map_err(|_| { - EthApiError::InvalidParams("Timeout on requesting metering".into()).into_rpc_err() - }) - .map_err(|e| e.to_string()); - - assert!(result.is_err()); - let error_string = format!("{:?}", result.unwrap_err()); - assert!(error_string.contains("Timeout on requesting metering")); - } - - #[tokio::test] - async fn test_timeout_logic_success() { - let timeout_duration = Duration::from_millis(200); - - // Test a future that completes within the timeout - let fast_future = async { - tokio::time::sleep(Duration::from_millis(50)).await; - Ok::(create_test_meter_bundle_response()) - }; - - let result = timeout(timeout_duration, fast_future) - .await - .map_err(|_| { - EthApiError::InvalidParams("Timeout on requesting metering".into()).into_rpc_err() - }) - .map_err(|e| e.to_string()); - - assert!(result.is_ok()); - // we're assuming that `base_meterBundle` will not error hence the second unwrap - let res = result.unwrap().unwrap(); - assert_eq!(res, create_test_meter_bundle_response()); - } - - // Replicate a failed `meter_bundle` request and instead of returning an error, we return a default `MeterBundleResponse` - #[tokio::test] - async fn test_meter_bundle_success() { - let mock_server = MockServer::start().await; - - // Mock error response from base_meterBundle - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": -32000, - "message": "Simulation failed" - } - }))) - .mount(&mock_server) - .await; - - let config = create_test_config(&mock_server); - - let simulation_provider: RootProvider = - RootProvider::new_http(mock_server.uri().parse().unwrap()); - - let (audit_tx, _audit_rx) = mpsc::channel(512); - let (builder_tx, _builder_rx) = broadcast::channel(1); - - let service = IngressService::new(simulation_provider, audit_tx, builder_tx, config); - - let bundle = Bundle::default(); - let bundle_hash = B256::default(); - - let result = service.meter_bundle(&bundle, &bundle_hash).await; - - // Test that meter_bundle returns an error, but we handle it gracefully - assert!(result.is_err()); - let response = result.unwrap_or_else(|_| MeterBundleResponse::default()); - assert_eq!(response, MeterBundleResponse::default()); - } - - #[tokio::test] - async fn test_send_raw_transaction_meters_broadcasts_and_audits() { - let simulation_server = MockServer::start().await; - - let meter_response = create_test_meter_bundle_response(); - - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "result": meter_response, - }))) - .expect(1) - .mount(&simulation_server) - .await; - - let mut config = create_test_config(&simulation_server); - config.send_to_builder = true; - - let simulation_provider = RootProvider::new_http(simulation_server.uri().parse().unwrap()); - let (audit_tx, mut audit_rx) = mpsc::channel(512); - let (builder_tx, mut builder_rx) = broadcast::channel(1); - - let service = IngressService::new(simulation_provider, audit_tx, builder_tx, config); - - // Valid signed transaction bytes - let tx_bytes = Bytes::from_str("0x02f86c0d010183072335825208940000000000000000000000000000000000000000872386f26fc1000080c001a0cdb9e4f2f1ba53f9429077e7055e078cf599786e29059cd80c5e0e923bb2c114a01c90e29201e031baf1da66296c3a5c15c200bcb5e6c34da2f05f7d1778f8be07").unwrap(); - - let result = service.send_raw_transaction(tx_bytes).await; - assert!(result.is_ok()); - - let requests = simulation_server.received_requests().await.unwrap(); - let request: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert!(request["params"][0]["txs"].is_array()); - assert!(request["params"][0].get("blockNumber").is_none()); - - let metering = timeout(Duration::from_secs(1), builder_rx.recv()).await.unwrap().unwrap(); - assert_eq!(metering.response, create_test_meter_bundle_response()); - - let audit_event = timeout(Duration::from_secs(1), audit_rx.recv()).await.unwrap().unwrap(); - assert!(matches!(audit_event, BundleEvent::Received { .. })); - } -} diff --git a/crates/infra/ingress-rpc/src/validation.rs b/crates/infra/ingress-rpc/src/validation.rs deleted file mode 100644 index 38ee8dece4..0000000000 --- a/crates/infra/ingress-rpc/src/validation.rs +++ /dev/null @@ -1,112 +0,0 @@ -use alloy_consensus::private::alloy_eips::{BlockId, BlockNumberOrTag}; -use alloy_primitives::{Address, B256, U256}; -use alloy_provider::{Provider, RootProvider}; -use async_trait::async_trait; -use base_common_evm::L1BlockInfo; -use base_common_network::Base; -use base_execution_evm::extract_l1_info_from_tx; -use jsonrpsee::core::RpcResult; -use reth_rpc_eth_types::{EthApiError, SignError}; -use tokio::time::Instant; -use tracing::warn; - -use crate::metrics::Metrics; - -/// Account info for a given address. -#[derive(Debug)] -pub struct AccountInfo { - /// Account balance in wei. - pub balance: U256, - /// Account transaction nonce. - pub nonce: u64, - /// Hash of the account's code. - pub code_hash: B256, -} - -/// Interface for fetching account info for a given address. -#[async_trait] -pub trait AccountInfoLookup: Send + Sync { - /// Fetches account info for the given address. - async fn fetch_account_info(&self, address: Address) -> RpcResult; -} - -/// Implementation of the `AccountInfoLookup` trait for the `RootProvider` -#[async_trait] -impl AccountInfoLookup for RootProvider { - async fn fetch_account_info(&self, address: Address) -> RpcResult { - let start = Instant::now(); - let account = self - .get_account(address) - .await - .map_err(|_| EthApiError::Signing(SignError::NoAccount))?; - Metrics::rpc_latency("eth_getAccount").record(start.elapsed().as_secs_f64()); - - Ok(AccountInfo { - balance: account.balance, - nonce: account.nonce, - code_hash: account.code_hash, - }) - } -} - -/// Interface for fetching L1 block info for a given block number. -#[async_trait] -pub trait L1BlockInfoLookup: Send + Sync { - /// Fetches the L1 block info from the latest L2 block. - async fn fetch_l1_block_info(&self) -> RpcResult; -} - -/// Implementation of the `L1BlockInfoLookup` trait for the `RootProvider` -#[async_trait] -impl L1BlockInfoLookup for RootProvider { - async fn fetch_l1_block_info(&self) -> RpcResult { - let start = Instant::now(); - let block = self - .get_block(BlockId::Number(BlockNumberOrTag::Latest)) - .full() - .await - .map_err(|e| { - warn!(message = "failed to fetch latest block", err = %e); - EthApiError::InternalEthError.into_rpc_err() - })? - .ok_or_else(|| { - warn!(message = "empty latest block returned"); - EthApiError::InternalEthError.into_rpc_err() - })?; - Metrics::rpc_latency("eth_getBlockByNumber").record(start.elapsed().as_secs_f64()); - - let txs = block.transactions; - let first_tx = txs.first_transaction().ok_or_else(|| { - warn!(message = "block contains no transactions"); - EthApiError::InternalEthError.into_rpc_err() - })?; - - Ok(extract_l1_info_from_tx(&first_tx.clone()).map_err(|e| { - warn!(message = "failed to extract l1_info from tx", err = %e); - EthApiError::InternalEthError.into_rpc_err() - })?) - } -} - -#[cfg(test)] -mod tests { - use alloy_eips::eip2718::Decodable2718; - use alloy_primitives::Bytes; - use base_common_consensus::BaseTxEnvelope; - - #[tokio::test] - async fn test_decode_tx_rejects_empty_bytes() { - // Test that empty bytes fail to decode - let empty_bytes = Bytes::new(); - let result = BaseTxEnvelope::decode_2718(&mut empty_bytes.as_ref()); - assert!(result.is_err(), "Empty bytes should fail decoding"); - } - - #[tokio::test] - async fn test_decode_tx_rejects_invalid_bytes() { - // Test that malformed bytes fail to decode - let invalid_bytes = Bytes::from(vec![0x01, 0x02, 0x03]); - let result = BaseTxEnvelope::decode_2718(&mut invalid_bytes.as_ref()); - assert!(result.is_err(), "Invalid bytes should fail decoding"); - } -} diff --git a/docs/transaction-events/README.md b/docs/transaction-events/README.md index 16eea8f344..4c9090cbbc 100644 --- a/docs/transaction-events/README.md +++ b/docs/transaction-events/README.md @@ -112,12 +112,12 @@ and `privateKey` before ingest. Core devnet (`just devnet up` / `just devnet up-single`) enables durable transaction event journals on `base-client` and `base-builder`, writing JSONL -under `.devnet/transaction-events/`. The ingress overlay adds the collection -pipeline (Vector, Postgres, `audit-archiver`) plus ingress/proxyd producers; it +under `.devnet/transaction-events/`. The tx-observability overlay adds the collection +pipeline (Vector, Postgres, `audit-archiver`) plus the proxyd producer; it does not own node journal config. ```bash -just devnet ingress +just devnet tx-observability just devnet tx-observability-smoke ``` @@ -126,12 +126,12 @@ testing proxyd transaction events before that implementation has landed in the default proxyd image: ```bash -BASE_ROUTING_CONTEXT=/path/to/base-routing just devnet ingress +BASE_ROUTING_CONTEXT=/path/to/base-routing just devnet tx-observability just devnet tx-observability-smoke ``` -The smoke test sends one transaction through ingress, waits for Vector to ship -JSONL events from ingress, proxyd, txpool tracing, and builder producers, and +The smoke test sends one transaction through proxyd, waits for Vector to ship +JSONL events from proxyd, txpool tracing, and builder producers, and verifies `audit-archiver` can read the persisted events back from Postgres by transaction hash. @@ -143,8 +143,8 @@ For local Vector health, alert or inspect `component_discarded_events_total`. - `base-reth-node` - `base-builder` -- `ingress-rpc` - `base-routing/proxyd` +- `ingress-rpc` (retired; retained so historical events remain readable) ## Txpool Tracing Example @@ -167,11 +167,11 @@ Edge/proxy: - `PROXY_ROUTED_TO_BACKEND` - `PROXY_BACKEND_SUCCESS` - `PROXY_BACKEND_FAILURE` -- `PROXY_INGRESS_RPC_ATTEMPT` -- `PROXY_INGRESS_RPC_SUCCESS` -- `PROXY_INGRESS_RPC_FAILURE` +- `PROXY_INGRESS_RPC_ATTEMPT` (retired) +- `PROXY_INGRESS_RPC_SUCCESS` (retired) +- `PROXY_INGRESS_RPC_FAILURE` (retired) -Ingress/audit: +Ingress/audit (retired producer; retained so historical events remain readable): - `INGRESS_RECEIVED` - `SIMULATION_STARTED` diff --git a/docs/transaction-events/tips-parity-checklist.md b/docs/transaction-events/tips-parity-checklist.md index 2c3e22c78b..707c92a2ba 100644 --- a/docs/transaction-events/tips-parity-checklist.md +++ b/docs/transaction-events/tips-parity-checklist.md @@ -34,9 +34,6 @@ against audit-archiver/Postgres. - `audit-archiver` has `TIPS_AUDIT_POSTGRES_URL` set and serves both JSON-RPC and transaction-event HTTP ingest on the audit service port. Vector posts NDJSON to `/v1/transaction-events/batch` on that same port. -- `ingress-rpc` has `TIPS_INGRESS_TRANSACTION_EVENTS_ENABLED=true` only in the - environment being tested, with the JSONL file path matching the Vector - sidecar mount. - The UI has `TIPS_UI_QUERY_BACKEND=audit` and `TIPS_UI_AUDIT_RPC_URL` pointed at the audit service. - S3 objects are still being written during the comparison window. diff --git a/etc/docker/Dockerfile.rust-services b/etc/docker/Dockerfile.rust-services index 3eeea029c4..f38af24fa8 100644 --- a/etc/docker/Dockerfile.rust-services +++ b/etc/docker/Dockerfile.rust-services @@ -167,15 +167,6 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ cargo build --profile $PROFILE --package websocket-proxy-bin --bin websocket-proxy && \ cp /app/target/$([ "$PROFILE" = "dev" ] && echo debug || echo "$PROFILE")/websocket-proxy /app/websocket-proxy -FROM source AS ingress-rpc-builder -ARG PROFILE=release -ARG SCCACHE_CACHE_ID=rust-services-sccache -RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ - --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ - --mount=type=cache,id=${SCCACHE_CACHE_ID},target=/sccache,sharing=locked \ - cargo build --profile $PROFILE --package ingress-rpc --bin ingress-rpc && \ - cp /app/target/$([ "$PROFILE" = "dev" ] && echo debug || echo "$PROFILE")/ingress-rpc /app/ingress-rpc - FROM source AS audit-archiver-builder ARG PROFILE=release ARG SCCACHE_CACHE_ID=rust-services-sccache @@ -273,10 +264,6 @@ FROM runtime-base AS websocket-proxy COPY --from=websocket-proxy-builder /app/websocket-proxy ./websocket-proxy ENTRYPOINT ["./websocket-proxy"] -FROM runtime-base AS ingress-rpc -COPY --from=ingress-rpc-builder /app/ingress-rpc ./ingress-rpc -ENTRYPOINT ["./ingress-rpc"] - FROM runtime-base AS audit-archiver COPY --from=audit-archiver-builder /app/audit-archiver ./audit-archiver ENTRYPOINT ["./audit-archiver"] diff --git a/etc/docker/Justfile b/etc/docker/Justfile index 0b89efc6c8..941d4d58aa 100644 --- a/etc/docker/Justfile +++ b/etc/docker/Justfile @@ -187,7 +187,7 @@ upgrade-signal-future upgrade='azul' offset='120': # Runs full devnet checks (status + smoke tests) checks: status smoke -# Sends an ingress transaction and verifies transaction events reached audit-archiver Postgres +# Sends a transaction through proxyd and verifies transaction events reached audit-archiver Postgres tx-observability-smoke: @./etc/scripts/devnet/transaction-events-smoke.sh @@ -216,19 +216,19 @@ transfer-leader to='': conductor: cargo run --quiet -p basectl -- -c devnet monitor conductor -# Stops devnet+ingress, deletes data, and starts fresh with full ingress stack -ingress: ingress-down _build-setup-image (_build-rust-images "ingress" "dev") +# Stops devnet+tx-observability, deletes data, and starts fresh with full observability stack +tx-observability: tx-observability-down _build-setup-image (_build-rust-images "tx-observability" "dev") mkdir -p .devnet - docker compose {{ _env }} {{ _base }} -f etc/docker/docker-compose.ingress.yml up -d --no-build + docker compose {{ _env }} {{ _base }} -f etc/docker/docker-compose.tx-observability.yml up -d --no-build -# Stops devnet+ingress and deletes all data -ingress-down: - -docker compose {{ _env }} {{ _base }} -f etc/docker/docker-compose.ingress.yml down +# Stops devnet+tx-observability and deletes all data +tx-observability-down: + -docker compose {{ _env }} {{ _base }} -f etc/docker/docker-compose.tx-observability.yml down -[ ! -d .devnet ] || docker run --rm -v "$(pwd)/.devnet:/devnet" alpine sh -c 'rm -rf /devnet/*' -# Stream logs from devnet+ingress containers (optionally specify container names) -ingress-logs *containers: - docker compose {{ _env }} {{ _base }} -f etc/docker/docker-compose.ingress.yml logs -f {{ containers }} +# Stream logs from devnet+tx-observability containers (optionally specify container names) +tx-observability-logs *containers: + docker compose {{ _env }} {{ _base }} -f etc/docker/docker-compose.tx-observability.yml logs -f {{ containers }} # Builds one of the shared Rust service images (default: base) build-image target='base' profile='dev': @@ -290,7 +290,7 @@ _build-rust-images group profile='dev': case "{{ profile }}" in dev|profiling) case "{{ group }}" in - devnet|ingress) elf_require=0 ;; + devnet|tx-observability) elf_require=0 ;; esac ;; esac @@ -325,7 +325,7 @@ _up-devnet compose_profile mode: ha) compose_files=({{ _ha }}) # Any of the three conductor-managed sequencers can be the leader/builder, - # so ingress nodes fan validity (advanced) txs out to all of them. + # so mempool nodes fan validity (advanced) txs out to all of them. export VALIDITY_BUILDER_RPC_URLS="http://base-builder:${L2_BUILDER_HTTP_PORT},http://base-sequencer-1:${L2_SEQ1_HTTP_PORT},http://base-sequencer-2:${L2_SEQ2_HTTP_PORT}" ;; base) compose_files=({{ _base }}) ;; diff --git a/etc/docker/README.md b/etc/docker/README.md index 306948c6b4..3c1c6d4db2 100644 --- a/etc/docker/README.md +++ b/etc/docker/README.md @@ -12,7 +12,7 @@ The public operator image (`ghcr.io/base/node`) is the `base` target in `Dockerf `Dockerfile.op-batcher` builds Go `op-batcher/v1.16.5` at commit [`abe047af`](https://github.com/ethereum-optimism/optimism/commit/abe047afc995e0e22abf5ea9b157e267e907d494), -matching the Base mainnet infrastructure source pin. The `devnet` and `ingress` +matching the Base mainnet infrastructure source pin. The `devnet` and `tx-observability` Bake groups build it as `op-batcher:local`; it runs as the canonical batcher. The Rust `base batcher` (the `base batcher` subcommand of `base:local`) runs alongside it in shadow mode; no separate Rust batcher image is needed. @@ -169,8 +169,8 @@ must schedule Denim and configure both sides of the forwarding path: - builder: `--builder.enable-experimental-validity-transactions` and `--builder.payload-builder-cutover`. The builder flag also registers - `base_sendRawTransactionValidity` for direct ingress. -- ingress/client: `--enable-experimental-validity-transactions` and a + `base_sendRawTransactionValidity` for direct submission. +- mempool/client: `--enable-experimental-validity-transactions` and a `--builder-rpc-urls` endpoint targeting the builder The default devnet compose files include these flags and schedule Cobalt at diff --git a/etc/docker/devnet-env b/etc/docker/devnet-env index 04b0d6b129..4601e3e4b9 100644 --- a/etc/docker/devnet-env +++ b/etc/docker/devnet-env @@ -70,7 +70,7 @@ L1_CL_P2P_PORT=4900 L2_EL_BOOTNODE_P2P_PORT=9303 L2_CL_BOOTNODE_P2P_PORT=9003 L2_BUILDER_HTTP_PORT=7545 -# Builder RPC endpoint(s) that ingress nodes forward validity (advanced) txs to. +# Builder RPC endpoint(s) that mempool nodes forward validity (advanced) txs to. # Single-sequencer default targets base-builder; the HA `up` recipe overrides # this to fan out to all three conductor-managed sequencers. VALIDITY_BUILDER_RPC_URLS=http://base-builder:7545 @@ -192,21 +192,19 @@ L2_BASE_RPC_WS_URL=ws://localhost:8646 L2_BASE_RPC_OP_RPC_URL=http://localhost:8649 L2_PROOFS_RPC_URL=http://localhost:8745 L2_PROOFS_OP_RPC_URL=http://localhost:8749 -L2_INGRESS_RPC_URL=http://localhost:8080 +L2_PROXYD_RPC_URL=http://localhost:8080 CONDUCTOR0_RPC_URL=http://localhost:6545 CONDUCTOR1_RPC_URL=http://localhost:6546 CONDUCTOR2_RPC_URL=http://localhost:6547 -# L2 Ingress Ports -L2_INGRESS_HTTP_PORT=8080 -L2_INGRESS_HEALTH_PORT=8081 -L2_INGRESS_METRICS_PORT=9002 +# L2 Proxyd Entrypoint Ports +L2_PROXYD_HTTP_PORT=8080 # L2 Audit Archiver Ports AUDIT_RPC_PORT=9100 AUDIT_METRICS_PORT=9102 -# Ingress Infrastructure Ports +# Transaction Observability Infrastructure Ports MINIO_API_PORT=7000 MINIO_CONSOLE_PORT=7001 TRANSACTION_EVENTS_POSTGRES_PORT=5434 diff --git a/etc/docker/docker-bake.hcl b/etc/docker/docker-bake.hcl index c8581d05c5..efb0fb304c 100644 --- a/etc/docker/docker-bake.hcl +++ b/etc/docker/docker-bake.hcl @@ -20,8 +20,8 @@ variable "DEVNET_TARGETS" { default = ["base", "op-batcher"] } -variable "INGRESS_TARGETS" { - default = ["base", "ingress-rpc", "audit-archiver", "op-batcher"] +variable "TX_OBSERVABILITY_TARGETS" { + default = ["base", "audit-archiver", "op-batcher"] } group "default" { @@ -38,7 +38,6 @@ group "rust-services" { "snapshotter", "proposer", "websocket-proxy", - "ingress-rpc", "audit-archiver", "sidecrush", "prover-service", @@ -50,8 +49,8 @@ group "devnet" { targets = DEVNET_TARGETS } -group "ingress" { - targets = INGRESS_TARGETS +group "tx-observability" { + targets = TX_OBSERVABILITY_TARGETS } target "profiling-tools" { @@ -157,16 +156,6 @@ target "websocket-proxy" { tags = ["websocket-proxy:local"] } -target "ingress-rpc" { - inherits = ["_rust-service-common"] - target = "ingress-rpc" - args = { - CARGO_CHEF_ARGS = "--package ingress-rpc" - SCCACHE_CACHE_ID = "rust-services-ingress-rpc-sccache" - } - tags = ["ingress-rpc:local"] -} - target "audit-archiver" { inherits = ["_rust-service-common"] target = "audit-archiver" diff --git a/etc/docker/docker-compose.ingress.yml b/etc/docker/docker-compose.tx-observability.yml similarity index 73% rename from etc/docker/docker-compose.ingress.yml rename to etc/docker/docker-compose.tx-observability.yml index 57989c152e..d81f83e0a8 100644 --- a/etc/docker/docker-compose.ingress.yml +++ b/etc/docker/docker-compose.tx-observability.yml @@ -4,7 +4,7 @@ x-rust-service-build: &rust-service-build args: PROFILE: "${CARGO_PROFILE:-dev}" -x-ingress-healthcheck: &ingress-healthcheck +x-overlay-healthcheck: &overlay-healthcheck interval: 5s timeout: 5s retries: 30 @@ -24,7 +24,7 @@ services: command: server /data --console-address ":9001" healthcheck: test: ["CMD", "mc", "ready", "local"] - <<: *ingress-healthcheck + <<: *overlay-healthcheck start_period: 5s restart: unless-stopped @@ -41,43 +41,6 @@ services: echo 'Bucket tips created' " - ingress-rpc: - image: ingress-rpc:local - build: - <<: *rust-service-build - target: ingress-rpc - container_name: ingress-rpc - depends_on: - base-builder: - condition: service_healthy - base-client: - condition: service_healthy - ports: - - "${L2_INGRESS_HEALTH_PORT}:${L2_INGRESS_HEALTH_PORT}" - - "${L2_INGRESS_METRICS_PORT}:${L2_INGRESS_METRICS_PORT}" - volumes: - - ../../.devnet/transaction-events:/var/log/transaction-events - environment: - - TIPS_INGRESS_PORT=${L2_INGRESS_HTTP_PORT} - - TIPS_INGRESS_HEALTH_CHECK_ADDR=0.0.0.0:${L2_INGRESS_HEALTH_PORT} - - TIPS_INGRESS_METRICS_ADDR=0.0.0.0 - - TIPS_INGRESS_METRICS_PORT=${L2_INGRESS_METRICS_PORT} - - TIPS_INGRESS_AUDIT_RPC_URL=http://audit-archiver:${AUDIT_RPC_PORT} - - TIPS_INGRESS_RPC_SIMULATION=http://base-client:${L2_CLIENT_HTTP_PORT} - # Fan out metering data to both builder and client; both expose - # `base_setMeteringInformation` for trusted ingestion. - - TIPS_INGRESS_BUILDER_RPCS=http://base-builder:${L2_BUILDER_HTTP_PORT},http://base-client:${L2_CLIENT_HTTP_PORT} - - TIPS_INGRESS_SEND_TO_BUILDER=true - - TIPS_INGRESS_CHAIN_ID=${L2_CHAIN_ID} - - TIPS_INGRESS_BLOCK_TIME_MILLISECONDS=2000 - - TIPS_INGRESS_TRANSACTION_EVENTS_ENABLED=true - - TIPS_INGRESS_TRANSACTION_EVENTS_FILE_PATH=/var/log/transaction-events/ingress-rpc/events.jsonl - - TIPS_INGRESS_TRANSACTION_EVENTS_NETWORK=base-devnet - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:${L2_INGRESS_HEALTH_PORT}/health"] - <<: *ingress-healthcheck - restart: unless-stopped - proxyd: build: context: . @@ -86,14 +49,12 @@ services: base-routing: ${BASE_ROUTING_CONTEXT:-https://github.com/base/infra-routing.git#release-rb} container_name: proxyd depends_on: - ingress-rpc: - condition: service_healthy base-builder: condition: service_healthy base-client: condition: service_healthy ports: - - "${L2_INGRESS_HTTP_PORT}:8080" + - "${L2_PROXYD_HTTP_PORT}:8080" volumes: - ./proxyd:/config:ro - ../../.devnet/transaction-events:/var/log/transaction-events @@ -105,7 +66,7 @@ services: command: ["/config/proxyd.toml"] healthcheck: test: ["CMD", "curl", "-sf", "-X", "POST", "-H", "Content-Type: application/json", "-H", "X-Forwarded-For: 127.0.0.1", "--data", "{\"jsonrpc\":\"2.0\",\"method\":\"eth_chainId\",\"params\":[],\"id\":1}", "http://localhost:8080"] - <<: *ingress-healthcheck + <<: *overlay-healthcheck restart: unless-stopped audit-archiver: @@ -186,7 +147,7 @@ services: ports: - "${TRANSACTION_EVENTS_VECTOR_METRICS_PORT}:8686" environment: - TRANSACTION_EVENTS_CONFIG_NAME: local-devnet-ingress + TRANSACTION_EVENTS_CONFIG_NAME: local-devnet-tx-observability TRANSACTION_EVENTS_ENVIRONMENT_NAME: devnet TRANSACTION_EVENTS_PROJECT_NAME: base TRANSACTION_EVENTS_COLLECTOR_CONTAINER_NAME: transaction-events-vector diff --git a/etc/docker/proxyd/proxyd.toml b/etc/docker/proxyd/proxyd.toml index 0b4cf9a3b1..e7e7766fde 100644 --- a/etc/docker/proxyd/proxyd.toml +++ b/etc/docker/proxyd/proxyd.toml @@ -1,8 +1,3 @@ -# Optimistic async forwarding of eth_sendRawTransaction to ingress-rpc -# (matches production topology where proxyd sends to mempool nodes and -# optimistically copies to ingress for metering and audit) -ingress_rpc = "http://ingress-rpc:8080" - [server] rpc_host = "0.0.0.0" rpc_port = 8080 @@ -26,9 +21,7 @@ backends = ["builder"] backends = ["client"] [rpc_method_mappings] -# Transaction submission routed to builder; proxyd optimistically copies -# eth_sendRawTransaction to ingress-rpc for metering and audit via the -# ingress_rpc config above. +# Transaction submission routed to builder. eth_sendRawTransaction = "builder" # All other methods routed to client diff --git a/etc/scripts/devnet/grafana/dashboards/base-mev.json b/etc/scripts/devnet/grafana/dashboards/base-mev.json index 585449c5c7..aecde00abc 100644 --- a/etc/scripts/devnet/grafana/dashboards/base-mev.json +++ b/etc/scripts/devnet/grafana/dashboards/base-mev.json @@ -414,92 +414,6 @@ "title": "Invalid Blocks", "type": "stat" }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "description": "Status of the Tips ingress metrics endpoint. OFFLINE is expected for the standard devnet; ONLINE appears when the ingress overlay is running.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "0": { - "color": "#8A95A5", - "text": "OFFLINE" - }, - "1": { - "color": "#3BD671", - "text": "ONLINE" - } - }, - "type": "value" - } - ], - "max": 1, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#8A95A5", - "value": null - }, - { - "color": "#3BD671", - "value": 1 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 4, - "x": 20, - "y": 4 - }, - "id": 6, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "10.4.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "max(up{job=\"tips_ingress\"}) or vector(0)", - "legendFormat": "Tips ingress", - "range": true, - "refId": "A" - } - ], - "title": "Optional Ingress", - "type": "stat" - }, { "collapsed": false, "gridPos": { @@ -3345,298 +3259,6 @@ ], "title": "Legacy Flashblock compatibility · Is the pre-Denim consumer stream healthy when active?", "type": "row" - }, - { - "collapsed": true, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 61 - }, - "id": 108, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "description": "Independent optional ingress transaction, bundle, and simulation rates. No data is expected when the ingress Compose overlay is not selected.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "drawStyle": "bars", - "fillOpacity": 45, - "lineWidth": 1, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - } - }, - "decimals": 3, - "mappings": [], - "unit": "cps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 56 - }, - "id": 32, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "10.4.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "sum(rate(tips_ingress_rpc_transactions_received{job=\"tips_ingress\"}[$window]))", - "legendFormat": "transactions received", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "sum(rate(tips_ingress_rpc_bundles_parsed{job=\"tips_ingress\"}[$window]))", - "legendFormat": "bundles parsed", - "range": true, - "refId": "B" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "sum(rate(tips_ingress_rpc_successful_simulations{job=\"tips_ingress\"}[$window]))", - "legendFormat": "simulation success", - "range": true, - "refId": "C" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "sum(rate(tips_ingress_rpc_failed_simulations{job=\"tips_ingress\"}[$window]))", - "legendFormat": "simulation failed", - "range": true, - "refId": "D" - } - ], - "title": "Ingress Lifecycle Activity", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "description": "Selected ingress summary quantiles for simulation-side RPC methods and successful sendRawTransaction calls. Send latency is recorded only on the successful return path.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "drawStyle": "line", - "fillOpacity": 15, - "lineWidth": 2, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - } - }, - "decimals": 4, - "mappings": [], - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 56 - }, - "id": 33, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "10.4.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "tips_ingress_rpc_rpc_latency{job=\"tips_ingress\",quantile=\"$quantile\"}", - "legendFormat": "{{rpc}} · p$quantile", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "tips_ingress_rpc_send_raw_transaction_duration{job=\"tips_ingress\",quantile=\"$quantile\"}", - "legendFormat": "sendRawTransaction · p$quantile", - "range": true, - "refId": "B" - } - ], - "title": "Ingress RPC Latency · p$quantile", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "description": "Buffered metering responses plus failed simulations, metering deadline exceedances, and full audit channels. Correlated growth indicates delayed or failing ingress fan-out.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "drawStyle": "line", - "fillOpacity": 15, - "lineWidth": 2, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - } - }, - "decimals": 3, - "mappings": [], - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 24, - "x": 0, - "y": 64 - }, - "id": 34, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "10.4.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "tips_ingress_rpc_buffered_meter_bundle_responses_size{job=\"tips_ingress\"}", - "legendFormat": "buffered responses", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "sum(rate(tips_ingress_rpc_failed_simulations{job=\"tips_ingress\"}[$window]))", - "legendFormat": "failed simulations / s", - "range": true, - "refId": "B" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "sum(rate(tips_ingress_rpc_bundles_exceeded_metering_time{job=\"tips_ingress\"}[$window]))", - "legendFormat": "metering deadline / s", - "range": true, - "refId": "C" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "editorMode": "code", - "expr": "sum(rate(tips_ingress_rpc_audit_channel_full{job=\"tips_ingress\"}[$window]))", - "legendFormat": "audit channel full / s", - "range": true, - "refId": "D" - } - ], - "title": "Ingress Backpressure & Faults", - "type": "timeseries" - } - ], - "title": "Optional Tips ingress · Are bundles simulated, metered, and delivered on time?", - "type": "row" } ], "refresh": "5s", diff --git a/etc/scripts/devnet/prometheus.yml b/etc/scripts/devnet/prometheus.yml index 66afa1edb6..4f7106811a 100644 --- a/etc/scripts/devnet/prometheus.yml +++ b/etc/scripts/devnet/prometheus.yml @@ -31,11 +31,6 @@ scrape_configs: static_configs: - targets: ['op-conductor-0:6090', 'op-conductor-1:6091', 'op-conductor-2:6092'] - - job_name: 'tips_ingress' - scrape_timeout: 2s - static_configs: - - targets: ['ingress-rpc:9002'] - # Canonical Go op-batcher and the shadow Rust base-batcher both run by default. - job_name: 'batcher' static_configs: diff --git a/etc/scripts/devnet/smoke.sh b/etc/scripts/devnet/smoke.sh index 59d9896765..df82b9b8e7 100755 --- a/etc/scripts/devnet/smoke.sh +++ b/etc/scripts/devnet/smoke.sh @@ -30,12 +30,11 @@ echo "Sending L2 tx to client..." cast send --private-key $PK --rpc-url $L2_CLIENT_RPC_URL $TO --value 0.001ether --json | jq -r '"TX: \(.transactionHash) block=\(.blockNumber)"' echo "" -echo "=== L2 Ingress Transaction Tests ===" -INGRESS_HEALTH_URL="http://localhost:${L2_INGRESS_HEALTH_PORT:-8081}/health" -if curl -sf "$INGRESS_HEALTH_URL" >/dev/null 2>&1; then - echo "Sending L2 tx through ingress..." +echo "=== L2 Proxyd Transaction Tests ===" +if cast chain-id --rpc-url $L2_PROXYD_RPC_URL >/dev/null 2>&1; then + echo "Sending L2 tx through proxyd..." sleep 3 # wait for the previous tx's nonce to be reflected on-chain - cast send --private-key $PK --rpc-url $L2_INGRESS_RPC_URL $TO --value 0.001ether --json | jq -r '"TX: \(.transactionHash) block=\(.blockNumber)"' + cast send --private-key $PK --rpc-url $L2_PROXYD_RPC_URL $TO --value 0.001ether --json | jq -r '"TX: \(.transactionHash) block=\(.blockNumber)"' else - echo "Ingress not running (start with: just devnet ingress)" + echo "Proxyd not running (start with: just devnet tx-observability)" fi diff --git a/etc/scripts/devnet/status.sh b/etc/scripts/devnet/status.sh index 3bbfc221c5..f17d7fd32e 100755 --- a/etc/scripts/devnet/status.sh +++ b/etc/scripts/devnet/status.sh @@ -31,9 +31,8 @@ printf "%-12s | %-10s | %-10s\n" "L1" "$L1_BLOCK" "$L1_SAFE" printf "%-12s | %-10s | %-10s\n" "L2 Builder" "$BUILDER_UNSAFE" "$BUILDER_SAFE" printf "%-12s | %-10s | %-10s\n" "L2 Client" "$CLIENT_UNSAFE" "$CLIENT_SAFE" -# Check ingress health -INGRESS_HEALTH_URL="http://localhost:${L2_INGRESS_HEALTH_PORT:-8081}/health" -INGRESS_STATUS=$(curl -sf "$INGRESS_HEALTH_URL" >/dev/null 2>&1 && echo "healthy" || echo "not running") -printf "%-12s | %-10s | %-10s\n" "L2 Ingress" "$INGRESS_STATUS" "-" +# Check proxyd health +PROXYD_STATUS=$(cast chain-id --rpc-url $L2_PROXYD_RPC_URL >/dev/null 2>&1 && echo "healthy" || echo "not running") +printf "%-12s | %-10s | %-10s\n" "L2 Proxyd" "$PROXYD_STATUS" "-" printf "\n" diff --git a/etc/scripts/devnet/transaction-events-smoke.sh b/etc/scripts/devnet/transaction-events-smoke.sh index aac39cec4d..c082864106 100755 --- a/etc/scripts/devnet/transaction-events-smoke.sh +++ b/etc/scripts/devnet/transaction-events-smoke.sh @@ -3,13 +3,13 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/common.sh" -RPC_URL="${1:-$L2_INGRESS_RPC_URL}" +RPC_URL="${1:-$L2_PROXYD_RPC_URL}" AUDIT_RPC_URL="${2:-http://localhost:${AUDIT_RPC_PORT:-9100}}" PK="${3:-$ANVIL_ACCOUNT_5_KEY}" TO="${4:-$ANVIL_ACCOUNT_6_ADDR}" echo "=== Transaction Events Smoke ===" -echo "Sending L2 tx through ingress..." +echo "Sending L2 tx through proxyd..." from="$(cast wallet address --private-key "$PK")" nonce="$(cast nonce --block pending --rpc-url "$RPC_URL" "$from")" tx_hash="$( @@ -43,8 +43,7 @@ for attempt in $(seq 1 60); do and .producer == $producer and (.event_type as $event_type | $event_types | index($event_type))); - has_event("ingress-rpc"; ["INGRESS_RECEIVED"]) - and has_event("base-routing/proxyd"; ["PROXY_RECEIVED"]) + has_event("base-routing/proxyd"; ["PROXY_RECEIVED"]) and has_event("base-reth-node"; [ "TXPOOL_PENDING", "TXPOOL_QUEUED", @@ -64,7 +63,7 @@ for attempt in $(seq 1 60); do fi if [ "$attempt" = 60 ]; then - echo "Timed out waiting for ingress, proxyd, txpool, and builder transaction events for ${tx_hash}" >&2 + echo "Timed out waiting for proxyd, txpool, and builder transaction events for ${tx_hash}" >&2 if [ -n "$last_response" ]; then echo "$last_response" | jq . >&2 || echo "$last_response" >&2 fi From 88cb8f8a4ea781642f742697e33ed2977d7d0ca0 Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Mon, 21 Sep 2026 22:05:24 +0000 Subject: [PATCH 079/299] feat(rpc): reject admin_postUnsafePayload for isolated sequencers (#5145) * feat(consensus): add disabled-mode derivation and L1 watcher clients Introduce Enabled/Disabled variants for QueuedEngineDerivationClient and QueuedL1WatcherDerivationClient. Both types now expose new() and disabled() constructors and use a consistent Enabled { tx }/Disabled enum internally, making the disabled state explicit in the type system. * feat(rpc): reject admin_postUnsafePayload for isolated sequencers * refactor(rpc): encode admin network access --- Cargo.lock | 1 + crates/consensus/rpc/Cargo.toml | 1 + crates/consensus/rpc/src/admin.rs | 133 ++++++++++++++++-- crates/consensus/rpc/src/client.rs | 1 + crates/consensus/rpc/src/lib.rs | 2 +- .../consensus/service/src/actors/rpc/actor.rs | 17 ++- 6 files changed, 136 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f2d5480163..3313f38437 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4180,6 +4180,7 @@ dependencies = [ "ipnet", "jsonrpsee", "libp2p", + "mockall", "rstest", "serde", "serde_json", diff --git a/crates/consensus/rpc/Cargo.toml b/crates/consensus/rpc/Cargo.toml index 882bec5635..8fec7ef452 100644 --- a/crates/consensus/rpc/Cargo.toml +++ b/crates/consensus/rpc/Cargo.toml @@ -43,6 +43,7 @@ jsonrpsee = { workspace = true, features = ["macros", "server"] } [dev-dependencies] rstest.workspace = true +mockall.workspace = true serde_json = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt", "sync"] } diff --git a/crates/consensus/rpc/src/admin.rs b/crates/consensus/rpc/src/admin.rs index dcfefe9ec5..c79e93a632 100644 --- a/crates/consensus/rpc/src/admin.rs +++ b/crates/consensus/rpc/src/admin.rs @@ -31,15 +31,35 @@ pub enum NetworkAdminQuery { }, } -type NetworkAdminQuerySender = mpsc::Sender; +/// Sender for network-backed admin RPC requests. +pub type NetworkAdminQuerySender = mpsc::Sender; + +/// Access to network-backed admin RPC methods. +#[derive(Debug)] +pub enum AdminNetworkAccess { + /// Network-backed methods send requests through the network actor. + Enabled(NetworkAdminQuerySender), + /// Network-backed methods are unavailable because no network actor exists. + Disabled, +} + +impl AdminNetworkAccess { + /// Returns the network sender or an isolated-mode RPC error. + pub fn sender(&self) -> RpcResult<&NetworkAdminQuerySender> { + match self { + Self::Enabled(sender) => Ok(sender), + Self::Disabled => Err(isolated_network_access_rejected()), + } + } +} /// The admin rpc server. #[derive(Debug)] pub struct AdminRpc { /// The sequencer admin API client. pub sequencer_admin_client: Option, - /// The sender to the network actor. - pub network_sender: NetworkAdminQuerySender, + /// Access to network-backed admin RPC methods. + pub network_access: AdminNetworkAccess, /// Runtime upgrade signal refresher. pub upgrade_signal_refresher: Option, } @@ -48,22 +68,27 @@ impl AdminRpc where SequencerAdminAPIClient_: SequencerAdminAPIClient, { - /// Constructs a new [`AdminRpc`] given the sequencer sender and network sender. + /// Constructs a new [`AdminRpc`] with the supplied network capability. /// /// # Parameters /// /// - `sequencer_sender`: The [`SequencerAdminAPIClient`] used to fulfill sequencer admin /// queries. - /// - `network_sender`: The sender to the network actor. + /// - `network_access`: Access to network-backed admin RPC methods. /// /// # Returns /// /// A new [`AdminRpc`] instance. pub const fn new( sequencer_admin_client: Option, - network_sender: NetworkAdminQuerySender, + network_access: AdminNetworkAccess, ) -> Self { - Self { sequencer_admin_client, network_sender, upgrade_signal_refresher: None } + Self { sequencer_admin_client, network_access, upgrade_signal_refresher: None } + } + + /// Constructs an admin RPC for an isolated sequencer without a network actor. + pub const fn new_isolated(sequencer_admin_client: Option) -> Self { + Self::new(sequencer_admin_client, AdminNetworkAccess::Disabled) } /// Sets the runtime upgrade signal refresher. @@ -105,6 +130,15 @@ fn upgrade_signal_refresh_failed() -> ErrorObject<'static> { ErrorObject::owned(-32003, "failed to refresh upgrade signal", None::<()>) } +/// Returns an RPC error indicating network-backed methods are disabled for an isolated sequencer. +fn isolated_network_access_rejected() -> ErrorObject<'static> { + ErrorObject::owned( + -32005, + "network-backed admin methods disabled on isolated sequencer", + None::<()>, + ) +} + #[async_trait] impl AdminApiServer for AdminRpc where @@ -114,20 +148,22 @@ where &self, payload: BaseExecutionPayloadEnvelope, ) -> RpcResult<()> { + let network_sender = self.network_access.sender()?; // Note: intentionally no sequencer guard here. Posting an unsafe payload is a P2P/gossip // operation that is valid on both sequencer and validator nodes. Metrics::rpc_calls("admin_postUnsafePayload").increment(1.0); - self.network_sender + network_sender .send(NetworkAdminQuery::PostUnsafePayload { payload: Box::new(payload) }) .await .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) } async fn admin_clear_pending_p2p_connections(&self) -> RpcResult { + let network_sender = self.network_access.sender()?; Metrics::rpc_calls("admin_clearPendingP2pConnections").increment(1.0); let (tx, rx) = oneshot::channel(); - self.network_sender + network_sender .send(NetworkAdminQuery::ClearPendingP2pConnections { out: tx }) .await .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; @@ -248,10 +284,85 @@ where #[cfg(test)] mod tests { + use alloy_primitives::{Address, B256, Bloom, Bytes}; + use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; use jsonrpsee::types::{ErrorCode, ErrorObject}; + use serde_json::json; + + use super::{ + AdminRpc, sequencer_admin_error, upgrade_signal_refresh_failed, upgrade_signal_unavailable, + }; + use crate::{AdminApiServer, SequencerAdminAPIError, client::MockSequencerAdminAPIClient}; + + fn payload() -> BaseExecutionPayloadEnvelope { + serde_json::from_value(json!({ + "executionPayload": { + "parentHash": B256::ZERO, + "feeRecipient": Address::ZERO, + "stateRoot": B256::ZERO, + "receiptsRoot": B256::ZERO, + "logsBloom": Bloom::ZERO, + "prevRandao": B256::ZERO, + "blockNumber": "0x1", + "gasLimit": "0x0", + "gasUsed": "0x0", + "timestamp": "0x0", + "extraData": Bytes::new(), + "baseFeePerGas": "0x0", + "blockHash": B256::ZERO, + "transactions": [], + "withdrawals": [] + }, + "parentBeaconBlockRoot": null + })) + .unwrap() + } + + #[tokio::test] + async fn isolated_rejects_admin_post_unsafe_payload() { + let rpc = AdminRpc::new_isolated(Some(MockSequencerAdminAPIClient::new())); + + let error = rpc.admin_post_unsafe_payload(payload()).await.unwrap_err(); - use super::{sequencer_admin_error, upgrade_signal_refresh_failed, upgrade_signal_unavailable}; - use crate::SequencerAdminAPIError; + assert_eq!(error.code(), -32005); + } + + #[tokio::test] + async fn isolated_rejects_admin_clear_pending_p2p_connections() { + let rpc = AdminRpc::new_isolated(Some(MockSequencerAdminAPIClient::new())); + + let error = rpc.admin_clear_pending_p2p_connections().await.unwrap_err(); + + assert_eq!(error.code(), -32005); + } + + #[tokio::test] + async fn isolated_keeps_admin_start_sequencer_enabled() { + let unsafe_head = B256::repeat_byte(0x11); + let mut client = MockSequencerAdminAPIClient::new(); + client + .expect_start_sequencer() + .with(mockall::predicate::eq(unsafe_head)) + .times(1) + .returning(|_| Ok(())); + let rpc = AdminRpc::new_isolated(Some(client)); + + let result = rpc.admin_start_sequencer(unsafe_head).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn isolated_keeps_admin_stop_sequencer_enabled() { + let unsafe_head = B256::repeat_byte(0x22); + let mut client = MockSequencerAdminAPIClient::new(); + client.expect_stop_sequencer().times(1).returning(move || Ok(unsafe_head)); + let rpc = AdminRpc::new_isolated(Some(client)); + + let result = rpc.admin_stop_sequencer().await; + + assert_eq!(result.unwrap(), unsafe_head); + } #[test] fn sequencer_admin_error_redacts_internal_failure_details() { diff --git a/crates/consensus/rpc/src/client.rs b/crates/consensus/rpc/src/client.rs index 71397f8a16..01ea2df2fc 100644 --- a/crates/consensus/rpc/src/client.rs +++ b/crates/consensus/rpc/src/client.rs @@ -36,6 +36,7 @@ pub trait EngineRpcClient: Debug + Send + Sync + Clone { } /// Client trait wrapping RPC implementation for the Sequencer admin endpoints. +#[cfg_attr(test, mockall::automock)] #[async_trait] pub trait SequencerAdminAPIClient: Send + Sync + Debug { /// Check if the sequencer is active. diff --git a/crates/consensus/rpc/src/lib.rs b/crates/consensus/rpc/src/lib.rs index 62ba5dcd64..f1bccda553 100644 --- a/crates/consensus/rpc/src/lib.rs +++ b/crates/consensus/rpc/src/lib.rs @@ -11,7 +11,7 @@ extern crate tracing; mod admin; -pub use admin::{AdminRpc, NetworkAdminQuery}; +pub use admin::{AdminNetworkAccess, AdminRpc, NetworkAdminQuery, NetworkAdminQuerySender}; mod base; pub use base::BaseRpc; diff --git a/crates/consensus/service/src/actors/rpc/actor.rs b/crates/consensus/service/src/actors/rpc/actor.rs index 90832c139f..477401da9c 100644 --- a/crates/consensus/service/src/actors/rpc/actor.rs +++ b/crates/consensus/service/src/actors/rpc/actor.rs @@ -5,10 +5,10 @@ use std::sync::Arc; use async_trait::async_trait; use base_consensus_gossip::P2pRpcRequest; use base_consensus_rpc::{ - AdminApiServer, AdminRpc, BaseApiServer, BaseP2PApiServer, BaseRpc, DevEngineApiServer, - DevEngineRpc, EngineRpcClient, HealthzApiServer, HealthzRpc, L1WatcherQueries, - NetworkAdminQuery, P2pRpc, RollupNodeApiServer, RollupRpc, RpcBuilder, SequencerAdminAPIClient, - WsRPC, WsServer, + AdminApiServer, AdminNetworkAccess, AdminRpc, BaseApiServer, BaseP2PApiServer, BaseRpc, + DevEngineApiServer, DevEngineRpc, EngineRpcClient, HealthzApiServer, HealthzRpc, + L1WatcherQueries, NetworkAdminQuery, P2pRpc, RollupNodeApiServer, RollupRpc, RpcBuilder, + SequencerAdminAPIClient, WsRPC, WsServer, }; use base_consensus_safedb::SafeDBReader; use base_health::EthHealthCheckLayer; @@ -143,9 +143,12 @@ where && let Some(network_admin) = network_admin { modules.merge( - AdminRpc::new(self.sequencer_admin_rpc_client, network_admin) - .with_upgrade_signal_refresher(self.upgrade_signal_refresher) - .into_rpc(), + AdminRpc::new( + self.sequencer_admin_rpc_client, + AdminNetworkAccess::Enabled(network_admin), + ) + .with_upgrade_signal_refresher(self.upgrade_signal_refresher) + .into_rpc(), )?; } From 9780173f47e1e4d46d9f18b90a24b2f8e74af7dc Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Mon, 21 Sep 2026 22:33:50 +0000 Subject: [PATCH 080/299] feat(consensus): wire isolated sequencer mode end to end (#5146) * feat(consensus): wire isolated sequencer mode end to end * refactor(consensus): adapt isolated-mode capability constructors --- .../consensus/service/src/actors/rpc/actor.rs | 30 ++++--- .../service/src/actors/sequencer/actor.rs | 4 +- .../service/src/actors/sequencer/seal.rs | 8 +- .../src/actors/sequencer/tests/actor_test.rs | 37 +++++++- .../src/actors/sequencer/tests/test_util.rs | 4 +- crates/consensus/service/src/service/node.rs | 86 +++++++++++++------ 6 files changed, 123 insertions(+), 46 deletions(-) diff --git a/crates/consensus/service/src/actors/rpc/actor.rs b/crates/consensus/service/src/actors/rpc/actor.rs index 477401da9c..cba87eb96d 100644 --- a/crates/consensus/service/src/actors/rpc/actor.rs +++ b/crates/consensus/service/src/actors/rpc/actor.rs @@ -50,6 +50,8 @@ pub struct RpcContext { pub p2p_network: Option>, /// The network admin rpc sender. pub network_admin: Option>, + /// Whether the node is an isolated sequencer without a network actor. + pub isolated_sequencer: bool, /// The l1 watcher queries sender. pub l1_watcher_queries: mpsc::Sender, /// The cancellation token, shared between all tasks. @@ -127,6 +129,7 @@ where p2p_network, l1_watcher_queries, network_admin, + isolated_sequencer, }: Self::StartData, ) -> Result<(), Self::Error> { let mut modules = RpcModule::new(()); @@ -139,17 +142,22 @@ where } // Build the admin rpc module, gated on the `--rpc.enable-admin` flag. - if self.config.admin_enabled() - && let Some(network_admin) = network_admin - { - modules.merge( - AdminRpc::new( - self.sequencer_admin_rpc_client, - AdminNetworkAccess::Enabled(network_admin), - ) - .with_upgrade_signal_refresher(self.upgrade_signal_refresher) - .into_rpc(), - )?; + if self.config.admin_enabled() { + let sequencer_admin_client = self.sequencer_admin_rpc_client.take(); + let admin_rpc = if isolated_sequencer { + Some(AdminRpc::new_isolated(sequencer_admin_client)) + } else { + network_admin.map(|sender| { + AdminRpc::new(sequencer_admin_client, AdminNetworkAccess::Enabled(sender)) + }) + }; + if let Some(admin_rpc) = admin_rpc { + modules.merge( + admin_rpc + .with_upgrade_signal_refresher(self.upgrade_signal_refresher) + .into_rpc(), + )?; + } } // Create context for communication between actors. diff --git a/crates/consensus/service/src/actors/sequencer/actor.rs b/crates/consensus/service/src/actors/sequencer/actor.rs index 61e48e2818..70b57e489c 100644 --- a/crates/consensus/service/src/actors/sequencer/actor.rs +++ b/crates/consensus/service/src/actors/sequencer/actor.rs @@ -137,7 +137,9 @@ where .increment(handle.attributes_with_parent.count_transactions()); if self.is_shadow_sequencer() { - Ok(PayloadSealer::new_private(envelope)) + Ok(PayloadSealer::new_private(envelope, "shadow")) + } else if self.unsafe_payload_gossip_client.seals_privately() { + Ok(PayloadSealer::new_private(envelope, "isolated")) } else { Ok(PayloadSealer::new(envelope)) } diff --git a/crates/consensus/service/src/actors/sequencer/seal.rs b/crates/consensus/service/src/actors/sequencer/seal.rs index 1551f11407..6e11898fb7 100644 --- a/crates/consensus/service/src/actors/sequencer/seal.rs +++ b/crates/consensus/service/src/actors/sequencer/seal.rs @@ -84,14 +84,18 @@ impl PayloadSealer { } /// Creates a private sealer that skips conductor commit and gossip. - pub fn new_private(envelope: BaseExecutionPayloadEnvelope) -> Self { + /// + /// `mode` distinguishes the reason payloads are sealed privately (e.g. `"shadow"` for a + /// shadow-sequencing cycle, `"isolated"` for an isolated sequencer) so traces can tell the + /// two apart. + pub fn new_private(envelope: BaseExecutionPayloadEnvelope, mode: &'static str) -> Self { let block_hash = envelope.execution_payload.block_hash(); let block_num = envelope.execution_payload.block_number(); let seal_span = tracing::info_span!( "seal_payload_pipeline", block_hash = %block_hash, block_number = block_num, - mode = "shadow", + mode = mode, ); Self { envelope, state: SealState::Private, seal_span, started_at: Instant::now() } diff --git a/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs b/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs index 959ec2c48d..f532e1172a 100644 --- a/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs +++ b/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs @@ -25,7 +25,7 @@ use tokio::sync::{mpsc, oneshot}; use crate::{ ConductorError, L1OriginSelectorError, NodeActor, ResetReason, ScheduledTicker, SealState, SealStepError, SealStepOutcome, SequencerActor, SequencerActorError, SequencerAdminQuery, - ShadowFunding, UnsafePayloadGossipClientError, UnsealedPayloadHandle, + SequencerConfig, ShadowFunding, UnsafePayloadGossipClientError, UnsealedPayloadHandle, actors::{ MockConductor, MockOriginSelector, MockSequencerEngineClient, MockUnsafePayloadGossipClient, @@ -168,6 +168,7 @@ async fn test_on_time_or_late_insert_starts_child_build_immediately(#[case] seco origin_selector.expect_next_l1_origin().times(2).returning(|_| Ok(BlockInfo::default())); let mut gossip = MockUnsafePayloadGossipClient::new(); + gossip.expect_seals_privately().return_const(false); gossip.expect_schedule_execution_payload_gossip().times(1).return_once(|_| Ok(())); let rollup_config = Arc::new(base_common_genesis::RollupConfig { @@ -309,6 +310,7 @@ async fn test_early_insert_defers_child_build_until_parent_timestamp() { origin_selector.expect_next_l1_origin().times(2).returning(|_| Ok(BlockInfo::default())); let mut gossip = MockUnsafePayloadGossipClient::new(); + gossip.expect_seals_privately().return_const(false); gossip.expect_schedule_execution_payload_gossip().times(1).return_once(|_| Ok(())); let rollup_config = Arc::new(base_common_genesis::RollupConfig { @@ -402,6 +404,7 @@ async fn test_stop_discards_queued_parent_and_restart_builds_immediately_on_fres origin_selector.expect_next_l1_origin().times(2).returning(|_| Ok(BlockInfo::default())); let mut gossip = MockUnsafePayloadGossipClient::new(); + gossip.expect_seals_privately().return_const(false); gossip.expect_schedule_execution_payload_gossip().times(1).return_once(|_| Ok(())); let rollup_config = Arc::new(base_common_genesis::RollupConfig { @@ -492,7 +495,7 @@ async fn shadow_cycle_reconciles_after_configured_private_block_count() { actor.builder.rollup_config = Arc::clone(&rollup_config); actor.rollup_config = rollup_config; actor.shadow_blocks_per_cycle = NonZeroU64::new(1); - actor.sealer = Some(PayloadSealer::new_private(dummy_envelope())); + actor.sealer = Some(PayloadSealer::new_private(dummy_envelope(), "shadow")); actor.start(()).await.unwrap(); } @@ -861,6 +864,32 @@ async fn test_shadow_seal_payload_returns_private_sealer() { assert_eq!(sealer.state, SealState::Private); } +#[tokio::test] +async fn isolated_private_sealing_is_not_capped_by_shadow_cycle_limit() { + let private_block_count = + usize::try_from(SequencerConfig::MAX_SHADOW_BLOCKS_PER_CYCLE + 1).unwrap(); + let mut client = MockSequencerEngineClient::new(); + client + .expect_get_sealed_payload() + .times(private_block_count) + .returning(|_, _| Ok(dummy_envelope())); + + let mut actor = test_actor(); + actor.engine_client = Arc::new(client); + let mut private_sealing = MockUnsafePayloadGossipClient::new(); + private_sealing.expect_seals_privately().times(private_block_count).return_const(true); + actor.unsafe_payload_gossip_client = private_sealing; + let handle = UnsealedPayloadHandle { + payload_id: Default::default(), + attributes_with_parent: dummy_attributes_with_parent(), + }; + + for _ in 0..private_block_count { + let sealer = actor.seal_payload(&handle).await.unwrap(); + assert_eq!(sealer.state, SealState::Private); + } +} + #[tokio::test] async fn test_seal_payload_failure_propagates() { let mut client = MockSequencerEngineClient::new(); @@ -896,7 +925,7 @@ async fn test_private_sealer_only_inserts() { let mut engine = MockSequencerEngineClient::new(); engine.expect_insert_unsafe_payload().times(1).return_once(|_| Ok(L2BlockInfo::default())); - let mut sealer = PayloadSealer::new_private(envelope); + let mut sealer = PayloadSealer::new_private(envelope, "shadow"); let result = sealer.step(&Some(conductor), &gossip, &engine).await; assert_eq!(result.unwrap(), SealStepOutcome::Inserted(L2BlockInfo::default())); @@ -919,7 +948,7 @@ async fn test_private_sealer_insert_failure_stays_private() { .times(1) .return_once(|_| Err(EngineClientError::RequestError("channel closed".to_string()))); - let mut sealer = PayloadSealer::new_private(envelope); + let mut sealer = PayloadSealer::new_private(envelope, "shadow"); let result = sealer.step(&Some(conductor), &gossip, &engine).await; assert!(matches!(result.unwrap_err(), SealStepError::Insert(_))); diff --git a/crates/consensus/service/src/actors/sequencer/tests/test_util.rs b/crates/consensus/service/src/actors/sequencer/tests/test_util.rs index 886ee2c136..d464cdb0f7 100644 --- a/crates/consensus/service/src/actors/sequencer/tests/test_util.rs +++ b/crates/consensus/service/src/actors/sequencer/tests/test_util.rs @@ -28,6 +28,8 @@ pub(in crate::actors::sequencer) fn test_actor() -> SequencerActor< let rollup_config = Arc::new(RollupConfig::default()); let recovery_mode = RecoveryModeGuard::new(false); let engine_client = Arc::new(MockSequencerEngineClient::new()); + let mut unsafe_payload_gossip_client = MockUnsafePayloadGossipClient::new(); + unsafe_payload_gossip_client.expect_seals_privately().return_const(false); SequencerActor { admin_api_rx, builder: PayloadBuilder { @@ -46,7 +48,7 @@ pub(in crate::actors::sequencer) fn test_actor() -> SequencerActor< recovery_mode, rollup_config, seal_offset: base_protocol::DEFAULT_SEAL_OFFSET, - unsafe_payload_gossip_client: MockUnsafePayloadGossipClient::new(), + unsafe_payload_gossip_client, sealer: None, pending_stop: None, } diff --git a/crates/consensus/service/src/service/node.rs b/crates/consensus/service/src/service/node.rs index d55c2ac130..cd496036ca 100644 --- a/crates/consensus/service/src/service/node.rs +++ b/crates/consensus/service/src/service/node.rs @@ -474,12 +474,17 @@ impl RollupNode { let engine_conductor: Option> = conductor.clone().map(|c| Arc::new(c) as Arc); + let engine_derivation_client = if self.sequencer_config.derivation_enabled() { + QueuedEngineDerivationClient::new(derivation_actor_request_tx.clone()) + } else { + QueuedEngineDerivationClient::disabled() + }; let (engine_actor, engine_rpc_processor, sequencer_engine_state_rx) = self .create_engine_actor( engine_client, cancellation.clone(), engine_actor_request_rx, - QueuedEngineDerivationClient::new(derivation_actor_request_tx.clone()), + engine_derivation_client, unsafe_head_tx, engine_conductor, checkpoint_client, @@ -487,8 +492,11 @@ impl RollupNode { // Select the concrete derivation actor implementation based on // RollupNode configuration. - let derivation: ConfiguredDerivationActor

= - if let Some(provider) = self.derivation_delegate_provider.clone() { + let derivation: Option> = if self + .sequencer_config + .derivation_enabled() + { + Some(if let Some(provider) = self.derivation_delegate_provider.clone() { // L1 Provider for sanity checking Derivation Delegation let l1_provider = AlloyChainProvider::new( self.l1_config.engine_provider.clone(), @@ -515,24 +523,41 @@ impl RollupNode { safe_head_listener, derivation_origin_tx, ))) - }; + }) + } else { + None + }; // Create the p2p actor. - let ( - NetworkInboundData { - signer, - p2p_rpc: network_rpc, - gossip_payload_tx, - admin_rpc: net_admin_rpc, - }, - network, - ) = NetworkActor::new( - QueuedNetworkEngineClient { engine_actor_request_tx: engine_actor_request_tx.clone() }, - cancellation.clone(), - self.network_builder(), - ) - .await - .map_err(|e| format!("Failed to start network actor: {e}"))?; + let (signer, network_rpc, gossip_payload_tx, net_admin_rpc, network) = + if self.sequencer_config.network_enabled() { + let ( + NetworkInboundData { + signer, + p2p_rpc: network_rpc, + gossip_payload_tx, + admin_rpc: net_admin_rpc, + }, + network, + ) = NetworkActor::new( + QueuedNetworkEngineClient { + engine_actor_request_tx: engine_actor_request_tx.clone(), + }, + cancellation.clone(), + self.network_builder(), + ) + .await + .map_err(|e| format!("Failed to start network actor: {e}"))?; + ( + Some(signer), + Some(network_rpc), + Some(gossip_payload_tx), + Some(net_admin_rpc), + Some(network), + ) + } else { + (None, None, None, None, None) + }; let (l1_head_updates_tx, l1_head_updates_rx) = watch::channel(None); @@ -557,8 +582,12 @@ impl RollupNode { Arc::clone(&self.config), AlloyL1BlockFetcher(self.l1_config.engine_provider.clone()), l1_head_updates_tx.clone(), - QueuedL1WatcherDerivationClient::new(derivation_actor_request_tx), - Some(signer), + if self.sequencer_config.derivation_enabled() { + QueuedL1WatcherDerivationClient::new(derivation_actor_request_tx) + } else { + QueuedL1WatcherDerivationClient::disabled() + }, + signer, cancellation.clone(), head_stream, finalized_stream, @@ -600,8 +629,10 @@ impl RollupNode { // Create the admin API channel let (sequencer_admin_api_tx, sequencer_admin_api_rx) = mpsc::channel(1024); - let queued_gossip_client = - QueuedUnsafePayloadGossipClient::new(gossip_payload_tx.clone()); + let queued_gossip_client = gossip_payload_tx.map_or_else( + QueuedUnsafePayloadGossipClient::private, + QueuedUnsafePayloadGossipClient::new, + ); let recovery_mode = RecoveryModeGuard::new(self.sequencer_config.sequencer_recovery_mode); @@ -666,17 +697,18 @@ impl RollupNode { r, RpcContext { cancellation: cancellation.clone(), - p2p_network: Some(network_rpc), - network_admin: Some(net_admin_rpc), + p2p_network: network_rpc, + network_admin: net_admin_rpc, + isolated_sequencer: self.sequencer_config.isolated, l1_watcher_queries: l1_query_tx, } )), sequencer_actor.map(|s| (s, ())), - Some((network, ())), + network.map(|network| (network, ())), Some((l1_watcher, ())), Some((l1_query_processor, ())), upgrade_signal_metrics_actor.map(|actor| (actor, ())), - Some((derivation, ())), + derivation.map(|derivation| (derivation, ())), Some((checkpoint_actor, cancellation.clone())), Some((engine_actor, ())), engine_rpc_actor, From 47d81798b9d0cbda61e2a4e1b3ca24d7842c285b Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 22 Sep 2026 08:34:55 +0000 Subject: [PATCH 081/299] refactor(batcher): name the admin lifecycle commands stop and start (#5175) * refactor(batcher): name the admin lifecycle commands stop and start * refactor(batcher): rename the admin_pause reset reason to admin_stop --- crates/batcher/admin/src/api.rs | 8 ++-- crates/batcher/core/Cargo.toml | 2 +- crates/batcher/core/src/admin.rs | 28 +++++++------- crates/batcher/core/src/driver.rs | 32 ++++++++-------- .../tests/{pause_resume.rs => stop_start.rs} | 38 +++++++++---------- crates/batcher/encoder/src/metrics.rs | 6 +-- crates/batcher/service/src/service.rs | 2 +- crates/batcher/source/src/traits.rs | 2 +- 8 files changed, 59 insertions(+), 59 deletions(-) rename crates/batcher/core/tests/{pause_resume.rs => stop_start.rs} (87%) diff --git a/crates/batcher/admin/src/api.rs b/crates/batcher/admin/src/api.rs index 8081b80aa1..9d02f7bf62 100644 --- a/crates/batcher/admin/src/api.rs +++ b/crates/batcher/admin/src/api.rs @@ -12,11 +12,11 @@ use tracing::warn; #[rpc(server, namespace = "admin")] pub trait BatcherAdminApi { - /// Resume block ingestion after a previous stop. + /// Start block ingestion again after a previous stop. #[method(name = "startBatcher")] async fn start_batcher(&self) -> RpcResult<()>; - /// Pause block ingestion without stopping the driver task. + /// Stop block ingestion; the driver task keeps running. #[method(name = "stopBatcher")] async fn stop_batcher(&self) -> RpcResult<()>; @@ -76,11 +76,11 @@ impl BatcherAdminApiServerImpl { #[async_trait] impl BatcherAdminApiServer for BatcherAdminApiServerImpl { async fn start_batcher(&self) -> RpcResult<()> { - self.handle.resume().await.map_err(Self::admin_error) + self.handle.start().await.map_err(Self::admin_error) } async fn stop_batcher(&self) -> RpcResult<()> { - self.handle.pause().await.map_err(Self::admin_error) + self.handle.stop().await.map_err(Self::admin_error) } async fn flush_batcher(&self) -> RpcResult<()> { diff --git a/crates/batcher/core/Cargo.toml b/crates/batcher/core/Cargo.toml index ee69ad2890..41d02cff3e 100644 --- a/crates/batcher/core/Cargo.toml +++ b/crates/batcher/core/Cargo.toml @@ -61,7 +61,7 @@ name = "lifecycle" required-features = ["test-utils"] [[test]] -name = "pause_resume" +name = "stop_start" required-features = ["test-utils"] [features] diff --git a/crates/batcher/core/src/admin.rs b/crates/batcher/core/src/admin.rs index 95323c4939..06629736c8 100644 --- a/crates/batcher/core/src/admin.rs +++ b/crates/batcher/core/src/admin.rs @@ -15,7 +15,7 @@ pub const ADMIN_CHANNEL_CAPACITY: usize = 32; /// Serialised directly as the `admin_getBatcherStatus` JSON-RPC response. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BatcherStatus { - /// Whether block ingestion is currently stopped (paused via admin or `--stopped` flag). + /// Whether block ingestion is currently stopped (via admin or the `--stopped` flag). pub stopped: bool, /// Number of L1 transactions submitted but not yet confirmed. pub in_flight: usize, @@ -40,10 +40,10 @@ pub type AdminResult = Result; /// Commands the admin HTTP server can send to the running driver task. #[derive(derive_more::Debug)] pub enum AdminCommand { - /// Resume block ingestion after a [`Pause`](Self::Pause). - Resume, - /// Pause block ingestion without stopping the driver task. - Pause, + /// Start block ingestion again after a [`Stop`](Self::Stop). + Start, + /// Stop block ingestion; the driver task keeps running. + Stop, /// Flush the current encoding channel. Flush { /// Fired once the driver's encoding and submission are both fully drained (not just @@ -92,17 +92,17 @@ impl AdminHandle { (Self { tx }, rx) } - /// Resume block ingestion if currently paused. - pub async fn resume(&self) -> AdminResult<()> { - self.send(AdminCommand::Resume).await + /// Start block ingestion again if currently stopped. + pub async fn start(&self) -> AdminResult<()> { + self.send(AdminCommand::Start).await } - /// Pause block ingestion without stopping the driver task. + /// Stop block ingestion; the driver task keeps running. /// /// In-flight submissions continue to resolve; no new blocks are ingested - /// until [`resume`](Self::resume) is called. - pub async fn pause(&self) -> AdminResult<()> { - self.send(AdminCommand::Pause).await + /// until [`start`](Self::start) is called. + pub async fn stop(&self) -> AdminResult<()> { + self.send(AdminCommand::Stop).await } /// Flush the current encoding channel, submitting any buffered frames. @@ -187,10 +187,10 @@ mod tests { use super::*; #[tokio::test] - async fn resume_returns_channel_closed_when_rx_dropped() { + async fn start_returns_channel_closed_when_rx_dropped() { let (handle, rx) = AdminHandle::channel(); drop(rx); - let err = handle.resume().await.unwrap_err(); + let err = handle.start().await.unwrap_err(); assert!(matches!(err, AdminError::ChannelClosed)); } diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 5aad3bea2a..ca4c0eab5f 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -93,7 +93,7 @@ where /// Maximum wall-clock time to wait for in-flight submissions to settle /// when draining on cancellation or source exhaustion. drain_timeout: Duration, - /// Whether block ingestion is currently stopped (paused via admin or `--stopped` flag). + /// Whether block ingestion is currently stopped (via admin or the `--stopped` flag). stopped: bool, /// Admin command channel, wired in via [`Self::with_admin_rx`]. admin_rx: Option>, @@ -203,10 +203,10 @@ where } /// Start the driver in a stopped state, deferring block ingestion until - /// [`AdminCommand::Resume`] is received via the admin API. + /// [`AdminCommand::Start`] is received via the admin API. /// /// Equivalent to the batcher starting normally and immediately receiving - /// a pause command. Use this when the `--stopped` flag is set at startup. + /// a stop command. Use this when the `--stopped` flag is set at startup. pub const fn with_stopped(mut self, stopped: bool) -> Self { self.stopped = stopped; self @@ -469,16 +469,16 @@ where /// /// Admin commands are handled inline in the loop — only non-admin events /// are returned to the caller. Admin commands are placed before the source - /// arm so control-plane operations (pause, resume, flush) are never starved + /// arm so control-plane operations (stop, start, flush) are never starved /// by sustained block throughput. /// Derivation-status changes are also handled before unsafe blocks so pruning and /// recovery cannot be starved by sequential catchup. /// - /// [`AdminCommand::Pause`] immediately resets the pipeline, then drops - /// `Block` and `Flush` source events until [`AdminCommand::Resume`] is - /// received. Reorg events propagate regardless of pause state. On resume - /// the source is reset to catch up sequentially from the last known safe - /// L2 head. + /// [`AdminCommand::Stop`] immediately resets the pipeline, then drops + /// `Block` and `Flush` source events until [`AdminCommand::Start`] is + /// received. Reorg events propagate regardless of the stopped state. On + /// start the source is reset to catch up sequentially from the last known + /// safe L2 head. /// /// Non-fatal L1 head source errors loop internally to avoid polluting the /// return type with a no-op variant. @@ -492,21 +492,21 @@ where cmd = Self::next_admin_cmd(&mut self.admin_rx) => { match cmd { AdminCommand::Flush { ack } => return Ok(DriverEvent::Flush(ack)), - AdminCommand::Pause => { - self.reset_pipeline(BatcherMetrics::RESET_ADMIN_PAUSE); + AdminCommand::Stop => { + self.reset_pipeline(BatcherMetrics::RESET_ADMIN_STOP); self.stopped = true; - info!(stopped = true, "batcher paused via admin"); + info!(stopped = true, "batcher stopped via admin"); } - AdminCommand::Resume => { + AdminCommand::Start => { if let Some(safe_head) = self.safe_head { self.source.reset_catchup(safe_head); info!( stopped = false, safe_l2 = %safe_head.number, - "batcher resumed via admin, catching up from safe head" + "batcher started via admin, catching up from safe head" ); } else { - info!(stopped = false, "batcher resumed via admin"); + info!(stopped = false, "batcher started via admin"); } self.stopped = false; } @@ -555,7 +555,7 @@ where continue; } Ok(L2BlockEvent::Flush { ack }) if self.stopped => { - // Drop (rather than fire) any ack: the batcher is paused, so this + // Drop (rather than fire) any ack: the batcher is stopped, so this // flush produces no frames and firing would falsely report // settlement. The waiter observes a closed-channel error instead of // a silent, indefinite-looking drop. diff --git a/crates/batcher/core/tests/pause_resume.rs b/crates/batcher/core/tests/stop_start.rs similarity index 87% rename from crates/batcher/core/tests/pause_resume.rs rename to crates/batcher/core/tests/stop_start.rs index b823877bf5..5ede6d9ef9 100644 --- a/crates/batcher/core/tests/pause_resume.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -1,4 +1,4 @@ -//! Integration tests for pause/resume admin commands in [`BatchDriver`]. +//! Integration tests for stop/start admin commands in [`BatchDriver`]. use std::{ sync::{Arc, Mutex}, @@ -27,11 +27,11 @@ use base_runtime::{ }; use tokio::sync::mpsc; -/// `AdminCommand::Pause` must immediately reset the pipeline. This is verified -/// by checking that `pipeline.reset()` is called exactly once after the pause +/// `AdminCommand::Stop` must immediately reset the pipeline. This is verified +/// by checking that `pipeline.reset()` is called exactly once after the stop /// command is processed. #[test] -fn test_pause_resets_pipeline() { +fn test_stop_resets_pipeline() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); @@ -42,7 +42,7 @@ fn test_pause_resets_pipeline() { .with_admin_rx(admin_rx); let handle = ctx.spawn(driver.run()); - admin_handle.pause().await.unwrap(); + admin_handle.stop().await.unwrap(); ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); @@ -50,16 +50,16 @@ fn test_pause_resets_pipeline() { assert_eq!( recorded.lock().unwrap().resets, 1, - "pipeline must be reset exactly once when paused" + "pipeline must be reset exactly once when stopped" ); }); } -/// `AdminCommand::Resume` must reanchor the source at the safe head so it +/// `AdminCommand::Start` must reanchor the source at the safe head so it /// delivers missed blocks sequentially after that head. When no derivation-status feed /// is wired, no catchup is triggered. #[test] -fn test_resume_triggers_catchup_from_safe_head() { +fn test_start_triggers_catchup_from_safe_head() { Runner::start(Config::seeded(0), |ctx| async move { let (source, catchup_args) = TrackingSource::new(); let (admin_handle, admin_rx) = AdminHandle::channel(); @@ -85,10 +85,10 @@ fn test_resume_triggers_catchup_from_safe_head() { let handle = ctx.spawn(driver.run()); - // Pause then resume with safe_head = 42; the source will poll 43 next. - admin_handle.pause().await.unwrap(); + // Stop then start with safe_head = 42; the source will poll 43 next. + admin_handle.stop().await.unwrap(); ctx.sleep(Duration::from_millis(10)).await; - admin_handle.resume().await.unwrap(); + admin_handle.start().await.unwrap(); ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); @@ -98,15 +98,15 @@ fn test_resume_triggers_catchup_from_safe_head() { assert_eq!( *catchup_args.lock().unwrap(), vec![safe_head], - "source must be reanchored at the safe head on resume" + "source must be reanchored at the safe head on start" ); }); } -/// While paused, `Block` and `Flush` source events must be dropped; the +/// While stopped, `Block` and `Flush` source events must be dropped; the /// pipeline must not receive any blocks. #[test] -fn test_paused_drops_block_and_flush_events() { +fn test_stopped_drops_block_and_flush_events() { Runner::start(Config::seeded(0), |ctx| async move { let (admin_handle, admin_rx) = AdminHandle::channel(); let (source, source_tx) = ChannelBlockSource::new(); @@ -180,20 +180,20 @@ fn test_paused_drops_block_and_flush_events() { .with_admin_rx(admin_rx); let handle = ctx.spawn(driver.run()); - // Pause, then send a block — it must be dropped. - admin_handle.pause().await.unwrap(); + // Stop, then send a block — it must be dropped. + admin_handle.stop().await.unwrap(); ctx.sleep(Duration::from_millis(10)).await; source_tx.send(L2BlockEvent::Block(Box::default())).unwrap(); ctx.sleep(Duration::from_millis(10)).await; - // A flush's ack must also be dropped (not silently leaked/hung) while paused, so a + // A flush's ack must also be dropped (not silently leaked/hung) while stopped, so a // waiter sees an immediate closed-channel error rather than an indefinite wait. let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); source_tx.send(L2BlockEvent::Flush { ack: Some(ack_tx) }).unwrap(); ctx.sleep(Duration::from_millis(10)).await; assert!( ack_rx.await.is_err(), - "flush ack must be dropped (not fired) while the batcher is paused" + "flush ack must be dropped (not fired) while the batcher is stopped" ); ctx.cancel(); @@ -202,7 +202,7 @@ fn test_paused_drops_block_and_flush_events() { assert_eq!( *add_block_calls.lock().unwrap(), 0, - "add_block must not be called while paused" + "add_block must not be called while stopped" ); }); } diff --git a/crates/batcher/encoder/src/metrics.rs b/crates/batcher/encoder/src/metrics.rs index 4444502d72..0964120dee 100644 --- a/crates/batcher/encoder/src/metrics.rs +++ b/crates/batcher/encoder/src/metrics.rs @@ -26,7 +26,7 @@ base_metrics::define_metrics! { "safe_head_reorg", "safe_head_mismatch", "stalled_channel", - "admin_pause", + "admin_stop", ] )] pipeline_reset_total: counter, @@ -96,8 +96,8 @@ impl BatcherMetrics { /// The rollup node passed a fully confirmed channel without deriving it. pub const RESET_STALLED_CHANNEL: &'static str = "stalled_channel"; - /// The batcher was paused through the admin API. - pub const RESET_ADMIN_PAUSE: &'static str = "admin_pause"; + /// The batcher was stopped through the admin API. + pub const RESET_ADMIN_STOP: &'static str = "admin_stop"; /// Submission accepted and handed to the tx manager. pub const OUTCOME_SUBMITTED: &'static str = "submitted"; diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index b5fda07eff..536bace29b 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -379,7 +379,7 @@ impl BatcherService { if self.config.stopped && self.config.admin_addr.is_none() { eyre::bail!( "--stopped requires --admin-port: the batcher would start stopped with no way to \ - resume because the admin JSON-RPC server is not enabled" + start it because the admin JSON-RPC server is not enabled" ); } if self.config.l1_rpc_url.is_empty() { diff --git a/crates/batcher/source/src/traits.rs b/crates/batcher/source/src/traits.rs index 2840ffecef..c6f2e72f1d 100644 --- a/crates/batcher/source/src/traits.rs +++ b/crates/batcher/source/src/traits.rs @@ -18,7 +18,7 @@ pub trait UnsafeBlockSource: Send { /// Reset the source to begin sequential catchup above `safe_head`. /// - /// Called by the driver on resume after a pause, ensuring blocks between + /// Called by the driver on start after a stop, ensuring blocks between /// the last safe head and the current unsafe tip are not skipped. The /// source should validate the first block against the safe-head hash, then /// continue delivering subsequent blocks in order. From 9d521d80934cdb464c8ba3ff95cf95a9db12d074 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 22 Sep 2026 11:33:27 +0000 Subject: [PATCH 082/299] fix(tx-manager): keep polling a mined tx past the confirmation timeout (#5159) * fix(tx-manager): keep polling a mined tx past the confirmation timeout * docs(tx-manager): space out the mined-tx polling test * docs(tx-manager): phrase the confirmation polling comments as actions * fix(tx-manager): stop receipt polling once the receipt receiver is dropped --- crates/utilities/tx-manager/src/config.rs | 2 +- crates/utilities/tx-manager/src/macros.rs | 4 +- crates/utilities/tx-manager/src/manager.rs | 164 +++++++++++++++++- crates/utilities/tx-manager/src/send_state.rs | 7 + .../tx-manager/tests/send_lifecycle.rs | 9 +- 5 files changed, 170 insertions(+), 16 deletions(-) diff --git a/crates/utilities/tx-manager/src/config.rs b/crates/utilities/tx-manager/src/config.rs index 623235f207..98f16f2c70 100644 --- a/crates/utilities/tx-manager/src/config.rs +++ b/crates/utilities/tx-manager/src/config.rs @@ -123,7 +123,7 @@ pub struct TxManagerConfig { pub tx_send_timeout: Duration, /// Mempool appearance timeout (zero = disabled). pub tx_not_in_mempool_timeout: Duration, - /// Maximum time to poll for confirmation before giving up. + /// Maximum time to poll for a transaction that is not mined before giving up. pub confirmation_timeout: Duration, /// Minimum blob base fee (in wei) to use for blob transactions. pub min_blob_fee: u128, diff --git a/crates/utilities/tx-manager/src/macros.rs b/crates/utilities/tx-manager/src/macros.rs index 65ede66408..bed9e8d4da 100644 --- a/crates/utilities/tx-manager/src/macros.rs +++ b/crates/utilities/tx-manager/src/macros.rs @@ -173,8 +173,8 @@ macro_rules! define_tx_manager_cli { )] pub tx_not_in_mempool_timeout: ::std::time::Duration, - /// Maximum time to poll for transaction confirmation before giving - /// up (e.g., "5m", "300s"). + /// Maximum time to poll for a transaction that is not mined before + /// giving up (e.g., "5m", "300s"). #[arg( long = "tx-manager.confirmation-timeout", env = concat!($prefix, "_", "CONFIRMATION_TIMEOUT"), diff --git a/crates/utilities/tx-manager/src/manager.rs b/crates/utilities/tx-manager/src/manager.rs index 59ccc43b20..759cfb347a 100644 --- a/crates/utilities/tx-manager/src/manager.rs +++ b/crates/utilities/tx-manager/src/manager.rs @@ -1495,8 +1495,8 @@ where /// Accumulated tasks are bounded by three mechanisms: /// - The `closed` flag causes all pollers to exit on shutdown. /// - Each poller exits once it delivers a receipt through the mpsc - /// channel (or finds the channel closed because a different poller - /// already delivered). + /// channel, or as soon as the receiver is dropped because the send + /// loop returned or was cancelled. /// - Polling frequency is governed by `receipt_query_interval`, so /// RPC load is proportional to `bump_count × 1/interval`. pub fn wait_for_tx( @@ -1510,7 +1510,13 @@ where tokio::spawn(async move { debug!(tx_hash = %tx_hash, "starting receipt polling"); - let receipt = Self::wait_mined(&send_state, &provider, tx_hash, &config, &closed).await; + // Stop polling once the receiver is dropped. Nobody can consume the receipt then. + let polling = Self::wait_mined(&send_state, &provider, tx_hash, &config, &closed); + let receipt = tokio::select! { + biased; + () = receipt_tx.closed() => None, + receipt = polling => receipt, + }; if let Some(receipt) = receipt { // Best-effort send — if the receiver is dropped, the // send loop has already exited (e.g., another tx confirmed). @@ -1532,7 +1538,12 @@ where self.runtime.spawn(async move { debug!(tx_hash = %tx_hash, "starting receipt polling"); - let receipt = manager.wait_mined_for_tx(&send_state, tx_hash).await; + // Stop polling once the receiver is dropped. Nobody can consume the receipt then. + let receipt = tokio::select! { + biased; + () = receipt_tx.closed() => None, + receipt = manager.wait_mined_for_tx(&send_state, tx_hash) => receipt, + }; if let Some(receipt) = receipt { let _ = receipt_tx.send(receipt).await; } @@ -1563,7 +1574,8 @@ where /// /// Returns `Some(receipt)` when the transaction reaches /// `num_confirmations` depth, or `None` if the manager is closed or - /// the `confirmation_timeout` deadline is exceeded. + /// the transaction is still not mined when the `confirmation_timeout` + /// deadline is exceeded. pub async fn wait_mined( send_state: &SendState, provider: &P, @@ -1625,7 +1637,10 @@ where } } - if runtime.now() >= deadline { + // Give up at the deadline only if the transaction is not mined. Once it is mined + // the send loop stops fee bumping and relies on this poller alone, so keep polling + // until the confirmation depth is reached. + if runtime.now() >= deadline && !send_state.is_mined(tx_hash) { warn!( tx_hash = %tx_hash, timeout = ?config.confirmation_timeout, @@ -1832,18 +1847,20 @@ mod tests { time::Duration, }; - use alloy_consensus::TxEip1559; + use alloy_consensus::{Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom, TxEip1559}; use alloy_network::EthereumWallet; use alloy_node_bindings::Anvil; - use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; + use alloy_primitives::{Address, B256, Bloom, Bytes, TxKind, U256}; use alloy_provider::{ProviderBuilder, RootProvider}; + use alloy_rpc_types_eth::{Block, TransactionReceipt}; use alloy_signer_local::PrivateKeySigner; use alloy_transport::mock::Asserter; use base_runtime::{ Clock, - deterministic::{Config, Runner}, + deterministic::{Config, Context, Runner}, }; use rstest::rstest; + use tokio::sync::mpsc; use super::{BumpState, PreparedTx, SimpleTxManager, TxEnvelope}; use crate::{ @@ -2060,6 +2077,135 @@ mod tests { }); } + /// Build a successful receipt for `tx_hash` and the canonical block that contains it. + fn mined_receipt(tx_hash: B256, block_number: u64) -> (TransactionReceipt, Block) { + let block_hash = B256::with_last_byte(2); + let receipt = TransactionReceipt { + inner: ReceiptEnvelope::Legacy(ReceiptWithBloom { + receipt: Receipt { + status: Eip658Value::Eip658(true), + cumulative_gas_used: 21_000, + logs: vec![], + }, + logs_bloom: Bloom::ZERO, + }), + transaction_hash: tx_hash, + transaction_index: Some(0), + block_hash: Some(block_hash), + block_number: Some(block_number), + gas_used: 21_000, + effective_gas_price: 1, + blob_gas_used: None, + blob_gas_price: None, + from: Address::ZERO, + to: Some(Address::ZERO), + contract_address: None, + }; + let mut block: Block = Block::default(); + block.header.hash = block_hash; + + (receipt, block) + } + + /// A mined transaction that reaches the confirmation depth only after the + /// confirmation timeout must still be delivered: once it is mined nothing + /// else watches it, so abandoning it would leave the send pending forever. + #[test] + fn wait_mined_keeps_polling_mined_tx_past_confirmation_timeout() { + Runner::start(Config::seeded(0), |ctx| async move { + let tx_hash = B256::with_last_byte(1); + let (receipt, block) = mined_receipt(tx_hash, 10); + + // Script the chain tip so the 5 required confirmations are only reached at tip 14, + // on the poll after the 3s timeout. + let asserter = Asserter::new(); + for tip in [10u64, 10, 10, 10, 14] { + asserter.push_success(&tip); + asserter.push_success(&Some(&receipt)); + asserter.push_success(&Some(&block)); + } + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + + let send_state = SendState::new(3).expect("send state should be valid"); + let config = TxManagerConfig { + num_confirmations: 5, + receipt_query_interval: Duration::from_secs(1), + confirmation_timeout: Duration::from_secs(3), + network_timeout: Duration::from_secs(30), + ..TxManagerConfig::default() + }; + + let confirmed = SimpleTxManager::<_, Context>::wait_mined_using_runtime( + &ctx, + &send_state, + &provider, + tx_hash, + &config, + &AtomicBool::new(false), + ) + .await; + + assert_eq!(confirmed.map(|receipt| receipt.transaction_hash), Some(tx_hash)); + assert_eq!(ctx.now(), Duration::from_secs(4)); + }); + } + + /// A poller stops querying L1 once the receipt receiver is dropped, as happens when the + /// send is cancelled, even if its transaction is mined and not yet confirmed. + #[test] + fn receipt_polling_stops_once_the_receiver_is_dropped() { + Runner::start(Config::seeded(0), |ctx| async move { + let tx_hash = B256::with_last_byte(1); + let (receipt, block) = mined_receipt(tx_hash, 10); + + // Script a chain tip that never moves, so the mined transaction never reaches the + // 5 required confirmations. + let asserter = Asserter::new(); + for _ in 0..8 { + asserter.push_success(&10u64); + asserter.push_success(&Some(&receipt)); + asserter.push_success(&Some(&block)); + } + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + + let config = TxManagerConfig { + num_confirmations: 5, + receipt_query_interval: Duration::from_secs(1), + confirmation_timeout: Duration::from_secs(3), + network_timeout: Duration::from_secs(30), + ..TxManagerConfig::default() + }; + let nonce_manager = NonceManager::with_runtime( + ctx.clone(), + provider.clone(), + Address::ZERO, + config.network_timeout, + ); + let manager = SimpleTxManager { + provider, + runtime: ctx.clone(), + wallet: EthereumWallet::from(PrivateKeySigner::random()), + config, + nonce_manager, + chain_id: 1, + closed: Arc::new(AtomicBool::new(false)), + metrics: Arc::new(NoopTxMetrics), + }; + let send_state = Arc::new(SendState::new(3).expect("send state should be valid")); + let (receipt_tx, receipt_rx) = mpsc::channel(1); + + manager.spawn_wait_for_tx(send_state, tx_hash, receipt_tx); + + // Let the poller see the transaction mined, then drop the receiver. + ctx.sleep(Duration::from_millis(2500)).await; + drop(receipt_rx); + let unused_responses = asserter.read_q().len(); + + ctx.sleep(Duration::from_secs(10)).await; + assert_eq!(asserter.read_q().len(), unused_responses); + }); + } + // ── apply_bump_result ───────────────────────────────────────────── #[rstest] diff --git a/crates/utilities/tx-manager/src/send_state.rs b/crates/utilities/tx-manager/src/send_state.rs index 15cde5e769..72592f67b4 100644 --- a/crates/utilities/tx-manager/src/send_state.rs +++ b/crates/utilities/tx-manager/src/send_state.rs @@ -191,6 +191,13 @@ impl SendState { None } + /// Returns `true` when the given transaction is currently recorded as mined. + #[must_use] + pub fn is_mined(&self, tx_hash: B256) -> bool { + let inner = self.inner.lock().expect("SendState mutex poisoned"); + inner.mined_txs.contains(&tx_hash) + } + /// Returns `true` when there are mined transactions awaiting confirmation. #[must_use] pub fn is_waiting_for_confirmation(&self) -> bool { diff --git a/crates/utilities/tx-manager/tests/send_lifecycle.rs b/crates/utilities/tx-manager/tests/send_lifecycle.rs index d00734c0d8..c91be016e8 100644 --- a/crates/utilities/tx-manager/tests/send_lifecycle.rs +++ b/crates/utilities/tx-manager/tests/send_lifecycle.rs @@ -245,21 +245,22 @@ async fn wait_mined_returns_none_on_shutdown() { assert!(receipt.is_none(), "should return None when closed"); } -/// `wait_mined` returns `None` when the confirmation timeout expires. +/// `wait_mined` returns `None` when the transaction is still not mined at the +/// confirmation timeout. #[tokio::test] async fn wait_mined_returns_none_on_timeout() { let config = TxManagerConfig { confirmation_timeout: Duration::from_millis(200), - ..unconfirmable_config() + ..fast_polling_config() }; let (manager, _anvil) = setup_with_config(config).await; - let (tx_hash, send_state, _) = publish_simple_tx(&manager).await; + let send_state = SendState::new(SAFE_ABORT_DEPTH).expect("should create send state"); let closed = AtomicBool::new(false); let receipt = SimpleTxManager::::wait_mined( &send_state, manager.provider(), - tx_hash, + B256::with_last_byte(1), manager.config(), &closed, ) From becfa98dc4d95cdf5545a1e875f1efabe6576c5c Mon Sep 17 00:00:00 2001 From: Haardik Date: Tue, 22 Sep 2026 14:05:38 +0000 Subject: [PATCH 083/299] ci: publish split release images (#5213) Amp-Thread-ID: https://ampcode.com/threads/T-01a0c5bb-0474-70f3-96fb-bd20260e7ff3 Co-authored-by: Amp --- .github/workflows/build-release.yml | 87 +++++++++++++++++++---------- .github/workflows/ghcr-cleanup.yml | 32 +++++++++++ docs/guides/RELEASE.md | 5 +- etc/docker/Dockerfile.rust-services | 10 ++-- etc/docker/README.md | 15 ++++- etc/docker/docker-bake.hcl | 12 ++++ 6 files changed, 122 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 0a14945dd5..3fc1f42533 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -14,7 +14,7 @@ on: default: false env: - REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/node + REGISTRY_NAMESPACE: ghcr.io/${{ github.repository_owner }} CARGO_TERM_COLOR: always SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" @@ -190,6 +190,18 @@ jobs: image: - target: base name: node + - target: unified + name: base + - target: execution + name: base-reth-node + - target: consensus + name: base-consensus + - target: builder + name: base-builder + - target: basectl + name: basectl + - target: snapshotter + name: base-snapshotter runs-on: ${{ matrix.settings.runs-on }} @@ -255,12 +267,12 @@ jobs: - name: Upload digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: digests-${{ matrix.image.target }}-${{ env.PLATFORM_PAIR }} + name: digests-${{ matrix.image.name }}-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 - # Check which Docker artifacts exist after partial matrix failures + # Publish successful Docker builds even when another matrix entry fails. find-docker-artifacts: needs: [build-and-push] if: ${{ !cancelled() }} @@ -282,18 +294,13 @@ jobs: GH_RUN_ID: ${{ github.run_id }} run: | set -euo pipefail - - artifact_names=$(gh api "repos/$GH_REPO/actions/runs/$GH_RUN_ID/artifacts" --paginate --jq '.artifacts[].name') - - has_digests=false - - while IFS= read -r name; do - if [[ "$name" == digests-* ]]; then - has_digests=true - fi - done <<< "$artifact_names" - - echo "has_digests=$has_digests" >> "$GITHUB_OUTPUT" + artifact_names=$(gh api "repos/$GH_REPO/actions/runs/$GH_RUN_ID/artifacts" \ + --paginate --jq '.artifacts[].name') + if grep -q '^digests-' <<< "$artifact_names"; then + echo "has_digests=true" >> "$GITHUB_OUTPUT" + else + echo "has_digests=false" >> "$GITHUB_OUTPUT" + fi # Merge Docker manifests and publish publish: @@ -317,9 +324,8 @@ jobs: - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - path: ${{ runner.temp }}/digests-base - pattern: digests-base-* - merge-multiple: true + path: ${{ runner.temp }}/digests + pattern: digests-* - name: Login to GHCR uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 @@ -351,12 +357,19 @@ jobs: publish_image() { local image="$1" - local digest_dir="$2" - local digests=("$digest_dir"/*) + local package="$2" + local digests=("${{ runner.temp }}/digests/digests-${package}-linux-"*/*) if [[ ${#digests[@]} -eq 0 ]]; then - echo "No digests in $digest_dir — skipping $image" + echo "::warning::No successful architecture builds found for $image" return 1 fi + if [[ ${#digests[@]} -gt 2 ]]; then + echo "::error::Expected at most two architecture digests for $image, found ${#digests[@]}" + return 1 + fi + if [[ ${#digests[@]} -eq 1 ]]; then + echo "::warning::Publishing $image with only one successful architecture" + fi local tag_args=() IFS=',' read -ra tags <<< "$(image_tags "$image")" @@ -373,17 +386,26 @@ jobs: docker buildx imagetools inspect "${image}:${TAG}" } + packages=( + node + base + base-reth-node + base-consensus + base-builder + basectl + base-snapshotter + ) pushed=false - if publish_image "${{ env.REGISTRY_IMAGE }}" "${{ runner.temp }}/digests-base"; then - pushed=true - fi + published=() + for package in "${packages[@]}"; do + if publish_image "${{ env.REGISTRY_NAMESPACE }}/${package}" "$package"; then + pushed=true + published+=("$package") + fi + done - if [[ "$pushed" != "true" ]]; then - echo "::warning::No Docker digests found — all Docker builds may have failed, skipping manifest push" - echo "pushed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "pushed=true" >> "$GITHUB_OUTPUT" + echo "pushed=$pushed" >> "$GITHUB_OUTPUT" + echo "published=$(IFS=,; echo "${published[*]}")" >> "$GITHUB_OUTPUT" - name: Create or reuse GitHub Release if: inputs.is_final && steps.create-manifest.outputs.pushed == 'true' @@ -412,6 +434,9 @@ jobs: echo "### Docker Images" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ env.REGISTRY_IMAGE }}:${TAG}" >> $GITHUB_STEP_SUMMARY + IFS=',' read -ra packages <<< "${{ steps.create-manifest.outputs.published }}" + for package in "${packages[@]}"; do + echo "docker pull ${{ env.REGISTRY_NAMESPACE }}/${package}:${TAG}" >> $GITHUB_STEP_SUMMARY + done echo "\`\`\`" >> $GITHUB_STEP_SUMMARY fi diff --git a/.github/workflows/ghcr-cleanup.yml b/.github/workflows/ghcr-cleanup.yml index 5ec2b3fa00..0413e8559b 100644 --- a/.github/workflows/ghcr-cleanup.yml +++ b/.github/workflows/ghcr-cleanup.yml @@ -40,3 +40,35 @@ jobs: older-than: 60 days dry-run: ${{ inputs.dry_run }} validate: true + + # The cleanup action fails when any explicitly configured package does not + # exist, so wait until the first release has created the complete set. + - name: Find split release images + id: split-images + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + run: | + set -euo pipefail + packages=(base base-reth-node base-consensus base-builder basectl base-snapshotter) + for package in "${packages[@]}"; do + if ! gh api "/orgs/${OWNER}/packages/container/${package}/versions?per_page=1" >/dev/null 2>&1; then + echo "::notice::Skipping split-image cleanup until ${package} exists and is readable" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Clean up split release images + if: steps.split-images.outputs.ready == 'true' + uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + owner: ${{ github.repository_owner }} + packages: base,base-reth-node,base-consensus,base-builder,basectl,base-snapshotter + delete-tags: "*" + delete-untagged: true + exclude-tags: "v*" + older-than: 60 days + dry-run: ${{ inputs.dry_run }} + validate: true diff --git a/docs/guides/RELEASE.md b/docs/guides/RELEASE.md index c2611e63ac..a19f2054c3 100644 --- a/docs/guides/RELEASE.md +++ b/docs/guides/RELEASE.md @@ -32,7 +32,7 @@ Every commit pushed to the release branch triggers the **Create RC** workflow, w - Otherwise creates the next RC tag (e.g., `v0.6.0-rc.1`, `v0.6.0-rc.2`, …) at the triggering commit - Dispatches the independent **Build RC** workflow at that tag, then finishes without waiting for artifacts -**Build RC** builds multi-arch Docker images and native binaries using **Build Release**. Docker images are tagged with the RC tag only (not `latest`). Each RC builds in its own workflow run, so newer merges do not cancel or wait for older artifact builds (subject to runner availability). +**Build RC** builds multi-arch Docker images and native binaries using **Build Release**. The compatibility `node` image and every single-binary image receive the same RC tag only (not `latest`). Each RC builds in its own workflow run, so newer merges do not cancel or wait for older artifact builds (subject to runner availability). To create additional RCs, simply push more commits (bug fixes, backports) to the release branch. @@ -43,7 +43,8 @@ Once you are satisfied with an RC, run the **Publish Release** workflow (`Action - Enter the version number (e.g., `0.6.0` — no `v` prefix, no `releases/` prefix) - The workflow validates that the release branch exists and `Cargo.toml` is not `0.0.0` - Creates the final tag `vX.Y.Z` on the release branch -- Builds the `base` image once (`PROFILE=maxperf`) and tags it as `vX.Y.Z`, `X.Y`, `X`, and `latest` on `ghcr.io/base/node` +- Builds the compatibility `node` image and the `base`, `base-reth-node`, `base-consensus`, `base-builder`, `basectl`, and `base-snapshotter` images for amd64 and arm64 (`PROFILE=maxperf`) +- Tags every image as `vX.Y.Z`, `X.Y`, `X`, and `latest` - Creates a draft GitHub release with auto-generated changelog and uploads binaries - Review and publish the draft release on GitHub diff --git a/etc/docker/Dockerfile.rust-services b/etc/docker/Dockerfile.rust-services index f38af24fa8..c80599c601 100644 --- a/etc/docker/Dockerfile.rust-services +++ b/etc/docker/Dockerfile.rust-services @@ -219,10 +219,8 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* RUN mkdir -p /var/log/supervisor COPY --from=base-image-builder /app/base ./base -# Keep split binaries here for now, needed for bare metal work -# Remove only when there is an alternative available -# e.g. when we publish independent separate Docker images for each -# via the CI +# Keep the split binaries in the public node image for compatibility. Release +# builds also publish each binary in its own image. COPY --from=base-image-builder /app/base-reth-node ./base-reth-node COPY --from=base-image-builder /app/base-consensus ./base-consensus COPY --from=base-image-builder /app/snapshotter ./snapshotter @@ -235,6 +233,10 @@ RUN chmod +x ./base-entrypoint.sh execution-entrypoint consensus-entrypoint # Operator default. Devnet compose overrides entrypoint to /app/base or base-entrypoint.sh. CMD ["/usr/bin/supervisord"] +FROM runtime-base AS unified +COPY --from=base-builder /app/base ./base +ENTRYPOINT ["./base"] + FROM runtime-base AS execution COPY --from=execution-builder /app/base-reth-node ./base-reth-node ENTRYPOINT ["./base-reth-node"] diff --git a/etc/docker/README.md b/etc/docker/README.md index 3c1c6d4db2..5c49da0e7d 100644 --- a/etc/docker/README.md +++ b/etc/docker/README.md @@ -2,11 +2,22 @@ This directory contains the Dockerfiles and Compose configuration for the **local devnet** and internal Rust services. -The public operator image (`ghcr.io/base/node`) is the `base` target in `Dockerfile.rust-services`. Published images and operator `--build` use `PROFILE=release` (same as `base/node`), while `just devnet` builds `dev`. `PROFILE` is set on the shared `_rust-service-common` target, so passing it as an environment variable applies it to every target in the invocation; to give one target a different profile, override just that target's build arg — `docker buildx bake -f etc/docker/docker-bake.hcl builder consensus --set builder.args.PROFILE=release-symbols --load` builds `builder` with profiling symbols while `consensus` stays on the default `release`. Operator entrypoints live in `etc/scripts/node/`; operators edit `.env.mainnet` / `.env.sepolia` at the repo root. Root `docker-compose.yml` pulls the published image, or compiles this tree with `--build`. `just devnet up` overrides the entrypoint to `./base`. +The public operator image (`ghcr.io/base/node`) is the `base` target in `Dockerfile.rust-services`. It retains the unified binary, execution node, consensus node, and snapshotter for compatibility. Releases also publish single-binary images: + +| Image | Bake target | Entrypoint | +| --- | --- | --- | +| `ghcr.io/base/base` | `unified` | `base` | +| `ghcr.io/base/base-reth-node` | `execution` | `base-reth-node` | +| `ghcr.io/base/base-consensus` | `consensus` | `base-consensus` | +| `ghcr.io/base/base-builder` | `builder` | `base-builder` | +| `ghcr.io/base/basectl` | `basectl` | `basectl` | +| `ghcr.io/base/base-snapshotter` | `snapshotter` | `snapshotter` | + +RC and final releases apply the same version tag to every image. Published images and operator `--build` use `PROFILE=release` (same as `base/node`), while `just devnet` builds `dev`. `PROFILE` is set on the shared `_rust-service-common` target, so passing it as an environment variable applies it to every target in the invocation; to give one target a different profile, override just that target's build arg — `docker buildx bake -f etc/docker/docker-bake.hcl builder consensus --set builder.args.PROFILE=release-symbols --load` builds `builder` with profiling symbols while `consensus` stays on the default `release`. Operator entrypoints live in `etc/scripts/node/`; operators edit `.env.mainnet` / `.env.sepolia` at the repo root. Root `docker-compose.yml` pulls the published image, or compiles this tree with `--build`. `just devnet up` overrides the entrypoint to `./base`. ## Dockerfiles -`Dockerfile.rust-services` is the shared multi-target Dockerfile for the Debian-based Rust services. The `base` target is published as `ghcr.io/base/node` and is also the local devnet image. Devnet compose overrides the default supervisord CMD. +`Dockerfile.rust-services` is the shared multi-target Dockerfile for the Debian-based Rust services. The `base` target is published as `ghcr.io/base/node` and is also the local devnet image. The `unified` target contains only the `base` binary. Devnet compose overrides the compatibility image's default supervisord CMD. `Dockerfile.devnet` builds a utility image containing genesis generation tools (`eth-genesis-state-generator`, `eth2-val-tools`, `op-deployer`) and setup scripts. This image bootstraps L1 and L2 chain configurations for local development. diff --git a/etc/docker/docker-bake.hcl b/etc/docker/docker-bake.hcl index efb0fb304c..e6e6fa79a5 100644 --- a/etc/docker/docker-bake.hcl +++ b/etc/docker/docker-bake.hcl @@ -31,6 +31,7 @@ group "default" { group "rust-services" { targets = [ "base", + "unified", "execution", "consensus", "builder", @@ -84,6 +85,17 @@ target "base" { tags = ["base:local"] } +target "unified" { + inherits = ["_rust-service-common"] + target = "unified" + args = { + CARGO_CHEF_ARGS = "--package base" + CARGO_FEATURES = PROFILE == "profiling" ? "--features=jemalloc-prof" : "" + SCCACHE_CACHE_ID = "rust-services-unified-sccache" + } + tags = ["base-unified:local"] +} + target "execution" { inherits = ["_rust-service-common"] target = "execution" From 66c1ccce05c76a1f6793d106ef7edff18580859e Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Tue, 22 Sep 2026 16:36:58 +0000 Subject: [PATCH 084/299] fix(tests): make Docker-backed tests runnable on Podman (#5221) The snapshotter e2e test hardcoded /var/run/docker.sock, which is where Docker exposes its socket but not where Podman does. Add DockerContainerManager::connect_with_defaults(), which delegates to bollard's connect_with_socket_defaults() and honors DOCKER_HOST (how Podman exposes its socket through a Docker-compatible shim). Use it in the test instead of the hardcoded path. The system-test setup container called .with_network("none"), which also tries to create a network literally named "none" if one isn't already listed. Docker and Podman both reject that as a reserved name, so remove the call; the setup container is fully offline and does not need network isolation. --- crates/infra/snapshotter/src/container.rs | 13 ++++++++++++- crates/infra/snapshotter/tests/e2e_test.rs | 2 +- etc/systems/src/setup/container.rs | 1 - 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/infra/snapshotter/src/container.rs b/crates/infra/snapshotter/src/container.rs index bcea77290b..b704262e88 100644 --- a/crates/infra/snapshotter/src/container.rs +++ b/crates/infra/snapshotter/src/container.rs @@ -50,7 +50,18 @@ pub struct DockerContainerManager { } impl DockerContainerManager { - /// Connects to the Docker daemon via a Unix socket. + /// Connects to the Docker or Podman daemon using environment defaults. + /// + /// Respects the `DOCKER_HOST` environment variable, which Podman sets when + /// exposing its socket through a Docker-compatible shim. Falls back to the + /// platform default socket path when `DOCKER_HOST` is not set. + pub fn connect_with_defaults() -> Result { + let client = Docker::connect_with_socket_defaults() + .context("failed to connect to Docker/Podman daemon")?; + Ok(Self { client }) + } + + /// Connects to the Docker daemon via an explicit Unix socket path. pub fn new(socket_path: &str) -> Result { let client = Docker::connect_with_socket(socket_path, 120, bollard::API_DEFAULT_VERSION) .with_context(|| format!("failed to connect to Docker socket at {socket_path}"))?; diff --git a/crates/infra/snapshotter/tests/e2e_test.rs b/crates/infra/snapshotter/tests/e2e_test.rs index 73021e3c60..6a3790c869 100644 --- a/crates/infra/snapshotter/tests/e2e_test.rs +++ b/crates/infra/snapshotter/tests/e2e_test.rs @@ -1572,7 +1572,7 @@ async fn e2e_stop_upload_restart_real_container() -> Result<()> { docker.start_container(&container_name, None::).await?; - let container_manager = DockerContainerManager::new("/var/run/docker.sock")?; + let container_manager = DockerContainerManager::connect_with_defaults()?; assert!( container_manager.is_running(&container_name).await?, diff --git a/etc/systems/src/setup/container.rs b/etc/systems/src/setup/container.rs index 1feee79e4c..1a599a415f 100644 --- a/etc/systems/src/setup/container.rs +++ b/etc/systems/src/setup/container.rs @@ -329,7 +329,6 @@ impl SetupContainer { let mut container = SetupImage::request() .with_wait_for(WaitFor::exit(ExitWaitStrategy::default().with_exit_code(0))) .with_startup_timeout(Duration::from_secs(SETUP_TIMEOUT_SECS)) - .with_network("none") .with_env_var("OUTPUT_DIR", "/output") .with_env_var("L2_OUTPUT_DIR", "/output/l2") .with_env_var("SHARED_DIR", "/output/shared") From 86cb25010e4ae7f7b6775bc58e991a3357505e7d Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 22 Sep 2026 18:37:39 +0000 Subject: [PATCH 085/299] fix(batcher): answer admin stop, start and flush once the driver has applied them (#5174) * fix(batcher): answer admin stop, start and flush once the driver has applied them * docs(batcher): structure the admin stop and start handling with comments * docs(batcher): describe admin errors in words instead of numeric codes * docs(batcher): phrase the admin stop and start comments as actions * docs(batcher): correct the admin command loop comment * docs(batcher): clarify what a start does to a waiting stop request * refactor(batcher): handle admin stop and start in methods with early returns * refactor(batcher): name the source flush driver event SourceFlush * test(batcher): cover shared stop deadlines and a successful admin flush, and align the docs * refactor(batcher): answer admin stop once applied, without waiting for in-flight submissions --- crates/batcher/admin/README.md | 12 ++ crates/batcher/admin/src/api.rs | 15 ++- crates/batcher/core/src/admin.rs | 67 +++++----- crates/batcher/core/src/driver.rs | 159 ++++++++++++++++-------- crates/batcher/core/src/event.rs | 9 +- crates/batcher/core/tests/stop_start.rs | 99 +++++++++++++-- crates/batcher/source/src/event.rs | 4 +- 7 files changed, 257 insertions(+), 108 deletions(-) diff --git a/crates/batcher/admin/README.md b/crates/batcher/admin/README.md index 1b9c000611..dfe341aaeb 100644 --- a/crates/batcher/admin/README.md +++ b/crates/batcher/admin/README.md @@ -1 +1,13 @@ Admin JSON-RPC API server for the Base Batcher. + +Lifecycle methods answer once the driver has applied them, not when the command is queued. + +| Method | Behavior | +|---|---| +| `admin_stopBatcher` | Stops block ingestion and drops buffered encoding state. Submissions already in flight keep settling; `admin_getBatcherStatus` reports how many remain. Does nothing if the batcher is already stopped. | +| `admin_startBatcher` | Starts ingestion again from the safe L2 head. Does nothing if the batcher is already running. | +| `admin_flushBatcher` | Closes the current channel so its frames become eligible for submission; it does not wait for L1 inclusion. Fails if the batcher is stopped. | +| `admin_getBatcherStatus` | Returns `stopped`, `in_flight` and `da_backlog_bytes`. | + +Error codes: `-32001` the driver has shut down, `-32002` the batcher is in the wrong state for the +request. diff --git a/crates/batcher/admin/src/api.rs b/crates/batcher/admin/src/api.rs index 9d02f7bf62..d37bce9ec7 100644 --- a/crates/batcher/admin/src/api.rs +++ b/crates/batcher/admin/src/api.rs @@ -12,15 +12,17 @@ use tracing::warn; #[rpc(server, namespace = "admin")] pub trait BatcherAdminApi { - /// Start block ingestion again after a previous stop. + /// Start block ingestion again after a previous stop. Does nothing if already running. #[method(name = "startBatcher")] async fn start_batcher(&self) -> RpcResult<()>; - /// Stop block ingestion; the driver task keeps running. + /// Stop block ingestion; the driver task keeps running. Does nothing if already stopped. #[method(name = "stopBatcher")] async fn stop_batcher(&self) -> RpcResult<()>; - /// Flush the current encoding channel, submitting any buffered frames. + /// Flush the current encoding channel, making its frames eligible for submission. + /// + /// Fails if the batcher is stopped. #[method(name = "flushBatcher")] async fn flush_batcher(&self) -> RpcResult<()>; @@ -68,6 +70,7 @@ impl BatcherAdminApiServerImpl { let code = match e { AdminError::NotSupported(_) => -32601, AdminError::ChannelClosed => -32001, + AdminError::Stopped => -32002, }; ErrorObjectOwned::owned(code, e.to_string(), None::<()>) } @@ -129,4 +132,10 @@ mod tests { let err = BatcherAdminApiServerImpl::admin_error(AdminError::ChannelClosed); assert_eq!(err.code(), -32001); } + + #[test] + fn admin_error_stopped_uses_invalid_state_code() { + let err = BatcherAdminApiServerImpl::admin_error(AdminError::Stopped); + assert_eq!(err.code(), -32002); + } } diff --git a/crates/batcher/core/src/admin.rs b/crates/batcher/core/src/admin.rs index 06629736c8..a799cb95e0 100644 --- a/crates/batcher/core/src/admin.rs +++ b/crates/batcher/core/src/admin.rs @@ -32,6 +32,9 @@ pub enum AdminError { /// The requested operation is not yet supported. #[error("not yet supported: {0}")] NotSupported(&'static str), + /// The operation needs a running batcher, but it is stopped. + #[error("batcher is stopped")] + Stopped, } /// Result type alias for admin operations. @@ -41,16 +44,22 @@ pub type AdminResult = Result; #[derive(derive_more::Debug)] pub enum AdminCommand { /// Start block ingestion again after a [`Stop`](Self::Stop). - Start, + Start { + /// Answered once the driver has applied the command. + #[debug(skip)] + reply: oneshot::Sender>, + }, /// Stop block ingestion; the driver task keeps running. - Stop, + Stop { + /// Answered once the driver has applied the command. + #[debug(skip)] + reply: oneshot::Sender>, + }, /// Flush the current encoding channel. Flush { - /// Fired once the driver's encoding and submission are both fully drained (not just - /// after the first frame) — see [`AdminHandle::flush_and_wait`] for the precise - /// "whole pipeline idle, not just this flush" caveat. + /// Answered once the pipeline is flushed, or with an error if the batcher is stopped. #[debug(skip)] - ack: Option>, + reply: oneshot::Sender>, }, /// Replace the throttle strategy and configuration. SetThrottle { @@ -92,45 +101,25 @@ impl AdminHandle { (Self { tx }, rx) } - /// Start block ingestion again if currently stopped. + /// Start block ingestion again. Does nothing if the batcher is already running. pub async fn start(&self) -> AdminResult<()> { - self.send(AdminCommand::Start).await + self.request(|reply| AdminCommand::Start { reply }).await } /// Stop block ingestion; the driver task keeps running. /// /// In-flight submissions continue to resolve; no new blocks are ingested - /// until [`start`](Self::start) is called. + /// until [`start`](Self::start) is called. Does nothing if the batcher is already stopped. pub async fn stop(&self) -> AdminResult<()> { - self.send(AdminCommand::Stop).await + self.request(|reply| AdminCommand::Stop { reply }).await } - /// Flush the current encoding channel, submitting any buffered frames. + /// Flush the current encoding channel, making its frames eligible for submission. /// - /// Returns once the command is queued — use - /// [`flush_and_wait`](Self::flush_and_wait) if the caller needs to know when the - /// resulting frames have actually been handed to the tx manager. + /// Returns [`AdminError::Stopped`] if the batcher is stopped. It does not wait for L1 + /// inclusion. pub async fn flush(&self) -> AdminResult<()> { - self.send(AdminCommand::Flush { ack: None }).await - } - - /// Flush the current encoding channel and wait until every resulting frame has - /// been encoded and handed to the tx manager. - /// - /// Unlike [`flush`](Self::flush), which only guarantees the command was queued, this - /// waits for the driver to report that encoding and submission are both fully drained. - /// At that point every frame produced by this flush has been handed to the tx manager. - /// - /// The wait is for the *whole pipeline* going idle, not specifically for this flush's own - /// frames: if new blocks keep arriving and producing fresh encoding/submission work while - /// this call is outstanding, the ack is delayed until that work drains too, and under - /// sustained continuous ingestion it may not fire at all. This call therefore gives a - /// precise, meaningful guarantee only when the source is otherwise quiesced (as in the - /// action-test harness, which never calls this while blocks are still streaming in). - pub async fn flush_and_wait(&self) -> AdminResult<()> { - let (tx, rx) = oneshot::channel(); - self.send(AdminCommand::Flush { ack: Some(tx) }).await?; - rx.await.map_err(|_| AdminError::ChannelClosed) + self.request(|reply| AdminCommand::Flush { reply }).await } /// Replace the throttle strategy and configuration. @@ -180,6 +169,16 @@ impl AdminHandle { async fn send(&self, cmd: AdminCommand) -> AdminResult<()> { self.tx.send(cmd).await.map_err(|_| AdminError::ChannelClosed) } + + /// Send a command carrying a reply channel and wait for the driver's answer. + async fn request( + &self, + command: impl FnOnce(oneshot::Sender>) -> AdminCommand, + ) -> AdminResult<()> { + let (reply, rx) = oneshot::channel(); + self.send(command(reply)).await?; + rx.await.map_err(|_| AdminError::ChannelClosed)? + } } #[cfg(test)] diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index ca4c0eab5f..08ba779a0b 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -16,8 +16,8 @@ use tokio::sync::{mpsc, oneshot}; use tracing::{debug, error, info, warn}; use crate::{ - AdminCommand, BatchDriverConfig, BatchDriverError, BatcherStatus, DaThrottle, DerivationStatus, - SubmissionQueue, ThrottleClient, ThrottleController, event::DriverEvent, + AdminCommand, AdminError, BatchDriverConfig, BatchDriverError, BatcherStatus, DaThrottle, + DerivationStatus, SubmissionQueue, ThrottleClient, ThrottleController, event::DriverEvent, }; /// Initial L1 and derivation inputs consumed by a [`BatchDriver`]. @@ -221,7 +221,7 @@ where /// When shutting down (after cancellation or source exhaustion), the I/O phase is /// replaced by a bounded drain of all in-flight receipts. /// - /// If a [`DriverEvent::Flush`] carried an acknowledgement, it fires as soon as a later + /// If a [`DriverEvent::SourceFlush`] carried an acknowledgement, it fires as soon as a later /// CPU phase reports both encoding and submission fully drained (i.e. the flush's frames /// have all been handed to the tx manager) — see the `pending_flush_acks` field. /// @@ -290,13 +290,18 @@ where DriverEvent::Block(b) => { self.on_block(b); } - DriverEvent::Flush(ack) => { + DriverEvent::SourceFlush(ack) => { self.pipeline.flush()?; if let Some(ack) = ack { self.pending_flush_acks.push(ack); } debug!("flush signal received, released channel artifacts"); } + DriverEvent::AdminFlush(reply) => { + self.pipeline.flush()?; + let _ = reply.send(Ok(())); + debug!("admin flush applied, released channel artifacts"); + } DriverEvent::Reorg => { warn!("L2 reorg detected, resetting pipeline and catching up from safe head"); self.reset_to_safe_head(BatcherMetrics::RESET_SOURCE_REORG); @@ -465,12 +470,47 @@ where self.pending_flush_acks.clear(); } + /// Stop block ingestion and drop the buffered pipeline state. + /// + /// Submissions already in flight keep settling. + fn on_admin_stop(&mut self) { + // Leave a stopped batcher alone. It holds nothing to reset. + if self.stopped { + return; + } + + self.reset_pipeline(BatcherMetrics::RESET_ADMIN_STOP); + self.stopped = true; + info!(stopped = true, "batcher stopped via admin"); + } + + /// Start block ingestion again from the safe head. + fn on_admin_start(&mut self) { + // Leave a running batcher alone. Re-anchoring its source would replay blocks the + // pipeline already holds. + if !self.stopped { + return; + } + + if let Some(safe_head) = self.safe_head { + self.source.reset_catchup(safe_head); + info!( + stopped = false, + safe_l2 = %safe_head.number, + "batcher started via admin, catching up from safe head" + ); + } else { + info!(stopped = false, "batcher started via admin"); + } + self.stopped = false; + } + /// Block on the next external event using a biased `tokio::select!`. /// - /// Admin commands are handled inline in the loop — only non-admin events - /// are returned to the caller. Admin commands are placed before the source - /// arm so control-plane operations (stop, start, flush) are never starved - /// by sustained block throughput. + /// Admin commands are handled inline in the loop. Only a flush on a running + /// batcher is returned to the caller, as [`DriverEvent::AdminFlush`]. Admin + /// commands are placed before the source arm so control-plane operations + /// (stop, start, flush) are never starved by sustained block throughput. /// Derivation-status changes are also handled before unsafe blocks so pruning and /// recovery cannot be starved by sequential catchup. /// @@ -478,7 +518,8 @@ where /// `Block` and `Flush` source events until [`AdminCommand::Start`] is /// received. Reorg events propagate regardless of the stopped state. On /// start the source is reset to catch up sequentially from the last known - /// safe L2 head. + /// safe L2 head. Stopping a stopped batcher or starting a running one does + /// nothing. Each command is answered once it has been applied. /// /// Non-fatal L1 head source errors loop internally to avoid polluting the /// return type with a no-op variant. @@ -491,24 +532,19 @@ where cmd = Self::next_admin_cmd(&mut self.admin_rx) => { match cmd { - AdminCommand::Flush { ack } => return Ok(DriverEvent::Flush(ack)), - AdminCommand::Stop => { - self.reset_pipeline(BatcherMetrics::RESET_ADMIN_STOP); - self.stopped = true; - info!(stopped = true, "batcher stopped via admin"); + AdminCommand::Flush { reply } if self.stopped => { + let _ = reply.send(Err(AdminError::Stopped)); + } + AdminCommand::Flush { reply } => { + return Ok(DriverEvent::AdminFlush(reply)); } - AdminCommand::Start => { - if let Some(safe_head) = self.safe_head { - self.source.reset_catchup(safe_head); - info!( - stopped = false, - safe_l2 = %safe_head.number, - "batcher started via admin, catching up from safe head" - ); - } else { - info!(stopped = false, "batcher started via admin"); - } - self.stopped = false; + AdminCommand::Stop { reply } => { + self.on_admin_stop(); + let _ = reply.send(Ok(())); + } + AdminCommand::Start { reply } => { + self.on_admin_start(); + let _ = reply.send(Ok(())); } AdminCommand::SetThrottle { strategy, config } => { self.throttle.set_controller( @@ -533,7 +569,7 @@ where }); } } - // All commands except Flush loop to await the next real event. + // Await the next real event. Only a flush on a running batcher returns above. continue; } @@ -565,7 +601,7 @@ where continue; } Ok(L2BlockEvent::Block(block)) => DriverEvent::Block(block), - Ok(L2BlockEvent::Flush { ack }) => DriverEvent::Flush(ack), + Ok(L2BlockEvent::Flush { ack }) => DriverEvent::SourceFlush(ack), Ok(L2BlockEvent::Reorg) => DriverEvent::Reorg, Err(SourceError::Exhausted) => DriverEvent::Shutdown, Err(e) => return Err(e.into()), @@ -806,6 +842,37 @@ mod tests { ) } + /// Build a driver whose source delivers a single acknowledged flush. + fn driver_with_flush_ack( + runtime: R, + pipeline: TrackingPipeline, + tx_manager: TM, + max_pending_transactions: usize, + ack: oneshot::Sender<()>, + ) -> BatchDriver< + R, + TrackingPipeline, + QueuedSource, + TM, + Arc, + QueuedL1HeadSource, + > { + BatchDriver::new_without_derivation_status( + runtime, + pipeline, + QueuedSource::new([Ok(L2BlockEvent::Flush { ack: Some(ack) })]), + tx_manager, + BatchDriverConfig { + inbox: Address::ZERO, + max_pending_transactions, + drain_timeout: Duration::from_millis(10), + force_blobs_when_throttling: true, + }, + DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + QueuedL1HeadSource::new(std::iter::empty()), + ) + } + #[derive(Debug, Default)] struct TxpoolBlockedState { sends: AtomicU64, @@ -893,8 +960,9 @@ mod tests { fn next_event_prioritizes_cancellation_over_ready_admin() { Runner::start(Config::seeded(0), |ctx| async move { let (admin_tx, admin_rx) = mpsc::channel(1); + let (reply, _reply_rx) = oneshot::channel(); admin_tx - .send(AdminCommand::Flush { ack: None }) + .send(AdminCommand::Flush { reply }) .await .expect("admin receiver should be open"); @@ -917,8 +985,9 @@ mod tests { fn next_event_prioritizes_admin_before_source() { Runner::start(Config::seeded(0), |ctx| async move { let (admin_tx, admin_rx) = mpsc::channel(1); + let (reply, _reply_rx) = oneshot::channel(); admin_tx - .send(AdminCommand::Flush { ack: None }) + .send(AdminCommand::Flush { reply }) .await .expect("admin receiver should be open"); @@ -931,7 +1000,7 @@ mod tests { .with_admin_rx(admin_rx); let event = driver.next_event().await.expect("next_event should succeed"); - assert!(matches!(event, DriverEvent::Flush(_))); + assert!(matches!(event, DriverEvent::AdminFlush(_))); }); } @@ -950,7 +1019,7 @@ mod tests { driver.submissions.submit_pending(&mut driver.pipeline).await; let event = driver.next_event().await.expect("next_event should succeed"); - assert!(matches!(event, DriverEvent::Flush(_))); + assert!(matches!(event, DriverEvent::SourceFlush(_))); }); } @@ -1318,21 +1387,15 @@ mod tests { pipeline.submissions.push_back(SubmissionStub::with_id(0)); pipeline.submissions.push_back(SubmissionStub::with_id(1)); - let (admin_tx, admin_rx) = mpsc::channel(1); let (ack_tx, ack_rx) = oneshot::channel(); - admin_tx - .send(AdminCommand::Flush { ack: Some(ack_tx) }) - .await - .expect("admin receiver should be open"); - let handle = ctx.spawn( - DriverFixture::build_with_max_pending( + driver_with_flush_ack( ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }, 2, + ack_tx, ) - .with_admin_rx(admin_rx) .run(), ); @@ -1359,22 +1422,10 @@ mod tests { pipeline.submissions.push_back(SubmissionStub::with_id(0)); pipeline.submissions.push_back(SubmissionStub::with_id(1)); - let (admin_tx, admin_rx) = mpsc::channel(1); let (ack_tx, mut ack_rx) = oneshot::channel(); - admin_tx - .send(AdminCommand::Flush { ack: Some(ack_tx) }) - .await - .expect("admin receiver should be open"); - let handle = ctx.spawn( - DriverFixture::build_with_max_pending( - ctx.clone(), - pipeline, - NeverConfirmTxManager, - 1, - ) - .with_admin_rx(admin_rx) - .run(), + driver_with_flush_ack(ctx.clone(), pipeline, NeverConfirmTxManager, 1, ack_tx) + .run(), ); ctx.sleep(Duration::from_millis(50)).await; diff --git a/crates/batcher/core/src/event.rs b/crates/batcher/core/src/event.rs index 2fd0910ff1..6fdc3ca4ef 100644 --- a/crates/batcher/core/src/event.rs +++ b/crates/batcher/core/src/event.rs @@ -4,7 +4,7 @@ use base_batcher_encoder::SubmissionId; use base_common_consensus::BaseBlock; use tokio::sync::oneshot; -use crate::{DerivationStatus, TxOutcome}; +use crate::{AdminResult, DerivationStatus, TxOutcome}; /// Events the driver can receive from external sources during the I/O phase. #[derive(Debug)] @@ -13,11 +13,14 @@ pub enum DriverEvent { Shutdown, /// New L2 unsafe block from the source. Block(Box), - /// Source (or admin) requested a force-flush of the current channel. + /// Source requested a force-flush of the current channel. No production source emits this, + /// only the action-test harness and tests. /// /// If the ack is set, it fires once every frame resulting from this flush has been /// encoded and handed to the tx manager. - Flush(Option>), + SourceFlush(Option>), + /// Admin requested a force-flush of the current channel; answered once the pipeline is flushed. + AdminFlush(oneshot::Sender>), /// L2 reorganisation detected. Reorg, /// An in-flight L1 transaction settled, carrying one or more submissions. diff --git a/crates/batcher/core/tests/stop_start.rs b/crates/batcher/core/tests/stop_start.rs index 5ede6d9ef9..80aaf7cdc7 100644 --- a/crates/batcher/core/tests/stop_start.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -7,11 +7,11 @@ use std::{ use alloy_primitives::Address; use base_batcher_core::{ - AdminHandle, BatchDriver, BatchDriverConfig, DaThrottle, DerivationStatus, NoopThrottleClient, - ThrottleController, + AdminError, AdminHandle, BatchDriver, BatchDriverConfig, DaThrottle, DerivationStatus, + NoopThrottleClient, ThrottleController, test_utils::{ - DriverFixture, ImmediateConfirmTxManager, PendingL1HeadSource, Recorded, TrackingPipeline, - TrackingSource, + DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, PendingL1HeadSource, + Recorded, SubmissionStub, TrackingPipeline, TrackingSource, }, }; use base_batcher_encoder::{ @@ -27,9 +27,8 @@ use base_runtime::{ }; use tokio::sync::mpsc; -/// `AdminCommand::Stop` must immediately reset the pipeline. This is verified -/// by checking that `pipeline.reset()` is called exactly once after the stop -/// command is processed. +/// `AdminCommand::Stop` must immediately reset the pipeline. Stopping a batcher +/// that is already stopped succeeds without resetting it again. #[test] fn test_stop_resets_pipeline() { Runner::start(Config::seeded(0), |ctx| async move { @@ -43,7 +42,7 @@ fn test_stop_resets_pipeline() { let handle = ctx.spawn(driver.run()); admin_handle.stop().await.unwrap(); - ctx.sleep(Duration::from_millis(10)).await; + admin_handle.stop().await.unwrap(); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); @@ -56,8 +55,9 @@ fn test_stop_resets_pipeline() { } /// `AdminCommand::Start` must reanchor the source at the safe head so it -/// delivers missed blocks sequentially after that head. When no derivation-status feed -/// is wired, no catchup is triggered. +/// delivers missed blocks sequentially after that head. Starting a batcher that +/// is already running must not reanchor it again: that would replay blocks the +/// pipeline already holds. #[test] fn test_start_triggers_catchup_from_safe_head() { Runner::start(Config::seeded(0), |ctx| async move { @@ -87,9 +87,8 @@ fn test_start_triggers_catchup_from_safe_head() { // Stop then start with safe_head = 42; the source will poll 43 next. admin_handle.stop().await.unwrap(); - ctx.sleep(Duration::from_millis(10)).await; admin_handle.start().await.unwrap(); - ctx.sleep(Duration::from_millis(10)).await; + admin_handle.start().await.unwrap(); ctx.cancel(); // Keep the derivation-status channel alive until the driver stops. @@ -206,3 +205,79 @@ fn test_stopped_drops_block_and_flush_events() { ); }); } + +/// A stop does not wait for the submissions in flight. They keep settling while the +/// batcher is stopped, and the status reports how many remain. +#[test] +fn test_stop_leaves_in_flight_submissions_to_settle() { + Runner::start(Config::seeded(0), |ctx| async move { + let mut pipeline = TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))); + pipeline.submissions.push_back(SubmissionStub::stub()); + let tx_manager = ManualConfirmTxManager::default(); + let (admin_handle, admin_rx) = AdminHandle::channel(); + + let driver = + DriverFixture::build(ctx.clone(), pipeline, tx_manager.clone()).with_admin_rx(admin_rx); + let handle = ctx.spawn(driver.run()); + + // Stop while the stub is in flight. + admin_handle.stop().await.unwrap(); + let status = admin_handle.get_status().await.unwrap(); + assert!(status.stopped); + assert_eq!(status.in_flight, 1); + + // Confirm the submission and let the driver process the receipt. + tx_manager.confirm_next(1); + ctx.sleep(Duration::from_millis(1)).await; + assert_eq!(admin_handle.get_status().await.unwrap().in_flight, 0); + + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + }); +} + +/// A flush on a running batcher closes the current channel and reports success. +#[test] +fn test_flush_closes_the_channel_on_a_running_batcher() { + Runner::start(Config::seeded(0), |ctx| async move { + let recorded = Arc::new(Mutex::new(Recorded::default())); + let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let (admin_handle, admin_rx) = AdminHandle::channel(); + + let driver = + DriverFixture::build(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .with_admin_rx(admin_rx); + let handle = ctx.spawn(driver.run()); + + admin_handle.flush().await.unwrap(); + + assert_eq!(recorded.lock().unwrap().flush_count, 1); + + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + }); +} + +/// A stopped batcher refuses to flush instead of reporting a flush that produces nothing. +#[test] +fn test_flush_is_rejected_while_stopped() { + Runner::start(Config::seeded(0), |ctx| async move { + let recorded = Arc::new(Mutex::new(Recorded::default())); + let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let (admin_handle, admin_rx) = AdminHandle::channel(); + + let driver = + DriverFixture::build(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .with_admin_rx(admin_rx); + let handle = ctx.spawn(driver.run()); + + admin_handle.stop().await.unwrap(); + let result = admin_handle.flush().await; + + assert!(matches!(result, Err(AdminError::Stopped))); + assert_eq!(recorded.lock().unwrap().flush_count, 0); + + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + }); +} diff --git a/crates/batcher/source/src/event.rs b/crates/batcher/source/src/event.rs index 867c121dde..e8e2f066c1 100644 --- a/crates/batcher/source/src/event.rs +++ b/crates/batcher/source/src/event.rs @@ -29,8 +29,8 @@ pub enum L2BlockEvent { /// /// This is a "whole pipeline idle" signal, not scoped strictly to this flush's own /// frames: it never fires before them, but concurrent `Block` events arriving after - /// this one can delay it further (see `base_batcher_core::AdminHandle::flush_and_wait` - /// for the full caveat, which applies here identically). + /// this one can delay it further (see `base_batcher_core::BatchDriver::run` for the + /// full caveat). ack: Option>, }, } From c4126b1f6fe09c3faec1b1d25f0fa58f985d7e3b Mon Sep 17 00:00:00 2001 From: wbj-cb Date: Tue, 22 Sep 2026 20:16:19 +0000 Subject: [PATCH 086/299] fix(shadow-indexer): stop retention from racing writer migrations (#5230) The retention sweeper and the writer both ran the full shadow-indexer migration suite at node startup. Two migrations build an index with CREATE INDEX CONCURRENTLY, which runs outside a transaction and is not serialized by sqlx's advisory lock, so the two concurrent runs could make Postgres report a deadlock. - Split ShadowDbConfig::init_pool into connect_pool (connect only) and init_pool (connect + migrate) - Retention now uses connect_pool; the writer's critical task is the only migration runner for this schema Co-authored-by: OpenCode --- .../execution/shadow-indexer-db/src/config.rs | 24 +++++++++++++++---- .../execution/shadow-indexer/src/retention.rs | 5 +++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/execution/shadow-indexer-db/src/config.rs b/crates/execution/shadow-indexer-db/src/config.rs index c32429e1f5..7e4abdafd6 100644 --- a/crates/execution/shadow-indexer-db/src/config.rs +++ b/crates/execution/shadow-indexer-db/src/config.rs @@ -86,18 +86,32 @@ pub struct ShadowDbConfig { } impl ShadowDbConfig { - /// Initialize the database connection pool and run migrations. + /// Open a connection pool without running migrations. + /// + /// Exactly one task per schema may call [`Self::init_pool`]; every other pool on that + /// schema, such as the retention sweeper's, must use this instead. This crate's + /// `CREATE INDEX CONCURRENTLY` migrations run outside a transaction, so sqlx's advisory + /// lock does not serialize concurrent [`Self::init_pool`] callers on the same schema. /// /// # Errors /// - /// Returns an error when the connection or migrations fail. - pub async fn init_pool(&self) -> Result { - let pool = PgPoolOptions::new() + /// Returns an error when the connection fails. + pub async fn connect_pool(&self) -> Result { + PgPoolOptions::new() .max_connections(self.max_connections) .acquire_timeout(self.connection_timeout) .connect_with(self.connection.connect_options()) .await - .context("failed to connect to shadow indexer database")?; + .context("failed to connect to shadow indexer database") + } + + /// Initialize the database connection pool and run migrations. + /// + /// # Errors + /// + /// Returns an error when the connection or migrations fail. + pub async fn init_pool(&self) -> Result { + let pool = self.connect_pool().await?; // Migrations run at node startup, from a critical task: a failure here panics the // builder, and this schema has taken the mainnet builder down that way before. Both diff --git a/crates/execution/shadow-indexer/src/retention.rs b/crates/execution/shadow-indexer/src/retention.rs index 6fd841c669..90a99f3009 100644 --- a/crates/execution/shadow-indexer/src/retention.rs +++ b/crates/execution/shadow-indexer/src/retention.rs @@ -56,10 +56,13 @@ impl ShadowRetention { let mut ticker = interval(self.config.interval); ticker.set_missed_tick_behavior(MissedTickBehavior::Delay); + // `connect_pool`, not `init_pool`: migrations for this schema run only from the writer's + // critical task (see `ShadowDbConfig::connect_pool`). A sweep against a not-yet-migrated + // schema just fails and retries on the next tick like any other sweep failure. let repo = loop { ticker.tick().await; - match self.db_config.init_pool().await { + match self.db_config.connect_pool().await { Ok(pool) => break ShadowRetentionRepo::new(pool), Err(error) => error!( target: "base::shadow-indexer", From 70517e2794849f27ded2eb9acfa8dd7aea933bd5 Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Tue, 22 Sep 2026 22:19:41 +0000 Subject: [PATCH 087/299] fix(proof): requeue failed witness jobs (#5231) Co-authored-by: OpenCode --- crates/proof/host/src/error.rs | 5 +- crates/proof/host/src/handler.rs | 91 ++++++++++---- .../proof/prover-service/client/src/worker.rs | 111 +++++++++++++++-- crates/proof/prover-service/db/src/lib.rs | 15 +-- crates/proof/prover-service/db/src/models.rs | 34 +++++ crates/proof/prover-service/db/src/repo.rs | 104 +++++++++++++++- .../db/tests/postgres_integration.rs | 116 +++++++++++++++++- .../proof/prover-service/protocol/src/api.rs | 18 ++- .../proof/prover-service/protocol/src/lib.rs | 19 +-- .../prover-service/protocol/src/types.rs | 20 +++ .../prover-service/src/server/worker_api.rs | 96 ++++++++++++++- .../prover-service/tests/worker_api_e2e.rs | 78 ++++++++++-- .../tee/nitro-host/src/proof_generator.rs | 113 +++++++++++++++-- crates/proof/worker/src/heartbeat.rs | 14 ++- crates/proof/worker/src/job_discovery.rs | 16 ++- crates/proof/worker/src/proof_submitter.rs | 15 ++- crates/proof/zk/host/src/proof_generator.rs | 50 +++++++- etc/systems/src/zk_host.rs | 7 ++ 18 files changed, 820 insertions(+), 102 deletions(-) diff --git a/crates/proof/host/src/error.rs b/crates/proof/host/src/error.rs index fe987017cc..347349db61 100644 --- a/crates/proof/host/src/error.rs +++ b/crates/proof/host/src/error.rs @@ -1,4 +1,4 @@ -use std::array::TryFromSliceError; +use std::{array::TryFromSliceError, time::Duration}; use alloy_primitives::B256; use alloy_rlp::Error as RlpError; @@ -96,6 +96,9 @@ pub enum HostError { /// Transport error. #[error("Transport error: {0}")] Transport(#[from] TransportError), + /// Payload witness RPC exceeded its deadline. + #[error("debug_executePayload timed out after {0:?}")] + PayloadWitnessTimeout(Duration), /// RLP decoding error. #[error("RLP decoding error: {0}")] Rlp(#[from] RlpError), diff --git a/crates/proof/host/src/handler.rs b/crates/proof/host/src/handler.rs index ac2c9347c8..20c383f061 100644 --- a/crates/proof/host/src/handler.rs +++ b/crates/proof/host/src/handler.rs @@ -1,8 +1,10 @@ use std::{ any::Any, collections::{HashMap, HashSet, VecDeque}, + future::Future, panic::AssertUnwindSafe, sync::{Arc, Mutex}, + time::Duration, }; use alloy_consensus::Header; @@ -14,6 +16,7 @@ use alloy_primitives::{Address, B64, B256, Bytes, keccak256}; use alloy_provider::Provider; use alloy_rlp::Decodable; use alloy_rpc_types::{Block, debug::ExecutionWitness}; +use alloy_transport::TransportError; use ark_ff::{BigInteger, PrimeField}; use base_common_consensus::{HoloceneExtraData, JovianExtraData, Predeploys}; use base_common_network::Base; @@ -37,6 +40,7 @@ const PAYLOAD_WITNESS_PREFETCH_MAX_READY: usize = 16; const PAYLOAD_WITNESS_PREFETCH_MAX_SCHEDULED_BLOCKS: usize = 128; const PAYLOAD_WITNESS_PREFETCH_MAX_SCHEDULED_LOOKAHEADS: usize = 128; const PAYLOAD_WITNESS_PREFETCH_PREIMAGE_WRITE_BATCH_SIZE: usize = 1024; +const PAYLOAD_WITNESS_RPC_TIMEOUT: Duration = Duration::from_secs(60); const L1_HEADER_PREFETCH_LOOKBEHIND_BLOCKS: u64 = 512; // Keep several lookbehind windows ready without retaining every header seen by a long proof. Raw // L1 headers are hundreds of bytes each, so this bounds cached header bytes to a few MiB. @@ -365,15 +369,12 @@ impl PayloadWitnessPrefetcher { let execute_payload_response = match base_metrics::time!(Metrics::l2_proof_node_rpc_latency_seconds(), { - self.inner - .providers - .l2 - .client() - .request::<(B256, BasePayloadAttributes), ExecutionWitness>( - "debug_executePayload", - (parent_block_hash, payload_attributes), - ) - .await + fetch_payload_witness( + &self.inner.providers.l2, + parent_block_hash, + payload_attributes, + ) + .await }) { Ok(response) => response, Err(err) => { @@ -784,6 +785,31 @@ fn payload_attributes_digest(payload_attributes: &BasePayloadAttributes) -> Resu Ok(keccak256(serde_json::to_vec(payload_attributes)?)) } +async fn fetch_payload_witness( + provider: &alloy_provider::RootProvider, + parent_block_hash: B256, + payload_attributes: BasePayloadAttributes, +) -> Result { + payload_witness_request( + provider.client().request::<(B256, BasePayloadAttributes), ExecutionWitness>( + "debug_executePayload", + (parent_block_hash, payload_attributes), + ), + PAYLOAD_WITNESS_RPC_TIMEOUT, + ) + .await +} + +async fn payload_witness_request(request: F, timeout: Duration) -> Result +where + F: Future>, +{ + tokio::time::timeout(timeout, request) + .await + .map_err(|_| HostError::PayloadWitnessTimeout(timeout))? + .map_err(HostError::from) +} + fn payload_attributes_from_l2_block( cfg: &HostConfig, block: Block<::TransactionResponse, ::HeaderResponse>, @@ -1275,22 +1301,10 @@ async fn handle_hint_inner( serde_json::from_slice(encoded_payload_attributes)?; let execute_payload_response = - match base_metrics::time!(Metrics::l2_proof_node_rpc_latency_seconds(), { - providers - .l2 - .client() - .request::<(B256, BasePayloadAttributes), ExecutionWitness>( - "debug_executePayload", - (parent_block_hash, payload_attributes), - ) + base_metrics::time!(Metrics::l2_proof_node_rpc_latency_seconds(), { + fetch_payload_witness(&providers.l2, parent_block_hash, payload_attributes) .await - }) { - Ok(response) => response, - Err(e) => { - error!(error = %e, "debug_executePayload failed"); - return Ok(()); - } - }; + })?; insert_execution_witness_preimages(Arc::clone(&kv), execute_payload_response).await?; @@ -1649,4 +1663,33 @@ mod tests { assert!(kv.read().await.get(PreimageKey::new_keccak256(*requested_hash).into()).is_none()); assert!(kv.read().await.get(PreimageKey::new_keccak256(*actual_hash).into()).is_none()); } + + #[tokio::test] + async fn test_payload_witness_foreground_rpc_failure_is_propagated() { + let asserter = Asserter::new(); + asserter.push_failure_msg("proof node unavailable"); + let l2 = provider_builder::().connect_mocked_client(asserter); + let providers = test_providers(l2); + let kv: SharedKeyValueStore = Arc::new(RwLock::new(MemoryKeyValueStore::new())); + let mut cfg = test_cfg(); + cfg.prover.enable_experimental_witness_endpoint = true; + let encoded_attributes = serde_json::to_vec(&BasePayloadAttributes::default()).unwrap(); + let hint = HintType::L2PayloadWitness + .with_data(&[TEST_HASH.as_slice(), encoded_attributes.as_slice()]); + + let error = handle_hint(hint, &cfg, &providers, kv).await.unwrap_err(); + + assert!(matches!(error, HostError::Transport(_))); + } + + #[tokio::test] + async fn test_payload_witness_request_times_out() { + let request = + std::future::pending::>(); + let timeout = Duration::from_millis(1); + + let error = payload_witness_request(request, timeout).await.unwrap_err(); + + assert!(matches!(error, HostError::PayloadWitnessTimeout(actual) if actual == timeout)); + } } diff --git a/crates/proof/prover-service/client/src/worker.rs b/crates/proof/prover-service/client/src/worker.rs index 3ce1b953b8..29cb68ea83 100644 --- a/crates/proof/prover-service/client/src/worker.rs +++ b/crates/proof/prover-service/client/src/worker.rs @@ -3,9 +3,10 @@ use async_trait::async_trait; use backon::Retryable; use base_prover_service_protocol::{ - GetNextProofRequest, GetNextProofResponse, GetProofSessionRequest, GetProofSessionResponse, - HeartbeatRequest, HeartbeatResponse, ProverWorkerApiClient, RecordProofSessionRequest, - RecordProofSessionResponse, WorkerSubmitProofRequest, WorkerSubmitProofResponse, + AbandonProofRequest, AbandonProofResponse, GetNextProofRequest, GetNextProofResponse, + GetProofSessionRequest, GetProofSessionResponse, HeartbeatRequest, HeartbeatResponse, + ProverWorkerApiClient, RecordProofSessionRequest, RecordProofSessionResponse, + WorkerSubmitProofRequest, WorkerSubmitProofResponse, }; use base_retry::RetryConfig; use jsonrpsee::http_client::HttpClient; @@ -32,6 +33,12 @@ pub trait ProverWorkerProvider: Send + Sync { request: HeartbeatRequest, ) -> Result; + /// Abandon a claimed proof job after generation fails. + async fn abandon_proof( + &self, + request: AbandonProofRequest, + ) -> Result; + /// Submit a proof result for a proof job. async fn submit_proof( &self, @@ -130,6 +137,23 @@ impl ProverWorkerClient { Ok(self.inner.heartbeat(request).await?) } + /// Abandon a claimed proof job after generation fails. + /// + /// Single-attempt: a lost success response cannot be replayed idempotently after the server + /// clears the fencing token. Lease expiry remains the fallback if delivery fails. + pub async fn abandon_proof( + &self, + request: AbandonProofRequest, + ) -> Result { + debug!( + session_id = %request.session_id, + lock_id = %request.lock_id, + worker_id = %request.worker_id, + "abandoning proof job" + ); + Ok(self.inner.abandon_proof(request).await?) + } + /// Submit a proof result for a proof job. pub async fn submit_proof( &self, @@ -242,6 +266,13 @@ impl ProverWorkerProvider for ProverWorkerClient { Self::heartbeat(self, request).await } + async fn abandon_proof( + &self, + request: AbandonProofRequest, + ) -> Result { + Self::abandon_proof(self, request).await + } + async fn submit_proof( &self, request: WorkerSubmitProofRequest, @@ -277,10 +308,10 @@ mod tests { }; use base_prover_service_protocol::{ - BackendSession, BackendSessionState, GetProofSessionRequest, GetProofSessionResponse, - ProofJob, ProofJobStatus, ProofRequest, ProofRequestKind, ProofResult, ProofType, - ProverWorkerApiServer, RecordProofSessionRequest, RecordProofSessionResponse, SessionType, - ZkBackend, ZkProofRequest, ZkProofResult, ZkVm, + AbandonProofRequest, AbandonProofResponse, BackendSession, BackendSessionState, + GetProofSessionRequest, GetProofSessionResponse, ProofJob, ProofJobStatus, ProofRequest, + ProofRequestKind, ProofResult, ProofType, ProverWorkerApiServer, RecordProofSessionRequest, + RecordProofSessionResponse, SessionType, ZkBackend, ZkProofRequest, ZkProofResult, ZkVm, }; use base_retry::RetryConfig; use chrono::Utc; @@ -311,11 +342,13 @@ mod tests { reject_heartbeat: bool, get_next_script: Arc>>, heartbeat_script: Arc>>, + abandon_script: Arc>>, submit_script: Arc>>, get_session_script: Arc>>, record_session_script: Arc>>, get_next_calls: Arc, heartbeat_calls: Arc, + abandon_calls: Arc, submit_calls: Arc, get_session_calls: Arc, record_session_calls: Arc, @@ -341,11 +374,13 @@ mod tests { reject_heartbeat: false, get_next_script: Arc::new(Mutex::new(VecDeque::new())), heartbeat_script: Arc::new(Mutex::new(VecDeque::new())), + abandon_script: Arc::new(Mutex::new(VecDeque::new())), submit_script: Arc::new(Mutex::new(VecDeque::new())), get_session_script: Arc::new(Mutex::new(VecDeque::new())), record_session_script: Arc::new(Mutex::new(VecDeque::new())), get_next_calls: Arc::new(AtomicU32::new(0)), heartbeat_calls: Arc::new(AtomicU32::new(0)), + abandon_calls: Arc::new(AtomicU32::new(0)), submit_calls: Arc::new(AtomicU32::new(0)), get_session_calls: Arc::new(AtomicU32::new(0)), record_session_calls: Arc::new(AtomicU32::new(0)), @@ -366,6 +401,10 @@ mod tests { self.heartbeat_script.lock().expect("script lock").extend(outcomes); } + fn queue_abandon_outcomes>(&self, outcomes: I) { + self.abandon_script.lock().expect("script lock").extend(outcomes); + } + fn queue_submit_outcomes>(&self, outcomes: I) { self.submit_script.lock().expect("script lock").extend(outcomes); } @@ -389,6 +428,10 @@ mod tests { self.heartbeat_calls.load(Ordering::SeqCst) } + fn abandon_calls(&self) -> u32 { + self.abandon_calls.load(Ordering::SeqCst) + } + fn submit_calls(&self) -> u32 { self.submit_calls.load(Ordering::SeqCst) } @@ -472,6 +515,32 @@ mod tests { }) } + async fn abandon_proof( + &self, + request: AbandonProofRequest, + ) -> RpcResult { + self.abandon_calls.fetch_add(1, Ordering::SeqCst); + match self.abandon_script.lock().expect("script lock").pop_front() { + Some(ScriptedOutcome::Retryable) => { + return Err(unavailable_error("scripted abandon_proof retryable failure")); + } + Some(ScriptedOutcome::Fatal) => { + return Err(invalid_params_error("scripted abandon_proof fatal failure")); + } + None | Some(ScriptedOutcome::Success) => {} + } + + Ok(AbandonProofResponse { + job: proof_job( + request.session_id, + ProofJobStatus::Pending, + None, + None, + Some(request.error_message), + ), + }) + } + async fn submit_proof( &self, request: WorkerSubmitProofRequest, @@ -621,6 +690,15 @@ mod tests { } } + fn sample_abandon_request(session_id: &str) -> AbandonProofRequest { + AbandonProofRequest { + session_id: session_id.to_owned(), + lock_id: "lock-abandon".to_owned(), + worker_id: "worker-abandon".to_owned(), + error_message: "generation failed".to_owned(), + } + } + fn sample_get_session_request(session_id: &str) -> GetProofSessionRequest { GetProofSessionRequest { session_id: session_id.to_owned(), @@ -820,6 +898,25 @@ mod tests { server.shutdown().await; } + #[tokio::test] + async fn worker_does_not_retry_retryable_abandon_proof() { + let api = MockWorkerApi::new(); + api.queue_abandon_outcomes([ScriptedOutcome::Retryable, ScriptedOutcome::Success]); + let api_clone = api.clone(); + let server = RunningWorkerServer::spawn_with_retry(api, fast_retry_config()).await; + + let error = server + .client + .abandon_proof(sample_abandon_request("session-abandon-retry")) + .await + .expect_err("abandon_proof retryable error should not be retried"); + + assert!(error.is_retryable()); + assert_eq!(api_clone.abandon_calls(), 1); + + server.shutdown().await; + } + #[tokio::test] async fn worker_retries_retryable_submit_proof_until_success() { let api = MockWorkerApi::new(); diff --git a/crates/proof/prover-service/db/src/lib.rs b/crates/proof/prover-service/db/src/lib.rs index a0338d451d..791d77338c 100644 --- a/crates/proof/prover-service/db/src/lib.rs +++ b/crates/proof/prover-service/db/src/lib.rs @@ -8,13 +8,14 @@ pub use conversions::ConversionError; mod models; pub use models::{ - ApiProofType, ClaimAuth, ClaimProofJob, CompleteClaimedProofJob, CreateProofRequest, - CreateProofRequestError, CreateProofRequestOutcome, CreateProofRequestValidationError, - CreateProofSession, DeleteProofRequestOutcome, DerivedProofRequestFields, FailExpiredProofJobs, - HeartbeatOutcome, HeartbeatProofJob, JobLockState, ProofJob, ProofJobStatus, ProofRequest, - ProofRequestListItem, ProofRequestPage, ProofSession, ProofStatus, ProofType, - RecordSessionOutcome, RetryOutcome, SessionStatus, SessionType, SubmitProofOutcome, TeeKind, - UpdateProofSession, UpdateReceipt, WorkerSessionUpsert, ZkVmKind, canonical_session_id, + AbandonProofJob, AbandonProofOutcome, ApiProofType, ClaimAuth, ClaimProofJob, + CompleteClaimedProofJob, CreateProofRequest, CreateProofRequestError, + CreateProofRequestOutcome, CreateProofRequestValidationError, CreateProofSession, + DeleteProofRequestOutcome, DerivedProofRequestFields, FailExpiredProofJobs, HeartbeatOutcome, + HeartbeatProofJob, JobLockState, ProofJob, ProofJobStatus, ProofRequest, ProofRequestListItem, + ProofRequestPage, ProofSession, ProofStatus, ProofType, RecordSessionOutcome, RetryOutcome, + SessionStatus, SessionType, SubmitProofOutcome, TeeKind, UpdateProofSession, UpdateReceipt, + WorkerSessionUpsert, ZkVmKind, canonical_session_id, }; mod repo; diff --git a/crates/proof/prover-service/db/src/models.rs b/crates/proof/prover-service/db/src/models.rs index f616030bcf..1917614821 100644 --- a/crates/proof/prover-service/db/src/models.rs +++ b/crates/proof/prover-service/db/src/models.rs @@ -1030,6 +1030,40 @@ pub struct HeartbeatProofJob { pub lock_duration_seconds: u32, } +/// Parameters for abandoning the currently owned worker proof job. +#[derive(Debug, Clone)] +pub struct AbandonProofJob { + /// Public proof session identifier. + pub session_id: String, + /// Current worker fencing token. + pub lock_id: Uuid, + /// Worker identifier that owns the claim. + pub worker_id: String, + /// Proof generation failure message. + pub error_message: String, + /// Reclaim budget after which the job fails terminally. + pub max_attempts: u32, +} + +/// Outcome of attempting to abandon a worker proof job. +#[derive(Debug, Clone)] +pub enum AbandonProofOutcome { + /// The job was made immediately claimable again. + Requeued(ProofJob), + /// The job exhausted its claim budget and failed terminally. + Failed(ProofJob), + /// No proof job exists for the supplied `session_id`. + NotFound, + /// The job exists but is not currently claimed. + NotClaimed(ProofJob), + /// The supplied worker or lock no longer owns the job. + StaleLock(ProofJob), + /// The lock matched the job but had expired. + Expired(ProofJob), + /// The job is already terminal. + Terminal(ProofJob), +} + /// Outcome of attempting to heartbeat a worker proof job. #[derive(Debug, Clone)] pub enum HeartbeatOutcome { diff --git a/crates/proof/prover-service/db/src/repo.rs b/crates/proof/prover-service/db/src/repo.rs index a075163093..a922c805dc 100644 --- a/crates/proof/prover-service/db/src/repo.rs +++ b/crates/proof/prover-service/db/src/repo.rs @@ -6,12 +6,13 @@ use sqlx::{PgPool, Result, Row}; use uuid::Uuid; use crate::{ - ApiProofType, ClaimAuth, ClaimProofJob, CompleteClaimedProofJob, CreateProofRequest, - CreateProofRequestError, CreateProofRequestOutcome, CreateProofRequestValidationError, - CreateProofSession, DeleteProofRequestOutcome, FailExpiredProofJobs, HeartbeatOutcome, - HeartbeatProofJob, JobLockState, ProofJob, ProofJobStatus, ProofRequest, ProofRequestListItem, - ProofRequestPage, ProofSession, ProofStatus, ProofType, RecordSessionOutcome, RetryOutcome, - SessionStatus, SessionType, SubmitProofOutcome, TeeKind, UpdateProofSession, UpdateReceipt, + AbandonProofJob, AbandonProofOutcome, ApiProofType, ClaimAuth, ClaimProofJob, + CompleteClaimedProofJob, CreateProofRequest, CreateProofRequestError, + CreateProofRequestOutcome, CreateProofRequestValidationError, CreateProofSession, + DeleteProofRequestOutcome, FailExpiredProofJobs, HeartbeatOutcome, HeartbeatProofJob, + JobLockState, ProofJob, ProofJobStatus, ProofRequest, ProofRequestListItem, ProofRequestPage, + ProofSession, ProofStatus, ProofType, RecordSessionOutcome, RetryOutcome, SessionStatus, + SessionType, SubmitProofOutcome, TeeKind, UpdateProofSession, UpdateReceipt, WorkerSessionUpsert, ZkVmKind, canonical_session_id, }; @@ -701,6 +702,97 @@ impl ProofRequestRepo { } } + /// Abandon the currently owned proof job, requeueing it immediately while attempts remain. + pub async fn abandon_proof_job(&self, req: AbandonProofJob) -> Result { + let session_id = canonical_session_id(&req.session_id) + .map_err(|e| sqlx::Error::InvalidArgument(e.to_string()))?; + let mut tx = self.pool.begin().await?; + let columns = PROOF_JOB_RETURNING_COLUMNS; + let sql = format!( + r#" + SELECT {columns}, NOW() AS database_now + FROM proof_requests + WHERE COALESCE(session_id, id::text) = $1 + FOR UPDATE + "# + ); + let row = sqlx::query(&sql).bind(&session_id).fetch_optional(&mut *tx).await?; + let Some(row) = row else { + return Ok(AbandonProofOutcome::NotFound); + }; + let job = row_to_proof_job(&row)?; + let now: chrono::DateTime = row.get("database_now"); + + match ClaimAuth::classify( + JobLockState { + status: job.job_status, + lock_id: job.lock_id, + worker_id: job.worker_id.as_deref(), + lock_expires_at: job.lock_expires_at, + }, + req.lock_id, + &req.worker_id, + now, + ) { + ClaimAuth::Authorized => {} + ClaimAuth::Terminal => return Ok(AbandonProofOutcome::Terminal(job)), + ClaimAuth::NotClaimed => return Ok(AbandonProofOutcome::NotClaimed(job)), + ClaimAuth::StaleLock => return Ok(AbandonProofOutcome::StaleLock(job)), + ClaimAuth::Expired => return Ok(AbandonProofOutcome::Expired(job)), + } + + let exhausted = job.attempt >= i32::try_from(req.max_attempts).unwrap_or(i32::MAX); + if exhausted { + sqlx::query( + r#" + UPDATE proof_sessions + SET status = 'FAILED', error_message = $2, completed_at = NOW() + WHERE proof_request_id = $1 AND status IN ('SUBMITTING', 'RUNNING') + "#, + ) + .bind(job.id) + .bind(&req.error_message) + .execute(&mut *tx) + .await?; + } + + let sql = format!( + r#" + UPDATE proof_requests + SET job_status = $2, + status = $3, + error_message = $4, + completed_at = CASE WHEN $2 = 'FAILED' THEN NOW() ELSE NULL END, + worker_id = NULL, + lock_id = NULL, + lock_expires_at = NULL, + claimed_at = NULL, + last_heartbeat_at = NULL + WHERE id = $1 + RETURNING {columns} + "# + ); + let row = sqlx::query(&sql) + .bind(job.id) + .bind(if exhausted { "FAILED" } else { "PENDING" }) + .bind(if exhausted { + ProofStatus::Failed.as_str() + } else { + ProofStatus::Created.as_str() + }) + .bind(exhausted.then_some(req.error_message.as_str())) + .fetch_one(&mut *tx) + .await?; + let job = row_to_proof_job(&row)?; + tx.commit().await?; + + Ok(if exhausted { + AbandonProofOutcome::Failed(job) + } else { + AbandonProofOutcome::Requeued(job) + }) + } + /// Complete the currently owned worker proof job (`submitProof`). pub async fn complete_claimed_proof_job( &self, diff --git a/crates/proof/prover-service/db/tests/postgres_integration.rs b/crates/proof/prover-service/db/tests/postgres_integration.rs index 9d5e18a40f..f7880e8ec0 100644 --- a/crates/proof/prover-service/db/tests/postgres_integration.rs +++ b/crates/proof/prover-service/db/tests/postgres_integration.rs @@ -18,8 +18,8 @@ use std::time::Duration; use alloy_primitives::Address; use base_proof_primitives::Proposal; use base_prover_service_db::{ - ApiProofType, ClaimProofJob, CompleteClaimedProofJob, CreateProofRequest, - CreateProofRequestError, CreateProofRequestOutcome, CreateProofSession, + AbandonProofJob, AbandonProofOutcome, ApiProofType, ClaimProofJob, CompleteClaimedProofJob, + CreateProofRequest, CreateProofRequestError, CreateProofRequestOutcome, CreateProofSession, DeleteProofRequestOutcome, FailExpiredProofJobs, HeartbeatOutcome, HeartbeatProofJob, ProofJobStatus, ProofRequestPage, ProofRequestRepo, ProofStatus, ProofType, RecordSessionOutcome, RetryOutcome, SessionStatus, SessionType, SubmitProofOutcome, TeeKind, @@ -1837,6 +1837,118 @@ async fn test_heartbeat_proof_job_guards_current_expired_and_reclaimed_locks() { assert!(matches!(stale, HeartbeatOutcome::StaleLock(_))); } +#[tokio::test] +#[ignore = "requires a running Postgres with the prover schema (set DATABASE_URL); run with `cargo nextest run --run-ignored all -p base-prover-service-db --test postgres_integration --test-threads=1`"] +async fn test_abandon_proof_job_requeues_then_terminally_fails_with_fencing() { + let pool = test_pool().await; + let repo = test_repo(pool); + + drain_claimable_compressed_jobs(&repo).await; + let session_id = Uuid::new_v4().to_string(); + let mut request = compressed_request(); + set_request_session_id(&mut request, session_id.clone()); + let id = repo.create(request).await.unwrap(); + let first = repo + .claim_next_proof_job(compressed_claim("abandon-worker-a", 2)) + .await + .unwrap() + .expect("first claim should succeed"); + let first_lock = first.lock_id.expect("first claim has lock"); + + repo.record_worker_proof_session(WorkerSessionUpsert { + session_id: session_id.clone(), + lock_id: first_lock, + worker_id: "abandon-worker-a".to_owned(), + session_type: SessionType::Stark, + backend_session_id: "abandon-backend-session".to_owned(), + status: SessionStatus::Running, + error_message: None, + }) + .await + .unwrap(); + + let stale = repo + .abandon_proof_job(AbandonProofJob { + session_id: session_id.clone(), + lock_id: Uuid::new_v4(), + worker_id: "abandon-worker-a".to_owned(), + error_message: "stale failure".to_owned(), + max_attempts: 2, + }) + .await + .unwrap(); + assert!(matches!(stale, AbandonProofOutcome::StaleLock(_))); + + let requeued = repo + .abandon_proof_job(AbandonProofJob { + session_id: session_id.clone(), + lock_id: first_lock, + worker_id: "abandon-worker-a".to_owned(), + error_message: "first generation failure".to_owned(), + max_attempts: 2, + }) + .await + .unwrap(); + let AbandonProofOutcome::Requeued(requeued) = requeued else { + panic!("first abandon should requeue the job"); + }; + assert_eq!(requeued.attempt, 1); + assert_eq!(requeued.job_status, ProofJobStatus::Pending); + assert!(requeued.lock_id.is_none()); + assert!(requeued.error_message.is_none()); + assert_eq!( + repo.get_active_session(&session_id, SessionType::Stark) + .await + .unwrap() + .expect("active session should survive requeue") + .backend_session_id, + "abandon-backend-session" + ); + + let second = repo + .claim_next_proof_job(compressed_claim("abandon-worker-b", 2)) + .await + .unwrap() + .expect("abandoned job should be immediately reclaimable"); + assert_eq!(second.id, id); + assert_eq!(second.attempt, 2); + let second_lock = second.lock_id.expect("second claim has lock"); + + let old_fence = repo + .abandon_proof_job(AbandonProofJob { + session_id: session_id.clone(), + lock_id: first_lock, + worker_id: "abandon-worker-a".to_owned(), + error_message: "old worker failure".to_owned(), + max_attempts: 2, + }) + .await + .unwrap(); + assert!(matches!(old_fence, AbandonProofOutcome::StaleLock(_))); + + let failed = repo + .abandon_proof_job(AbandonProofJob { + session_id: session_id.clone(), + lock_id: second_lock, + worker_id: "abandon-worker-b".to_owned(), + error_message: "final generation failure".to_owned(), + max_attempts: 2, + }) + .await + .unwrap(); + let AbandonProofOutcome::Failed(failed) = failed else { + panic!("second abandon should exhaust the reclaim budget"); + }; + assert_eq!(failed.job_status, ProofJobStatus::Failed); + assert_eq!(failed.attempt, 2); + assert_eq!(failed.error_message.as_deref(), Some("final generation failure")); + assert!(repo.get_active_session(&session_id, SessionType::Stark).await.unwrap().is_none()); + let sessions = repo.get_sessions_for_request(id).await.unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].status, SessionStatus::Failed); + assert_eq!(sessions[0].error_message.as_deref(), Some("final generation failure")); +} + #[tokio::test] #[ignore = "requires a running Postgres with the prover schema (set DATABASE_URL); run with `cargo nextest run --run-ignored all -p base-prover-service-db --test postgres_integration --test-threads=1`"] async fn test_complete_claimed_proof_job_guards_and_stores_result() { diff --git a/crates/proof/prover-service/protocol/src/api.rs b/crates/proof/prover-service/protocol/src/api.rs index 47eda1b646..97fd520380 100644 --- a/crates/proof/prover-service/protocol/src/api.rs +++ b/crates/proof/prover-service/protocol/src/api.rs @@ -3,11 +3,12 @@ use jsonrpsee::proc_macros::rpc; use crate::{ - DeleteProofRequest, DeleteProofsByTeeSignerRequest, GetNextProofRequest, GetNextProofResponse, - GetProofRequest, GetProofResponse, GetProofSessionRequest, GetProofSessionResponse, - HeartbeatRequest, HeartbeatResponse, ListProofsRequest, ListProofsResponse, - ProveBlockRangeRequest, ProveBlockRangeResponse, RecordProofSessionRequest, - RecordProofSessionResponse, WorkerSubmitProofRequest, WorkerSubmitProofResponse, + AbandonProofRequest, AbandonProofResponse, DeleteProofRequest, DeleteProofsByTeeSignerRequest, + GetNextProofRequest, GetNextProofResponse, GetProofRequest, GetProofResponse, + GetProofSessionRequest, GetProofSessionResponse, HeartbeatRequest, HeartbeatResponse, + ListProofsRequest, ListProofsResponse, ProveBlockRangeRequest, ProveBlockRangeResponse, + RecordProofSessionRequest, RecordProofSessionResponse, WorkerSubmitProofRequest, + WorkerSubmitProofResponse, }; #[cfg_attr( @@ -88,6 +89,13 @@ pub trait ProverWorkerApi { request: HeartbeatRequest, ) -> jsonrpsee::core::RpcResult; + /// Abandon a worker-owned proof job after generation fails. + #[method(name = "abandonProof")] + async fn abandon_proof( + &self, + request: AbandonProofRequest, + ) -> jsonrpsee::core::RpcResult; + /// Submit a proof result for a proof job. #[method(name = "submitProof")] async fn submit_proof( diff --git a/crates/proof/prover-service/protocol/src/lib.rs b/crates/proof/prover-service/protocol/src/lib.rs index 161c8b64ed..4e6039f039 100644 --- a/crates/proof/prover-service/protocol/src/lib.rs +++ b/crates/proof/prover-service/protocol/src/lib.rs @@ -12,13 +12,14 @@ pub use session::ProofSessionId; mod types; pub use types::{ - BackendSession, BackendSessionState, DeleteProofRequest, DeleteProofsByTeeSignerRequest, - ExecutionStats, GetNextProofRequest, GetNextProofResponse, GetProofRequest, GetProofResponse, - GetProofSessionRequest, GetProofSessionResponse, HeartbeatRequest, HeartbeatResponse, - ListProofsRequest, ListProofsResponse, PROOF_REQUEST_NOT_FOUND_MESSAGE, ProofJob, - ProofJobStatus, ProofRequest, ProofRequestIdCollisionMessage, ProofRequestKind, ProofResult, - ProofStatus, ProofSummary, ProofType, ProveBlockRangeRequest, ProveBlockRangeResponse, - RecordProofSessionRequest, RecordProofSessionResponse, SessionType, SnarkPlonkProofRequest, - SnarkPlonkProofResult, TeeKind, TeeProofRequest, TeeProofResult, WorkerSubmitProofRequest, - WorkerSubmitProofResponse, ZkBackend, ZkProofRequest, ZkProofResult, ZkVm, + AbandonProofRequest, AbandonProofResponse, BackendSession, BackendSessionState, + DeleteProofRequest, DeleteProofsByTeeSignerRequest, ExecutionStats, GetNextProofRequest, + GetNextProofResponse, GetProofRequest, GetProofResponse, GetProofSessionRequest, + GetProofSessionResponse, HeartbeatRequest, HeartbeatResponse, ListProofsRequest, + ListProofsResponse, PROOF_REQUEST_NOT_FOUND_MESSAGE, ProofJob, ProofJobStatus, ProofRequest, + ProofRequestIdCollisionMessage, ProofRequestKind, ProofResult, ProofStatus, ProofSummary, + ProofType, ProveBlockRangeRequest, ProveBlockRangeResponse, RecordProofSessionRequest, + RecordProofSessionResponse, SessionType, SnarkPlonkProofRequest, SnarkPlonkProofResult, + TeeKind, TeeProofRequest, TeeProofResult, WorkerSubmitProofRequest, WorkerSubmitProofResponse, + ZkBackend, ZkProofRequest, ZkProofResult, ZkVm, }; diff --git a/crates/proof/prover-service/protocol/src/types.rs b/crates/proof/prover-service/protocol/src/types.rs index 1a6744fe96..f44d40fe24 100644 --- a/crates/proof/prover-service/protocol/src/types.rs +++ b/crates/proof/prover-service/protocol/src/types.rs @@ -437,6 +437,26 @@ pub struct HeartbeatResponse { pub job: ProofJob, } +/// Request to abandon a claimed proof job after generation fails. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AbandonProofRequest { + /// Proof session identifier. + pub session_id: String, + /// Server-issued lock identifier for this worker claim. + pub lock_id: String, + /// Worker identifier. + pub worker_id: String, + /// Proof generation failure message. + pub error_message: String, +} + +/// Response returned after abandoning a claimed proof job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AbandonProofResponse { + /// Requeued or terminally failed proof job. + pub job: ProofJob, +} + /// Request to submit a proof result for a proof job. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkerSubmitProofRequest { diff --git a/crates/proof/prover-service/src/server/worker_api.rs b/crates/proof/prover-service/src/server/worker_api.rs index 19194f3ac8..f189d5fd9c 100644 --- a/crates/proof/prover-service/src/server/worker_api.rs +++ b/crates/proof/prover-service/src/server/worker_api.rs @@ -1,14 +1,15 @@ //! Implementation of the prover worker JSON-RPC endpoints. use base_prover_service_db::{ - ClaimProofJob, CompleteClaimedProofJob, HeartbeatOutcome, HeartbeatProofJob, - RecordSessionOutcome, SubmitProofOutcome, WorkerSessionUpsert, canonical_session_id, + AbandonProofJob, AbandonProofOutcome, ClaimProofJob, CompleteClaimedProofJob, HeartbeatOutcome, + HeartbeatProofJob, RecordSessionOutcome, SubmitProofOutcome, WorkerSessionUpsert, + canonical_session_id, }; use base_prover_service_protocol::{ - GetNextProofRequest, GetNextProofResponse, GetProofSessionRequest, GetProofSessionResponse, - HeartbeatRequest, HeartbeatResponse, ProofJob as ProtocolProofJob, ProverWorkerApiServer, - RecordProofSessionRequest, RecordProofSessionResponse, WorkerSubmitProofRequest, - WorkerSubmitProofResponse, + AbandonProofRequest, AbandonProofResponse, GetNextProofRequest, GetNextProofResponse, + GetProofSessionRequest, GetProofSessionResponse, HeartbeatRequest, HeartbeatResponse, + ProofJob as ProtocolProofJob, ProverWorkerApiServer, RecordProofSessionRequest, + RecordProofSessionResponse, WorkerSubmitProofRequest, WorkerSubmitProofResponse, }; use jsonrpsee::{ core::{RpcResult, async_trait}, @@ -38,6 +39,10 @@ impl ProverWorkerApiServer for ProverServiceServer { self.heartbeat_impl(request).await } + async fn abandon_proof(&self, request: AbandonProofRequest) -> RpcResult { + self.abandon_proof_impl(request).await + } + async fn submit_proof( &self, request: WorkerSubmitProofRequest, @@ -200,6 +205,85 @@ impl ProverServiceServer { } } + /// Abandons a worker-owned proof job after generation failure. + #[tracing::instrument( + name = "prover.abandon_proof", + skip_all, + fields(session_id = %request.session_id, worker_id = %request.worker_id) + )] + pub async fn abandon_proof_impl( + &self, + request: AbandonProofRequest, + ) -> RpcResult { + let start = std::time::Instant::now(); + let result = self.abandon_proof_inner(request).await; + record_rpc_result("AbandonProof", start, &result); + result + } + + async fn abandon_proof_inner( + &self, + request: AbandonProofRequest, + ) -> RpcResult { + let session_id = canonical_session_id(&request.session_id) + .map_err(|e| invalid_argument(format!("{e}")))?; + let lock_id = parse_lock_id(&request.lock_id)?; + let outcome = self + .repo + .abandon_proof_job(AbandonProofJob { + session_id, + lock_id, + worker_id: request.worker_id.clone(), + error_message: request.error_message.clone(), + max_attempts: self.config.worker_queue.reclaim_attempts, + }) + .await + .map_err(|e| internal(format!("Database error: {e}")))?; + + match outcome { + AbandonProofOutcome::Requeued(job) => { + info!( + worker_id = %request.worker_id, + session_id = %request.session_id, + attempt = job.attempt, + "worker abandoned and requeued proof job" + ); + Ok(AbandonProofResponse { job: into_protocol_job(job)? }) + } + AbandonProofOutcome::Failed(job) => { + metrics::record_terminal_proof_job(metrics::PROOF_STATUS_FAILED, &job); + warn!( + worker_id = %request.worker_id, + session_id = %request.session_id, + attempt = job.attempt, + "worker abandoned proof job after reclaim budget was exhausted" + ); + Ok(AbandonProofResponse { job: into_protocol_job(job)? }) + } + AbandonProofOutcome::NotFound => { + Err(not_found(format!("proof job not found for session_id {}", request.session_id))) + } + AbandonProofOutcome::NotClaimed(_) => Err(reject_ownership( + "abandon_proof", + &request.session_id, + "job is not currently claimed", + )), + AbandonProofOutcome::StaleLock(_) => Err(reject_ownership( + "abandon_proof", + &request.session_id, + "lock is held by another worker or has been rotated", + )), + AbandonProofOutcome::Expired(_) => { + Err(reject_ownership("abandon_proof", &request.session_id, "lock has expired")) + } + AbandonProofOutcome::Terminal(_) => Err(reject_ownership( + "abandon_proof", + &request.session_id, + "job has already reached a terminal state", + )), + } + } + /// Records a worker proof submission and completes the job. #[tracing::instrument( name = "prover.submit_proof", diff --git a/crates/proof/prover-service/tests/worker_api_e2e.rs b/crates/proof/prover-service/tests/worker_api_e2e.rs index 13517d7fb7..a07eff4aa5 100644 --- a/crates/proof/prover-service/tests/worker_api_e2e.rs +++ b/crates/proof/prover-service/tests/worker_api_e2e.rs @@ -19,10 +19,11 @@ use base_prover_service_db::{ ProofStatus as DbProofStatus, ZkVmKind, }; use base_prover_service_protocol::{ - GetNextProofRequest, HeartbeatRequest, ProofJobStatus, ProofRequest as ProtocolProofRequest, - ProofRequestKind, ProofResult, ProofType, ProverRequesterApiServer, ProverWorkerApiClient, - ProverWorkerApiServer, WorkerSubmitProofRequest, ZkBackend, ZkProofRequest, ZkProofResult, - ZkVm, + AbandonProofRequest, BackendSessionState, GetNextProofRequest, GetProofSessionRequest, + HeartbeatRequest, ProofJobStatus, ProofRequest as ProtocolProofRequest, ProofRequestKind, + ProofResult, ProofType, ProverRequesterApiServer, ProverWorkerApiClient, ProverWorkerApiServer, + RecordProofSessionRequest, SessionType, WorkerSubmitProofRequest, ZkBackend, ZkProofRequest, + ZkProofResult, ZkVm, }; use jsonrpsee::{ core::client::Error as ClientError, @@ -197,12 +198,75 @@ async fn worker_claim_heartbeat_submit_round_trip() { .expect_err("a non-owning lock must be rejected"); assert_rpc_error_code(&stale, ERROR_FAILED_PRECONDITION); - let submitted = server + server .client - .submit_proof(WorkerSubmitProofRequest { + .record_proof_session(RecordProofSessionRequest { + session_id: session_id.clone(), + lock_id: lock_id.clone(), + worker_id: "worker-e2e".to_owned(), + session_type: SessionType::Stark, + backend_session_id: "backend-e2e".to_owned(), + state: BackendSessionState::Running, + }) + .await + .expect("recording an active backend session should succeed"); + + let stale_abandon = server + .client + .abandon_proof(AbandonProofRequest { + session_id: session_id.clone(), + lock_id: Uuid::new_v4().to_string(), + worker_id: "worker-e2e".to_owned(), + error_message: "stale worker failure".to_owned(), + }) + .await + .expect_err("a stale fencing token must not abandon the job"); + assert_rpc_error_code(&stale_abandon, ERROR_FAILED_PRECONDITION); + + let abandoned = server + .client + .abandon_proof(AbandonProofRequest { session_id: session_id.clone(), lock_id: lock_id.clone(), worker_id: "worker-e2e".to_owned(), + error_message: "generation failed".to_owned(), + }) + .await + .expect("the owning worker should abandon the job"); + assert_eq!(abandoned.job.status, ProofJobStatus::Pending); + assert_eq!(abandoned.job.attempt, 1); + + let active_session = server + .client + .get_proof_session(GetProofSessionRequest { + session_id: session_id.clone(), + session_type: SessionType::Stark, + }) + .await + .expect("backend session lookup should succeed") + .session + .expect("active backend session should survive requeue"); + assert_eq!(active_session.backend_session_id, "backend-e2e"); + assert_eq!(active_session.state, BackendSessionState::Running); + + let reclaimed = server + .client + .get_next_proof(worker_claim("worker-e2e-reclaim")) + .await + .expect("abandoned job should be immediately reclaimable") + .job + .expect("abandoned job should be returned without waiting for lease expiry"); + assert_eq!(reclaimed.session_id, session_id); + assert_eq!(reclaimed.attempt, 2); + let reclaimed_lock_id = reclaimed.lock_id.expect("reclaimed job carries a new lock id"); + assert_ne!(reclaimed_lock_id, lock_id); + + let submitted = server + .client + .submit_proof(WorkerSubmitProofRequest { + session_id: session_id.clone(), + lock_id: reclaimed_lock_id, + worker_id: "worker-e2e-reclaim".to_owned(), result: ProofResult::Compressed(ZkProofResult { zk_vm: ZkVm::Sp1, proof: vec![1, 2, 3].into(), @@ -220,7 +284,7 @@ async fn worker_claim_heartbeat_submit_round_trip() { .expect("the submitted proof request should exist"); assert_eq!(stored.status, DbProofStatus::Succeeded); assert!(stored.result_payload.is_some(), "submitted result payload should be persisted"); - assert_eq!(stored.submitted_by_worker_id.as_deref(), Some("worker-e2e")); + assert_eq!(stored.submitted_by_worker_id.as_deref(), Some("worker-e2e-reclaim")); server.shutdown().await; } diff --git a/crates/proof/tee/nitro-host/src/proof_generator.rs b/crates/proof/tee/nitro-host/src/proof_generator.rs index 503ae5c776..f95962d54e 100644 --- a/crates/proof/tee/nitro-host/src/proof_generator.rs +++ b/crates/proof/tee/nitro-host/src/proof_generator.rs @@ -16,7 +16,7 @@ pub use base_proof_worker::{ WorkerHeartbeatConfig as ProofGeneratorHeartbeatConfig, }; use base_prover_service_client::{ProverServiceClientError, ProverWorkerProvider}; -use base_prover_service_protocol::{ProofJob, ProofRequestKind, TeeKind}; +use base_prover_service_protocol::{AbandonProofRequest, ProofJob, ProofRequestKind, TeeKind}; use chrono::{DateTime, Utc}; use thiserror::Error; use tokio::task::JoinHandle; @@ -109,7 +109,7 @@ where "starting nitro proof generation" ); - let (proof, permit) = self + let generated = self .with_heartbeat_while_generating(&request, async { let proof = self .pool @@ -146,7 +146,19 @@ where ); } _ => {} - })?; + }); + let (proof, permit) = match generated { + Ok(generated) => generated, + Err(error) => { + if matches!( + error, + ProofGeneratorError::Generate { source: NitroEnclavePoolError::Prover(_), .. } + ) { + self.abandon_generation_failure(&request, &error).await; + } + return Err(error); + } + }; let submit_request = ProofSubmitterRequest::from_tee_proof( request.claim.session_id.clone(), @@ -171,6 +183,32 @@ where Ok(()) } + async fn abandon_generation_failure( + &self, + request: &ProofGeneratorRequest, + error: &ProofGeneratorError, + ) { + if let Err(abandon_error) = self + .submitter + .client() + .abandon_proof(AbandonProofRequest { + session_id: request.claim.session_id.clone(), + lock_id: request.claim.lock_id.clone(), + worker_id: request.claim.worker_id.clone(), + error_message: error.to_string(), + }) + .await + { + warn!( + session_id = %request.claim.session_id, + lock_id = %request.claim.lock_id, + worker_id = %request.claim.worker_id, + error = %abandon_error, + "failed to abandon nitro proof job after generation failure" + ); + } + } + async fn with_heartbeat_while_generating( &self, request: &ProofGeneratorRequest, @@ -379,8 +417,8 @@ mod tests { use base_proof_worker::ProofSubmitter; use base_prover_service_client::ProverServiceClientError; use base_prover_service_protocol::{ - GetNextProofRequest, GetNextProofResponse, GetProofSessionRequest, GetProofSessionResponse, - HeartbeatRequest, HeartbeatResponse, ProofJobStatus, ProofRequest, + AbandonProofResponse, GetNextProofRequest, GetNextProofResponse, GetProofSessionRequest, + GetProofSessionResponse, HeartbeatRequest, HeartbeatResponse, ProofJobStatus, ProofRequest, RecordProofSessionRequest, RecordProofSessionResponse, TeeKind, TeeProofRequest, WorkerSubmitProofRequest, WorkerSubmitProofResponse, }; @@ -404,6 +442,8 @@ mod tests { struct MockWorkerState { heartbeats: Vec, heartbeat_failure: Option, + abandons: Vec, + abandon_failure: bool, submissions: Vec, } @@ -422,6 +462,15 @@ mod tests { } } + fn with_abandon_failure() -> Self { + Self { + state: Arc::new(Mutex::new(MockWorkerState { + abandon_failure: true, + ..Default::default() + })), + } + } + fn heartbeats(&self) -> Vec { self.state.lock().expect("mock state lock should not be poisoned").heartbeats.clone() } @@ -429,6 +478,10 @@ mod tests { fn submissions(&self) -> Vec { self.state.lock().expect("mock state lock should not be poisoned").submissions.clone() } + + fn abandons(&self) -> Vec { + self.state.lock().expect("mock state lock should not be poisoned").abandons.clone() + } } #[async_trait] @@ -468,6 +521,28 @@ mod tests { } } + async fn abandon_proof( + &self, + request: AbandonProofRequest, + ) -> Result { + let mut state = self.state.lock().expect("mock state lock should not be poisoned"); + state.abandons.push(request); + if state.abandon_failure { + return Err(ProverServiceClientError::MissingResult( + "mock abandon failed".to_owned(), + )); + } + Ok(AbandonProofResponse { + job: proof_job( + TEST_SESSION_ID, + ProofJobStatus::Pending, + None, + None, + PrimitiveRequestKind::Tee, + ), + }) + } + async fn submit_proof( &self, request: WorkerSubmitProofRequest, @@ -530,14 +605,7 @@ mod tests { fn test_pool() -> NitroEnclavePool { let server = Arc::new(EnclaveServer::new_local().unwrap()); let transport = Arc::new(NitroTransport::local(server)); - let checker = Arc::new( - RegistrationChecker::new(vec![Arc::clone(&transport)], MockRegistry::new(false)) - .unwrap(), - ); - - NitroEnclavePool::new(test_prover_config(), Arc::clone(&transport)) - .with_registration_checker(checker) - .unwrap() + NitroEnclavePool::new(test_prover_config(), transport) } fn test_pool_with_registry(registry: MockRegistry) -> NitroEnclavePool { @@ -787,6 +855,25 @@ mod tests { let err = generator.generate_and_submit(claimed_tee_job()).await.unwrap_err(); assert!(matches!(err, ProofGeneratorError::Generate { .. })); + let abandons = client.abandons(); + assert_eq!(abandons.len(), 1); + assert_eq!(abandons[0].session_id, TEST_SESSION_ID); + assert_eq!(abandons[0].lock_id, TEST_LOCK_ID); + assert_eq!(abandons[0].worker_id, TEST_WORKER_ID); + assert!(abandons[0].error_message.contains("proof generation failed")); + assert!(client.submissions().is_empty()); + } + + #[tokio::test] + async fn abandon_failure_preserves_generation_error() { + let client = MockWorkerClient::with_abandon_failure(); + let generator = + generator_with_heartbeat(client.clone(), ProofGeneratorHeartbeatConfig::default()); + + let err = generator.generate_and_submit(claimed_tee_job()).await.unwrap_err(); + + assert!(matches!(err, ProofGeneratorError::Generate { .. })); + assert_eq!(client.abandons().len(), 1); assert!(client.submissions().is_empty()); } } diff --git a/crates/proof/worker/src/heartbeat.rs b/crates/proof/worker/src/heartbeat.rs index a0d512ccd3..eaa009b4ac 100644 --- a/crates/proof/worker/src/heartbeat.rs +++ b/crates/proof/worker/src/heartbeat.rs @@ -216,9 +216,10 @@ mod tests { use async_trait::async_trait; use base_prover_service_client::{ProverServiceClientError, ProverWorkerProvider}; use base_prover_service_protocol::{ - GetNextProofRequest, GetNextProofResponse, GetProofSessionRequest, GetProofSessionResponse, - HeartbeatRequest, HeartbeatResponse, RecordProofSessionRequest, RecordProofSessionResponse, - WorkerSubmitProofRequest, WorkerSubmitProofResponse, + AbandonProofRequest, AbandonProofResponse, GetNextProofRequest, GetNextProofResponse, + GetProofSessionRequest, GetProofSessionResponse, HeartbeatRequest, HeartbeatResponse, + RecordProofSessionRequest, RecordProofSessionResponse, WorkerSubmitProofRequest, + WorkerSubmitProofResponse, }; use tokio::time::advance; @@ -247,6 +248,13 @@ mod tests { std::future::pending().await } + async fn abandon_proof( + &self, + _request: AbandonProofRequest, + ) -> Result { + unreachable!() + } + async fn submit_proof( &self, _request: WorkerSubmitProofRequest, diff --git a/crates/proof/worker/src/job_discovery.rs b/crates/proof/worker/src/job_discovery.rs index 8578fb9168..3debb050e1 100644 --- a/crates/proof/worker/src/job_discovery.rs +++ b/crates/proof/worker/src/job_discovery.rs @@ -488,10 +488,11 @@ mod tests { use async_trait::async_trait; use base_prover_service_protocol::{ - GetNextProofResponse, GetProofSessionRequest, GetProofSessionResponse, HeartbeatRequest, - HeartbeatResponse, ProofJob, ProofJobStatus, ProofRequest, ProofRequestKind, - RecordProofSessionRequest, RecordProofSessionResponse, SnarkPlonkProofRequest, - WorkerSubmitProofRequest, WorkerSubmitProofResponse, ZkProofRequest, + AbandonProofRequest, AbandonProofResponse, GetNextProofResponse, GetProofSessionRequest, + GetProofSessionResponse, HeartbeatRequest, HeartbeatResponse, ProofJob, ProofJobStatus, + ProofRequest, ProofRequestKind, RecordProofSessionRequest, RecordProofSessionResponse, + SnarkPlonkProofRequest, WorkerSubmitProofRequest, WorkerSubmitProofResponse, + ZkProofRequest, }; use chrono::Utc; use tokio::time::timeout; @@ -564,6 +565,13 @@ mod tests { panic!("heartbeat is not used by job discovery tests") } + async fn abandon_proof( + &self, + _request: AbandonProofRequest, + ) -> Result { + panic!("abandon_proof is not used by job discovery tests") + } + async fn submit_proof( &self, _request: WorkerSubmitProofRequest, diff --git a/crates/proof/worker/src/proof_submitter.rs b/crates/proof/worker/src/proof_submitter.rs index 7396a4dcf9..efb410ee75 100644 --- a/crates/proof/worker/src/proof_submitter.rs +++ b/crates/proof/worker/src/proof_submitter.rs @@ -320,10 +320,10 @@ mod tests { use async_trait::async_trait; use base_prover_service_protocol::{ - GetNextProofRequest, GetNextProofResponse, GetProofSessionRequest, GetProofSessionResponse, - ProofJob, ProofJobStatus, ProofRequest, ProofRequestKind, ProofResult, - RecordProofSessionRequest, RecordProofSessionResponse, ZkBackend, ZkProofRequest, - ZkProofResult, ZkVm, + AbandonProofRequest, AbandonProofResponse, GetNextProofRequest, GetNextProofResponse, + GetProofSessionRequest, GetProofSessionResponse, ProofJob, ProofJobStatus, ProofRequest, + ProofRequestKind, ProofResult, RecordProofSessionRequest, RecordProofSessionResponse, + ZkBackend, ZkProofRequest, ZkProofResult, ZkVm, }; use chrono::Utc; use tokio::{sync::Notify, time::timeout}; @@ -382,6 +382,13 @@ mod tests { panic!("heartbeat is not used by proof submitter tests") } + async fn abandon_proof( + &self, + _request: AbandonProofRequest, + ) -> Result { + panic!("abandon_proof is not used by proof submitter tests") + } + async fn submit_proof( &self, request: WorkerSubmitProofRequest, diff --git a/crates/proof/zk/host/src/proof_generator.rs b/crates/proof/zk/host/src/proof_generator.rs index 3285e0e119..9bc67abab9 100644 --- a/crates/proof/zk/host/src/proof_generator.rs +++ b/crates/proof/zk/host/src/proof_generator.rs @@ -16,8 +16,8 @@ pub use base_proof_worker::{ }; use base_prover_service_client::{ProverServiceClientError, ProverWorkerProvider}; use base_prover_service_protocol::{ - BackendSession, BackendSessionState, ProofJob, ProofRequestKind, ProofResult, SessionType, - WorkerSubmitProofRequest, ZkBackend, + AbandonProofRequest, BackendSession, BackendSessionState, ProofJob, ProofRequestKind, + ProofResult, SessionType, WorkerSubmitProofRequest, ZkBackend, }; use chrono::{DateTime, Utc}; use thiserror::Error; @@ -155,7 +155,7 @@ where "starting zk proof generation" ); - let (result, permit) = self + let generated = self .with_heartbeat_while_generating(&request, async { let result = self.prove_to_completion(&request).await?; let permit = self.tasks.acquire_submission_permit().await; @@ -184,7 +184,23 @@ where ); } _ => {} - })?; + }); + let (result, permit) = match generated { + Ok(generated) => generated, + Err(error) => { + if matches!( + error, + ProofGeneratorError::Generate { + source: ZkProverError::BackendSessionFailed { .. } + | ZkProverError::BackendSessionNotFound { .. }, + .. + } + ) { + self.abandon_generation_failure(&request, &error).await; + } + return Err(error); + } + }; let submit_request = WorkerSubmitProofRequest::try_from(ProofSubmitterRequest { session_id: request.claim.session_id.clone(), @@ -209,6 +225,32 @@ where Ok(()) } + async fn abandon_generation_failure( + &self, + request: &ProofGeneratorRequest, + error: &ProofGeneratorError, + ) { + if let Err(abandon_error) = self + .submitter + .client() + .abandon_proof(AbandonProofRequest { + session_id: request.claim.session_id.clone(), + lock_id: request.claim.lock_id.clone(), + worker_id: request.claim.worker_id.clone(), + error_message: error.to_string(), + }) + .await + { + warn!( + session_id = %request.claim.session_id, + lock_id = %request.claim.lock_id, + worker_id = %request.claim.worker_id, + error = %abandon_error, + "failed to abandon zk proof job after generation failure" + ); + } + } + async fn prove_to_completion( &self, request: &ProofGeneratorRequest, diff --git a/etc/systems/src/zk_host.rs b/etc/systems/src/zk_host.rs index 960b3b8613..98afb8d115 100644 --- a/etc/systems/src/zk_host.rs +++ b/etc/systems/src/zk_host.rs @@ -177,6 +177,13 @@ impl ProverWorkerProvider for FirstPollWorker { self.inner.heartbeat(request).await } + async fn abandon_proof( + &self, + request: base_prover_service_protocol::AbandonProofRequest, + ) -> Result { + self.inner.abandon_proof(request).await + } + async fn submit_proof( &self, request: WorkerSubmitProofRequest, From ed5abaf84f4e240347369a572320cee7b993671a Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 23 Sep 2026 08:22:07 +0000 Subject: [PATCH 088/299] feat(basectl): add the batcher command group (#5184) * feat(basectl): add the batcher command group * fix(basectl): report batcher rejections readably and tighten the batcher docs and tests * refactor(basectl): drop the batcher stop wait from the client and the docs * refactor(basectl): resolve the batcher admin RPC like the other internal services --- bin/basectl/README.md | 63 ++++ crates/infra/basectl/README.md | 2 +- crates/infra/basectl/src/commands/batcher.rs | 327 +++++++++++++++++++ crates/infra/basectl/src/commands/cli.rs | 35 +- crates/infra/basectl/src/commands/mod.rs | 6 + crates/infra/basectl/src/commands/p2p.rs | 1 + crates/infra/basectl/src/config.rs | 51 +++ crates/infra/basectl/src/doctor.rs | 1 + crates/infra/basectl/src/errors.rs | 50 +++ crates/infra/basectl/src/lib.rs | 60 ++-- crates/infra/basectl/src/rpc/batcher.rs | 144 ++++++++ crates/infra/basectl/src/rpc/mod.rs | 3 + 12 files changed, 712 insertions(+), 31 deletions(-) create mode 100644 crates/infra/basectl/src/commands/batcher.rs create mode 100644 crates/infra/basectl/src/rpc/batcher.rs diff --git a/bin/basectl/README.md b/bin/basectl/README.md index 428aa97738..87781b36a6 100644 --- a/bin/basectl/README.md +++ b/bin/basectl/README.md @@ -315,6 +315,48 @@ Safety notes: before reporting success so an acknowledged RPC is not confused with the node actually reaching the desired state. +### `basectl batcher` + +Batcher inspection and control commands, served by the batcher admin RPC. + +- `basectl batcher status` shows whether batch submission is stopped, the number + of L1 submissions in flight, and the DA backlog, through + `admin_getBatcherStatus`. +- `basectl batcher stop` stops batch submission through `admin_stopBatcher`. The + batcher drops its buffered encoding state and the process keeps running. + Submissions already in flight keep settling, and `status` reports how many + remain. It does nothing if the batcher is already stopped. +- `basectl batcher start` starts batch submission again from the safe L2 head + through `admin_startBatcher`. It does nothing if the batcher is already running. +- `basectl batcher flush` closes the current channel through `admin_flushBatcher` + so its frames become eligible for submission. It fails while the batcher is + stopped. + +The admin RPC URL comes from the `--batcher-rpc` flag, then the +`BASECTL_BATCHER_RPC` environment variable, then the selected config's +`batcher_rpc` field. The built-in presets ship without a `batcher_rpc` because +the batcher admin server is internal, so one of the three must be provided. + +| Flag | Description | +| --------------------- | ------------------------------------------------------------------------------- | +| `--batcher-rpc ` | Batcher admin RPC URL. Also `BASECTL_BATCHER_RPC` or config `batcher_rpc`. | +| `--json` | For `status`, emit a structured JSON status instead of the pretty table output. | + +Destructive batcher commands also support: + +| Flag | Description | +| -------- | -------------------------------------------------------------------------------------------------------------------------- | +| `--yes` | Skip the interactive confirmation prompt. | +| `--json` | Emit a structured action outcome instead of pretty text. Requires `--yes` so scripts do not hang on an interactive prompt. | + +Safety notes: + +- `stop`, `start` and `flush` prompt with the network name and the admin RPC URL. +- The network name in prompts and output is only the selected config. basectl + cannot check which chain the batcher behind the admin RPC URL serves. +- Output, logs and errors only show the origin of the admin RPC URL, so + credentials in the URL never leak. + ### `basectl doctor` Runs read-only diagnostics for a single node and prints one row per check. The @@ -628,6 +670,27 @@ basectl -c devnet sequencer start op-conductor-0 --yes basectl -c devnet sequencer start op-conductor-0 0x1111111111111111111111111111111111111111111111111111111111111111 --yes --json | jq . ``` +### `basectl batcher` + +```sh +# Show the batcher state, as JSON +basectl -c sepolia batcher status --batcher-rpc http://your-batcher.example/ --json | jq . + +# The commands below rely on `BASECTL_BATCHER_RPC` or `batcher_rpc` from the selected config + +# Show the batcher state +basectl -c sepolia batcher status + +# Stop batch submission +basectl -c sepolia batcher stop --yes + +# Start batch submission again +basectl -c sepolia batcher start --yes --json | jq . + +# Close the current channel so its frames can be submitted +basectl -c sepolia batcher flush --yes +``` + ### `basectl doctor` ```sh diff --git a/crates/infra/basectl/README.md b/crates/infra/basectl/README.md index 0294671583..0f65bee0d7 100644 --- a/crates/infra/basectl/README.md +++ b/crates/infra/basectl/README.md @@ -5,7 +5,7 @@ CLI parser, command implementations, and interactive monitor for Base infrastruc ## Overview Owns the `basectl` clap parser and all command behavior, including block, sync, -txpool, peer, proof, conductor, sequencer, and diagnostic workflows. `Cli::run` +txpool, peer, proof, conductor, sequencer, batcher, and diagnostic workflows. `Cli::run` dispatches parsed commands and returns a process outcome. The crate also provides the interactive terminal monitor for block production, diff --git a/crates/infra/basectl/src/commands/batcher.rs b/crates/infra/basectl/src/commands/batcher.rs new file mode 100644 index 0000000000..950aea662d --- /dev/null +++ b/crates/infra/basectl/src/commands/batcher.rs @@ -0,0 +1,327 @@ +//! Implementation of the `basectl batcher` command group. + +use std::io::{self, Write}; + +use anyhow::Result; +use clap::{Args, Subcommand}; +use serde::Serialize; +use tracing::{debug, info, warn}; +use url::Url; + +use crate::{BatcherClient, BatcherStatus, Confirm, JsonOutput, KeyValueTable, MonitoringConfig}; + +/// Inspect and control the batcher through its admin RPC. +#[derive(Debug, Args)] +pub struct BatcherCommand { + /// Batcher operation to run. + #[command(subcommand)] + pub command: BatcherCommands, +} + +/// Batcher inspection and control commands. +#[derive(Debug, Subcommand)] +pub enum BatcherCommands { + /// Show whether the batcher is stopped, its in-flight submissions and its DA backlog. + Status(BatcherStatusArgs), + /// Stop batch submission. Submissions already in flight keep settling. + Stop(BatcherActionArgs), + /// Start batch submission again from the safe L2 head. + Start(BatcherActionArgs), + /// Close the current channel so its frames become eligible for submission. + Flush(BatcherActionArgs), +} + +/// Flags for `basectl batcher status`. +#[derive(Debug, Args)] +pub struct BatcherStatusArgs { + /// Batcher admin RPC URL. Overrides `batcher_rpc` from the selected config. + #[arg(long = "batcher-rpc", env = "BASECTL_BATCHER_RPC", value_name = "URL")] + pub batcher_rpc: Option, + /// Emit a structured JSON status instead of pretty text. + #[arg(long)] + pub json: bool, +} + +/// Flags for the mutating `basectl batcher` actions. +#[derive(Debug, Args)] +pub struct BatcherActionArgs { + /// Batcher admin RPC URL. Overrides `batcher_rpc` from the selected config. + #[arg(long = "batcher-rpc", env = "BASECTL_BATCHER_RPC", value_name = "URL")] + pub batcher_rpc: Option, + /// Skip the interactive confirmation prompt. + #[arg(long)] + pub yes: bool, + /// Emit a structured JSON action outcome instead of pretty text. + #[arg(long, requires = "yes")] + pub json: bool, +} + +impl BatcherCommand { + /// Runs the selected batcher subcommand. + pub async fn run(self, config: MonitoringConfig) -> Result<()> { + match self.command { + BatcherCommands::Status(args) => run_status(config, args).await, + BatcherCommands::Stop(args) => run_action(config, BatcherAction::Stop, args).await, + BatcherCommands::Start(args) => run_action(config, BatcherAction::Start, args).await, + BatcherCommands::Flush(args) => run_action(config, BatcherAction::Flush, args).await, + } + } +} + +async fn run_status(config: MonitoringConfig, args: BatcherStatusArgs) -> Result<()> { + let rpc = config.resolve_batcher_rpc(args.batcher_rpc.as_ref())?; + let display_rpc = BatcherClient::display_url(&rpc); + info!( + network = %config.name, + rpc = %display_rpc, + json = args.json, + "running batcher status command" + ); + + let status = BatcherClient::status(&rpc).await.inspect_err(|error| { + warn!(error = %error, network = %config.name, rpc = %display_rpc, "batcher status failed"); + })?; + + let status = BatcherStatusJson::new(&config.name, &rpc, status); + if args.json { + JsonOutput::print(&status)?; + } else { + print_status_pretty(&status)?; + } + Ok(()) +} + +async fn run_action( + config: MonitoringConfig, + action: BatcherAction, + args: BatcherActionArgs, +) -> Result<()> { + let rpc = config.resolve_batcher_rpc(args.batcher_rpc.as_ref())?; + let display_rpc = BatcherClient::display_url(&rpc); + info!( + network = %config.name, + rpc = %display_rpc, + action = %action.as_str(), + json = args.json, + yes = args.yes, + "running batcher action command" + ); + + let prompt = format!("{} on {} ({display_rpc})? [y/N] ", action.prompt(), config.name); + if !Confirm::prompt_or_abort(&prompt, args.yes)? { + debug!( + network = %config.name, + rpc = %display_rpc, + action = %action.as_str(), + "batcher action confirmation declined" + ); + return Ok(()); + } + + let result = match action { + BatcherAction::Stop => BatcherClient::stop(&rpc).await, + BatcherAction::Start => BatcherClient::start(&rpc).await, + BatcherAction::Flush => BatcherClient::flush(&rpc).await, + }; + result.inspect_err(|error| { + warn!( + error = %error, + network = %config.name, + rpc = %display_rpc, + action = %action.as_str(), + "batcher action failed" + ); + })?; + + let outcome = BatcherActionJson::new(&config.name, &rpc, action); + JsonOutput::print_or_ok(&outcome, &outcome.message, args.json)?; + info!( + network = %config.name, + rpc = %display_rpc, + action = %action.as_str(), + "batcher action completed" + ); + Ok(()) +} + +/// Mutating batcher admin action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BatcherAction { + /// Stop batch submission. + Stop, + /// Start batch submission again. + Start, + /// Close the current channel. + Flush, +} + +impl BatcherAction { + /// Returns the action name used in logs. + pub const fn as_str(self) -> &'static str { + match self { + Self::Stop => "stop", + Self::Start => "start", + Self::Flush => "flush", + } + } + + /// Returns the question asked before running the action. + pub const fn prompt(self) -> &'static str { + match self { + Self::Stop => "Stop batch submission", + Self::Start => "Start batch submission", + Self::Flush => "Flush the current channel", + } + } + + /// Returns the result reported once the batcher has applied the action. + pub const fn message(self) -> &'static str { + match self { + Self::Stop => "batch submission stopped", + Self::Start => "batch submission running", + Self::Flush => "current channel flushed", + } + } +} + +/// JSON shape for `basectl batcher status`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BatcherStatusJson { + /// Network name. + pub network: String, + /// Batcher admin RPC URL, without credentials. + pub rpc: String, + /// Whether batch submission is stopped. + pub stopped: bool, + /// Number of L1 transactions submitted but not yet confirmed. + pub in_flight: u64, + /// Estimated unsubmitted DA backlog in bytes. + pub da_backlog_bytes: u64, +} + +impl BatcherStatusJson { + /// Builds the status output for one batcher. + pub fn new(network: &str, rpc: &Url, status: BatcherStatus) -> Self { + Self { + network: network.to_string(), + rpc: BatcherClient::display_url(rpc), + stopped: status.stopped, + in_flight: status.in_flight, + da_backlog_bytes: status.da_backlog_bytes, + } + } +} + +/// JSON shape for the mutating `basectl batcher` actions. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BatcherActionJson { + /// Network name. + pub network: String, + /// Batcher admin RPC URL, without credentials. + pub rpc: String, + /// Action performed. + pub action: BatcherAction, + /// Human-readable result. + pub message: String, +} + +impl BatcherActionJson { + /// Builds the outcome of an action the batcher has applied. + pub fn new(network: &str, rpc: &Url, action: BatcherAction) -> Self { + Self { + network: network.to_string(), + rpc: BatcherClient::display_url(rpc), + action, + message: action.message().to_string(), + } + } +} + +fn print_status_pretty(status: &BatcherStatusJson) -> Result<()> { + let mut stdout = io::stdout().lock(); + print_status_pretty_to(&mut stdout, status)?; + Ok(()) +} + +fn print_status_pretty_to(writer: &mut W, status: &BatcherStatusJson) -> Result<()> { + let mut table = KeyValueTable::new(); + table + .row("network", status.network.as_str()) + .row("rpc", status.rpc.as_str()) + .row("stopped", status.stopped.to_string()) + .row("in flight", status.in_flight.to_string()) + .row("da backlog bytes", status.da_backlog_bytes.to_string()); + table.render(writer)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn rpc() -> Url { + Url::parse("http://operator:secret@127.0.0.1:6545").unwrap() + } + + #[test] + fn status_json_is_camel_case_and_hides_credentials() { + let status = BatcherStatus { stopped: true, in_flight: 2, da_backlog_bytes: 1024 }; + + let value = + serde_json::to_value(BatcherStatusJson::new("sepolia", &rpc(), status)).unwrap(); + + assert_eq!( + value, + json!({ + "network": "sepolia", + "rpc": "http://127.0.0.1:6545", + "stopped": true, + "inFlight": 2, + "daBacklogBytes": 1024, + }) + ); + } + + #[test] + fn action_json_names_the_action() { + let outcome = BatcherActionJson::new("sepolia", &rpc(), BatcherAction::Stop); + + assert_eq!( + serde_json::to_value(outcome).unwrap(), + json!({ + "network": "sepolia", + "rpc": "http://127.0.0.1:6545", + "action": "stop", + "message": "batch submission stopped", + }) + ); + } + + #[test] + fn pretty_status_lists_every_field() { + let status = BatcherStatus { stopped: false, in_flight: 3, da_backlog_bytes: 42 }; + let mut rendered = Vec::new(); + + print_status_pretty_to(&mut rendered, &BatcherStatusJson::new("mainnet", &rpc(), status)) + .unwrap(); + + let rendered = String::from_utf8(rendered).unwrap(); + for (label, value) in [ + ("network", "mainnet"), + ("rpc", "http://127.0.0.1:6545"), + ("stopped", "false"), + ("in flight", "3"), + ("da backlog bytes", "42"), + ] { + assert!( + rendered.lines().any(|line| line.starts_with(label) && line.ends_with(value)), + "missing `{label}` row with `{value}` in {rendered}" + ); + } + } +} diff --git a/crates/infra/basectl/src/commands/cli.rs b/crates/infra/basectl/src/commands/cli.rs index 4d23e86f8b..e3f06b8b4d 100644 --- a/crates/infra/basectl/src/commands/cli.rs +++ b/crates/infra/basectl/src/commands/cli.rs @@ -5,8 +5,8 @@ use clap::{CommandFactory, Parser, Subcommand}; use url::Url; use super::{ - BlockCommand, CommandOutcome, ConductorCommand, DoctorCommand, P2pCommand, ProofsCommand, - SequencerCommand, SyncStatusCommand, TxpoolCommand, UpgradeReadinessCommand, + BatcherCommand, BlockCommand, CommandOutcome, ConductorCommand, DoctorCommand, P2pCommand, + ProofsCommand, SequencerCommand, SyncStatusCommand, TxpoolCommand, UpgradeReadinessCommand, }; use crate::{MonitoringConfig, ViewId, run_app, run_flashblocks_json}; @@ -58,6 +58,8 @@ pub enum Commands { Conductor(ConductorCommand), /// Inspect and control sequencer activity on HA conductor nodes. Sequencer(SequencerCommand), + /// Inspect and control the batcher through its admin RPC. + Batcher(BatcherCommand), /// Run read-only diagnostics for a single node. Doctor(DoctorCommand), /// Request and inspect ZK proofs on the internal prover service. @@ -131,6 +133,9 @@ impl Cli { Commands::Sequencer(command) => { command.run(config, conductor_rpc).await.map(|()| CommandOutcome::Success) } + Commands::Batcher(command) => { + command.run(config).await.map(|()| CommandOutcome::Success) + } Commands::Proofs(command) => command.run(config).await, Commands::Doctor(command) => command.run(config).await, Commands::Flashblocks => { @@ -337,6 +342,32 @@ mod tests { assert!(try_parse(["basectl", "txpool", "clear", "--yes", "--json"]).is_ok()); } + #[test] + fn batcher_commands_parse() { + for command in ["status", "stop", "start", "flush"] { + assert!(try_parse(["basectl", "batcher", command]).is_ok()); + } + assert!( + try_parse([ + "basectl", + "batcher", + "status", + "--batcher-rpc", + "http://127.0.0.1:6545", + "--json", + ]) + .is_ok() + ); + } + + #[test] + fn destructive_batcher_json_requires_yes() { + for command in ["stop", "start", "flush"] { + assert!(try_parse(["basectl", "batcher", command, "--json"]).is_err()); + assert!(try_parse(["basectl", "batcher", command, "--yes", "--json"]).is_ok()); + } + } + #[test] fn unban_all_rejects_el_rpc() { assert!( diff --git a/crates/infra/basectl/src/commands/mod.rs b/crates/infra/basectl/src/commands/mod.rs index 9e3de5181f..9bdd8e06c4 100644 --- a/crates/infra/basectl/src/commands/mod.rs +++ b/crates/infra/basectl/src/commands/mod.rs @@ -1,5 +1,11 @@ //! CLI parsing and command argument types for basectl. +mod batcher; +pub use batcher::{ + BatcherAction, BatcherActionArgs, BatcherActionJson, BatcherCommand, BatcherCommands, + BatcherStatusArgs, BatcherStatusJson, +}; + mod block; pub use block::{BlockCommand, BlockSummaryJson}; diff --git a/crates/infra/basectl/src/commands/p2p.rs b/crates/infra/basectl/src/commands/p2p.rs index d2ed80ca30..ab8ab66de4 100644 --- a/crates/infra/basectl/src/commands/p2p.rs +++ b/crates/infra/basectl/src/commands/p2p.rs @@ -895,6 +895,7 @@ mod tests { consensus_node_rpc, chain_id: None, prover_rpc: None, + batcher_rpc: None, upgrades: None, system_config: Address::ZERO, batcher_address: None, diff --git a/crates/infra/basectl/src/config.rs b/crates/infra/basectl/src/config.rs index 7d8cddf3fb..61caa27273 100644 --- a/crates/infra/basectl/src/config.rs +++ b/crates/infra/basectl/src/config.rs @@ -375,6 +375,13 @@ pub struct MonitoringConfig { /// config, via `BASECTL_PROVER_RPC`, or with `--prover-rpc`. #[serde(default, skip_serializing_if = "Option::is_none")] pub prover_rpc: Option, + /// Optional batcher admin JSON-RPC endpoint URL. + /// + /// Used by the `basectl batcher` command group. The built-in presets leave + /// this unset because the batcher admin server is internal; set it in your + /// YAML config, via `BASECTL_BATCHER_RPC`, or with `--batcher-rpc`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub batcher_rpc: Option, /// Live rollup upgrade configuration fetched from the consensus node when available. #[serde(default, skip_serializing_if = "Option::is_none", alias = "hardforks")] pub upgrades: Option, @@ -435,6 +442,19 @@ impl MonitoringConfig { }) } + /// Resolves the batcher admin RPC URL from the flag override or this config. + pub fn resolve_batcher_rpc( + &self, + override_url: Option<&Url>, + ) -> Result { + if let Some(url) = override_url { + return Ok(url.clone()); + } + self.batcher_rpc + .clone() + .ok_or_else(|| crate::MissingBatcherRpcError { config_name: self.name.clone() }) + } + /// Returns the block explorer base URL for this chain, if known. pub fn explorer_base_url(&self) -> Option<&'static str> { match self.name.as_str() { @@ -529,6 +549,7 @@ struct MonitoringConfigOverride { consensus_node_rpc: Option, chain_id: Option, prover_rpc: Option, + batcher_rpc: Option, #[serde(alias = "hardforks")] upgrades: Option, #[serde(default)] @@ -581,6 +602,7 @@ impl MonitoringConfig { consensus_node_rpc: Some(Url::parse("http://127.0.0.1:9545").unwrap()), chain_id: Some(8453), prover_rpc: None, + batcher_rpc: None, upgrades: Some(rollup.upgrades), system_config: rollup.l1_system_config_address, batcher_address: Some("0x5050F69a9786F081509234F1a7F4684b5E5b76C9".parse().unwrap()), @@ -609,6 +631,7 @@ impl MonitoringConfig { consensus_node_rpc: Some(Url::parse("http://127.0.0.1:9545").unwrap()), chain_id: Some(84532), prover_rpc: None, + batcher_rpc: None, upgrades: Some(rollup.upgrades), system_config: rollup.l1_system_config_address, batcher_address: Some("0xfc56E7272EEBBBA5bC6c544e159483C4a38f8bA3".parse().unwrap()), @@ -644,6 +667,7 @@ impl MonitoringConfig { // Populated from optimism_rollupConfig in load_devnet. chain_id: None, prover_rpc: None, + batcher_rpc: None, upgrades: None, // These will be populated by fetch_rollup_config system_config: Address::ZERO, @@ -823,6 +847,7 @@ impl MonitoringConfig { consensus_node_rpc: overrides.consensus_node_rpc.or(base.consensus_node_rpc), chain_id: overrides.chain_id.or(base.chain_id), prover_rpc: overrides.prover_rpc.or(base.prover_rpc), + batcher_rpc: overrides.batcher_rpc.or(base.batcher_rpc), upgrades: overrides.upgrades.or(base.upgrades), system_config: overrides.system_config.unwrap_or(base.system_config), batcher_address: overrides.batcher_address.or(base.batcher_address), @@ -887,6 +912,32 @@ mod tests { assert_eq!(error.config_name, "mainnet"); } + #[test] + fn resolve_batcher_rpc_prefers_override() { + let config = MonitoringConfig::mainnet(); + let override_url = Url::parse("http://127.0.0.1:16545").unwrap(); + + assert_eq!(config.resolve_batcher_rpc(Some(&override_url)).unwrap(), override_url); + } + + #[test] + fn resolve_batcher_rpc_falls_back_to_config() { + let mut config = MonitoringConfig::sepolia(); + let configured = Url::parse("http://127.0.0.1:16545").unwrap(); + config.batcher_rpc = Some(configured.clone()); + + assert_eq!(config.resolve_batcher_rpc(None).unwrap(), configured); + } + + #[test] + fn resolve_batcher_rpc_errors_without_config() { + let config = MonitoringConfig::mainnet(); + + let error = config.resolve_batcher_rpc(None).unwrap_err(); + + assert_eq!(error.config_name, "mainnet"); + } + #[test] fn test_builtin_configs() { let mainnet = MonitoringConfig::mainnet(); diff --git a/crates/infra/basectl/src/doctor.rs b/crates/infra/basectl/src/doctor.rs index 53e1a20248..516d0b70a5 100644 --- a/crates/infra/basectl/src/doctor.rs +++ b/crates/infra/basectl/src/doctor.rs @@ -1424,6 +1424,7 @@ mod tests { consensus_node_rpc: None, chain_id, prover_rpc: None, + batcher_rpc: None, upgrades: None, system_config: alloy_primitives::Address::ZERO, batcher_address: None, diff --git a/crates/infra/basectl/src/errors.rs b/crates/infra/basectl/src/errors.rs index 220c84c6bc..8cfb6f2a78 100644 --- a/crates/infra/basectl/src/errors.rs +++ b/crates/infra/basectl/src/errors.rs @@ -364,6 +364,56 @@ pub struct MissingConsensusRpcError { pub config_name: String, } +/// Error returned when a command cannot resolve a batcher admin RPC URL from flags or config. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[non_exhaustive] +#[error( + "batcher commands need a batcher admin RPC URL.\n\ + The '{config_name}' config does not set `batcher_rpc`.\n\ + Override with `--batcher-rpc `, set `BASECTL_BATCHER_RPC`, \ + or set `batcher_rpc` in your YAML config." +)] +pub struct MissingBatcherRpcError { + /// The config name selected for the command. + pub config_name: String, +} + +/// Error returned by batcher admin RPC helpers. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum BatcherCommandError { + /// The batcher admin client could not be built. + #[error("failed to build batcher admin client for {rpc}")] + BuildClient { + /// The batcher admin RPC URL selected for the command, without credentials. + rpc: String, + /// The underlying client construction error. + #[source] + source: JsonRpcClientError, + }, + /// The batcher refused the request, for example a flush while it is stopped. + #[error("batcher at {rpc} rejected `{method}`: {message}")] + Rejected { + /// The batcher admin RPC URL selected for the command, without credentials. + rpc: String, + /// The admin RPC method that was rejected. + method: &'static str, + /// The reason given by the batcher. + message: String, + }, + /// A batcher admin RPC call failed. + #[error("batcher admin RPC method `{method}` failed on {rpc}")] + Rpc { + /// The batcher admin RPC URL selected for the command, without credentials. + rpc: String, + /// The admin RPC method that failed. + method: &'static str, + /// The underlying RPC error. + #[source] + source: JsonRpcClientError, + }, +} + /// Error returned by txpool RPC helpers and command execution. #[derive(Debug, Error)] #[non_exhaustive] diff --git a/crates/infra/basectl/src/lib.rs b/crates/infra/basectl/src/lib.rs index 5e37884dfb..f075e43a23 100644 --- a/crates/infra/basectl/src/lib.rs +++ b/crates/infra/basectl/src/lib.rs @@ -2,23 +2,25 @@ mod commands; pub use commands::{ - AddTarget, BanAction, BlockCommand, BlockSummaryJson, Cli, ClusterNodeScope, CommandOutcome, - Commands, ConductorAction, ConductorActionJson, ConductorActionName, - ConductorClusterActionArgs, ConductorCommand, ConductorCommands, ConductorFailureJson, - ConductorFanoutJson, ConductorLeaderArgs, ConductorNodeActionArgs, ConductorNodeJson, - ConductorStatusArgs, ConductorStatusJson, DestructiveClBulkArgs, DestructivePeerArgs, - DoctorCommand, ElSyncInfoJson, ExecutionStatsJson, FinalizeTarget, GameDetailsJson, - GameSummaryJson, GamesListJson, HeadJson, LeadershipStatus, MonitorCommands, NodeMetricsJson, - OptionalValue, P2pArgs, P2pCommand, P2pCommands, PausedSummaryJson, PeerAction, PeerActionJson, - PeerBulkAction, PeerBulkActionResultJson, PeerLayer, PeerTarget, PeersJson, ProofOutputStatus, - ProofResultJson, ProofStatusFilter, ProofSummaryJson, ProofsCommand, ProofsCommands, - ProofsFinalizeArgs, ProofsGamesArgs, ProofsListArgs, ProofsListJson, ProofsProposeArgs, - ProofsProposeJson, ProofsStatusArgs, ProofsStatusJson, ProofsSubmitArgs, ProofsSubmitJson, - SequencerAction, SequencerActionJson, SequencerCommand, SequencerCommands, - SequencerNodeActionArgs, SequencerNodeJson, SequencerRole, SequencerStartArgs, - SequencerStatusArgs, SequencerStatusJson, SyncStatusCommand, SyncStatusJson, TipReferenceJson, - TipStatus, TxpoolClearArgs, TxpoolClearJson, TxpoolCommand, TxpoolCommands, TxpoolReadArgs, - TxpoolReadJson, UnsafeHeadSource, UpgradeReadinessCommand, ZkBackendOption, + AddTarget, BanAction, BatcherAction, BatcherActionArgs, BatcherActionJson, BatcherCommand, + BatcherCommands, BatcherStatusArgs, BatcherStatusJson, BlockCommand, BlockSummaryJson, Cli, + ClusterNodeScope, CommandOutcome, Commands, ConductorAction, ConductorActionJson, + ConductorActionName, ConductorClusterActionArgs, ConductorCommand, ConductorCommands, + ConductorFailureJson, ConductorFanoutJson, ConductorLeaderArgs, ConductorNodeActionArgs, + ConductorNodeJson, ConductorStatusArgs, ConductorStatusJson, DestructiveClBulkArgs, + DestructivePeerArgs, DoctorCommand, ElSyncInfoJson, ExecutionStatsJson, FinalizeTarget, + GameDetailsJson, GameSummaryJson, GamesListJson, HeadJson, LeadershipStatus, MonitorCommands, + NodeMetricsJson, OptionalValue, P2pArgs, P2pCommand, P2pCommands, PausedSummaryJson, + PeerAction, PeerActionJson, PeerBulkAction, PeerBulkActionResultJson, PeerLayer, PeerTarget, + PeersJson, ProofOutputStatus, ProofResultJson, ProofStatusFilter, ProofSummaryJson, + ProofsCommand, ProofsCommands, ProofsFinalizeArgs, ProofsGamesArgs, ProofsListArgs, + ProofsListJson, ProofsProposeArgs, ProofsProposeJson, ProofsStatusArgs, ProofsStatusJson, + ProofsSubmitArgs, ProofsSubmitJson, SequencerAction, SequencerActionJson, SequencerCommand, + SequencerCommands, SequencerNodeActionArgs, SequencerNodeJson, SequencerRole, + SequencerStartArgs, SequencerStatusArgs, SequencerStatusJson, SyncStatusCommand, + SyncStatusJson, TipReferenceJson, TipStatus, TxpoolClearArgs, TxpoolClearJson, TxpoolCommand, + TxpoolCommands, TxpoolReadArgs, TxpoolReadJson, UnsafeHeadSource, UpgradeReadinessCommand, + ZkBackendOption, }; mod denim; @@ -63,22 +65,24 @@ pub use doctor::{ mod errors; pub use errors::{ - BlockRefParseError, ConductorCommandError, DoctorArgsError, MissingConsensusRpcError, - NodeLookupError, P2pCommandError, P2pTargetError, ProofsCommandError, SequencerCommandError, - StateConvergenceTimeoutError, TxpoolCommandError, + BatcherCommandError, BlockRefParseError, ConductorCommandError, DoctorArgsError, + MissingBatcherRpcError, MissingConsensusRpcError, NodeLookupError, P2pCommandError, + P2pTargetError, ProofsCommandError, SequencerCommandError, StateConvergenceTimeoutError, + TxpoolCommandError, }; mod rpc; pub use rpc::{ BacklogBlock, BacklogFetchResult, BacklogProgress, BaseTxpoolContent, BaseTxpoolContentFrom, - BlockDaInfo, ClInfoReport, ClNodeIdentity, ConductorClusterSnapshot, ConductorControl, - ConductorFanoutAction, ConductorFanoutReport, ConductorNodeFailure, ConductorNodeStatus, - ConductorPollUpdate, DiscoveryInfo, EXPECTED_RESOLUTION_NEVER, ElInfoReport, ElNodeIdentity, - GameDetails, GameListFilter, GameStatus, GameSummary, GamesClient, InitialBacklog, L1BlockInfo, - L1ConnectionMode, LatestProposal, NodeEndpoint, NodeInfoReport, PausedPeers, PeerDirection, - PeerListReport, PeerStatsReport, PeerSummary, PodGroupStatus, PodStatus, PodsPoller, - PodsSnapshot, ProofProposeRequest, ProofsClient, ProofsSnapshot, ProposalProofSubmitter, - RawInfoReport, RawPeerCounts, RawPeersReport, ReachabilityOutcome, ReachabilityResponse, + BatcherClient, BatcherStatus, BlockDaInfo, ClInfoReport, ClNodeIdentity, + ConductorClusterSnapshot, ConductorControl, ConductorFanoutAction, ConductorFanoutReport, + ConductorNodeFailure, ConductorNodeStatus, ConductorPollUpdate, DiscoveryInfo, + EXPECTED_RESOLUTION_NEVER, ElInfoReport, ElNodeIdentity, GameDetails, GameListFilter, + GameStatus, GameSummary, GamesClient, InitialBacklog, L1BlockInfo, L1ConnectionMode, + LatestProposal, NodeEndpoint, NodeInfoReport, PausedPeers, PeerDirection, PeerListReport, + PeerStatsReport, PeerSummary, PodGroupStatus, PodStatus, PodsPoller, PodsSnapshot, + ProofProposeRequest, ProofsClient, ProofsSnapshot, ProposalProofSubmitter, RawInfoReport, + RawPeerCounts, RawPeersReport, ReachabilityOutcome, ReachabilityResponse, SEQUENCER_ACTIVE_RPC_TIMEOUT, SnarkPlonkProofBytes, SubmittedProof, SubmitterKey, SyncStatusReport, TelemetryApiError, TelemetryClient, TelemetryClientError, TelemetryErrorResponse, TimestampedFlashblock, TxSummary, TxpoolClient, TxpoolCounts, diff --git a/crates/infra/basectl/src/rpc/batcher.rs b/crates/infra/basectl/src/rpc/batcher.rs new file mode 100644 index 0000000000..252f282ea8 --- /dev/null +++ b/crates/infra/basectl/src/rpc/batcher.rs @@ -0,0 +1,144 @@ +//! Batcher admin JSON-RPC client. + +use std::time::Duration; + +use jsonrpsee::{ + core::client::{ClientT, Error as JsonRpcClientError}, + http_client::HttpClientBuilder, + rpc_params, +}; +use serde::{Deserialize, de::DeserializeOwned}; +use url::Url; + +use crate::errors::BatcherCommandError; + +/// Runtime state returned by `admin_getBatcherStatus`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub struct BatcherStatus { + /// Whether batch submission is stopped. + pub stopped: bool, + /// Number of L1 transactions submitted but not yet confirmed. + pub in_flight: u64, + /// Estimated unsubmitted DA backlog in bytes. + pub da_backlog_bytes: u64, +} + +/// Batcher admin RPC client helpers. +#[derive(Debug)] +pub struct BatcherClient; + +impl BatcherClient { + /// Timeout of every batcher admin RPC call. + pub const RPC_TIMEOUT: Duration = Duration::from_secs(10); + + /// Reads the batcher runtime state via `admin_getBatcherStatus`. + pub async fn status(rpc: &Url) -> Result { + Self::call(rpc, "admin_getBatcherStatus").await + } + + /// Stops batch submission via `admin_stopBatcher`. + pub async fn stop(rpc: &Url) -> Result<(), BatcherCommandError> { + Self::call(rpc, "admin_stopBatcher").await + } + + /// Starts batch submission again via `admin_startBatcher`. + pub async fn start(rpc: &Url) -> Result<(), BatcherCommandError> { + Self::call(rpc, "admin_startBatcher").await + } + + /// Closes the current channel via `admin_flushBatcher`. + pub async fn flush(rpc: &Url) -> Result<(), BatcherCommandError> { + Self::call(rpc, "admin_flushBatcher").await + } + + /// Origin-only URL for output, logs and errors so credentials never leak. + pub fn display_url(rpc: &Url) -> String { + rpc.origin().ascii_serialization() + } + + async fn call( + rpc: &Url, + method: &'static str, + ) -> Result { + let client = HttpClientBuilder::default() + .request_timeout(Self::RPC_TIMEOUT) + .build(rpc.as_str()) + .map_err(|source| BatcherCommandError::BuildClient { + rpc: Self::display_url(rpc), + source, + })?; + + ClientT::request(&client, method, rpc_params![]).await.map_err(|source| match source { + JsonRpcClientError::Call(rejection) => BatcherCommandError::Rejected { + rpc: Self::display_url(rpc), + method, + message: rejection.message().to_string(), + }, + source => BatcherCommandError::Rpc { rpc: Self::display_url(rpc), method, source }, + }) + } +} + +#[cfg(test)] +mod tests { + use jsonrpsee::{ + server::{RpcModule, Server}, + types::ErrorObjectOwned, + }; + + use super::*; + + #[tokio::test] + async fn calls_decode_answers_and_report_rejections() { + let mut module = RpcModule::new(()); + module + .register_method("admin_getBatcherStatus", |_, _, _| { + serde_json::json!({ "stopped": true, "in_flight": 0, "da_backlog_bytes": 7 }) + }) + .unwrap(); + module.register_method("admin_stopBatcher", |_, _, _| ()).unwrap(); + module + .register_method("admin_flushBatcher", |_, _, _| { + Err::<(), _>(ErrorObjectOwned::owned(-32002, "batcher is stopped", None::<()>)) + }) + .unwrap(); + let server = Server::builder().build("127.0.0.1:0").await.unwrap(); + let address = server.local_addr().unwrap(); + let handle = server.start(module); + let rpc = Url::parse(&format!("http://operator:secret@{address}")).unwrap(); + + let status = BatcherClient::status(&rpc).await.unwrap(); + assert_eq!(status, BatcherStatus { stopped: true, in_flight: 0, da_backlog_bytes: 7 }); + + BatcherClient::stop(&rpc).await.unwrap(); + + let error = BatcherClient::flush(&rpc).await.unwrap_err().to_string(); + assert_eq!( + error, + format!( + "batcher at http://{address} rejected `admin_flushBatcher`: batcher is stopped" + ) + ); + + handle.stop().unwrap(); + } + + #[test] + fn display_url_drops_credentials_path_and_query() { + let rpc = Url::parse("https://operator:secret@batcher.example:6545/admin?key=abc").unwrap(); + + assert_eq!(BatcherClient::display_url(&rpc), "https://batcher.example:6545"); + } + + #[test] + fn status_deserializes_the_admin_response() { + let status: BatcherStatus = serde_json::from_value(serde_json::json!({ + "stopped": true, + "in_flight": 2, + "da_backlog_bytes": 1024, + })) + .unwrap(); + + assert_eq!(status, BatcherStatus { stopped: true, in_flight: 2, da_backlog_bytes: 1024 }); + } +} diff --git a/crates/infra/basectl/src/rpc/mod.rs b/crates/infra/basectl/src/rpc/mod.rs index 547426fcad..60606e6fdd 100644 --- a/crates/infra/basectl/src/rpc/mod.rs +++ b/crates/infra/basectl/src/rpc/mod.rs @@ -6,6 +6,9 @@ pub use admin::{ start_sequencer_node, stop_sequencer, stop_sequencer_node, unpause_sequencer_node, }; +mod batcher; +pub use batcher::{BatcherClient, BatcherStatus}; + mod conductor; pub use conductor::{ ConductorClusterSnapshot, ConductorControl, ConductorFanoutAction, ConductorFanoutReport, From e15103bc61966aefbbc8b649efe6b604c2246313 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 23 Sep 2026 08:52:49 +0000 Subject: [PATCH 089/299] fix(batcher): reject a zero max_pending_transactions at startup (#5199) --- crates/batcher/service/src/service.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index 536bace29b..8e3157f8b9 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -376,6 +376,12 @@ impl BatcherService { if self.config.poll_interval.is_zero() { eyre::bail!("poll_interval must be greater than zero"); } + if self.config.max_pending_transactions == 0 { + eyre::bail!( + "max_pending_transactions must be greater than zero: the batcher would never \ + submit a transaction" + ); + } if self.config.stopped && self.config.admin_addr.is_none() { eyre::bail!( "--stopped requires --admin-port: the batcher would start stopped with no way to \ @@ -784,4 +790,19 @@ mod tests { "timeout error should name the operation, got {error}" ); } + + #[tokio::test] + async fn setup_rejects_zero_max_pending_transactions() { + let config = BatcherConfig { max_pending_transactions: 0, ..BatcherConfig::default() }; + + let error = BatcherService::new(config) + .setup(TokioRuntime::new()) + .await + .expect_err("a batcher that can never submit must not start"); + + assert!( + error.to_string().contains("max_pending_transactions"), + "error should name the setting, got {error}" + ); + } } From bce2cef31ecddcf599b2a4bd146e300446b4e232 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Wed, 23 Sep 2026 12:31:56 +0000 Subject: [PATCH 090/299] fix(challenger-e2e): stop after ZK-first path 4 (#5227) Co-authored-by: Codex --- crates/infra/challenger-e2e/README.md | 31 +++++----- .../challenger-e2e/src/challenger_e2e.rs | 60 +++++-------------- 2 files changed, 29 insertions(+), 62 deletions(-) diff --git a/crates/infra/challenger-e2e/README.md b/crates/infra/challenger-e2e/README.md index 14bd0b3303..02df7438eb 100644 --- a/crates/infra/challenger-e2e/README.md +++ b/crates/infra/challenger-e2e/README.md @@ -5,7 +5,7 @@ Behavioural end-to-end test of the challenger. Forks the target L1 into a pod-local Anvil, hands the fork to a real `base-challenger` binary running alongside it, and asserts on what that challenger does — first that it leaves valid games alone, then that it -disputes every classifier path we can stage honestly on that same fork. +disputes the classifier paths that can run serially on the same fork. Patching an existing game rather than creating one is what keeps the test honest. The games were created and verified on the real chain before the fork @@ -24,8 +24,8 @@ delta on B. Both are generated per run and never leave the pod. One process, one fork, two TEE-only in-progress games (newest-first, lookback 50, ≥1 intermediate root, all above the anchor game). Game A is Path 1 / Path 2 -skip. Game B is Path 4 and then whichever path Path 4 leaves behind. The run -bails if fewer than two such games exist. +skip. Game B is Path 4, followed by Path 3 only when the TEE proof is removed +first. The run bails if fewer than two such games exist. The anchor bound is not cosmetic: the scanner starts at one past the anchor game's factory index, so a game at or before the anchor is one the challenger @@ -96,24 +96,21 @@ disputes games it was never given would pass the run. `verifyProposalProof`. `zkProver != 0` and `counteredIndex == 0`. After the quiet window, B is patched. The challenger must drop one of B's two proofs. B's nonce must advance. -6. **Whatever Path 4 left behind.** A dual-proof game takes two disputes to - clear, and either proof may go first — so which assertion runs is decided by - what step 5 observed, not fixed in advance. TEE first (`tee=0`, `zk≠0`) is - **Path 3**: the next scan ZK-nullifies (`zkProver == 0`). ZK first is the - supported **TEE-fallback** case, where the TEE request or submission failed; - that leaves a TEE-only game (`tee≠0`, `zk=0`) and the next scan disputes it - as **Path 1**, by nullify or by challenge. Insisting on the TEE proof going - first would sit out the whole `CHALLENGER_E2E_DISPUTE_TIMEOUT` on a - correctly behaving challenger. +6. **Whatever Path 4 left behind.** TEE first (`tee=0`, `zk≠0`) is **Path 3**: + the next scan ZK-nullifies (`zkProver == 0`). ZK first is the supported + **TEE-fallback** case, where the TEE request or submission failed. A ZK + nullification disables the global ZK verifier, so the remaining TEE proof + cannot then be challenged by another ZK proof on the same fork. That branch + ends after Path 4 instead of waiting for a transaction the verifier rejects + with `Nullified()`. The E2E's throwaway B key is not registered as a TEE + proposer, so current deployments take this branch after the TEE submission + is rejected with `InvalidProposer(B)`. Step 5 reads both prover fields in one observation, and a challenger that scans faster than `CHALLENGER_E2E_POLL_INTERVAL` may have cleared both before the first look; that is a third branch, not a failure. Attribution is - therefore one assertion at the end — B's nonce must have advanced by at - least two against the baseline taken before the patch — rather than one per - step. A per-step delta credits both transactions to the first step whenever - the challenger beats the poll, and then demands a third that is never - coming. + therefore one assertion at the end: B's nonce must advance once for ZK-first + Path 4, or twice when TEE-first Path 4 continues through Path 3. 7. **No collateral damage.** Every bystander game snapshotted in step 0 must still read the same `(teeProver, zkProver, counteredIndex)`. Catches what the per-game assertions cannot see: a challenger misconfigured on diff --git a/crates/infra/challenger-e2e/src/challenger_e2e.rs b/crates/infra/challenger-e2e/src/challenger_e2e.rs index daf610cdaa..847f628e8c 100644 --- a/crates/infra/challenger-e2e/src/challenger_e2e.rs +++ b/crates/infra/challenger-e2e/src/challenger_e2e.rs @@ -142,16 +142,8 @@ impl ChallengerE2e { .await?; Self::assert_game_a_settled(&config, &verifier, &provider, &challenger, game_a, path1) .await?; - Self::run_path4_then_3( - &config, - &fork_url, - &verifier, - &provider, - &driver, - &challenger, - game_b, - ) - .await?; + Self::run_path4(&config, &fork_url, &verifier, &provider, &driver, &challenger, game_b) + .await?; Self::assert_bystanders_untouched(&verifier, &bystanders).await?; @@ -653,18 +645,16 @@ impl ChallengerE2e { Ok(()) } - /// Path 4 then whatever Path 4 leaves behind. Each step must move B's nonce. + /// Path 4, plus Path 3 when the TEE proof is nullified first. /// /// A dual-proof game takes two disputes to clear, and the challenger is - /// free to drop either proof first. With a TEE prover available it nullifies - /// the TEE proof, leaving a ZK-only game the next scan disputes as Path 3. - /// When the TEE request or submission fails it falls back to a ZK nullify — - /// a supported path, covered by the dual-proof ZK-fallback test in - /// `crates/proof/challenge/tests/driver.rs` — which leaves a TEE-only game - /// the next scan disputes as Path 1, by nullify or by challenge. Demanding - /// the TEE proof go first would sit out the whole dispute budget on a - /// challenger doing exactly what it is supposed to. - async fn run_path4_then_3( + /// free to drop either proof first. If B is registered as a TEE proposer it + /// nullifies the TEE proof, leaving a ZK-only game the next scan disputes as + /// Path 3. B is normally a throwaway, unregistered key, so the TEE submission + /// fails and the ZK fallback nullifies the global ZK verifier. The remaining + /// TEE proof cannot then be challenged with another ZK proof on the same fork, + /// so that branch ends after Path 4. + async fn run_path4( config: &Config, fork_url: &Url, verifier: &AggregateVerifierContractClient, @@ -721,41 +711,21 @@ impl ChallengerE2e { info!(game = %game.address, "Path 3: ZK proof nullified"); } else { info!(game = %game.address, "Path 4: ZK fallback nullified, TEE proof remains"); - // `nonce + 1`, not `nonce`: the cleared ZK proof already accounts - // for one transaction, so the baseline `nonce` would let the - // nullify we just observed satisfy the dispute check below. - // Derived from the state read rather than sampled fresh -- a fresh - // read reintroduces the race the end-of-step delta removes. - let outcome = Self::await_dispute( - config, - verifier, - provider, - game.address, - challenger, - nonce + 1, - checkpoint.index + 1, - ) - .await?; - info!(game = %game.address, ?outcome, "Path 1: the remaining TEE proof was disputed"); } - // Two disputes clear a dual-proof game, whichever order they arrived in. - // Asserted once against the pre-patch baseline rather than per step: a - // per-step delta attributes both transactions to the first step whenever - // the challenger beats the poll, and then demands a third that is never - // coming. + let expected_transactions = if tee_cleared { 2 } else { 1 }; let nonce_after = provider.get_transaction_count(challenger.address()).await?; ensure!( - nonce_after >= nonce + 2, - "both of game {}'s proofs are gone but the challenger sent {} transaction(s), not the \ - two a dual-proof game takes; something other than the challenger disputed it", + nonce_after >= nonce + expected_transactions, + "game {} changed but the challenger sent {} transaction(s), fewer than the expected \ + {expected_transactions}; something other than the challenger disputed it", game.address, nonce_after - nonce ); info!( game = %game.address, transactions = nonce_after - nonce, - "the challenger cleared both of the dual-proof game's proofs" + "the challenger completed Path 4" ); Ok(()) } From fad270db5362a4816ef5c3a3b9185f97378f8e59 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 23 Sep 2026 14:15:13 +0000 Subject: [PATCH 091/299] refactor(batcher): move the channel-backed sources under the source test utilities (#5204) --- actions/harness/src/batcher/actor.rs | 7 +++++-- actions/harness/src/batcher/tx_manager.rs | 6 +++--- crates/batcher/core/tests/heads.rs | 2 +- crates/batcher/core/tests/lifecycle.rs | 5 ++++- crates/batcher/core/tests/reorg.rs | 2 +- crates/batcher/core/tests/stop_start.rs | 2 +- crates/batcher/source/src/lib.rs | 6 ------ crates/batcher/source/src/{ => test_utils}/channel.rs | 0 crates/batcher/source/src/{ => test_utils}/l1_channel.rs | 0 crates/batcher/source/src/test_utils/mod.rs | 7 ++++++- 10 files changed, 21 insertions(+), 16 deletions(-) rename crates/batcher/source/src/{ => test_utils}/channel.rs (100%) rename crates/batcher/source/src/{ => test_utils}/l1_channel.rs (100%) diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index da4111df63..4373cd636e 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -7,7 +7,10 @@ use base_batcher_core::{ ThrottleConfig, ThrottleController, ThrottleStrategy, }; use base_batcher_encoder::{BatchEncoder, EncoderConfig}; -use base_batcher_source::{ChannelBlockSource, ChannelL1HeadSource, L2BlockEvent}; +use base_batcher_source::{ + L2BlockEvent, + test_utils::{ChannelBlockSource, ChannelL1HeadSource}, +}; use base_common_consensus::BaseBlock; use base_common_genesis::RollupConfig; use base_runtime::TokioRuntime; @@ -96,7 +99,7 @@ pub enum BatcherError { /// /// [`advance`]: Batcher::advance /// [`BatchDriver`]: base_batcher_core::BatchDriver -/// [`ChannelL1HeadSource`]: base_batcher_source::ChannelL1HeadSource +/// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource /// [`L1HeadEvent::NewHead`]: base_batcher_source::L1HeadEvent /// [`L2BlockEvent::Flush`]: base_batcher_source::L2BlockEvent::Flush pub struct Batcher { diff --git a/actions/harness/src/batcher/tx_manager.rs b/actions/harness/src/batcher/tx_manager.rs index bbc9de2373..2b3a38c776 100644 --- a/actions/harness/src/batcher/tx_manager.rs +++ b/actions/harness/src/batcher/tx_manager.rs @@ -93,7 +93,7 @@ pub struct Inner { /// [`mine_block`]: L1MinerTxManager::mine_block /// [`with_l1_head_tx`]: L1MinerTxManager::with_l1_head_tx /// [`BatchDriver`]: base_batcher_core::BatchDriver -/// [`ChannelL1HeadSource`]: base_batcher_source::ChannelL1HeadSource +/// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource #[derive(Debug, Clone)] pub struct L1MinerTxManager { inner: Arc>, @@ -105,7 +105,7 @@ pub struct L1MinerTxManager { /// can advance the driver's L1 head. /// /// [`mine_block`]: L1MinerTxManager::mine_block - /// [`ChannelL1HeadSource`]: base_batcher_source::ChannelL1HeadSource + /// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource l1_head_tx: Option>, } @@ -130,7 +130,7 @@ impl L1MinerTxManager { /// /// [`mine_block`]: L1MinerTxManager::mine_block /// [`BatchDriver`]: base_batcher_core::BatchDriver - /// [`ChannelL1HeadSource`]: base_batcher_source::ChannelL1HeadSource + /// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource pub fn with_l1_head_tx(mut self, tx: mpsc::UnboundedSender) -> Self { self.l1_head_tx = Some(tx); self diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index df9ca723b3..bfd7a429ae 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -14,7 +14,7 @@ use base_batcher_core::{ SubmissionStub, TrackingPipeline, TrackingSource, }, }; -use base_batcher_source::{ChannelL1HeadSource, L1HeadEvent}; +use base_batcher_source::{L1HeadEvent, test_utils::ChannelL1HeadSource}; use base_protocol::BlockInfo; use base_runtime::{ Cancellation, Clock, Spawner, diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index 471171fa3d..bea70f161d 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -15,7 +15,10 @@ use base_batcher_core::{ }, }; use base_batcher_encoder::{ChannelLimit, StepError, SubmissionId}; -use base_batcher_source::{ChannelBlockSource, L2BlockEvent, test_utils::InMemoryBlockSource}; +use base_batcher_source::{ + L2BlockEvent, + test_utils::{ChannelBlockSource, InMemoryBlockSource}, +}; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, diff --git a/crates/batcher/core/tests/reorg.rs b/crates/batcher/core/tests/reorg.rs index b860393b03..239bbfd6c4 100644 --- a/crates/batcher/core/tests/reorg.rs +++ b/crates/batcher/core/tests/reorg.rs @@ -14,7 +14,7 @@ use base_batcher_core::{ Recorded, ReorgPipeline, SubmissionStub, TrackingPipeline, }, }; -use base_batcher_source::{ChannelBlockSource, L2BlockEvent}; +use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, diff --git a/crates/batcher/core/tests/stop_start.rs b/crates/batcher/core/tests/stop_start.rs index 80aaf7cdc7..b67d49c4c4 100644 --- a/crates/batcher/core/tests/stop_start.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -18,7 +18,7 @@ use base_batcher_encoder::{ BatchPipeline, BatchSubmission, DerivationReconciliation, ReorgError, StepError, StepResult, SubmissionId, }; -use base_batcher_source::{ChannelBlockSource, L2BlockEvent}; +use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; use base_common_consensus::BaseBlock; use base_protocol::BlockInfo; use base_runtime::{ diff --git a/crates/batcher/source/src/lib.rs b/crates/batcher/source/src/lib.rs index b16755090b..c20be767ac 100644 --- a/crates/batcher/source/src/lib.rs +++ b/crates/batcher/source/src/lib.rs @@ -22,9 +22,6 @@ pub use polling::{PollingBlockSource, PollingSource}; mod stream_subscription; pub use stream_subscription::{KeepAliveSubscription, PendingSubscription, StreamSubscription}; -mod channel; -pub use channel::ChannelBlockSource; - mod l1_event; pub use l1_event::L1HeadEvent; @@ -40,7 +37,4 @@ pub use l1_subscription::L1HeadSubscription; mod l1_hybrid; pub use l1_hybrid::HybridL1HeadSource; -mod l1_channel; -pub use l1_channel::ChannelL1HeadSource; - pub mod test_utils; diff --git a/crates/batcher/source/src/channel.rs b/crates/batcher/source/src/test_utils/channel.rs similarity index 100% rename from crates/batcher/source/src/channel.rs rename to crates/batcher/source/src/test_utils/channel.rs diff --git a/crates/batcher/source/src/l1_channel.rs b/crates/batcher/source/src/test_utils/l1_channel.rs similarity index 100% rename from crates/batcher/source/src/l1_channel.rs rename to crates/batcher/source/src/test_utils/l1_channel.rs diff --git a/crates/batcher/source/src/test_utils/mod.rs b/crates/batcher/source/src/test_utils/mod.rs index 33d7876143..c6dd3c58a9 100644 --- a/crates/batcher/source/src/test_utils/mod.rs +++ b/crates/batcher/source/src/test_utils/mod.rs @@ -5,9 +5,14 @@ use std::collections::VecDeque; use async_trait::async_trait; use base_common_consensus::BaseBlock; -pub use crate::{ChannelBlockSource, ChannelL1HeadSource}; use crate::{L1HeadEvent, L1HeadSource, L2BlockEvent, SourceError, UnsafeBlockSource}; +mod channel; +pub use channel::ChannelBlockSource; + +mod l1_channel; +pub use l1_channel::ChannelL1HeadSource; + /// In-memory block source for action tests and unit tests. /// /// Events are pushed into a FIFO queue and drained by calls to From 90df0d3810c967ed93ea22e195aa35835e9e6e40 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 23 Sep 2026 14:26:46 +0000 Subject: [PATCH 092/299] refactor(batcher): remove the unused MockBatchPipeline test double (#5203) --- crates/batcher/encoder/src/test_utils/mod.rs | 87 +------------------- 1 file changed, 2 insertions(+), 85 deletions(-) diff --git a/crates/batcher/encoder/src/test_utils/mod.rs b/crates/batcher/encoder/src/test_utils/mod.rs index 54266ac5e1..1c5a8a7e5f 100644 --- a/crates/batcher/encoder/src/test_utils/mod.rs +++ b/crates/batcher/encoder/src/test_utils/mod.rs @@ -1,91 +1,8 @@ //! Test utilities for the batcher encoder. -use std::collections::VecDeque; +use base_protocol::{ChannelId, Frame}; -use base_common_consensus::BaseBlock; -use base_protocol::{BlockInfo, ChannelId, Frame}; - -use crate::{ - BatchPipeline, BatchSubmission, Channel, DerivationReconciliation, ReorgError, StepError, - StepResult, SubmissionId, -}; - -/// A mock implementation of [`BatchPipeline`] for testing downstream consumers -/// such as the [`BatchDriver`](crate::BatchPipeline). -/// -/// Records all method calls for assertion in tests. -#[derive(Debug, Default)] -pub struct MockBatchPipeline { - /// Blocks that were added via [`add_block`](BatchPipeline::add_block). - pub blocks_added: Vec, - /// Number of times [`step`](BatchPipeline::step) was called. - pub steps_taken: usize, - /// Submissions to return from [`next_submission`](BatchPipeline::next_submission). - pub submissions: VecDeque, - /// Confirmed submissions (id, `l1_block`). - pub confirmed: Vec<(SubmissionId, u64)>, - /// Requeued submission ids. - pub requeued: Vec, - /// Number of times [`reset`](BatchPipeline::reset) was called. - pub resets: usize, - /// L1 heads that were advanced to. - pub l1_heads: Vec, - /// Safe L2 block numbers passed to derivation reconciliation. - pub safe_l2_numbers_reconciled: Vec, -} - -impl BatchPipeline for MockBatchPipeline { - fn add_block(&mut self, block: BaseBlock) -> Result<(), (ReorgError, Box)> { - self.blocks_added.push(block); - Ok(()) - } - - fn step(&mut self) -> Result { - self.steps_taken += 1; - Ok(StepResult::Idle) - } - - fn next_submission(&mut self) -> Option { - self.submissions.pop_front() - } - - fn has_ready_submission(&self) -> bool { - !self.submissions.is_empty() - } - - fn confirm(&mut self, id: SubmissionId, l1_block: u64) { - self.confirmed.push((id, l1_block)); - } - - fn requeue(&mut self, id: SubmissionId) { - self.requeued.push(id); - } - - fn flush(&mut self) -> Result<(), StepError> { - Ok(()) - } - - fn advance_l1_head(&mut self, l1_block: u64) { - self.l1_heads.push(l1_block); - } - - fn reconcile_derivation( - &mut self, - safe_l2: BlockInfo, - _: Option, - ) -> DerivationReconciliation { - self.safe_l2_numbers_reconciled.push(safe_l2.number); - DerivationReconciliation::Consistent - } - - fn reset(&mut self) { - self.resets += 1; - } - - fn da_backlog_bytes(&self) -> u64 { - 0 - } -} +use crate::Channel; /// One-shot framing for Span fixtures and tests. /// From 6bd7b1e55c2afa2a19e907920531c2350f5121ff Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 23 Sep 2026 14:31:39 +0000 Subject: [PATCH 093/299] refactor(batcher): gate the test utilities behind the test-utils feature (#5223) * refactor(batcher): gate the test utilities behind the test-utils feature * docs(batcher): list the source test utilities and their feature in the README --- actions/harness/Cargo.toml | 4 ++-- crates/batcher/core/Cargo.toml | 1 + crates/batcher/core/src/lib.rs | 1 + crates/batcher/core/src/test_utils/mod.rs | 6 ------ crates/batcher/encoder/Cargo.toml | 1 + crates/batcher/encoder/src/lib.rs | 1 + crates/batcher/source/Cargo.toml | 3 +++ crates/batcher/source/README.md | 2 +- crates/batcher/source/src/error.rs | 2 +- crates/batcher/source/src/lib.rs | 1 + 10 files changed, 12 insertions(+), 10 deletions(-) diff --git a/actions/harness/Cargo.toml b/actions/harness/Cargo.toml index 1312de761d..da61b89c87 100644 --- a/actions/harness/Cargo.toml +++ b/actions/harness/Cargo.toml @@ -55,8 +55,8 @@ base-common-chains.workspace = true base-execution-evm.workspace = true base-consensus-rpc.workspace = true base-consensus-node.workspace = true -base-batcher-source.workspace = true -base-batcher-encoder.workspace = true +base-batcher-source = { workspace = true, features = ["test-utils"] } +base-batcher-encoder = { workspace = true, features = ["test-utils"] } base-consensus-safedb.workspace = true base-consensus-gossip.workspace = true base-consensus-engine.workspace = true diff --git a/crates/batcher/core/Cargo.toml b/crates/batcher/core/Cargo.toml index 41d02cff3e..ab16c84b2e 100644 --- a/crates/batcher/core/Cargo.toml +++ b/crates/batcher/core/Cargo.toml @@ -42,6 +42,7 @@ async-trait.workspace = true alloy-consensus = { workspace = true, features = ["std"] } base-runtime = { workspace = true, features = ["test-utils"] } alloy-rpc-types-eth = { workspace = true, features = ["std"] } +base-batcher-source = { workspace = true, features = ["test-utils"] } tokio = { workspace = true, features = ["macros", "rt", "time"] } [[test]] diff --git a/crates/batcher/core/src/lib.rs b/crates/batcher/core/src/lib.rs index fac0429b8c..d1bae3537a 100644 --- a/crates/batcher/core/src/lib.rs +++ b/crates/batcher/core/src/lib.rs @@ -41,4 +41,5 @@ pub use admin::{ mod driver; pub use driver::{BatchDriver, BatchDriverHeads}; +#[cfg(any(test, feature = "test-utils"))] pub mod test_utils; diff --git a/crates/batcher/core/src/test_utils/mod.rs b/crates/batcher/core/src/test_utils/mod.rs index 9ac5ffd348..4d817c2ad0 100644 --- a/crates/batcher/core/src/test_utils/mod.rs +++ b/crates/batcher/core/src/test_utils/mod.rs @@ -6,19 +6,13 @@ pub use throttle::{ThrottleCallLog, TrackingThrottleClient}; mod pipeline; pub use pipeline::{Recorded, ReorgPipeline, TrackingPipeline}; -#[cfg(any(test, feature = "test-utils"))] mod source; -#[cfg(any(test, feature = "test-utils"))] pub use source::{OneBlockSource, PendingL1HeadSource, PendingSource, TrackingSource}; -#[cfg(any(test, feature = "test-utils"))] mod builder; -#[cfg(any(test, feature = "test-utils"))] pub use builder::{DriverFixture, SubmissionStub}; -#[cfg(any(test, feature = "test-utils"))] mod tx_manager; -#[cfg(any(test, feature = "test-utils"))] pub use tx_manager::{ ImmediateConfirmTxManager, ImmediateFailTxManager, ManualConfirmTxManager, NeverConfirmTxManager, diff --git a/crates/batcher/encoder/Cargo.toml b/crates/batcher/encoder/Cargo.toml index 9f523d1530..0439bc443e 100644 --- a/crates/batcher/encoder/Cargo.toml +++ b/crates/batcher/encoder/Cargo.toml @@ -34,3 +34,4 @@ alloy-consensus = { workspace = true, features = ["std"] } [features] clap = [ "dep:clap" ] metrics = [ "base-metrics/metrics", "dep:metrics" ] +test-utils = [] diff --git a/crates/batcher/encoder/src/lib.rs b/crates/batcher/encoder/src/lib.rs index 57c4a06874..ae67b05bb3 100644 --- a/crates/batcher/encoder/src/lib.rs +++ b/crates/batcher/encoder/src/lib.rs @@ -38,4 +38,5 @@ pub use encoder::BatchEncoder; mod metrics; pub use metrics::BatcherMetrics; +#[cfg(any(test, feature = "test-utils"))] pub mod test_utils; diff --git a/crates/batcher/source/Cargo.toml b/crates/batcher/source/Cargo.toml index c2e49acbbe..6917e8b5f8 100644 --- a/crates/batcher/source/Cargo.toml +++ b/crates/batcher/source/Cargo.toml @@ -28,3 +28,6 @@ alloy-consensus.workspace = true tokio = { workspace = true, features = ["rt"] } alloy-primitives = { workspace = true, features = ["std"] } base-runtime = { workspace = true, features = ["test-utils"] } + +[features] +test-utils = [] diff --git a/crates/batcher/source/README.md b/crates/batcher/source/README.md index 9bf6ae5b65..e2dd66b629 100644 --- a/crates/batcher/source/README.md +++ b/crates/batcher/source/README.md @@ -11,4 +11,4 @@ Provides ordered L2 block polling, reorg detection, and L1 head tracking. - **`PollingBlockSource`** — fetches consecutive L2 blocks above a safe head - **`PollingSource`** — trait for fetching an L2 block by number - **`HybridL1HeadSource`** — combines L1 subscription and polling -- **`InMemoryBlockSource`** (`test_utils`) — in-memory source for action tests +- **`test_utils`** (feature `test-utils`) — in-memory and channel-backed sources for tests diff --git a/crates/batcher/source/src/error.rs b/crates/batcher/source/src/error.rs index b036df337d..d5098b8f5c 100644 --- a/crates/batcher/source/src/error.rs +++ b/crates/batcher/source/src/error.rs @@ -3,7 +3,7 @@ /// Errors produced by block sources. #[derive(Debug, thiserror::Error)] pub enum SourceError { - /// The source has no more blocks (used by [`InMemoryBlockSource`][crate::test_utils::InMemoryBlockSource] when empty). + /// The source has no more blocks. Only in-memory test sources report this. #[error("block source exhausted")] Exhausted, /// Provider or RPC error. diff --git a/crates/batcher/source/src/lib.rs b/crates/batcher/source/src/lib.rs index c20be767ac..4fc916c956 100644 --- a/crates/batcher/source/src/lib.rs +++ b/crates/batcher/source/src/lib.rs @@ -37,4 +37,5 @@ pub use l1_subscription::L1HeadSubscription; mod l1_hybrid; pub use l1_hybrid::HybridL1HeadSource; +#[cfg(any(test, feature = "test-utils"))] pub mod test_utils; From 5275feae5258621a5a37412995c3ba83ed4af411 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 23 Sep 2026 15:40:12 +0000 Subject: [PATCH 094/299] refactor(harness): synchronise the batcher actor with markers and the admin flush (#5224) * refactor(harness): synchronise the batcher actor with markers and the admin flush * test(batcher): state the driver loop contract as a general rule * docs(harness): say when signal_reorg panics * refactor(harness): return an error when the driver is gone and fix the docs left behind --- actions/harness/src/batcher/actor.rs | 158 ++++++++++------------ actions/harness/src/batcher/mod.rs | 3 + actions/harness/src/batcher/source.rs | 93 +++++++++++++ actions/harness/src/batcher/tx_manager.rs | 21 +-- actions/harness/src/lib.rs | 3 +- actions/harness/tests/batcher/channels.rs | 6 +- crates/batcher/core/src/admin.rs | 4 +- crates/batcher/core/src/driver.rs | 6 +- crates/batcher/core/src/event.rs | 2 +- crates/batcher/core/tests/lifecycle.rs | 79 ++++++++++- 10 files changed, 265 insertions(+), 110 deletions(-) create mode 100644 actions/harness/src/batcher/source.rs diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 4373cd636e..00d8c13071 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -3,21 +3,22 @@ use std::{sync::Arc, time::Duration}; use alloy_primitives::B256; use alloy_signer_local::PrivateKeySigner; use base_batcher_core::{ - BatchDriver, BatchDriverConfig, BatchDriverError, DaThrottle, NoopThrottleClient, - ThrottleConfig, ThrottleController, ThrottleStrategy, + AdminError, AdminHandle, BatchDriver, BatchDriverConfig, BatchDriverError, DaThrottle, + NoopThrottleClient, ThrottleConfig, ThrottleController, ThrottleStrategy, }; use base_batcher_encoder::{BatchEncoder, EncoderConfig}; -use base_batcher_source::{ - L2BlockEvent, - test_utils::{ChannelBlockSource, ChannelL1HeadSource}, -}; +use base_batcher_source::{L2BlockEvent, test_utils::ChannelL1HeadSource}; use base_common_consensus::BaseBlock; use base_common_genesis::RollupConfig; use base_runtime::TokioRuntime; use base_tx_manager::TxManager; +use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; -use crate::{ActionL2Source, L1Block, L1Miner, L1MinerTxManager, L2BlockProvider}; +use crate::{ + ActionL2Source, BlockSourceItem, HarnessBlockSource, L1Block, L1Miner, L1MinerTxManager, + L2BlockProvider, +}; /// Configuration for the [`Batcher`] actor. #[derive(Debug, Clone)] @@ -72,27 +73,32 @@ pub enum BatcherError { /// A new batch cycle was started before prior submissions were mined. #[error("cannot start a batch cycle with outstanding frame submissions")] OutstandingSubmissions, - /// The driver did not queue a frame submission before the harness timeout. - #[error("timed out waiting for batch frame submission")] - SubmissionTimeout, + /// The end-of-cycle flush failed, for example because the driver is stopped. + #[error("flush failed: {0}")] + Flush(#[from] AdminError), + /// The driver exited, or did not catch up with the harness within the timeout. + #[error("the batch driver exited or stalled")] + DriverUnavailable, } /// Batcher actor that drives a persistent [`BatchDriver`] through [`L1Miner`]. /// /// On construction, `Batcher` spawns a [`BatchDriver`] as a background tokio -/// task backed by a [`ChannelBlockSource`] for L2 block delivery and a -/// [`ChannelL1HeadSource`] fed by [`L1MinerTxManager`]. This mirrors the production -/// batcher architecture: the driver owns its encoding pipeline and -/// transaction manager and runs its own async loop. +/// task backed by a [`HarnessBlockSource`] for L2 block delivery, a +/// [`ChannelL1HeadSource`] fed by [`L1MinerTxManager`], and an admin channel. +/// This mirrors the production batcher architecture: the driver owns its +/// encoding pipeline and transaction manager and runs its own async loop. /// /// Each call to [`advance`] drives one complete batch cycle: -/// 1. Drain the L2 source and forward each block to the driver via the channel. -/// 2. Send a [`L2BlockEvent::Flush`] (with an ack) to close and release the current channel. -/// 3. Wait for the ack, confirming the driver has handed every resulting submission -/// to the tx manager (not just the first). -/// 4. Mine one L1 block via the shared [`L1MinerTxManager`], firing all +/// 1. Drain the L2 source and forward each block to the driver via the block source. +/// 2. Wait for the driver to take and encode every block (a marker in the block source). +/// 3. Flush through the admin channel, exactly as an operator would, to close and +/// release the current channel. +/// 4. Wait for the driver's next submit pass (a second marker), after which every +/// resulting submission has been handed to the tx manager, not just the first. +/// 5. Mine one L1 block via the shared [`L1MinerTxManager`], firing all /// receipt oneshots and delivering an [`L1HeadEvent::NewHead`] to the driver. -/// 5. Yield to let the driver confirm receipts and advance its L1 head. +/// 6. Yield to let the driver confirm receipts and advance its L1 head. /// /// The driver's [`BatchEncoder`] state is persistent across `advance()` calls. /// The driver task continues running between cycles, waiting for new events. @@ -101,12 +107,13 @@ pub enum BatcherError { /// [`BatchDriver`]: base_batcher_core::BatchDriver /// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource /// [`L1HeadEvent::NewHead`]: base_batcher_source::L1HeadEvent -/// [`L2BlockEvent::Flush`]: base_batcher_source::L2BlockEvent::Flush pub struct Batcher { /// The L2 block source to drain on each [`advance`](Batcher::advance) cycle. l2_source: S, - /// Channel sender for forwarding L2 block events to the background driver task. - block_tx: tokio::sync::mpsc::UnboundedSender, + /// Feeds the driver's block source with block and reorg events, and with markers. + source_tx: mpsc::UnboundedSender, + /// Admin channel to the driver, used to flush at the end of a cycle. + admin: AdminHandle, /// Shared tx manager — used to mine blocks and fire receipt/L1 head events. tx_manager: L1MinerTxManager, /// Background driver task handle. @@ -132,17 +139,13 @@ impl Batcher { /// /// [`advance`]: Batcher::advance pub fn new(l2_source: S, rollup_config: &RollupConfig, config: BatcherConfig) -> Self { - Self::build(l2_source, rollup_config, config) - } - - /// Shared constructor. Builds and spawns the [`BatchDriver`] task. - fn build(l2_source: S, rollup_config: &RollupConfig, config: BatcherConfig) -> Self { let l1_chain_id = rollup_config.l1_chain_id; let rollup_config = Arc::new(rollup_config.clone()); let pipeline = BatchEncoder::new(rollup_config, config.encoder.clone()).expect("valid encoder config"); - let (source, block_tx) = ChannelBlockSource::new(); + let (source, source_tx) = HarnessBlockSource::new(); + let (admin, admin_rx) = AdminHandle::channel(); // L1 head source: mine_block() sends L1HeadEvent::NewHead; the driver // calls advance_l1_head() when the channel delivers an event. @@ -168,31 +171,28 @@ impl Batcher { tx_manager.clone(), BatchDriverConfig { inbox: config.inbox_address, + // `encode_only` returns after one submit pass, so a cycle must not produce + // more submissions than this. No action test comes close. max_pending_transactions: 16, drain_timeout: Duration::from_secs(10), force_blobs_when_throttling: true, }, DaThrottle::new(throttle, Arc::new(NoopThrottleClient)), l1_source, - ); + ) + .with_admin_rx(admin_rx); let driver_task = tokio::spawn(async move { driver.run().await }); - Self { l2_source, block_tx, tx_manager, driver_task, cancel } + Self { l2_source, source_tx, admin, tx_manager, driver_task, cancel } } /// Drain the L2 source and forward all blocks to the driver, then flush. /// - /// Performs steps 1–3 of [`advance`] without mining: sends all L2 blocks, then a - /// [`L2BlockEvent::Flush`] carrying an acknowledgement, and waits for that ack. The ack - /// fires once the driver has fully drained encoding and submission for this cycle — i.e. - /// every frame resulting from the flush (not just the first) has been handed to the tx - /// manager — so after this returns, [`pending_count`] reflects the *complete* set of - /// frame transactions waiting, even when a cycle produces more than one frame. - /// - /// The ack is delivered through the same ordered channel as the preceding `Block` events - /// (rather than a side channel like the admin API) so it can't be observed before the - /// driver has actually ingested them. + /// Performs steps 1–4 of [`advance`] without mining. After this returns, every frame + /// resulting from the flush (not just the first) has been handed to the tx manager, so + /// [`pending_count`] reflects the *complete* set of frame transactions waiting, even + /// when a cycle produces more than one frame. /// /// # Panics /// @@ -213,21 +213,40 @@ impl Batcher { if self.pending_count() > 0 || self.staged_count() > 0 { return Err(BatcherError::OutstandingSubmissions); } + let mut block_count = 0u64; while let Some(block) = self.l2_source.next_block() { - self.block_tx.send(L2BlockEvent::Block(Box::new(block))).expect("driver task alive"); + self.send(BlockSourceItem::Event(L2BlockEvent::Block(Box::new(block))))?; block_count += 1; } if block_count == 0 { return Err(BatcherError::NoBlocks); } - let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); - self.block_tx.send(L2BlockEvent::Flush { ack: Some(ack_tx) }).expect("driver task alive"); - tokio::time::timeout(Duration::from_secs(10), ack_rx) - .await - .map_err(|_| BatcherError::SubmissionTimeout)? - .map_err(|_| BatcherError::SubmissionTimeout)?; - Ok(()) + + // Admin commands outrank the block source in the driver's select, so wait until every + // block above is taken and encoded before asking for the flush. + self.wait_for_driver().await?; + self.admin.flush().await?; + + // The flush is answered before the driver's next encode-and-submit pass. Wait for that + // pass so every frame the flush released has been handed to the tx manager. + self.wait_for_driver().await + } + + /// Queue an item for the driver's block source. Fails if the driver task has exited. + fn send(&self, item: BlockSourceItem) -> Result<(), BatcherError> { + self.source_tx.send(item).map_err(|_| BatcherError::DriverUnavailable) + } + + /// Wait until the driver has taken everything sent to its block source so far and run + /// the encode-and-submit pass that follows. + async fn wait_for_driver(&self) -> Result<(), BatcherError> { + let (reached_tx, reached_rx) = oneshot::channel(); + self.send(BlockSourceItem::Marker(reached_tx))?; + match tokio::time::timeout(Duration::from_secs(10), reached_rx).await { + Ok(Ok(())) => Ok(()), + _ => Err(BatcherError::DriverUnavailable), + } } /// Returns the number of encoded-but-not-yet-staged pending frame submissions. @@ -351,34 +370,6 @@ impl Batcher { self.tx_manager.reorg_to(block_number, l1); } - /// Wait until at least `min_frames` frames are in the pending queue. - /// - /// Yields to the tokio scheduler on each iteration to give the background - /// [`BatchDriver`] task time to encode blocks and call [`send_async`]. - /// Intended to be called after [`encode_only`] when a test needs to inspect - /// or act on pending frames before mining. - /// - /// # Panics - /// - /// Panics if `pending_count()` does not reach `min_frames` within the - /// polling iteration limit (20 yields). - /// - /// [`BatchDriver`]: base_batcher_core::BatchDriver - /// [`send_async`]: crate::L1MinerTxManager::send_async - /// [`encode_only`]: Batcher::encode_only - pub async fn wait_until_pending(&self, min_frames: usize) { - for _ in 0..20 { - if self.pending_count() >= min_frames { - return; - } - tokio::task::yield_now().await; - } - panic!( - "timed out waiting for {min_frames} pending frames after encoding; got {}", - self.pending_count() - ); - } - /// Wait until at least `expected` frames are back in the pending queue /// after an L1 reorg. /// @@ -410,17 +401,17 @@ impl Batcher { /// Signal that the batcher has been repointed to a different L2 node. /// - /// Sends an [`L2BlockEvent::Reorg`] to the background [`BatchDriver`], - /// which clears the encoder before blocks from the new chain are sent. + /// Sends an [`L2BlockEvent::Reorg`] to the background [`BatchDriver`] and waits until + /// it has applied it, so the encoder is known to be empty on return. /// /// # Panics /// - /// Panics if the driver task has already exited. + /// Panics if the driver task has exited, or did not apply the reorg within the timeout. /// /// [`BatchDriver`]: base_batcher_core::BatchDriver pub async fn signal_reorg(&self) { - self.block_tx.send(L2BlockEvent::Reorg).expect("driver task alive"); - tokio::task::yield_now().await; + self.send(BlockSourceItem::Event(L2BlockEvent::Reorg)).expect("driver task alive"); + self.wait_for_driver().await.expect("driver applies the reorg"); } /// Run one full batch cycle through the production [`BatchDriver`] path. @@ -449,8 +440,7 @@ impl Batcher { // oneshots, and publishes the block number to the L1 head watch. self.tx_manager.mine_block(l1); - // Yield to let the driver process receipts (in_flight.next()) - // and the L1 head update (l1_head_rx.changed()). + // Yield to let the driver process the receipts and the L1 head event. tokio::task::yield_now().await; Ok(()) diff --git a/actions/harness/src/batcher/mod.rs b/actions/harness/src/batcher/mod.rs index d5e833cebb..d9eecf604f 100644 --- a/actions/harness/src/batcher/mod.rs +++ b/actions/harness/src/batcher/mod.rs @@ -3,5 +3,8 @@ mod actor; pub use actor::{Batcher, BatcherConfig, BatcherError}; +mod source; +pub use source::{BlockSourceItem, HarnessBlockSource}; + mod tx_manager; pub use tx_manager::{Inner, L1MinerTxManager, L1SignedSubmission, Pending}; diff --git a/actions/harness/src/batcher/source.rs b/actions/harness/src/batcher/source.rs new file mode 100644 index 0000000000..c62168a419 --- /dev/null +++ b/actions/harness/src/batcher/source.rs @@ -0,0 +1,93 @@ +//! Block source that lets the harness wait for the driver. +//! +//! Hand-rolled rather than mocked: the driver polls it from its `select!` loop for the whole +//! test, and the harness must interleave sync markers with the events it feeds in FIFO order. + +use async_trait::async_trait; +use base_batcher_source::{L2BlockEvent, SourceError, UnsafeBlockSource}; +use tokio::sync::{mpsc, oneshot}; + +/// One item queued for the driver's block source. +#[derive(Debug)] +pub enum BlockSourceItem { + /// Delivered to the driver as is. + Event(L2BlockEvent), + /// Answered when the driver waits on the source again, that is once it has taken every + /// item queued before this one. + /// + /// [`BatchDriver::run`] does all the work it can before waiting for the next event, so by + /// then the last of those items has been encoded and any frames it released submitted. + /// + /// [`BatchDriver::run`]: base_batcher_core::BatchDriver::run + Marker(oneshot::Sender<()>), +} + +/// [`UnsafeBlockSource`] fed by the harness through a channel. +#[derive(Debug)] +pub struct HarnessBlockSource { + rx: mpsc::UnboundedReceiver, +} + +impl HarnessBlockSource { + /// Create a source and the sender that feeds it. + pub fn new() -> (Self, mpsc::UnboundedSender) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self { rx }, tx) + } +} + +#[async_trait] +impl UnsafeBlockSource for HarnessBlockSource { + async fn next(&mut self) -> Result { + loop { + match self.rx.recv().await.ok_or(SourceError::Exhausted)? { + BlockSourceItem::Event(event) => return Ok(event), + BlockSourceItem::Marker(reached) => { + let _ = reached.send(()); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[tokio::test] + async fn marker_is_answered_once_the_events_before_it_are_taken() { + let (mut source, tx) = HarnessBlockSource::new(); + let (reached_tx, mut reached_rx) = oneshot::channel(); + tx.send(BlockSourceItem::Event(L2BlockEvent::Reorg)).unwrap(); + tx.send(BlockSourceItem::Marker(reached_tx)).unwrap(); + tx.send(BlockSourceItem::Event(L2BlockEvent::Reorg)).unwrap(); + + assert!(matches!(source.next().await, Ok(L2BlockEvent::Reorg))); + assert!(reached_rx.try_recv().is_err(), "the marker must wait for the next poll"); + + assert!(matches!(source.next().await, Ok(L2BlockEvent::Reorg))); + assert!(reached_rx.try_recv().is_ok(), "the next poll must answer the marker"); + } + + #[tokio::test] + async fn marker_is_answered_even_when_nothing_follows() { + let (mut source, tx) = HarnessBlockSource::new(); + let (reached_tx, reached_rx) = oneshot::channel(); + tx.send(BlockSourceItem::Marker(reached_tx)).unwrap(); + + // The source keeps waiting for an event, but the marker is already answered. + let next = tokio::time::timeout(Duration::from_millis(10), source.next()); + assert!(next.await.is_err(), "no event must be returned"); + assert!(reached_rx.await.is_ok()); + } + + #[tokio::test] + async fn closed_channel_exhausts_the_source() { + let (mut source, tx) = HarnessBlockSource::new(); + drop(tx); + + assert!(matches!(source.next().await, Err(SourceError::Exhausted))); + } +} diff --git a/actions/harness/src/batcher/tx_manager.rs b/actions/harness/src/batcher/tx_manager.rs index 2b3a38c776..3428809102 100644 --- a/actions/harness/src/batcher/tx_manager.rs +++ b/actions/harness/src/batcher/tx_manager.rs @@ -78,9 +78,8 @@ pub struct Inner { /// /// [`send_async`] enqueues a [`TxCandidate`] and returns a [`SendHandle`] that /// resolves when [`mine_block`] is called. The spawned [`BatchDriver`] task -/// suspends on these handles; calling [`mine_block`] after -/// `tokio::task::yield_now().await` gives the driver time to populate its -/// in-flight set before receipts are fired. +/// suspends on these handles; [`Batcher::encode_only`] returns once every +/// submission of a cycle has been enqueued, so [`mine_block`] can follow it directly. /// /// [`L1MinerTxManager`] is cheaply cloneable (Arc bump). Pass one clone to /// [`BatchDriver`] and retain the other for [`mine_block`] calls from the test. @@ -93,6 +92,7 @@ pub struct Inner { /// [`mine_block`]: L1MinerTxManager::mine_block /// [`with_l1_head_tx`]: L1MinerTxManager::with_l1_head_tx /// [`BatchDriver`]: base_batcher_core::BatchDriver +/// [`Batcher::encode_only`]: crate::Batcher::encode_only /// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource #[derive(Debug, Clone)] pub struct L1MinerTxManager { @@ -308,19 +308,12 @@ impl L1MinerTxManager { /// /// # Timing /// - /// Call this after `tokio::task::yield_now().await` so the spawned - /// [`BatchDriver`] task has had one scheduling turn to process blocks, call - /// [`send_async`] for each submission, and suspend waiting on the oneshot - /// receivers. - /// - /// On a `current_thread` tokio runtime (the default for `#[tokio::test]`) a - /// single yield is sufficient: [`InMemoryBlockSource::next`] and - /// [`send_async`] both complete without suspending, so the driver runs the - /// full encoding and submission loop in one turn before sticking on - /// `in_flight.next().await`. + /// Call this once the spawned [`BatchDriver`] task has called [`send_async`] for + /// every submission of the cycle, which [`Batcher::encode_only`] guarantees on return. /// /// [`send_async`]: L1MinerTxManager::send_async - /// [`InMemoryBlockSource::next`]: base_batcher_source::test_utils::InMemoryBlockSource + /// [`BatchDriver`]: base_batcher_core::BatchDriver + /// [`Batcher::encode_only`]: crate::Batcher::encode_only pub fn mine_block(&self, l1: &mut L1Miner) -> u64 { self.stage_n_to_l1(l1, usize::MAX); let block = l1.mine_block().clone(); diff --git a/actions/harness/src/lib.rs b/actions/harness/src/lib.rs index c8081e166d..27e464d6c4 100644 --- a/actions/harness/src/lib.rs +++ b/actions/harness/src/lib.rs @@ -36,7 +36,8 @@ mod derivation_fixture; mod batcher; pub use batcher::{ - Batcher, BatcherConfig, BatcherError, Inner, L1MinerTxManager, L1SignedSubmission, Pending, + Batcher, BatcherConfig, BatcherError, BlockSourceItem, HarnessBlockSource, Inner, + L1MinerTxManager, L1SignedSubmission, Pending, }; mod matrix; diff --git a/actions/harness/tests/batcher/channels.rs b/actions/harness/tests/batcher/channels.rs index ff351f6323..4366499739 100644 --- a/actions/harness/tests/batcher/channels.rs +++ b/actions/harness/tests/batcher/channels.rs @@ -316,7 +316,7 @@ async fn multi_block_channel_assembles_across_l1_blocks() { /// with an **empty L1 block** between them. The derivation pipeline must /// correctly reassemble the channel across the gap. /// -/// Note: `encode_only()` sends a `Flush` event that closes the channel +/// Note: `encode_only()` ends with an admin flush that closes the channel /// immediately, so all frames are in the pending queue before any L1 head /// events arrive. This means this test exercises the multi-frame split /// submission scenario (frame 0 in block 1, empty block 2, rest in block 3), @@ -346,7 +346,7 @@ async fn multi_frame_channel_with_empty_l1_gap_derives_correctly() { ); // Encode block — produces multiple frames with max_frame_size=80. - // The Flush from encode_only() closes the channel; frames become pending. + // The admin flush at the end of encode_only() closes the channel; frames become pending. let mut source = ActionL2Source::new(); source.push(block); let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); @@ -374,7 +374,7 @@ async fn multi_frame_channel_with_empty_l1_gap_derives_correctly() { ); // Mine an empty L1 block 2. The channel was already closed by encode_only() - // (which sent Flush), so no staged items are confirmed here. The call to + // (its admin flush), so no staged items are confirmed here. The call to // confirm_staged is used solely to advance the driver's L1 head to block 2 // via L1HeadEvent::NewHead — confirm_all fires zero receipts and just sends // the head event. The remaining frames are already in `pending`. diff --git a/crates/batcher/core/src/admin.rs b/crates/batcher/core/src/admin.rs index a799cb95e0..ce443bcbbe 100644 --- a/crates/batcher/core/src/admin.rs +++ b/crates/batcher/core/src/admin.rs @@ -116,8 +116,8 @@ impl AdminHandle { /// Flush the current encoding channel, making its frames eligible for submission. /// - /// Returns [`AdminError::Stopped`] if the batcher is stopped. It does not wait for L1 - /// inclusion. + /// Answered once the channel is closed, before its frames are submitted; it does not + /// wait for L1 inclusion. Returns [`AdminError::Stopped`] if the batcher is stopped. pub async fn flush(&self) -> AdminResult<()> { self.request(|reply| AdminCommand::Flush { reply }).await } diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 08ba779a0b..ec4addfeb8 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -218,6 +218,10 @@ where /// 1. **CPU phase**: drain encoding, apply throttle, recover txpool, submit pending frames. /// 2. **I/O phase**: block on `tokio::select!` until one external event fires. /// + /// Every event the I/O phase returns is therefore followed by a CPU phase before the + /// driver waits again, so the work an event releases is done before the next one, up to + /// the encoding step budget. + /// /// When shutting down (after cancellation or source exhaustion), the I/O phase is /// replaced by a bounded drain of all in-flight receipts. /// @@ -231,7 +235,7 @@ where /// and producing fresh encoding/submission work while the ack is outstanding, it's delayed /// until that work drains too, and under sustained continuous ingestion may not fire at /// all. Callers that need a precise, always-terminating signal must ensure the source is - /// otherwise quiesced before flushing (as the action-test harness does). + /// otherwise quiesced before flushing. pub async fn run(mut self) -> Result<(), BatchDriverError> { if self.stopped { info!( diff --git a/crates/batcher/core/src/event.rs b/crates/batcher/core/src/event.rs index 6fdc3ca4ef..97637d2a10 100644 --- a/crates/batcher/core/src/event.rs +++ b/crates/batcher/core/src/event.rs @@ -14,7 +14,7 @@ pub enum DriverEvent { /// New L2 unsafe block from the source. Block(Box), /// Source requested a force-flush of the current channel. No production source emits this, - /// only the action-test harness and tests. + /// only tests. /// /// If the ack is set, it fires once every frame resulting from this flush has been /// encoded and handed to the tx manager. diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index bea70f161d..dc9e6fccdd 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -1,4 +1,5 @@ -//! Integration tests for [`BatchDriver`] lifecycle: source exhaustion, flush, and drain. +//! Integration tests for [`BatchDriver`] lifecycle: source exhaustion, flush, drain, and +//! the order of work and waiting in the loop. use std::{ sync::{Arc, Mutex}, @@ -6,17 +7,18 @@ use std::{ }; use alloy_primitives::Address; +use async_trait::async_trait; use base_batcher_core::{ BatchDriver, BatchDriverConfig, BatchDriverError, DaThrottle, NoopThrottleClient, ThrottleController, test_utils::{ - DriverFixture, ImmediateConfirmTxManager, NeverConfirmTxManager, PendingL1HeadSource, - Recorded, SubmissionStub, TrackingPipeline, + DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, NeverConfirmTxManager, + PendingL1HeadSource, Recorded, SubmissionStub, TrackingPipeline, }, }; use base_batcher_encoder::{ChannelLimit, StepError, SubmissionId}; use base_batcher_source::{ - L2BlockEvent, + L2BlockEvent, SourceError, UnsafeBlockSource, test_utils::{ChannelBlockSource, InMemoryBlockSource}, }; use base_runtime::{ @@ -162,3 +164,72 @@ fn test_shutdown_drains_in_flight_before_returning_flush_error() { assert_eq!(r.flush_count, 1); }); } + +/// Source that never delivers an event and records, each time the driver waits on it, how +/// many submissions have been dequeued so far. +/// +/// Hand-rolled rather than mocked: the count must be read when the driver polls, not when +/// the mock's scripted response is built. +struct PollRecorder { + recorded: Arc>, + dequeued_at_poll: Arc>>, +} + +#[async_trait] +impl UnsafeBlockSource for PollRecorder { + async fn next(&mut self) -> Result { + let dequeued = self.recorded.lock().unwrap().dequeued.len(); + self.dequeued_at_poll.lock().unwrap().push(dequeued); + std::future::pending().await + } +} + +/// The driver does all the work it can before waiting for the next event: a receipt that +/// frees a permit gets the next ready submission sent before any source is waited on again. +#[test] +fn test_driver_finishes_pending_work_before_waiting_for_events() { + Runner::start(Config::seeded(0), |ctx| async move { + let recorded = Arc::new(Mutex::new(Recorded::default())); + let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + pipeline.submissions.push_back(SubmissionStub::with_id(0)); + pipeline.submissions.push_back(SubmissionStub::with_id(1)); + let tx_manager = ManualConfirmTxManager::default(); + let dequeued_at_poll = Arc::new(Mutex::new(Vec::new())); + let source = PollRecorder { + recorded: Arc::clone(&recorded), + dequeued_at_poll: Arc::clone(&dequeued_at_poll), + }; + + // A single permit: the second submission can only leave the pipeline once the + // receipt of the first one has been processed. + let driver = BatchDriver::new_without_derivation_status( + ctx.clone(), + pipeline, + source, + tx_manager.clone(), + BatchDriverConfig { + inbox: Address::ZERO, + max_pending_transactions: 1, + drain_timeout: Duration::from_millis(10), + force_blobs_when_throttling: true, + }, + DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + PendingL1HeadSource, + ); + let handle = ctx.spawn(driver.run()); + + // Let the driver submit the first stub and wait on the source, then confirm that stub + // so the receipt releases the second one. + ctx.sleep(Duration::from_millis(1)).await; + tx_manager.confirm_next(1); + ctx.sleep(Duration::from_millis(1)).await; + + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + assert_eq!( + *dequeued_at_poll.lock().unwrap(), + vec![1, 2], + "the submission released by the receipt must be sent before the driver waits again" + ); + }); +} From ebef692a7f0a474a710c3b111aee6343ac3e6771 Mon Sep 17 00:00:00 2001 From: refcell Date: Wed, 23 Sep 2026 15:45:02 +0000 Subject: [PATCH 095/299] docs(agents): Add Minimal Devnet Verification Skill (#5246) * docs(agents): add minimal devnet verification skill Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0cada-bc37-7510-9e26-5f0aed782ce3 * docs(agents): simplify and rename verify-base skill Amp-Thread-ID: https://ampcode.com/threads/T-01a0cada-bc37-7510-9e26-5f0aed782ce3 Co-authored-by: Amp --------- Co-authored-by: Amp --- .agents/skills/verify-base/SKILL.md | 14 ++++++++++ .../verify-base/references/features/README.md | 9 +++++++ .../local-devnet-transaction-inclusion.md | 27 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 .agents/skills/verify-base/SKILL.md create mode 100644 .agents/skills/verify-base/references/features/README.md create mode 100644 .agents/skills/verify-base/references/features/local-devnet-transaction-inclusion.md diff --git a/.agents/skills/verify-base/SKILL.md b/.agents/skills/verify-base/SKILL.md new file mode 100644 index 0000000000..c29f4e69f8 --- /dev/null +++ b/.agents/skills/verify-base/SKILL.md @@ -0,0 +1,14 @@ +--- +name: verify-base +description: "Verifies Base local-devnet transaction inclusion using existing devnet commands and Foundry cast. Use when checking Base changes or demonstrating sequencer transaction inclusion." +--- + +# Verify Base + +Verify real behavior using Base's existing tools. + +1. Read the repository's `AGENTS.md` and the [feature map](references/features/README.md). +2. Exercise the relevant feature from the repository root. Investigate failures rather than reporting them as a pass; run relevant tests when checking a code change. +3. Report the tested revision and local changes, what was verified, and the supporting transaction/receipt/block evidence. + +Keep the feature map up to date when commands or behavior change. diff --git a/.agents/skills/verify-base/references/features/README.md b/.agents/skills/verify-base/references/features/README.md new file mode 100644 index 0000000000..1c0b0bea9f --- /dev/null +++ b/.agents/skills/verify-base/references/features/README.md @@ -0,0 +1,9 @@ +# Feature Map + +This is the index an agent reads before using Base's existing tools. Each linked page explains a behavior, how to exercise it, and what evidence establishes success. + +| Feature | Expected behavior | Tools | +| --- | --- | --- | +| [Local devnet transaction inclusion](local-devnet-transaction-inclusion.md) | A funded ETH transfer sent directly to the local sequencer succeeds and appears in its reported block. | `just devnet up-single`, `cast send`, `cast block` | + +Only this feature is in scope. The map is maintained with the commands and behavior it describes, not generated from package counts. diff --git a/.agents/skills/verify-base/references/features/local-devnet-transaction-inclusion.md b/.agents/skills/verify-base/references/features/local-devnet-transaction-inclusion.md new file mode 100644 index 0000000000..5e4e2ba6e2 --- /dev/null +++ b/.agents/skills/verify-base/references/features/local-devnet-transaction-inclusion.md @@ -0,0 +1,27 @@ +# Local Devnet Transaction Inclusion + +Start the local devnet, send an ETH transfer directly to the sequencer, and confirm it was included successfully. + +## Before starting + +Use Docker, `just`, and Foundry's `cast`. Startup and shutdown delete `.devnet` data; use a disposable devnet and check for conflicting containers/ports first. Never stop unrelated services. The devnet publishes ports on all interfaces, so use an isolated development machine. + +Use only the public test accounts in `etc/docker/devnet-env` against the local devnet. That file and `etc/docker/Justfile` are the source of truth for accounts, ports, and commands. + +## Verify + +From the repository root: + +```bash +just devnet up-single +source etc/docker/devnet-env +cast send "$ANVIL_ACCOUNT_2_ADDR" --value 0.001ether \ + --private-key "$ANVIL_ACCOUNT_1_KEY" \ + --rpc-url http://127.0.0.1:7545 --chain-id 84538453 --json +``` + +`cast send` waits for the receipt. Require status `0x1`, the expected sender/recipient, and a block number/hash. Use `cast block --rpc-url http://127.0.0.1:7545 --json` to confirm the block hash matches the receipt and its transactions include the submitted hash. A transaction hash alone is not a pass; this verifies sequencer inclusion, not L1 finality or validator synchronization. + +If startup or submission fails, inspect `just devnet ps` and `just devnet logs base-builder` before retrying. Do not blindly resend after a timeout: the transaction may already have been broadcast. + +Report the transaction hash, receipt status, and block number/hash. Shut down only the devnet you started with `just devnet down`, unless asked to leave it running. From 739b6efb662e590929db4c9b5c66ce01528ddeaa Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 23 Sep 2026 16:07:47 +0000 Subject: [PATCH 096/299] refactor(batcher): remove the source flush and its acknowledgement from the driver (#5225) * refactor(batcher): remove the source flush and its acknowledgement from the driver * refactor(batcher): count encoding steps once and say stop_start.rs covers flush --- crates/batcher/core/src/driver.rs | 209 +++--------------- crates/batcher/core/src/event.rs | 10 +- crates/batcher/core/src/submissions.rs | 61 +---- .../batcher/core/src/test_utils/pipeline.rs | 8 - crates/batcher/core/tests/lifecycle.rs | 47 +--- crates/batcher/core/tests/stop_start.rs | 21 +- crates/batcher/core/tests/throttle.rs | 4 - crates/batcher/encoder/src/egress.rs | 17 -- crates/batcher/encoder/src/encoder.rs | 44 ++-- crates/batcher/encoder/src/pipeline.rs | 3 - crates/batcher/source/src/event.rs | 26 +-- 11 files changed, 60 insertions(+), 390 deletions(-) diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index ec4addfeb8..e83d8f41e4 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -12,7 +12,7 @@ use base_common_consensus::BaseBlock; use base_protocol::BlockInfo; use base_runtime::Runtime; use base_tx_manager::TxManager; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; use crate::{ @@ -101,9 +101,6 @@ where /// whenever DA-backlog throttling activates. Lifted from /// [`BatchDriverConfig::force_blobs_when_throttling`]. force_blobs_when_throttling: bool, - /// Acknowledgements for in-progress flushes, fired once encoding and submission both - /// report no further ready work for the current channel (see [`Self::run`]). - pending_flush_acks: Vec>, } impl BatchDriver @@ -154,7 +151,6 @@ where stopped: false, admin_rx: None, force_blobs_when_throttling: config.force_blobs_when_throttling, - pending_flush_acks: Vec::new(), } } @@ -224,18 +220,6 @@ where /// /// When shutting down (after cancellation or source exhaustion), the I/O phase is /// replaced by a bounded drain of all in-flight receipts. - /// - /// If a [`DriverEvent::SourceFlush`] carried an acknowledgement, it fires as soon as a later - /// CPU phase reports both encoding and submission fully drained (i.e. the flush's frames - /// have all been handed to the tx manager) — see the `pending_flush_acks` field. - /// - /// This "fully drained" check is global, not scoped to the triggering flush: it's the - /// weakest condition that's still always *sufficient* (the flush's own frames can never be - /// dequeued before this fires) but not *tight* — if further `Block` events keep arriving - /// and producing fresh encoding/submission work while the ack is outstanding, it's delayed - /// until that work drains too, and under sustained continuous ingestion may not fire at - /// all. Callers that need a precise, always-terminating signal must ensure the source is - /// otherwise quiesced before flushing. pub async fn run(mut self) -> Result<(), BatchDriverError> { if self.stopped { info!( @@ -251,23 +235,13 @@ where self.apply_pending_derivation_status_updates()?; } - let encoding_idle = self.drain_encoding()?; + self.drain_encoding()?; let is_throttling = self.throttle.apply(self.pipeline.da_backlog_bytes()).await; if self.force_blobs_when_throttling { self.pipeline.set_blob_override(is_throttling); } self.submissions.recover_txpool().await; - let submissions_idle = self.submissions.submit_pending(&mut self.pipeline).await; - - if encoding_idle && submissions_idle && !self.pending_flush_acks.is_empty() { - debug!( - acks = %self.pending_flush_acks.len(), - "flush settled: encoding and submission fully drained" - ); - for ack in self.pending_flush_acks.drain(..) { - let _ = ack.send(()); - } - } + self.submissions.submit_pending(&mut self.pipeline).await; if shutting_down { self.submissions @@ -294,14 +268,7 @@ where DriverEvent::Block(b) => { self.on_block(b); } - DriverEvent::SourceFlush(ack) => { - self.pipeline.flush()?; - if let Some(ack) = ack { - self.pending_flush_acks.push(ack); - } - debug!("flush signal received, released channel artifacts"); - } - DriverEvent::AdminFlush(reply) => { + DriverEvent::Flush(reply) => { self.pipeline.flush()?; let _ = reply.send(Ok(())); debug!("admin flush applied, released channel artifacts"); @@ -330,21 +297,18 @@ where /// Drain encoding steps synchronously up to [`Self::STEP_BUDGET`]. /// - /// Returns `Ok(true)` if the pipeline reached [`StepResult::Idle`] (nothing left to - /// encode), or `Ok(false)` if the step budget ran out first. Returns `Err` on a fatal - /// [`StepError`](base_batcher_encoder::StepError). - fn drain_encoding(&mut self) -> Result { - let mut budget = Self::STEP_BUDGET; + /// Stops at [`StepResult::Idle`] (nothing left to encode) or when the step budget runs + /// out. Returns `Err` on a fatal [`StepError`]. + fn drain_encoding(&mut self) -> Result<(), BatchDriverError> { let mut steps = 0usize; - let idle = loop { + loop { match self.pipeline.step() { - Ok(StepResult::Idle) => break true, + Ok(StepResult::Idle) => break, Ok(StepResult::BlockEncoded | StepResult::ChannelClosed) => { steps += 1; - budget -= 1; - if budget == 0 { + if steps == Self::STEP_BUDGET { debug!(steps = %steps, "encoding step budget exhausted, yielding"); - break false; + break; } } Err(e) => { @@ -352,18 +316,17 @@ where return Err(e.into()); } } - }; + } if steps > 0 { debug!(steps = %steps, "completed encoding drain"); } - Ok(idle) + Ok(()) } /// Drop buffered pipeline state, recording why it was dropped. fn reset_pipeline(&mut self, reason: &'static str) { BatcherMetrics::pipeline_reset_total(reason).increment(1); self.pipeline.reset(); - self.discard_pending_flush_acks(); } /// Reset volatile state and restart delivery above the latest safe head. @@ -465,15 +428,6 @@ where } } - /// Drop any outstanding flush acknowledgements without firing them. - /// - /// Called whenever the pipeline is reset: the blocks a pending flush was waiting on no - /// longer exist, so firing the ack would falsely report settlement. Dropping the sender - /// surfaces as a closed-channel error to the waiter. - fn discard_pending_flush_acks(&mut self) { - self.pending_flush_acks.clear(); - } - /// Stop block ingestion and drop the buffered pipeline state. /// /// Submissions already in flight keep settling. @@ -512,18 +466,18 @@ where /// Block on the next external event using a biased `tokio::select!`. /// /// Admin commands are handled inline in the loop. Only a flush on a running - /// batcher is returned to the caller, as [`DriverEvent::AdminFlush`]. Admin + /// batcher is returned to the caller, as [`DriverEvent::Flush`]. Admin /// commands are placed before the source arm so control-plane operations /// (stop, start, flush) are never starved by sustained block throughput. /// Derivation-status changes are also handled before unsafe blocks so pruning and /// recovery cannot be starved by sequential catchup. /// - /// [`AdminCommand::Stop`] immediately resets the pipeline, then drops - /// `Block` and `Flush` source events until [`AdminCommand::Start`] is - /// received. Reorg events propagate regardless of the stopped state. On - /// start the source is reset to catch up sequentially from the last known - /// safe L2 head. Stopping a stopped batcher or starting a running one does - /// nothing. Each command is answered once it has been applied. + /// [`AdminCommand::Stop`] immediately resets the pipeline, then drops `Block` + /// source events until [`AdminCommand::Start`] is received. Reorg events + /// propagate regardless of the stopped state. On start the source is reset to + /// catch up sequentially from the last known safe L2 head. Stopping a stopped + /// batcher or starting a running one does nothing. Each command is answered + /// once it has been applied. /// /// Non-fatal L1 head source errors loop internally to avoid polluting the /// return type with a no-op variant. @@ -540,7 +494,7 @@ where let _ = reply.send(Err(AdminError::Stopped)); } AdminCommand::Flush { reply } => { - return Ok(DriverEvent::AdminFlush(reply)); + return Ok(DriverEvent::Flush(reply)); } AdminCommand::Stop { reply } => { self.on_admin_stop(); @@ -594,18 +548,7 @@ where Ok(L2BlockEvent::Block(_)) if self.stopped => { continue; } - Ok(L2BlockEvent::Flush { ack }) if self.stopped => { - // Drop (rather than fire) any ack: the batcher is stopped, so this - // flush produces no frames and firing would falsely report - // settlement. The waiter observes a closed-channel error instead of - // a silent, indefinite-looking drop. - if ack.is_some() { - debug!("flush ack dropped: batcher is stopped, flush produces no frames"); - } - continue; - } Ok(L2BlockEvent::Block(block)) => DriverEvent::Block(block), - Ok(L2BlockEvent::Flush { ack }) => DriverEvent::SourceFlush(ack), Ok(L2BlockEvent::Reorg) => DriverEvent::Reorg, Err(SourceError::Exhausted) => DriverEvent::Shutdown, Err(e) => return Err(e.into()), @@ -846,37 +789,6 @@ mod tests { ) } - /// Build a driver whose source delivers a single acknowledged flush. - fn driver_with_flush_ack( - runtime: R, - pipeline: TrackingPipeline, - tx_manager: TM, - max_pending_transactions: usize, - ack: oneshot::Sender<()>, - ) -> BatchDriver< - R, - TrackingPipeline, - QueuedSource, - TM, - Arc, - QueuedL1HeadSource, - > { - BatchDriver::new_without_derivation_status( - runtime, - pipeline, - QueuedSource::new([Ok(L2BlockEvent::Flush { ack: Some(ack) })]), - tx_manager, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), - QueuedL1HeadSource::new(std::iter::empty()), - ) - } - #[derive(Debug, Default)] struct TxpoolBlockedState { sends: AtomicU64, @@ -972,7 +884,7 @@ mod tests { let mut driver = driver_for_next_event( ctx.clone(), - [Ok(L2BlockEvent::Flush { ack: None })], + [Ok(L2BlockEvent::Block(Box::default()))], [Ok(L1HeadEvent::NewHead(9))], ImmediateConfirmTxManager { l1_block: 1 }, ) @@ -1004,7 +916,7 @@ mod tests { .with_admin_rx(admin_rx); let event = driver.next_event().await.expect("next_event should succeed"); - assert!(matches!(event, DriverEvent::AdminFlush(_))); + assert!(matches!(event, DriverEvent::Flush(_))); }); } @@ -1014,7 +926,7 @@ mod tests { let (_status_tx, status_rx) = mpsc::channel(1); let mut driver = driver_for_next_event( ctx, - [Ok(L2BlockEvent::Flush { ack: None })], + [Ok(L2BlockEvent::Block(Box::default()))], [Ok(L1HeadEvent::NewHead(9))], ImmediateConfirmTxManager { l1_block: 1 }, ) @@ -1023,7 +935,7 @@ mod tests { driver.submissions.submit_pending(&mut driver.pipeline).await; let event = driver.next_event().await.expect("next_event should succeed"); - assert!(matches!(event, DriverEvent::SourceFlush(_))); + assert!(matches!(event, DriverEvent::Block(_))); }); } @@ -1038,7 +950,7 @@ mod tests { let mut driver = driver_for_next_event( ctx, - [Ok(L2BlockEvent::Flush { ack: None })], + [Ok(L2BlockEvent::Block(Box::default()))], [Ok(L1HeadEvent::NewHead(9))], ImmediateConfirmTxManager { l1_block: 42 }, ) @@ -1378,73 +1290,4 @@ mod tests { ); }); } - - /// A flush acknowledgement must not fire until every ready submission has been dequeued - /// and handed to the tx manager — not just the first. Regression test for a race where a - /// caller could observe the ack after only the first frame of a multi-frame flush was - /// queued, then mine an L1 block missing the later frames. - #[test] - fn test_flush_ack_waits_for_all_ready_submissions() { - Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - pipeline.submissions.push_back(SubmissionStub::with_id(0)); - pipeline.submissions.push_back(SubmissionStub::with_id(1)); - - let (ack_tx, ack_rx) = oneshot::channel(); - let handle = ctx.spawn( - driver_with_flush_ack( - ctx.clone(), - pipeline, - ImmediateConfirmTxManager { l1_block: 1 }, - 2, - ack_tx, - ) - .run(), - ); - - ack_rx.await.expect("flush ack must fire"); - assert_eq!( - recorded.lock().unwrap().dequeued.len(), - 2, - "ack must not fire until both ready submissions are dequeued" - ); - - ctx.cancel(); - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - }); - } - - /// If a ready submission can't fit within semaphore capacity, the flush ack must not - /// fire — firing early would let a caller believe the flush fully settled before every - /// frame was actually handed to the tx manager. - #[test] - fn test_flush_ack_does_not_fire_while_backpressured() { - Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - pipeline.submissions.push_back(SubmissionStub::with_id(0)); - pipeline.submissions.push_back(SubmissionStub::with_id(1)); - - let (ack_tx, mut ack_rx) = oneshot::channel(); - let handle = ctx.spawn( - driver_with_flush_ack(ctx.clone(), pipeline, NeverConfirmTxManager, 1, ack_tx) - .run(), - ); - - ctx.sleep(Duration::from_millis(50)).await; - assert!( - ack_rx.try_recv().is_err(), - "ack must not fire while a ready submission is still waiting on semaphore capacity" - ); - assert_eq!( - recorded.lock().unwrap().dequeued.len(), - 1, - "only the single available permit should have been used" - ); - - ctx.cancel(); - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - }); - } } diff --git a/crates/batcher/core/src/event.rs b/crates/batcher/core/src/event.rs index 97637d2a10..64b3cc5853 100644 --- a/crates/batcher/core/src/event.rs +++ b/crates/batcher/core/src/event.rs @@ -13,14 +13,8 @@ pub enum DriverEvent { Shutdown, /// New L2 unsafe block from the source. Block(Box), - /// Source requested a force-flush of the current channel. No production source emits this, - /// only tests. - /// - /// If the ack is set, it fires once every frame resulting from this flush has been - /// encoded and handed to the tx manager. - SourceFlush(Option>), - /// Admin requested a force-flush of the current channel; answered once the pipeline is flushed. - AdminFlush(oneshot::Sender>), + /// Admin requested a flush of the current channel; answered once the pipeline is flushed. + Flush(oneshot::Sender>), /// L2 reorganisation detected. Reorg, /// An in-flight L1 transaction settled, carrying one or more submissions. diff --git a/crates/batcher/core/src/submissions.rs b/crates/batcher/core/src/submissions.rs index 97046673cc..f91ac574d0 100644 --- a/crates/batcher/core/src/submissions.rs +++ b/crates/batcher/core/src/submissions.rs @@ -111,26 +111,19 @@ impl SubmissionQueue { /// blob payload may contain frames from multiple channels. /// Loops until the semaphore is exhausted, the pipeline has no ready submissions, /// or the txpool is blocked. - /// - /// Returns `true` if the pipeline reported no further ready submissions (fully - /// drained), or `false` if it stopped early because the semaphore is exhausted, the - /// txpool is blocked, or a blob-encoding failure required a requeue. - pub async fn submit_pending(&mut self, pipeline: &mut P) -> bool { + pub async fn submit_pending(&mut self, pipeline: &mut P) { loop { if self.txpool_blocked { - return false; + return; } let Ok(permit) = Arc::clone(&self.semaphore).try_acquire_owned() else { - // Semaphore is exhausted. This is only real backpressure if the pipeline - // still has work waiting -- if it's actually empty, the caller should see - // this as fully drained rather than blocked (see `has_ready_submission`). - return !pipeline.has_ready_submission(); + return; }; let Some(sub) = pipeline.next_submission() else { drop(permit); - return true; + return; }; // Convert the submission into its final L1 transaction payload before @@ -158,7 +151,7 @@ impl SubmissionQueue { warn!(error = %e, "failed to encode frames to blob, requeueing"); pipeline.requeue(sub.id); drop(permit); - return false; + return; } } } @@ -343,12 +336,9 @@ impl SubmissionQueue { #[cfg(test)] mod tests { - use std::sync::Mutex; - use alloy_primitives::Address; use super::*; - use crate::test_utils::{NeverConfirmTxManager, Recorded, SubmissionStub, TrackingPipeline}; #[test] fn blob_candidate_rejects_empty_transaction() { @@ -357,45 +347,4 @@ mod tests { Err(BatchTxCandidateError::InvalidBlobCount { count: 0, .. }) )); } - - /// Regression test: if exactly `max_pending` submissions are ready, all permits are - /// handed out and held by in-flight (unconfirmed) transactions. The pipeline itself - /// is now empty, so `submit_pending` must report "fully drained" -- not backpressured - /// -- otherwise a caller waiting for drain-and-flush-ack (e.g. `BatchDriver::run`) - /// would wait forever for capacity that was never coming back this cycle. - #[tokio::test] - async fn submit_pending_reports_drained_when_ready_work_exactly_fills_permits() { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - pipeline.submissions.push_back(SubmissionStub::with_id(0)); - pipeline.submissions.push_back(SubmissionStub::with_id(1)); - - let mut queue = SubmissionQueue::new(NeverConfirmTxManager, Address::ZERO, 2); - - let drained = queue.submit_pending(&mut pipeline).await; - - assert!( - drained, - "pipeline has no more ready work even though all permits are held by \ - unconfirmed in-flight submissions" - ); - assert_eq!(recorded.lock().unwrap().dequeued.len(), 2, "both submissions must be sent"); - } - - /// Companion case: with a ready submission still queued behind exhausted permits, - /// `submit_pending` must report backpressure rather than falsely claiming drained. - #[tokio::test] - async fn submit_pending_reports_backpressure_when_ready_work_remains() { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - pipeline.submissions.push_back(SubmissionStub::with_id(0)); - pipeline.submissions.push_back(SubmissionStub::with_id(1)); - - let mut queue = SubmissionQueue::new(NeverConfirmTxManager, Address::ZERO, 1); - - let drained = queue.submit_pending(&mut pipeline).await; - - assert!(!drained, "a ready submission is still waiting on semaphore capacity"); - assert_eq!(recorded.lock().unwrap().dequeued.len(), 1, "only the single permit is used"); - } } diff --git a/crates/batcher/core/src/test_utils/pipeline.rs b/crates/batcher/core/src/test_utils/pipeline.rs index 184261c679..bcaf124d7d 100644 --- a/crates/batcher/core/src/test_utils/pipeline.rs +++ b/crates/batcher/core/src/test_utils/pipeline.rs @@ -100,10 +100,6 @@ impl BatchPipeline for TrackingPipeline { Some(sub) } - fn has_ready_submission(&self) -> bool { - !self.submissions.is_empty() - } - fn confirm(&mut self, _: SubmissionId, _: u64) {} fn requeue(&mut self, id: SubmissionId) { @@ -180,10 +176,6 @@ impl BatchPipeline for ReorgPipeline { None } - fn has_ready_submission(&self) -> bool { - false - } - fn confirm(&mut self, _: SubmissionId, _: u64) {} fn requeue(&mut self, _: SubmissionId) {} fn flush(&mut self) -> Result<(), StepError> { diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index dc9e6fccdd..0ae86b544a 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -1,5 +1,5 @@ -//! Integration tests for [`BatchDriver`] lifecycle: source exhaustion, flush, drain, and -//! the order of work and waiting in the loop. +//! Integration tests for [`BatchDriver`] lifecycle: source exhaustion, drain, and the order +//! of work and waiting in the loop. use std::{ sync::{Arc, Mutex}, @@ -18,8 +18,7 @@ use base_batcher_core::{ }; use base_batcher_encoder::{ChannelLimit, StepError, SubmissionId}; use base_batcher_source::{ - L2BlockEvent, SourceError, UnsafeBlockSource, - test_utils::{ChannelBlockSource, InMemoryBlockSource}, + L2BlockEvent, SourceError, UnsafeBlockSource, test_utils::InMemoryBlockSource, }; use base_runtime::{ Cancellation, Clock, Spawner, @@ -63,46 +62,6 @@ fn test_source_exhaustion_shuts_down_driver_gracefully() { }); } -/// When the source delivers `L2BlockEvent::Flush`, the driver must call -/// `flush` immediately. On subsequent shutdown it is called once -/// more, giving a total of two calls. -#[test] -fn test_flush_event_calls_pipeline_flush() { - Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - let (source, source_tx) = ChannelBlockSource::new(); - - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - source, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), - PendingL1HeadSource, - ); - let handle = ctx.spawn(driver.run()); - - source_tx.send(L2BlockEvent::Flush { ack: None }).unwrap(); - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok()); - // Flush arm: +1; Shutdown arm: +1 → total 2 - assert_eq!( - recorded.lock().unwrap().flush_count, - 2, - "flush must be called for the event and again on shutdown" - ); - }); -} - /// When cancellation fires while a submission is in-flight with a /// `NeverConfirmTxManager`, the drain timeout must fire and the driver must /// exit cleanly. This verifies the `runtime.sleep(drain_timeout)` fix. diff --git a/crates/batcher/core/tests/stop_start.rs b/crates/batcher/core/tests/stop_start.rs index b67d49c4c4..2185b0e8dd 100644 --- a/crates/batcher/core/tests/stop_start.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -1,4 +1,4 @@ -//! Integration tests for stop/start admin commands in [`BatchDriver`]. +//! Integration tests for the stop, start and flush admin commands in [`BatchDriver`]. use std::{ sync::{Arc, Mutex}, @@ -102,10 +102,10 @@ fn test_start_triggers_catchup_from_safe_head() { }); } -/// While stopped, `Block` and `Flush` source events must be dropped; the -/// pipeline must not receive any blocks. +/// While stopped, `Block` source events must be dropped; the pipeline must not +/// receive any blocks. #[test] -fn test_stopped_drops_block_and_flush_events() { +fn test_stopped_drops_block_events() { Runner::start(Config::seeded(0), |ctx| async move { let (admin_handle, admin_rx) = AdminHandle::channel(); let (source, source_tx) = ChannelBlockSource::new(); @@ -127,9 +127,6 @@ fn test_stopped_drops_block_and_flush_events() { fn next_submission(&mut self) -> Option { self.inner.next_submission() } - fn has_ready_submission(&self) -> bool { - self.inner.has_ready_submission() - } fn confirm(&mut self, id: SubmissionId, n: u64) { self.inner.confirm(id, n); } @@ -185,16 +182,6 @@ fn test_stopped_drops_block_and_flush_events() { source_tx.send(L2BlockEvent::Block(Box::default())).unwrap(); ctx.sleep(Duration::from_millis(10)).await; - // A flush's ack must also be dropped (not silently leaked/hung) while stopped, so a - // waiter sees an immediate closed-channel error rather than an indefinite wait. - let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); - source_tx.send(L2BlockEvent::Flush { ack: Some(ack_tx) }).unwrap(); - ctx.sleep(Duration::from_millis(10)).await; - assert!( - ack_rx.await.is_err(), - "flush ack must be dropped (not fired) while the batcher is stopped" - ); - ctx.cancel(); assert!(handle.await.unwrap().is_ok()); diff --git a/crates/batcher/core/tests/throttle.rs b/crates/batcher/core/tests/throttle.rs index 0080df936a..087b3d35f1 100644 --- a/crates/batcher/core/tests/throttle.rs +++ b/crates/batcher/core/tests/throttle.rs @@ -232,10 +232,6 @@ fn test_throttle_transitions_from_active_to_inactive() { None } - fn has_ready_submission(&self) -> bool { - false - } - fn confirm(&mut self, _: SubmissionId, _: u64) {} fn requeue(&mut self, _: SubmissionId) {} fn flush(&mut self) -> Result<(), StepError> { diff --git a/crates/batcher/encoder/src/egress.rs b/crates/batcher/encoder/src/egress.rs index d9ff551f3a..818c3b2d88 100644 --- a/crates/batcher/encoder/src/egress.rs +++ b/crates/batcher/encoder/src/egress.rs @@ -109,23 +109,6 @@ impl DaEgress { !channel.is_open() && available == 0 && !terminal_pending } - /// Returns whether an immutable ready artifact or buildable payload exists. - pub fn has_ready_submission( - &self, - channels: &VecDeque, - da_type: DaType, - l1_head: u64, - ) -> bool { - if self.artifacts.has_ready() { - return true; - } - - match da_type { - DaType::Blob => Self::plan_blob(channels, l1_head).is_some(), - DaType::Calldata => Self::plan_calldata(channels).is_some(), - } - } - /// Builds and leases one transaction-sized submission. pub fn next_submission( &mut self, diff --git a/crates/batcher/encoder/src/encoder.rs b/crates/batcher/encoder/src/encoder.rs index 9b09f94822..843f8c5f2d 100644 --- a/crates/batcher/encoder/src/encoder.rs +++ b/crates/batcher/encoder/src/encoder.rs @@ -550,15 +550,6 @@ impl BatchPipeline for BatchEncoder { Some(submission) } - fn has_ready_submission(&self) -> bool { - let effective_da_type = if self.blob_override && self.config.da_type == DaType::Calldata { - DaType::Blob - } else { - self.config.da_type - }; - self.egress.has_ready_submission(&self.channels, effective_da_type, self.l1_head) - } - fn confirm(&mut self, id: SubmissionId, l1_block: u64) { let Some(channel_ids) = self.egress.confirm(id) else { debug!(id = ?id, "ignoring confirmation for untracked submission"); @@ -1191,7 +1182,7 @@ mod tests { encoder.advance_l1_head(at_threshold); assert!(!has_open_channel(&encoder), "channel must close at effective timeout"); assert!(!encoder.channels.is_empty()); - assert!(encoder.has_ready_submission(), "timeout must release the partial blob"); + assert!(encoder.next_submission().is_some(), "timeout must release the partial blob"); } // --- max_blobs_per_tx tests --- @@ -1227,14 +1218,16 @@ mod tests { encoder.add_block(make_block_with_user_tx(B256::ZERO)).unwrap(); assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); assert!( - !encoder.has_ready_submission(), + encoder.next_submission().is_none(), "size-closed partial blob must wait even when the input queue is empty" ); encoder.flush().unwrap(); - assert!(encoder.has_ready_submission(), "explicit flush must release the partial blob"); - assert!(encoder.next_submission().is_some()); + assert!( + encoder.next_submission().is_some(), + "explicit flush must release the partial blob" + ); } #[test] @@ -1265,7 +1258,7 @@ mod tests { encoder.advance_l1_head(14); assert!( - !encoder.has_ready_submission(), + encoder.next_submission().is_none(), "size-closed tails must remain held before the oldest channel timeout" ); @@ -1273,7 +1266,7 @@ mod tests { let submission = encoder.next_submission().expect("oldest channel timeout releases FIFO"); encoder.requeue(submission.id); assert!( - encoder.has_ready_submission(), + encoder.next_submission().is_some(), "requeue must preserve an already-reached release deadline" ); } @@ -1289,6 +1282,7 @@ mod tests { BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); let mut parent_hash = B256::ZERO; + let mut submission = None; // Brotli may retain one 4 MiB window before exposing output. Feed // enough incompressible input to exercise emission without flushes. for seed in 1..=32 { @@ -1296,19 +1290,19 @@ mod tests { parent_hash = block.header.hash_slow(); encoder.add_block(block).unwrap(); assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - if encoder.has_ready_submission() { + submission = encoder.next_submission(); + if submission.is_some() { break; } } + let submission = submission.unwrap_or_else(|| { + panic!( + "Brotli emitted {} compressed bytes with {} bytes available", + encoder.channels[0].compressed_bytes(), + encoder.channels[0].available_output() + ) + }); assert!(has_open_channel(&encoder), "full blob emission must not close the channel"); - assert!( - encoder.has_ready_submission(), - "Brotli emitted {} compressed bytes with {} bytes available", - encoder.channels[0].compressed_bytes(), - encoder.channels[0].available_output() - ); - - let submission = encoder.next_submission().expect("open channel produced a full blob"); let SubmissionPayload::Blobs(blobs) = submission.payload() else { panic!("expected blob submission"); }; @@ -2027,7 +2021,7 @@ mod tests { let submissions = encoder.encode_and_drain().expect("encode_and_drain"); assert!(!submissions.is_empty(), "drain must release size-closed channel tails"); - assert!(!encoder.has_ready_submission(), "drain must consume every released tail"); + assert!(encoder.next_submission().is_none(), "drain must consume every released tail"); } /// `encode_and_drain` with no blocks added returns empty (Idle immediately). diff --git a/crates/batcher/encoder/src/pipeline.rs b/crates/batcher/encoder/src/pipeline.rs index d62e91c50c..6e853af9f6 100644 --- a/crates/batcher/encoder/src/pipeline.rs +++ b/crates/batcher/encoder/src/pipeline.rs @@ -79,9 +79,6 @@ pub trait BatchPipeline: Send { /// Next L1 transaction, if any. Each call assigns a new [`SubmissionId`]. fn next_submission(&mut self) -> Option; - /// Whether [`next_submission`](Self::next_submission) would return `Some`. - fn has_ready_submission(&self) -> bool; - /// Record L1 inclusion. Does not prune; [`reconcile_derivation`](Self::reconcile_derivation) does. /// /// Confirmed channels remain buffered so reconciliation can detect stalled derivation. diff --git a/crates/batcher/source/src/event.rs b/crates/batcher/source/src/event.rs index e8e2f066c1..676eb9cb80 100644 --- a/crates/batcher/source/src/event.rs +++ b/crates/batcher/source/src/event.rs @@ -1,36 +1,12 @@ //! Events emitted by an [`UnsafeBlockSource`][crate::UnsafeBlockSource]. use base_common_consensus::BaseBlock; -use tokio::sync::oneshot; /// Events emitted by an [`UnsafeBlockSource`][crate::UnsafeBlockSource]. -/// -/// No longer `Clone`: [`Flush`](Self::Flush)'s acknowledgement is a one-shot -/// [`oneshot::Sender`], which can't be cloned. Nothing in this workspace clones -/// `L2BlockEvent` today; if a future caller needs both, prefer redesigning around that need -/// (e.g. a shared completion handle) over re-adding `Clone` by wrapping the sender in -/// `Arc>>`, which would let the ack silently fire multiple times or never. -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum L2BlockEvent { /// A new unsafe L2 block arrived. Block(Box), /// An L2 reorg was detected. Reorg, - /// Signal the driver to close the current channel and release pending - /// frames as submissions without exhausting the source. - /// - /// The source remains open and the driver continues running, but the current - /// channel is closed so all accumulated blocks become available for submission. - Flush { - /// Fired once the driver's encoding and submission are both fully drained (not just - /// after the first frame). Delivered through this event — rather than a side channel - /// — so the acknowledgement preserves the flush's ordering relative to the `Block` - /// events that precede it in the same source. - /// - /// This is a "whole pipeline idle" signal, not scoped strictly to this flush's own - /// frames: it never fires before them, but concurrent `Block` events arriving after - /// this one can delay it further (see `base_batcher_core::BatchDriver::run` for the - /// full caveat). - ack: Option>, - }, } From 5f282a91961cae612da59b1e01fcbe2cc72f6427 Mon Sep 17 00:00:00 2001 From: Joe Parks Date: Wed, 23 Sep 2026 16:12:49 +0000 Subject: [PATCH 097/299] docs(agents): capture recurring human review expectations (#5235) --- CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a83132c647..69ee6fb1fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,3 +78,14 @@ let bal = wallet.account.balance(); - The double needs one call log ordered across multiple trait methods. - Tests must mutate scripted responses while calls are in flight. - Document the specific reason for a hand-rolled fake in its module doc comment. Examples live in `crates/consensus/service/src/test_utils/fake_engine_client.rs`, `fake_l1.rs`, and `fake_gossip.rs`. + +## Recurring Review Expectations + +- Keep changes scoped; split unrelated refactors and remove code and dependencies your change makes unused. +- Reuse existing types, APIs, and fixtures; add abstractions or configuration only for concrete supported use cases. +- Prefer domain types and named constants over magic strings and numbers; name values by their meaning and units. +- Keep modules and actor loops focused; put state-specific logic on the type that owns it. +- Document contracts and non-obvious reasons, not code narration or change history; keep docs consistent with behavior. +- Preserve historical fork behavior and parity across execution paths; test both old and new fork behavior. +- Avoid unnecessary I/O, cloning, and allocation on hot paths; benchmark production code when claiming performance gains. +- Make async lifetimes explicit: bound waits, distinguish retryable from terminal errors, and clean up tasks on failure and shutdown. From e4415e14cac2ea2e357396a11f04a1f70584f973 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 23 Sep 2026 16:29:39 +0000 Subject: [PATCH 098/299] refactor(batcher): merge SourceError::Exhausted into Closed and drop the in-memory sources (#5226) * refactor(batcher): merge SourceError::Exhausted into Closed and drop the in-memory sources * refactor(batcher): park the L1 head arm inside next_event and state the source error contract --- actions/harness/src/batcher/source.rs | 6 +- crates/batcher/core/src/config.rs | 4 +- crates/batcher/core/src/driver.rs | 37 ++-- crates/batcher/core/src/event.rs | 4 +- crates/batcher/core/src/test_utils/source.rs | 2 +- crates/batcher/core/tests/heads.rs | 10 +- crates/batcher/core/tests/lifecycle.rs | 33 ++-- crates/batcher/core/tests/throttle.rs | 25 +-- crates/batcher/source/README.md | 2 +- crates/batcher/source/src/error.rs | 9 +- crates/batcher/source/src/l1_source.rs | 3 + .../batcher/source/src/test_utils/channel.rs | 18 +- .../source/src/test_utils/l1_channel.rs | 21 +-- crates/batcher/source/src/test_utils/mod.rs | 178 ------------------ crates/batcher/source/src/traits.rs | 6 +- 15 files changed, 68 insertions(+), 290 deletions(-) diff --git a/actions/harness/src/batcher/source.rs b/actions/harness/src/batcher/source.rs index c62168a419..7c325f5fe7 100644 --- a/actions/harness/src/batcher/source.rs +++ b/actions/harness/src/batcher/source.rs @@ -40,7 +40,7 @@ impl HarnessBlockSource { impl UnsafeBlockSource for HarnessBlockSource { async fn next(&mut self) -> Result { loop { - match self.rx.recv().await.ok_or(SourceError::Exhausted)? { + match self.rx.recv().await.ok_or(SourceError::Closed)? { BlockSourceItem::Event(event) => return Ok(event), BlockSourceItem::Marker(reached) => { let _ = reached.send(()); @@ -84,10 +84,10 @@ mod tests { } #[tokio::test] - async fn closed_channel_exhausts_the_source() { + async fn closed_channel_closes_the_source() { let (mut source, tx) = HarnessBlockSource::new(); drop(tx); - assert!(matches!(source.next().await, Err(SourceError::Exhausted))); + assert!(matches!(source.next().await, Err(SourceError::Closed))); } } diff --git a/crates/batcher/core/src/config.rs b/crates/batcher/core/src/config.rs index 16635eeead..5cff3855c2 100644 --- a/crates/batcher/core/src/config.rs +++ b/crates/batcher/core/src/config.rs @@ -10,8 +10,8 @@ pub struct BatchDriverConfig { /// Maximum number of in-flight transactions before back-pressure kicks in. pub max_pending_transactions: usize, /// Maximum time to wait for in-flight transactions to settle when draining - /// on cancellation or source exhaustion. Submissions that have not - /// confirmed within this window are abandoned. + /// on cancellation. Submissions that have not confirmed within this window + /// are abandoned. pub drain_timeout: Duration, /// When `true` and DA-backlog throttling is active, force the encoder to /// emit blob-typed submissions even when its configured `da_type` is diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index e83d8f41e4..e246d0cc12 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -83,15 +83,15 @@ where throttle: DaThrottle, /// L1 head source for chain head advancement. /// - /// Set to `None` after the source returns [`SourceError::Exhausted`] or - /// [`SourceError::Closed`], causing the driver to park that select arm forever. + /// Set to `None` after the source returns [`SourceError::Closed`], causing the + /// driver to park that select arm forever. l1_head_source: Option, /// Last trusted L2 safe head. safe_head: Option, /// Ordered derivation-progress snapshots. derivation_status_rx: Option>, /// Maximum wall-clock time to wait for in-flight submissions to settle - /// when draining on cancellation or source exhaustion. + /// when draining on cancellation. drain_timeout: Duration, /// Whether block ingestion is currently stopped (via admin or the `--stopped` flag). stopped: bool, @@ -218,8 +218,8 @@ where /// driver waits again, so the work an event releases is done before the next one, up to /// the encoding step budget. /// - /// When shutting down (after cancellation or source exhaustion), the I/O phase is - /// replaced by a bounded drain of all in-flight receipts. + /// When shutting down after cancellation, the I/O phase is replaced by a bounded drain + /// of all in-flight receipts. pub async fn run(mut self) -> Result<(), BatchDriverError> { if self.stopped { info!( @@ -287,10 +287,6 @@ where DriverEvent::DerivationStatus(status) => { self.on_derivation_status(status); } - DriverEvent::L1SourceClosed => { - debug!("L1 head source closed, disabling arm"); - self.l1_head_source = None; - } } } } @@ -479,8 +475,9 @@ where /// batcher or starting a running one does nothing. Each command is answered /// once it has been applied. /// - /// Non-fatal L1 head source errors loop internally to avoid polluting the - /// return type with a no-op variant. + /// L1 head source errors are handled internally to avoid polluting the return + /// type with a no-op variant: [`SourceError::Closed`] parks the arm for good, + /// any other error is logged and the source polled again. async fn next_event(&mut self) -> Result { loop { let event = tokio::select! { @@ -544,14 +541,10 @@ where } } - event = self.source.next() => match event { - Ok(L2BlockEvent::Block(_)) if self.stopped => { - continue; - } - Ok(L2BlockEvent::Block(block)) => DriverEvent::Block(block), - Ok(L2BlockEvent::Reorg) => DriverEvent::Reorg, - Err(SourceError::Exhausted) => DriverEvent::Shutdown, - Err(e) => return Err(e.into()), + event = self.source.next() => match event? { + L2BlockEvent::Block(_) if self.stopped => continue, + L2BlockEvent::Block(block) => DriverEvent::Block(block), + L2BlockEvent::Reorg => DriverEvent::Reorg, }, Some((ids, outcome)) = self.submissions.next_settled() => { @@ -566,7 +559,11 @@ where } } => match l1_event { Ok(L1HeadEvent::NewHead(n)) => DriverEvent::L1Head(n), - Err(SourceError::Exhausted | SourceError::Closed) => DriverEvent::L1SourceClosed, + Err(SourceError::Closed) => { + warn!("L1 head source closed, L1 head tracking stopped"); + self.l1_head_source = None; + continue; + } Err(e) => { warn!(error = %e, "L1 head source error"); continue; diff --git a/crates/batcher/core/src/event.rs b/crates/batcher/core/src/event.rs index 64b3cc5853..47aa6e736d 100644 --- a/crates/batcher/core/src/event.rs +++ b/crates/batcher/core/src/event.rs @@ -9,7 +9,7 @@ use crate::{AdminResult, DerivationStatus, TxOutcome}; /// Events the driver can receive from external sources during the I/O phase. #[derive(Debug)] pub enum DriverEvent { - /// Cancellation token fired, or L2 source signalled exhausted. + /// Cancellation token fired. Shutdown, /// New L2 unsafe block from the source. Block(Box), @@ -23,6 +23,4 @@ pub enum DriverEvent { L1Head(u64), /// Derivation progress changed. DerivationStatus(DerivationStatus), - /// L1 head source permanently closed (Exhausted or Closed error). - L1SourceClosed, } diff --git a/crates/batcher/core/src/test_utils/source.rs b/crates/batcher/core/src/test_utils/source.rs index 0daa834fd8..1cebdc68ad 100644 --- a/crates/batcher/core/src/test_utils/source.rs +++ b/crates/batcher/core/src/test_utils/source.rs @@ -51,7 +51,7 @@ impl UnsafeBlockSource for TrackingSource { /// [`UnsafeBlockSource`] that delivers exactly one default block then parks forever. /// /// Useful for tests that need a single block ingestion event without the source -/// signalling exhaustion or causing a shutdown. +/// closing afterwards. #[derive(Debug)] pub struct OneBlockSource { delivered: bool, diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index bfd7a429ae..f7a9fe4093 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -67,11 +67,11 @@ fn test_l1_head_source_advances_pipeline() { }); } -/// When the L1 head source is exhausted, the driver must disable that arm and -/// continue running — it must not shut down. The L1 head delivered before -/// exhaustion must be processed normally. +/// When the L1 head source closes, the driver keeps running without L1 head +/// updates instead of shutting down. The L1 head delivered before the close +/// must be processed normally. #[test] -fn test_l1_source_exhausted_disables_arm_driver_continues() { +fn test_l1_source_closed_driver_continues() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); @@ -95,7 +95,7 @@ fn test_l1_source_exhausted_disables_arm_driver_continues() { l1_tx.send(L1HeadEvent::NewHead(77)).unwrap(); ctx.sleep(Duration::from_millis(20)).await; - drop(l1_tx); // triggers Exhausted → L1SourceClosed + drop(l1_tx); // the source returns Closed on its next poll // Driver must still be running after L1 source closes. ctx.sleep(Duration::from_millis(50)).await; diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index 0ae86b544a..71a523d567 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -1,5 +1,5 @@ -//! Integration tests for [`BatchDriver`] lifecycle: source exhaustion, drain, and the order -//! of work and waiting in the loop. +//! Integration tests for [`BatchDriver`] lifecycle: source errors, drain, and the order of +//! work and waiting in the loop. use std::{ sync::{Arc, Mutex}, @@ -18,26 +18,23 @@ use base_batcher_core::{ }; use base_batcher_encoder::{ChannelLimit, StepError, SubmissionId}; use base_batcher_source::{ - L2BlockEvent, SourceError, UnsafeBlockSource, test_utils::InMemoryBlockSource, + L2BlockEvent, SourceError, UnsafeBlockSource, test_utils::ChannelBlockSource, }; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, }; -/// When the block source returns `SourceError::Exhausted`, the driver must -/// treat it as a graceful shutdown signal: close the current channel, -/// drain in-flight submissions within the timeout, then exit cleanly. +/// An error from the block source is fatal: the driver exits with it instead of running +/// without input. #[test] -fn test_source_exhaustion_shuts_down_driver_gracefully() { +fn test_block_source_closed_is_fatal() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - + let (source, source_tx) = ChannelBlockSource::new(); let driver = BatchDriver::new_without_derivation_status( ctx.clone(), - pipeline, - InMemoryBlockSource::new(), // empty → Exhausted immediately + TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))), + source, ImmediateConfirmTxManager { l1_block: 1 }, BatchDriverConfig { inbox: Address::ZERO, @@ -48,17 +45,13 @@ fn test_source_exhaustion_shuts_down_driver_gracefully() { DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), PendingL1HeadSource, ); - let handle = ctx.spawn(driver.run()); - ctx.sleep(Duration::from_millis(50)).await; + + // Closing the channel makes the source fail on its next poll. + drop(source_tx); let result = handle.await.unwrap(); - assert!(result.is_ok(), "driver must exit cleanly when source exhausts"); - assert_eq!( - recorded.lock().unwrap().flush_count, - 1, - "flush must be called once on source exhaustion shutdown" - ); + assert!(matches!(result, Err(BatchDriverError::Source(SourceError::Closed)))); }); } diff --git a/crates/batcher/core/tests/throttle.rs b/crates/batcher/core/tests/throttle.rs index 087b3d35f1..03319873de 100644 --- a/crates/batcher/core/tests/throttle.rs +++ b/crates/batcher/core/tests/throttle.rs @@ -6,7 +6,6 @@ use std::{ }; use alloy_primitives::Address; -use async_trait::async_trait; use base_batcher_core::{ BatchDriver, BatchDriverConfig, DaThrottle, ThrottleConfig, ThrottleController, ThrottleStrategy, @@ -19,14 +18,13 @@ use base_batcher_encoder::{ BatchPipeline, BatchSubmission, DerivationReconciliation, ReorgError, StepError, StepResult, SubmissionId, }; -use base_batcher_source::{L2BlockEvent, SourceError, UnsafeBlockSource}; +use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; use base_common_consensus::BaseBlock; use base_protocol::BlockInfo; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, }; -use tokio::sync::mpsc; /// When the DA backlog exceeds the threshold, the driver must call /// `set_max_da_size` on the throttle client with reduced limits. @@ -252,25 +250,8 @@ fn test_throttle_transitions_from_active_to_inactive() { } } - // Source driven by an mpsc channel so the test can wake the driver loop - // by sending a dummy block event after changing the backlog. - struct ChannelSource { - rx: mpsc::UnboundedReceiver, - } - - #[async_trait] - impl UnsafeBlockSource for ChannelSource { - async fn next(&mut self) -> Result { - match self.rx.recv().await { - Some(event) => Ok(event), - // Channel closed: park until the driver is cancelled. - None => std::future::pending().await, - } - } - } - Runner::start(Config::seeded(0), |ctx| async move { - let (source_tx, source_rx) = mpsc::unbounded_channel(); + let (source, source_tx) = ChannelBlockSource::new(); // Start with 2 MB backlog — above the default 1 MB threshold. let backlog = Arc::new(Mutex::new(2_000_000u64)); @@ -282,7 +263,7 @@ fn test_throttle_transitions_from_active_to_inactive() { let driver = BatchDriver::new_without_derivation_status( ctx.clone(), pipeline, - ChannelSource { rx: source_rx }, + source, ImmediateConfirmTxManager { l1_block: 1 }, BatchDriverConfig { inbox: Address::ZERO, diff --git a/crates/batcher/source/README.md b/crates/batcher/source/README.md index e2dd66b629..593c3fa37b 100644 --- a/crates/batcher/source/README.md +++ b/crates/batcher/source/README.md @@ -11,4 +11,4 @@ Provides ordered L2 block polling, reorg detection, and L1 head tracking. - **`PollingBlockSource`** — fetches consecutive L2 blocks above a safe head - **`PollingSource`** — trait for fetching an L2 block by number - **`HybridL1HeadSource`** — combines L1 subscription and polling -- **`test_utils`** (feature `test-utils`) — in-memory and channel-backed sources for tests +- **`test_utils`** (feature `test-utils`) — channel-backed sources for tests diff --git a/crates/batcher/source/src/error.rs b/crates/batcher/source/src/error.rs index d5098b8f5c..a1a6fc556f 100644 --- a/crates/batcher/source/src/error.rs +++ b/crates/batcher/source/src/error.rs @@ -1,18 +1,15 @@ -//! Error types for block sources. +//! Error type shared by the L2 block and L1 head sources. -/// Errors produced by block sources. +/// Errors produced by the L2 block and L1 head sources. #[derive(Debug, thiserror::Error)] pub enum SourceError { - /// The source has no more blocks. Only in-memory test sources report this. - #[error("block source exhausted")] - Exhausted, /// Provider or RPC error. #[error("provider error: {0}")] Provider(String), /// A requested block has not become available from the provider yet. #[error("block {0} is not available yet")] BlockUnavailable(u64), - /// The underlying channel or stream was closed. + /// The source will deliver nothing more: its channel or stream closed. #[error("source closed")] Closed, } diff --git a/crates/batcher/source/src/l1_source.rs b/crates/batcher/source/src/l1_source.rs index 48d7368c9e..2a36fa9057 100644 --- a/crates/batcher/source/src/l1_source.rs +++ b/crates/batcher/source/src/l1_source.rs @@ -16,5 +16,8 @@ pub trait L1HeadSource: Send { /// Implementations are responsible for deduplicating redundant head updates — /// if both a subscription and a poller deliver the same block number, only /// one `NewHead` event is emitted. + /// + /// [`SourceError::Closed`] stops L1 head tracking in the driver for good; any + /// other error is logged and the source polled again. async fn next(&mut self) -> Result; } diff --git a/crates/batcher/source/src/test_utils/channel.rs b/crates/batcher/source/src/test_utils/channel.rs index 66514ce238..39b4afc0d3 100644 --- a/crates/batcher/source/src/test_utils/channel.rs +++ b/crates/batcher/source/src/test_utils/channel.rs @@ -1,4 +1,4 @@ -//! Channel-backed [`UnsafeBlockSource`] for tests and in-process pipelines. +//! Channel-backed [`UnsafeBlockSource`] for tests. use async_trait::async_trait; use tokio::sync::mpsc; @@ -9,10 +9,10 @@ use crate::{L2BlockEvent, SourceError, UnsafeBlockSource}; /// /// Use [`ChannelBlockSource::new`] to obtain a `(source, sender)` pair. /// Events sent on the [`mpsc::UnboundedSender`] side are consumed by -/// [`UnsafeBlockSource::next`]. +/// [`UnsafeBlockSource::next`]. When all senders are dropped, `next` returns +/// [`SourceError::Closed`]. #[derive(Debug)] pub struct ChannelBlockSource { - /// The receiving half of the unbounded channel. rx: mpsc::UnboundedReceiver, } @@ -27,13 +27,7 @@ impl ChannelBlockSource { #[async_trait] impl UnsafeBlockSource for ChannelBlockSource { async fn next(&mut self) -> Result { - match self.rx.try_recv() { - Ok(event) => return Ok(event), - Err(mpsc::error::TryRecvError::Disconnected) => return Err(SourceError::Exhausted), - Err(mpsc::error::TryRecvError::Empty) => {} - } - - self.rx.recv().await.ok_or(SourceError::Exhausted) + self.rx.recv().await.ok_or(SourceError::Closed) } } @@ -77,12 +71,12 @@ mod tests { } #[tokio::test] - async fn exhausted_when_sender_dropped() { + async fn closed_when_sender_dropped() { let (mut source, tx) = ChannelBlockSource::new(); drop(tx); let err = source.next().await.unwrap_err(); - assert!(matches!(err, SourceError::Exhausted)); + assert!(matches!(err, SourceError::Closed)); } #[tokio::test] diff --git a/crates/batcher/source/src/test_utils/l1_channel.rs b/crates/batcher/source/src/test_utils/l1_channel.rs index cd031f04f3..805d955359 100644 --- a/crates/batcher/source/src/test_utils/l1_channel.rs +++ b/crates/batcher/source/src/test_utils/l1_channel.rs @@ -1,4 +1,4 @@ -//! Channel-backed [`L1HeadSource`] for tests and in-process pipelines. +//! Channel-backed [`L1HeadSource`] for tests. use async_trait::async_trait; use tokio::sync::mpsc; @@ -9,11 +9,8 @@ use crate::{L1HeadEvent, L1HeadSource, SourceError}; /// /// Use [`ChannelL1HeadSource::new`] to obtain a `(source, sender)` pair. /// Events sent on the [`mpsc::UnboundedSender`] side are consumed by -/// [`L1HeadSource::next`]. -/// -/// When the channel is empty the source parks on [`recv`](mpsc::UnboundedReceiver::recv) -/// until a new event arrives. When all senders are dropped, `next` returns -/// [`SourceError::Exhausted`]. +/// [`L1HeadSource::next`]. When all senders are dropped, `next` returns +/// [`SourceError::Closed`]. #[derive(Debug)] pub struct ChannelL1HeadSource { rx: mpsc::UnboundedReceiver, @@ -30,13 +27,7 @@ impl ChannelL1HeadSource { #[async_trait] impl L1HeadSource for ChannelL1HeadSource { async fn next(&mut self) -> Result { - // Non-blocking drain first so burst events don't require a yield. - match self.rx.try_recv() { - Ok(event) => return Ok(event), - Err(mpsc::error::TryRecvError::Disconnected) => return Err(SourceError::Exhausted), - Err(mpsc::error::TryRecvError::Empty) => {} - } - self.rx.recv().await.ok_or(SourceError::Exhausted) + self.rx.recv().await.ok_or(SourceError::Closed) } } @@ -54,12 +45,12 @@ mod tests { } #[tokio::test] - async fn exhausted_when_sender_dropped() { + async fn closed_when_sender_dropped() { let (mut source, tx) = ChannelL1HeadSource::new(); drop(tx); let err = source.next().await.unwrap_err(); - assert!(matches!(err, SourceError::Exhausted)); + assert!(matches!(err, SourceError::Closed)); } #[tokio::test] diff --git a/crates/batcher/source/src/test_utils/mod.rs b/crates/batcher/source/src/test_utils/mod.rs index c6dd3c58a9..be7eb50d4a 100644 --- a/crates/batcher/source/src/test_utils/mod.rs +++ b/crates/batcher/source/src/test_utils/mod.rs @@ -1,185 +1,7 @@ //! Test utilities for block and L1 head sources. -use std::collections::VecDeque; - -use async_trait::async_trait; -use base_common_consensus::BaseBlock; - -use crate::{L1HeadEvent, L1HeadSource, L2BlockEvent, SourceError, UnsafeBlockSource}; - mod channel; pub use channel::ChannelBlockSource; mod l1_channel; pub use l1_channel::ChannelL1HeadSource; - -/// In-memory block source for action tests and unit tests. -/// -/// Events are pushed into a FIFO queue and drained by calls to -/// [`UnsafeBlockSource::next`]. -#[derive(Debug)] -pub struct InMemoryBlockSource { - /// FIFO queue of events to deliver. - queue: VecDeque, -} - -impl InMemoryBlockSource { - /// Create a new empty in-memory block source. - pub const fn new() -> Self { - Self { queue: VecDeque::new() } - } - - /// Push a block event into the queue. - pub fn push_block(&mut self, block: BaseBlock) { - self.queue.push_back(L2BlockEvent::Block(Box::new(block))); - } - - /// Push a reorg event into the queue. - pub fn push_reorg(&mut self) { - self.queue.push_back(L2BlockEvent::Reorg); - } -} - -impl Default for InMemoryBlockSource { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl UnsafeBlockSource for InMemoryBlockSource { - async fn next(&mut self) -> Result { - self.queue.pop_front().ok_or(SourceError::Exhausted) - } -} - -/// In-memory L1 head source for action tests and unit tests. -/// -/// Head block numbers are pushed into a FIFO queue and drained by calls to -/// [`L1HeadSource::next`]. -#[derive(Debug)] -pub struct InMemoryL1HeadSource { - /// FIFO queue of head numbers to deliver. - queue: VecDeque, -} - -impl InMemoryL1HeadSource { - /// Create a new empty in-memory L1 head source. - pub const fn new() -> Self { - Self { queue: VecDeque::new() } - } - - /// Push a head block number into the queue. - pub fn push_head(&mut self, head: u64) { - self.queue.push_back(head); - } -} - -impl Default for InMemoryL1HeadSource { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl L1HeadSource for InMemoryL1HeadSource { - async fn next(&mut self) -> Result { - self.queue.pop_front().map(L1HeadEvent::NewHead).ok_or(SourceError::Exhausted) - } -} - -#[cfg(test)] -mod tests { - use alloy_primitives::B256; - - use super::*; - - fn make_block(number: u64, parent_hash: B256) -> BaseBlock { - BaseBlock { - header: alloy_consensus::Header { number, parent_hash, ..Default::default() }, - body: Default::default(), - } - } - - #[tokio::test] - async fn test_in_memory_exhausted() { - let mut source = InMemoryBlockSource::new(); - let err = source.next().await.unwrap_err(); - assert!(matches!(err, SourceError::Exhausted)); - } - - #[tokio::test] - async fn test_in_memory_push_block() { - let mut source = InMemoryBlockSource::new(); - let block = make_block(42, B256::ZERO); - source.push_block(block); - - let event = source.next().await.unwrap(); - match event { - L2BlockEvent::Block(b) => assert_eq!(b.header.number, 42), - _ => panic!("expected Block event"), - } - } - - #[tokio::test] - async fn test_in_memory_push_reorg() { - let mut source = InMemoryBlockSource::new(); - source.push_reorg(); - - assert!(matches!(source.next().await.unwrap(), L2BlockEvent::Reorg)); - } - - #[tokio::test] - async fn test_in_memory_fifo() { - let mut source = InMemoryBlockSource::new(); - source.push_block(make_block(1, B256::ZERO)); - source.push_block(make_block(2, B256::ZERO)); - source.push_reorg(); - source.push_block(make_block(3, B256::ZERO)); - - match source.next().await.unwrap() { - L2BlockEvent::Block(b) => assert_eq!(b.header.number, 1), - _ => panic!("expected Block(1)"), - } - match source.next().await.unwrap() { - L2BlockEvent::Block(b) => assert_eq!(b.header.number, 2), - _ => panic!("expected Block(2)"), - } - assert!(matches!(source.next().await.unwrap(), L2BlockEvent::Reorg)); - match source.next().await.unwrap() { - L2BlockEvent::Block(b) => assert_eq!(b.header.number, 3), - _ => panic!("expected Block(3)"), - } - - assert!(matches!(source.next().await, Err(SourceError::Exhausted))); - } - - #[tokio::test] - async fn test_in_memory_l1_head_exhausted() { - let mut source = InMemoryL1HeadSource::new(); - let err = source.next().await.unwrap_err(); - assert!(matches!(err, SourceError::Exhausted)); - } - - #[tokio::test] - async fn test_in_memory_l1_head_push() { - let mut source = InMemoryL1HeadSource::new(); - source.push_head(42); - - let event = source.next().await.unwrap(); - assert_eq!(event, L1HeadEvent::NewHead(42)); - } - - #[tokio::test] - async fn test_in_memory_l1_head_fifo() { - let mut source = InMemoryL1HeadSource::new(); - source.push_head(1); - source.push_head(2); - source.push_head(3); - - assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(1)); - assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(2)); - assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(3)); - assert!(matches!(source.next().await, Err(SourceError::Exhausted))); - } -} diff --git a/crates/batcher/source/src/traits.rs b/crates/batcher/source/src/traits.rs index c6f2e72f1d..686955df5c 100644 --- a/crates/batcher/source/src/traits.rs +++ b/crates/batcher/source/src/traits.rs @@ -13,7 +13,9 @@ use crate::{L2BlockEvent, SourceError}; pub trait UnsafeBlockSource: Send { /// Wait for the next L2 block event. /// - /// Blocks (asynchronously) until a new block or reorg is available. + /// Blocks (asynchronously) until a new block or reorg is available. Any error is + /// fatal to the driver: implementations retry transient provider failures internally + /// and return [`SourceError::Closed`] only once nothing more will ever be delivered. async fn next(&mut self) -> Result; /// Reset the source to begin sequential catchup above `safe_head`. @@ -24,6 +26,6 @@ pub trait UnsafeBlockSource: Send { /// continue delivering subsequent blocks in order. /// /// The default implementation is a no-op, suitable for sources that do not - /// support positional reset (e.g. in-memory test sources). + /// support positional reset (e.g. channel-backed test sources). fn reset_catchup(&mut self, _safe_head: BlockInfo) {} } From 74669285460d1e43a4841afec01f49a5275d7fdb Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 23 Sep 2026 16:45:37 +0000 Subject: [PATCH 099/299] refactor(batcher): replace ThrottleController::noop with a disabled constructor (#5228) --- actions/harness/src/batcher/actor.rs | 4 ++-- crates/batcher/core/src/driver.rs | 4 ++-- crates/batcher/core/src/test_utils/builder.rs | 2 +- crates/batcher/core/src/throttle.rs | 11 +++-------- crates/batcher/core/tests/heads.rs | 8 ++++---- crates/batcher/core/tests/lifecycle.rs | 4 ++-- crates/batcher/core/tests/reorg.rs | 6 +++--- crates/batcher/core/tests/stop_start.rs | 4 ++-- crates/batcher/service/src/service.rs | 11 +++++------ 9 files changed, 24 insertions(+), 30 deletions(-) diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 00d8c13071..198ca5966d 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -4,7 +4,7 @@ use alloy_primitives::B256; use alloy_signer_local::PrivateKeySigner; use base_batcher_core::{ AdminError, AdminHandle, BatchDriver, BatchDriverConfig, BatchDriverError, DaThrottle, - NoopThrottleClient, ThrottleConfig, ThrottleController, ThrottleStrategy, + NoopThrottleClient, ThrottleController, }; use base_batcher_encoder::{BatchEncoder, EncoderConfig}; use base_batcher_source::{L2BlockEvent, test_utils::ChannelL1HeadSource}; @@ -163,7 +163,7 @@ impl Batcher { let cancel = CancellationToken::new(); let runtime = TokioRuntime::with_token(cancel.clone()); - let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Off); + let throttle = ThrottleController::disabled(); let driver = BatchDriver::new_without_derivation_status( runtime, pipeline, diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index e246d0cc12..115af36082 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -695,7 +695,7 @@ mod tests { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), BatchDriverHeads::new( QueuedL1HeadSource::new(std::iter::empty()), 50, @@ -781,7 +781,7 @@ mod tests { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), QueuedL1HeadSource::new(l1_events), ) } diff --git a/crates/batcher/core/src/test_utils/builder.rs b/crates/batcher/core/src/test_utils/builder.rs index 12e226ffce..e4998227a9 100644 --- a/crates/batcher/core/src/test_utils/builder.rs +++ b/crates/batcher/core/src/test_utils/builder.rs @@ -79,7 +79,7 @@ impl DriverFixture { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), PendingL1HeadSource, ) } diff --git a/crates/batcher/core/src/throttle.rs b/crates/batcher/core/src/throttle.rs index 23a19de4d0..e1fd367d8b 100644 --- a/crates/batcher/core/src/throttle.rs +++ b/crates/batcher/core/src/throttle.rs @@ -97,14 +97,9 @@ impl ThrottleController { Self { config, strategy } } - /// Returns a [`ThrottleController`] with [`ThrottleStrategy::Off`] that never throttles. - /// - /// Useful in tests and configurations where DA backlog throttling should be disabled. - pub fn noop() -> Self { - Self::new( - ThrottleConfig { threshold_bytes: 0, max_intensity: 0.0, ..Default::default() }, - ThrottleStrategy::Off, - ) + /// Returns a controller with [`ThrottleStrategy::Off`], which never throttles. + pub fn disabled() -> Self { + Self::new(ThrottleConfig::default(), ThrottleStrategy::Off) } /// Returns a reference to the throttle configuration. diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index f7a9fe4093..bb403dc73d 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -47,7 +47,7 @@ fn test_l1_head_source_advances_pipeline() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), l1_source, ); let handle = ctx.spawn(driver.run()); @@ -88,7 +88,7 @@ fn test_l1_source_closed_driver_continues() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), l1_source, ); let handle = ctx.spawn(driver.run()); @@ -130,7 +130,7 @@ fn test_safe_head_conflicts_reset_pipeline_and_source() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), BatchDriverHeads::new( PendingL1HeadSource, 0, @@ -177,7 +177,7 @@ fn test_derivation_cursor_advance_replays_stalled_channel() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), BatchDriverHeads::new( PendingL1HeadSource, 0, diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index 71a523d567..ba13684194 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -42,7 +42,7 @@ fn test_block_source_closed_is_fatal() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), PendingL1HeadSource, ); let handle = ctx.spawn(driver.run()); @@ -165,7 +165,7 @@ fn test_driver_finishes_pending_work_before_waiting_for_events() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), PendingL1HeadSource, ); let handle = ctx.spawn(driver.run()); diff --git a/crates/batcher/core/tests/reorg.rs b/crates/batcher/core/tests/reorg.rs index 239bbfd6c4..79b9cbc890 100644 --- a/crates/batcher/core/tests/reorg.rs +++ b/crates/batcher/core/tests/reorg.rs @@ -40,7 +40,7 @@ fn test_add_block_reorg_resets_pipeline_instead_of_fatal_error() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), PendingL1HeadSource, ); let handle = ctx.spawn(driver.run()); @@ -79,7 +79,7 @@ fn test_l2_reorg_event_resets_pipeline() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), PendingL1HeadSource, ); let handle = ctx.spawn(driver.run()); @@ -120,7 +120,7 @@ fn test_reorg_keeps_tracking_in_flight_submissions() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), PendingL1HeadSource, ) .with_admin_rx(admin_rx); diff --git a/crates/batcher/core/tests/stop_start.rs b/crates/batcher/core/tests/stop_start.rs index 2185b0e8dd..ebeb52d115 100644 --- a/crates/batcher/core/tests/stop_start.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -77,7 +77,7 @@ fn test_start_triggers_catchup_from_safe_head() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), PendingL1HeadSource, ) .with_admin_rx(admin_rx) @@ -170,7 +170,7 @@ fn test_stopped_drops_block_events() { drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, }, - DaThrottle::new(ThrottleController::noop(), Arc::new(NoopThrottleClient)), + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), PendingL1HeadSource, ) .with_admin_rx(admin_rx); diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index 8e3157f8b9..e19e2d19f0 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -12,7 +12,7 @@ use base_balance_monitor::BalanceMonitorLayer; use base_batcher_admin::AdminServer; use base_batcher_core::{ AdminHandle, BatchDriver, BatchDriverHeads, DaThrottle, NoopThrottleClient, ThrottleClient, - ThrottleConfig, ThrottleController, ThrottleStrategy, + ThrottleController, ThrottleStrategy, }; use base_batcher_encoder::{BatchEncoder, BatcherMetrics}; use base_batcher_source::{HybridL1HeadSource, PollingBlockSource, SourceError}; @@ -643,11 +643,10 @@ impl BatcherService { ServiceThrottle::Rpc(RpcThrottleClient::new(&urls)?) } }; - let (throttle_config, throttle_strategy) = self.config.throttle.clone().map_or_else( - || (ThrottleConfig::default(), ThrottleStrategy::Off), - |cfg| (cfg, ThrottleStrategy::Linear), - ); - let throttle = ThrottleController::new(throttle_config, throttle_strategy); + let throttle = + self.config.throttle.clone().map_or_else(ThrottleController::disabled, |cfg| { + ThrottleController::new(cfg, ThrottleStrategy::Linear) + }); // Build the L1 head source: a hybrid of optional WS subscription + polling. let l1_head_subscription = From daa583e3fff08d0ea97b40a65465fea4599d14df Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Wed, 23 Sep 2026 18:47:15 +0000 Subject: [PATCH 100/299] Activate validity RPC at Cobalt without node restart (#5256) * Activate validity RPC at Cobalt without node restart Co-authored-by: Toshi * Keep Cobalt and Zenith RPC error docs attached Co-authored-by: Toshi --------- Co-authored-by: Toshi --- bin/base/src/commands/sequencer.rs | 16 +-- bin/builder/src/main.rs | 15 ++- crates/builder/core/src/extension.rs | 1 + crates/builder/core/src/shadow_validity.rs | 56 +++++++-- crates/builder/core/tests/rpc.rs | 60 +++++++--- crates/execution/cli/src/standard_node.rs | 12 +- .../tx-forwarding/tests/forwarding.rs | 21 ++-- crates/execution/txpool-rpc/README.md | 17 +-- crates/execution/txpool-rpc/src/extension.rs | 6 +- crates/execution/txpool-rpc/src/lib.rs | 2 +- crates/execution/txpool-rpc/src/rpc.rs | 112 +++++++++++++++++- crates/infra/load-tests/README.md | 28 ++--- etc/systems/src/l2/in_process_builder.rs | 5 +- etc/systems/src/l2/in_process_client.rs | 5 +- 14 files changed, 265 insertions(+), 91 deletions(-) diff --git a/bin/base/src/commands/sequencer.rs b/bin/base/src/commands/sequencer.rs index 536e200f59..c25a10c24f 100644 --- a/bin/base/src/commands/sequencer.rs +++ b/bin/base/src/commands/sequencer.rs @@ -116,14 +116,14 @@ impl SequencerCommand { runner.install_ext::( builder_api_config.with_metering_provider(metering_provider), ); - if builder_api_config.accept_experimental_validity_transactions { - runner.install_ext::( - SendRawTransactionValidityConfig { - max_validity_predicates: builder_api_config.max_validity_predicates, - ..Default::default() - }, - ); - } + runner.install_ext::( + SendRawTransactionValidityConfig { + max_validity_predicates: builder_api_config.max_validity_predicates, + experimental_override: builder_api_config + .accept_experimental_validity_transactions, + ..Default::default() + }, + ); runner.install_ext::(shadow_indexer_config); StandardBaseRethNode::install_upgrade_signal_runtime_extension( &mut runner, diff --git a/bin/builder/src/main.rs b/bin/builder/src/main.rs index 9ad80145f3..35b1c30e32 100644 --- a/bin/builder/src/main.rs +++ b/bin/builder/src/main.rs @@ -71,14 +71,13 @@ fn main() { runner.install_ext::( builder_api_config.with_metering_provider(metering_provider), ); - if builder_api_config.accept_experimental_validity_transactions { - runner.install_ext::( - SendRawTransactionValidityConfig { - max_validity_predicates: builder_api_config.max_validity_predicates, - ..Default::default() - }, - ); - } + runner.install_ext::( + SendRawTransactionValidityConfig { + max_validity_predicates: builder_api_config.max_validity_predicates, + experimental_override: builder_api_config.accept_experimental_validity_transactions, + ..Default::default() + }, + ); runner.install_ext::(shadow_indexer_config); StandardBaseRethNode::install_upgrade_signal_runtime_extension(&mut runner, &rollup_args)?; runner.add_started_callback(|| { diff --git a/crates/builder/core/src/extension.rs b/crates/builder/core/src/extension.rs index 46700098ff..c3631d99a7 100644 --- a/crates/builder/core/src/extension.rs +++ b/crates/builder/core/src/extension.rs @@ -97,6 +97,7 @@ impl BaseNodeExtension for BuilderApiExtension { builder.add_rpc_module(move |ctx: &mut BaseRpcContext<'_>| { let api = ShadowValidityBuilderApi::new( ctx.pool().clone(), + ctx.provider().clone(), config, Arc::clone(&metering_provider), ); diff --git a/crates/builder/core/src/shadow_validity.rs b/crates/builder/core/src/shadow_validity.rs index 0199b699ed..2b8894c4ef 100644 --- a/crates/builder/core/src/shadow_validity.rs +++ b/crates/builder/core/src/shadow_validity.rs @@ -2,17 +2,27 @@ use std::sync::Arc; +use alloy_consensus::BlockHeader; use alloy_primitives::{TxHash, U256}; use base_bundles::MeterBundleResponse; +use base_common_chains::Upgrades; use base_execution_txpool::{ BasePooledTransaction, BuilderApiImpl, BuilderApiServer, InsertMetering, TransactionValidity, ValidatedTransaction, ValidityOperator, ValidityPredicate, }; -use jsonrpsee::core::RpcResult; +use jsonrpsee::{ + core::RpcResult, + types::{ErrorCode, ErrorObjectOwned}, +}; +use reth_chainspec::ChainSpecProvider; +use reth_storage_api::BlockReaderIdExt; use reth_transaction_pool::TransactionPool; use crate::{BuilderApiExtensionConfig, BuilderMetrics, SharedMeteringProvider}; +/// Cobalt activates before Denim, while full blocks are two seconds apart. +const PRE_DENIM_BLOCK_INTERVAL_SECS: u64 = 2; + /// Number of basis points representing a 100% sampling rate. pub const MAX_SHADOW_VALIDITY_SAMPLE_RATE_BPS: u16 = 10_000; @@ -122,39 +132,65 @@ impl InsertMetering for InsertMeteringAdapter { /// Builder API that decorates sampled transactions before normal validated insertion. #[derive(Debug)] -pub struct ShadowValidityBuilderApi

{ +pub struct ShadowValidityBuilderApi { inner: BuilderApiImpl, config: ShadowValidityConfig, + provider: Provider, + experimental_override: bool, } -impl

ShadowValidityBuilderApi

{ +impl ShadowValidityBuilderApi { /// Creates a builder API using validated configuration. pub fn new( pool: P, + provider: Provider, config: BuilderApiExtensionConfig, metering_provider: SharedMeteringProvider, ) -> Self { Self { - inner: BuilderApiImpl::with_extensions( - pool, - config.accept_experimental_validity_transactions, - config.max_validity_predicates, - ) - .with_metering_cache(Arc::new(InsertMeteringAdapter(metering_provider))), + inner: BuilderApiImpl::with_extensions(pool, true, config.max_validity_predicates) + .with_metering_cache(Arc::new(InsertMeteringAdapter(metering_provider))), config: config.shadow_validity, + provider, + experimental_override: config.accept_experimental_validity_transactions, } } } #[async_trait::async_trait] -impl

BuilderApiServer for ShadowValidityBuilderApi

+impl BuilderApiServer for ShadowValidityBuilderApi where P: TransactionPool + Send + Sync + 'static, + Provider: BlockReaderIdExt + ChainSpecProvider + Send + Sync + 'static, { async fn insert_validated_transaction( &self, mut tx: ValidatedTransaction, ) -> RpcResult<()> { + if !tx.extensions.validity.is_empty() && !self.experimental_override { + let active = self + .provider + .latest_header() + .map_err(|error| { + ErrorObjectOwned::owned( + ErrorCode::InternalError.code(), + error.to_string(), + None::<()>, + ) + })? + .is_some_and(|header| { + self.provider.chain_spec().is_cobalt_active_at_timestamp( + header.timestamp().saturating_add(PRE_DENIM_BLOCK_INTERVAL_SECS), + ) + }); + if !active { + return Err(ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + "transaction extensions are disabled before Cobalt", + None::<()>, + )); + } + } let outcome = self.config.inject(&mut tx); if self.config.is_enabled() { BuilderMetrics::shadow_validity_injection_total(outcome.label()).increment(1); diff --git a/crates/builder/core/tests/rpc.rs b/crates/builder/core/tests/rpc.rs index c29fb70759..f3882b27ff 100644 --- a/crates/builder/core/tests/rpc.rs +++ b/crates/builder/core/tests/rpc.rs @@ -1,4 +1,5 @@ //! Integration tests for the Builder RPC extension. +use std::sync::Arc; use alloy_consensus::{SignableTransaction, TxEip1559}; use alloy_eips::eip2718::Encodable2718; @@ -9,12 +10,13 @@ use alloy_signer::SignerSync; use base_builder_core::{BuilderApiExtension, BuilderApiExtensionConfig}; use base_common_consensus::{BaseTransactionSigned, BaseTypedTransaction, TxDeposit}; use base_common_rpc_types::BaseTransactionRequest; +use base_execution_chainspec::BaseChainSpec; use base_execution_txpool::{ DEFAULT_MAX_VALIDITY_PREDICATES, NoExtensions, TransactionValidity, ValidatedTransaction, ValidityOperator, ValidityPredicate, }; use base_node_runner::test_utils::TestHarness; -use base_test_utils::Account; +use base_test_utils::{Account, build_test_genesis_cobalt}; use base_txpool_rpc::{ SendRawTransactionValidityConfig, SendRawTransactionValidityExtension, SendRawTransactionValidityOptions, @@ -58,11 +60,12 @@ async fn setup_with_validity_ingress( let config = BuilderApiExtensionConfig::new(accept_validity, max_validity_predicates) .with_noop_metering(); let mut builder = TestHarness::builder().with_ext::(config); - if accept_validity { - builder = builder.with_ext::( - SendRawTransactionValidityConfig { max_validity_predicates, ..Default::default() }, - ); - } + builder = + builder.with_ext::(SendRawTransactionValidityConfig { + max_validity_predicates, + experimental_override: accept_validity, + ..Default::default() + }); let harness = builder.build().await?; let client = harness.rpc_client()?; Ok((harness, client)) @@ -208,6 +211,35 @@ async fn test_validity_transactions_require_explicit_opt_in() -> eyre::Result<() Ok(()) } +/// A builder deployed before Cobalt accepts extensions once the fork is active. +#[tokio::test] +async fn test_validity_extensions_open_at_cobalt() -> eyre::Result<()> { + let spec = BaseChainSpec::from_genesis(build_test_genesis_cobalt()); + let config = + BuilderApiExtensionConfig::new(false, DEFAULT_MAX_VALIDITY_PREDICATES).with_noop_metering(); + let harness = TestHarness::builder() + .with_chain_spec(Arc::new(spec)) + .with_ext::(config) + .build() + .await?; + let client = harness.rpc_client()?; + let tx = ValidatedTransaction { + sender: Account::Alice.address(), + raw: Bytes::from_static(&[0xff]), + metering: None, + extensions: TransactionValidity { + validity: vec![ValidityPredicate::Balance { + address: Account::Alice.address(), + op: ValidityOperator::Equal, + value: U256::ZERO, + }], + }, + }; + let result: Result<(), _> = client.request("base_insertValidatedTransaction", (tx,)).await; + assert!(result.unwrap_err().to_string().contains("failed to decode transaction")); + Ok(()) +} + /// Verifies the builder enforces its configured validity predicate limit. #[tokio::test] async fn test_validity_transactions_enforce_configured_limit() -> eyre::Result<()> { @@ -234,10 +266,11 @@ async fn test_validity_transactions_enforce_configured_limit() -> eyre::Result<( Ok(()) } -/// Verifies builders do not expose public validity ingress unless explicitly opted in. +/// The public method is wired before Cobalt but fails closed until activation. #[tokio::test] -async fn test_send_raw_transaction_validity_requires_explicit_opt_in() -> eyre::Result<()> { - let (disabled_harness, disabled_client) = setup(false, DEFAULT_MAX_VALIDITY_PREDICATES).await?; +async fn test_send_raw_transaction_validity_pre_cobalt_gate() -> eyre::Result<()> { + let (disabled_harness, disabled_client) = + setup_with_validity_ingress(false, DEFAULT_MAX_VALIDITY_PREDICATES).await?; let disabled: Result = disabled_client .request( "base_sendRawTransactionValidity", @@ -247,12 +280,11 @@ async fn test_send_raw_transaction_validity_requires_explicit_opt_in() -> eyre:: ), ) .await; - let disabled_error = disabled - .expect_err("disabled builder should not expose base_sendRawTransactionValidity") - .to_string(); + let disabled_error = + disabled.expect_err("pre-Cobalt builder should reject validity").to_string(); assert!( - disabled_error.contains("-32601") || disabled_error.to_ascii_lowercase().contains("method"), - "expected method-not-found for disabled builder, got: {disabled_error}" + disabled_error.contains("gated behind the Cobalt hard fork"), + "expected pre-Cobalt rejection, got: {disabled_error}" ); let (enabled_harness, enabled_client) = diff --git a/crates/execution/cli/src/standard_node.rs b/crates/execution/cli/src/standard_node.rs index 79b6461d26..5900f1b7f2 100644 --- a/crates/execution/cli/src/standard_node.rs +++ b/crates/execution/cli/src/standard_node.rs @@ -347,12 +347,8 @@ pub struct RpcStandardNodeArgs { )] pub enable_tx_forwarding: bool, - /// Enable the experimental validity transaction RPC. - /// - /// When transaction forwarding is enabled, validity predicates are forwarded to builders, which - /// evaluate and enforce them during block construction. This can also be enabled on a standalone - /// sequencer (e.g. a local devnet) that builds blocks itself, in which case forwarding is not - /// required. + /// Enable validity transactions before Cobalt activates. Without this override, the + /// endpoint is registered at startup but rejects submissions until Cobalt is active. #[arg(long = "enable-experimental-validity-transactions")] pub enable_experimental_validity_transactions: bool, @@ -774,11 +770,13 @@ impl StandardBaseRethNode { runner.install_ext::(metering_config); runner.install_ext::((&args.shadow_indexer).try_into()?); let tx_forwarding_config: TxForwardingConfig = (&args).into(); - if args.rpc.enable_experimental_validity_transactions { + // Query-only nodes must not accept a transaction they cannot forward to a builder. + if args.rpc.enable_tx_forwarding || args.rpc.enable_experimental_validity_transactions { runner.install_ext::( SendRawTransactionValidityConfig { max_validity_predicates: args.rpc.experimental_validity_max_predicates, max_validity_expiry_secs: args.rpc.experimental_validity_max_expiry_secs, + experimental_override: args.rpc.enable_experimental_validity_transactions, }, ); } diff --git a/crates/execution/tx-forwarding/tests/forwarding.rs b/crates/execution/tx-forwarding/tests/forwarding.rs index 5f1ec7fa3b..6dd904825f 100644 --- a/crates/execution/tx-forwarding/tests/forwarding.rs +++ b/crates/execution/tx-forwarding/tests/forwarding.rs @@ -156,18 +156,17 @@ async fn forwards_validity_to_every_builder() -> Result<()> { let (second_tx, mut second_rx) = mpsc::unbounded_channel(); let second = MockBuilder::spawn(second_tx, None, None).await?; let config = TxForwardingConfig::new(vec![first.url.clone(), second.url.clone()]); - // EIP-1559 validity transactions are gated by the experimental flag alone (not Cobalt), so this - // exercises the flow against a pre-Cobalt genesis. + // Exercise the explicit experimental override against a pre-Cobalt genesis. let chain_spec = Arc::new(BaseChainSpec::from_genesis(build_test_genesis())); - let harness = - TestHarness::builder() - .with_ext::( - SendRawTransactionValidityConfig::default(), - ) - .with_ext::(config) - .with_chain_spec(chain_spec) - .build() - .await?; + let harness = TestHarness::builder() + .with_ext::(SendRawTransactionValidityConfig { + experimental_override: true, + ..Default::default() + }) + .with_ext::(config) + .with_chain_spec(chain_spec) + .build() + .await?; let raw = signed_eip1559_transaction(); let validity = serde_json::from_value(serde_json::json!({ "type": "storage", diff --git a/crates/execution/txpool-rpc/README.md b/crates/execution/txpool-rpc/README.md index 74ac1620c0..146ba18a92 100644 --- a/crates/execution/txpool-rpc/README.md +++ b/crates/execution/txpool-rpc/README.md @@ -11,11 +11,11 @@ Exposes JSON-RPC APIs for transaction pool administration and transaction lifecy `AdminTxPoolApiImpl` provides admin-level pool management, while `TransactionStatusApiImpl` allows clients to query the current status of individual transactions by hash. The separate `SendRawTransactionValidityExtension` registers local ingress through -`base_sendRawTransactionValidity` on both mempool/client nodes and builder nodes. Typed +`base_sendRawTransactionValidity` on forwarding ingress nodes and builders. Typed validity predicates are preserved in the pool (and while forwarding to builders). This endpoint -is experimental, but predicates are evaluated and enforced by the builder during block -construction: a transaction is only included at a point where all of its predicates hold, and -it is evicted once it can no longer be included. +is registered at startup but accepts validity-bearing submissions only at Cobalt activation +(or earlier with the experimental override). Predicates are enforced by the builder during +block construction; an unsatisfied transaction is deferred and an expired one is evicted. ## Usage @@ -28,14 +28,15 @@ base-txpool-rpc = { workspace = true } ```rust,ignore use base_txpool_rpc::{ - DEFAULT_MAX_VALIDITY_PREDICATES, SendRawTransactionValidityExtension, TxPoolRpcConfig, + SendRawTransactionValidityConfig, SendRawTransactionValidityExtension, TxPoolRpcConfig, TxPoolRpcExtension, }; runner.install_ext::(TxPoolRpcConfig::default()); -// Install only when the node's explicit experimental validity flag is enabled. -// The config is the maximum number of validity predicates accepted per transaction. -runner.install_ext::(DEFAULT_MAX_VALIDITY_PREDICATES); +// The endpoint is pre-registered; the override permits validity before Cobalt. +runner.install_ext::( + SendRawTransactionValidityConfig { experimental_override: true, ..Default::default() }, +); ``` ## License diff --git a/crates/execution/txpool-rpc/src/extension.rs b/crates/execution/txpool-rpc/src/extension.rs index f8092a0fb5..449f2895e0 100644 --- a/crates/execution/txpool-rpc/src/extension.rs +++ b/crates/execution/txpool-rpc/src/extension.rs @@ -52,6 +52,8 @@ pub struct SendRawTransactionValidityConfig { pub max_validity_predicates: usize, /// Maximum validity-transaction lifetime, in seconds. pub max_validity_expiry_secs: u64, + /// Accept validity transactions before Cobalt activates. + pub experimental_override: bool, } impl Default for SendRawTransactionValidityConfig { @@ -59,6 +61,7 @@ impl Default for SendRawTransactionValidityConfig { Self { max_validity_predicates: DEFAULT_MAX_VALIDITY_PREDICATES, max_validity_expiry_secs: DEFAULT_MAX_VALIDITY_EXPIRY_SECS, + experimental_override: false, } } } @@ -79,7 +82,8 @@ impl BaseNodeExtension for SendRawTransactionValidityExtension { config.max_validity_predicates, config.max_validity_expiry_secs, transaction_sender, - ); + ) + .with_experimental_override(config.experimental_override); ctx.modules.merge_configured(api.into_rpc())?; Ok(()) }) diff --git a/crates/execution/txpool-rpc/src/lib.rs b/crates/execution/txpool-rpc/src/lib.rs index e967353090..fb14ff0112 100644 --- a/crates/execution/txpool-rpc/src/lib.rs +++ b/crates/execution/txpool-rpc/src/lib.rs @@ -12,7 +12,7 @@ pub use rpc::{ AdminTxPoolApiImpl, AdminTxPoolApiServer, SendRawTransactionValidityApiImpl, SendRawTransactionValidityApiServer, SendRawTransactionValidityOptions, Status, TransactionStatusApiImpl, TransactionStatusApiServer, TransactionStatusResponse, - VALIDITY_TX_PRE_ZENITH_RPC_ERROR, + VALIDITY_TX_PRE_COBALT_RPC_ERROR, VALIDITY_TX_PRE_ZENITH_RPC_ERROR, }; mod extension; diff --git a/crates/execution/txpool-rpc/src/rpc.rs b/crates/execution/txpool-rpc/src/rpc.rs index 287ebe2271..a77269535f 100644 --- a/crates/execution/txpool-rpc/src/rpc.rs +++ b/crates/execution/txpool-rpc/src/rpc.rs @@ -26,6 +26,10 @@ use reth_transaction_pool::{BatchTxRequest, PoolTransaction, TransactionOrigin, use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; +/// Rejection message when validity ingress is wired but Cobalt has not activated. +pub const VALIDITY_TX_PRE_COBALT_RPC_ERROR: &str = + "validity transactions are gated behind the Cobalt hard fork"; + /// Rejection message returned when an EIP-8130 (account abstraction) validity transaction is /// submitted before the Zenith hard fork is active at the latest block. /// @@ -116,6 +120,7 @@ pub struct SendRawTransactionValidityApiImpl { provider: Provider, max_validity_predicates: usize, max_validity_expiry_secs: u64, + experimental_override: bool, transaction_sender: tokio::sync::mpsc::UnboundedSender>, } @@ -160,7 +165,18 @@ impl SendRawTransactionValidityApiImpl { BatchTxRequest, >, ) -> Self { - Self { provider, max_validity_predicates, max_validity_expiry_secs, transaction_sender } + Self { + provider, + max_validity_predicates, + max_validity_expiry_secs, + experimental_override: false, + transaction_sender, + } + } + /// Allows validity submissions before Cobalt for experimental deployments. + pub const fn with_experimental_override(mut self, enabled: bool) -> Self { + self.experimental_override = enabled; + self } } @@ -263,6 +279,22 @@ where tx: Bytes, options: SendRawTransactionValidityOptions, ) -> RpcResult { + let latest = self.latest_block_number_and_timestamp()?; + if !self.experimental_override { + let active = latest.is_some_and(|(_, timestamp)| { + self.provider.chain_spec().is_cobalt_active_at_timestamp( + timestamp.saturating_add(LEGACY_BLOCK_INTERVAL_MILLIS / 1_000), + ) + }); + if !active { + return Err(ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + VALIDITY_TX_PRE_COBALT_RPC_ERROR, + None::<()>, + )); + } + } + ValidityPredicate::validate_batch(&options.validity, self.max_validity_predicates) .map_err(|error| { ErrorObjectOwned::owned( @@ -276,7 +308,6 @@ where // head exists, also reject bounds already in the past and bounds outside the configured // wall-clock window. This validates the submission without injecting or rewriting a // predicate. - let latest = self.latest_block_number_and_timestamp()?; let expiry_validation = match latest { Some((latest_block, latest_timestamp)) => { ValidityPredicate::validate_block_expiry_bounds( @@ -387,13 +418,13 @@ mod tests { BaseBlock, BasePooledTransaction as ConsensusPooledTransaction, BasePrimitives, Eip8130Signed, TxEip8130, }; - use base_common_genesis::BaseUpgrade; + use base_common_genesis::{BaseUpgrade, RuntimeUpgradeRegistry}; use base_execution_chainspec::{BaseChainSpec, BaseChainSpecBuilder}; use base_observability_events::{ TransactionEventBuilder, TransactionEventCapture, TransactionEventProducer, TransactionEventType, }; - use base_test_utils::build_test_genesis_zenith; + use base_test_utils::{build_test_genesis, build_test_genesis_zenith}; use httpmock::prelude::*; use reth_chainspec::ForkCondition; use reth_provider::test_utils::MockEthProvider; @@ -699,6 +730,79 @@ mod tests { ); } + #[tokio::test] + async fn runtime_cobalt_schedule_enables_existing_rpc() { + let chain_id = 9_100_202; + RuntimeUpgradeRegistry::clear_chain(chain_id); + let mut genesis = build_test_genesis(); + genesis.config.chain_id = chain_id; + let spec = BaseChainSpec::from_genesis(genesis); + let provider = MockEthProvider::::new() + .with_chain_spec(Arc::new(spec)) + .with_genesis_block(); + let rpc = validity_rpc(provider); + let request = || SendRawTransactionValidityOptions { validity: vec![] }; + assert_eq!( + rpc.send_raw_transaction_validity(Bytes::new(), request()).await.unwrap_err().message(), + VALIDITY_TX_PRE_COBALT_RPC_ERROR + ); + RuntimeUpgradeRegistry::set_activation_timestamp(chain_id, BaseUpgrade::Cobalt, 2); + assert!( + rpc.send_raw_transaction_validity(Bytes::new(), request()) + .await + .unwrap_err() + .message() + .contains("validity predicates must not be empty") + ); + RuntimeUpgradeRegistry::clear_chain(chain_id); + } + + #[tokio::test] + async fn validity_ingress_opens_at_cobalt_without_restart() { + let pre_cobalt = MockEthProvider::::new() + .with_chain_spec(Arc::new( + BaseChainSpecBuilder::base_mainnet() + .with_fork(BaseUpgrade::Cobalt, ForkCondition::Never) + .build(), + )) + .with_genesis_block(); + let rpc = validity_rpc(pre_cobalt.clone()); + let rejected = rpc + .send_raw_transaction_validity( + Bytes::new(), + SendRawTransactionValidityOptions { validity: vec![] }, + ) + .await + .unwrap_err(); + assert_eq!(rejected.message(), VALIDITY_TX_PRE_COBALT_RPC_ERROR); + + let override_rpc = rpc.with_experimental_override(true); + let rejected = override_rpc + .send_raw_transaction_validity( + Bytes::new(), + SendRawTransactionValidityOptions { validity: vec![] }, + ) + .await + .unwrap_err(); + assert!(rejected.message().contains("validity predicates must not be empty")); + + let cobalt = MockEthProvider::::new() + .with_chain_spec(Arc::new( + BaseChainSpecBuilder::base_mainnet() + .with_fork(BaseUpgrade::Cobalt, ForkCondition::Timestamp(0)) + .build(), + )) + .with_genesis_block(); + let rejected = validity_rpc(cobalt) + .send_raw_transaction_validity( + Bytes::new(), + SendRawTransactionValidityOptions { validity: vec![] }, + ) + .await + .unwrap_err(); + assert!(rejected.message().contains("validity predicates must not be empty")); + } + #[test] fn send_raw_transaction_validity_method_is_registered() { let rpc = diff --git a/crates/infra/load-tests/README.md b/crates/infra/load-tests/README.md index 3599f54f32..d55f9b0f7d 100644 --- a/crates/infra/load-tests/README.md +++ b/crates/infra/load-tests/README.md @@ -160,7 +160,7 @@ delay is measured for logging but is no longer included in the JSON output. | Config | Target | Notes | |--------|--------|-------| | `devnet.yaml` | Local devnet | Uses Anvil Account #1 | -| `validity-devnet.yaml` | Local devnet | Validity (conditional) workload; routes half the senders through `base_sendRawTransactionValidity`. Run with `FUNDER_KEY=... just load-test run validity-devnet`. Requires the node validity flags for end-to-end enforcement | +| `validity-devnet.yaml` | Local devnet | Validity (conditional) workload; routes half the senders through `base_sendRawTransactionValidity`. Run with `FUNDER_KEY=... just load-test run validity-devnet`. Before Cobalt, enable the experimental validity override on both ingress and builder | | `real-token-devnet.yaml.template` | Local devnet | Rendered by `just load-test real-token` after deploying the devnet WETH/USDC harness | | `validity-stress.yaml.template` | Local devnet | Rendered by `just load-test validity-stress` with a freshly deployed `DoubleCounter` | | `sepolia.yaml` | Base Sepolia | Requires `FUNDER_KEY` | @@ -472,22 +472,16 @@ confirm the spike landed via the `by_cohort` / `fullest_block` breakdown in the summary. Exactly one of `value` or `offset` may be set on a `block_number` predicate; setting both or neither is a configuration error. -**Required flags for end-to-end evaluation.** For predicates to actually be -evaluated (not merely transported), the target environment must be configured so -that: - -1. The ingress/sequencer node is started with - `--enable-experimental-validity-transactions`. This flag hard-requires - transaction forwarding, so it must be accompanied by `--enable-tx-forwarding` - and at least one `--builder-rpc-urls=`; the node refuses to start - otherwise. Only with this flag set is the `base_sendRawTransactionValidity` - endpoint registered. -2. The builder is started with - `--builder.enable-experimental-validity-transactions`. That flag both - registers `base_sendRawTransactionValidity` on the builder and accepts - forwarded validity metadata. If it is not set, forwarded transactions that - carry predicates are **rejected** ("transaction extensions are disabled"), so - a misconfiguration fails loudly rather than silently dropping predicates. +**Required setup for end-to-end evaluation.** The ingress and builder must run a +version that pre-registers the validity RPC. The endpoint and builder extension +acceptance become active at Cobalt, without a restart at activation. Before Cobalt, +opt in on ingress with `--enable-experimental-validity-transactions` and on the +builder with `--builder.enable-experimental-validity-transactions`. If forwarding +is used, configure `--enable-tx-forwarding` and `--builder-rpc-urls=` on ingress. +The submission proxy must also route `base_sendRawTransactionValidity` to that ingress. +Rejected requests must not be retried as plain `eth_sendRawTransaction`. +Both build paths evaluate state and block predicates. `flashblock_index` requires the +flashblocks path; the native/Denim builder permanently rejects that predicate. 3. The builder runs the flashblocks build path (the only builder path wired in the shipped binaries), which is where predicates are evaluated against state. diff --git a/etc/systems/src/l2/in_process_builder.rs b/etc/systems/src/l2/in_process_builder.rs index af0a03e64f..d8e68e93fb 100644 --- a/etc/systems/src/l2/in_process_builder.rs +++ b/etc/systems/src/l2/in_process_builder.rs @@ -201,7 +201,10 @@ impl InProcessBuilder { let mut hooks = NodeHooks::new(); if accept_validity_transactions { hooks = Box::new(SendRawTransactionValidityExtension::from_config( - SendRawTransactionValidityConfig::default(), + SendRawTransactionValidityConfig { + experimental_override: true, + ..Default::default() + }, )) .apply(hooks); } diff --git a/etc/systems/src/l2/in_process_client.rs b/etc/systems/src/l2/in_process_client.rs index ec59596899..b6a2827664 100644 --- a/etc/systems/src/l2/in_process_client.rs +++ b/etc/systems/src/l2/in_process_client.rs @@ -437,7 +437,10 @@ impl InProcessClient { && !tx_fwd_config.builder_urls.is_empty() { extensions.push(Box::new(SendRawTransactionValidityExtension::from_config( - SendRawTransactionValidityConfig::default(), + SendRawTransactionValidityConfig { + experimental_override: true, + ..Default::default() + }, ))); } extensions.push(Box::new(TxForwardingExtension::from_config(tx_fwd_config.clone()))); From 599fcf02b6bc8ef37af1775e1327f75294289845 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Wed, 23 Sep 2026 19:31:52 +0000 Subject: [PATCH 101/299] Proxy validity transactions from regular RPC nodes (#5257) * Proxy validity transactions through regular RPC nodes Co-authored-by: Toshi * Reuse SequencerClient for validity RPC proxy Co-authored-by: Toshi * Match SequencerClient forwarding timeout behavior Co-authored-by: Toshi --------- Co-authored-by: Toshi --- Cargo.lock | 1 + crates/execution/cli/src/standard_node.rs | 14 +++- crates/execution/rpc/src/sequencer.rs | 64 ++++++++++------- crates/execution/txpool-rpc/Cargo.toml | 1 + crates/execution/txpool-rpc/README.md | 2 + crates/execution/txpool-rpc/src/extension.rs | 17 ++++- crates/execution/txpool-rpc/src/rpc.rs | 73 ++++++++++++++++++++ 7 files changed, 143 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3313f38437..749f4c181b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6548,6 +6548,7 @@ dependencies = [ "base-common-consensus", "base-common-genesis", "base-execution-chainspec", + "base-execution-rpc", "base-execution-txpool", "base-node-runner", "base-observability-events", diff --git a/crates/execution/cli/src/standard_node.rs b/crates/execution/cli/src/standard_node.rs index 5900f1b7f2..e2614fc347 100644 --- a/crates/execution/cli/src/standard_node.rs +++ b/crates/execution/cli/src/standard_node.rs @@ -770,13 +770,23 @@ impl StandardBaseRethNode { runner.install_ext::(metering_config); runner.install_ext::((&args.shadow_indexer).try_into()?); let tx_forwarding_config: TxForwardingConfig = (&args).into(); - // Query-only nodes must not accept a transaction they cannot forward to a builder. - if args.rpc.enable_tx_forwarding || args.rpc.enable_experimental_validity_transactions { + // Query nodes proxy validity metadata to their sequencer; forwarders submit locally. + if args.rpc.enable_tx_forwarding + || args.rpc.rollup_args.sequencer.is_some() + || args.rpc.enable_experimental_validity_transactions + { runner.install_ext::( SendRawTransactionValidityConfig { max_validity_predicates: args.rpc.experimental_validity_max_predicates, max_validity_expiry_secs: args.rpc.experimental_validity_max_expiry_secs, experimental_override: args.rpc.enable_experimental_validity_transactions, + sequencer_url: args + .rpc + .rollup_args + .sequencer + .clone() + .filter(|_| !args.rpc.enable_tx_forwarding), + sequencer_headers: args.rpc.rollup_args.sequencer_headers, }, ); } diff --git a/crates/execution/rpc/src/sequencer.rs b/crates/execution/rpc/src/sequencer.rs index 69f26f2927..1ac8c22ca7 100644 --- a/crates/execution/rpc/src/sequencer.rs +++ b/crates/execution/rpc/src/sequencer.rs @@ -69,31 +69,8 @@ impl SequencerClient { ) -> Result { let sequencer_endpoint = sequencer_endpoint.into(); let endpoint = BuiltInConnectionString::from_str(&sequencer_endpoint)?; - if let BuiltInConnectionString::Http(url) = endpoint { - let mut builder = alloy_reqwest::Client::builder() - // we force use tls to prevent native issues - .use_rustls_tls(); - - if !headers.is_empty() { - let mut header_map = alloy_reqwest::header::HeaderMap::new(); - for header in headers { - if let Some((key, value)) = header.split_once('=') { - header_map.insert( - key.trim() - .parse::() - .map_err(|err| Error::InvalidHeader(err.to_string()))?, - value - .trim() - .parse::() - .map_err(|err| Error::InvalidHeader(err.to_string()))?, - ); - } - } - builder = builder.default_headers(header_map); - } - - let client = builder.build()?; - Self::with_http_client(url, client) + if matches!(&endpoint, BuiltInConnectionString::Http(_)) { + Self::new_http_with_headers(sequencer_endpoint, headers) } else { let client = ClientBuilder::default().connect_with(endpoint).await?; let inner = SequencerClientInner::new(sequencer_endpoint, client); @@ -101,6 +78,43 @@ impl SequencerClient { } } + /// Creates an HTTP sequencer client synchronously, with optional `header=value` headers. + /// + /// # Errors + /// + /// Returns an error for a non-HTTP URL, invalid headers, or HTTP client construction. + pub fn new_http_with_headers( + sequencer_endpoint: impl Into, + headers: Vec, + ) -> Result { + let sequencer_endpoint = sequencer_endpoint.into(); + if !matches!( + BuiltInConnectionString::from_str(&sequencer_endpoint)?, + BuiltInConnectionString::Http(_) + ) { + return Err(Error::InvalidScheme(sequencer_endpoint)); + } + let mut builder = alloy_reqwest::Client::builder().use_rustls_tls(); + if !headers.is_empty() { + let mut header_map = alloy_reqwest::header::HeaderMap::new(); + for header in headers { + if let Some((key, value)) = header.split_once('=') { + header_map.insert( + key.trim() + .parse::() + .map_err(|err| Error::InvalidHeader(err.to_string()))?, + value + .trim() + .parse::() + .map_err(|err| Error::InvalidHeader(err.to_string()))?, + ); + } + } + builder = builder.default_headers(header_map); + } + Self::with_http_client(sequencer_endpoint, builder.build()?) + } + /// Creates a new [`SequencerClient`] with http transport with the given http client. pub fn with_http_client( sequencer_endpoint: impl Into, diff --git a/crates/execution/txpool-rpc/Cargo.toml b/crates/execution/txpool-rpc/Cargo.toml index 5528645b6b..90502f9745 100644 --- a/crates/execution/txpool-rpc/Cargo.toml +++ b/crates/execution/txpool-rpc/Cargo.toml @@ -18,6 +18,7 @@ base-common-chains.workspace = true base-common-genesis.workspace = true base-execution-txpool.workspace = true base-common-consensus.workspace = true +base-execution-rpc.workspace = true base-observability-events.workspace = true # reth diff --git a/crates/execution/txpool-rpc/README.md b/crates/execution/txpool-rpc/README.md index 146ba18a92..67b6f865d0 100644 --- a/crates/execution/txpool-rpc/README.md +++ b/crates/execution/txpool-rpc/README.md @@ -16,6 +16,8 @@ validity predicates are preserved in the pool (and while forwarding to builders) is registered at startup but accepts validity-bearing submissions only at Cobalt activation (or earlier with the experimental override). Predicates are enforced by the builder during block construction; an unsatisfied transaction is deferred and an expired one is evicted. +Regular RPC nodes with a configured sequencer URL proxy this method, including predicates and +the upstream error response, rather than admitting the transaction into their local pool. ## Usage diff --git a/crates/execution/txpool-rpc/src/extension.rs b/crates/execution/txpool-rpc/src/extension.rs index 449f2895e0..3964c3b18f 100644 --- a/crates/execution/txpool-rpc/src/extension.rs +++ b/crates/execution/txpool-rpc/src/extension.rs @@ -1,5 +1,6 @@ //! `TxPool` RPC extension for registering transaction pool management APIs. +use base_execution_rpc::SequencerClient; pub use base_execution_txpool::{ DEFAULT_MAX_VALIDITY_EXPIRY_SECS, DEFAULT_MAX_VALIDITY_PREDICATES, }; @@ -46,7 +47,7 @@ impl BaseNodeExtension for TxPoolRpcExtension { } /// Configuration for local validity-bearing transaction ingress. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct SendRawTransactionValidityConfig { /// Maximum number of validity predicates accepted per transaction. pub max_validity_predicates: usize, @@ -54,6 +55,10 @@ pub struct SendRawTransactionValidityConfig { pub max_validity_expiry_secs: u64, /// Accept validity transactions before Cobalt activates. pub experimental_override: bool, + /// Upstream sequencer RPC for query nodes that proxy rather than build transactions. + pub sequencer_url: Option, + /// HTTP headers sent to the upstream sequencer, in `name=value` form. + pub sequencer_headers: Vec, } impl Default for SendRawTransactionValidityConfig { @@ -62,6 +67,8 @@ impl Default for SendRawTransactionValidityConfig { max_validity_predicates: DEFAULT_MAX_VALIDITY_PREDICATES, max_validity_expiry_secs: DEFAULT_MAX_VALIDITY_EXPIRY_SECS, experimental_override: false, + sequencer_url: None, + sequencer_headers: Vec::new(), } } } @@ -77,13 +84,19 @@ impl BaseNodeExtension for SendRawTransactionValidityExtension { let config = self.config; builder.add_rpc_module(move |ctx: &mut BaseRpcContext<'_>| { let transaction_sender = ctx.registry.eth_api().eth_api().tx_batch_sender().clone(); - let api = SendRawTransactionValidityApiImpl::with_validity_limits( + let mut api = SendRawTransactionValidityApiImpl::with_validity_limits( ctx.provider().clone(), config.max_validity_predicates, config.max_validity_expiry_secs, transaction_sender, ) .with_experimental_override(config.experimental_override); + if let Some(url) = &config.sequencer_url { + api = api.with_sequencer_client(SequencerClient::new_http_with_headers( + url, + config.sequencer_headers.clone(), + )?); + } ctx.modules.merge_configured(api.into_rpc())?; Ok(()) }) diff --git a/crates/execution/txpool-rpc/src/rpc.rs b/crates/execution/txpool-rpc/src/rpc.rs index a77269535f..9c8d0cfde2 100644 --- a/crates/execution/txpool-rpc/src/rpc.rs +++ b/crates/execution/txpool-rpc/src/rpc.rs @@ -5,6 +5,7 @@ use alloy_primitives::{Address, Bytes, TxHash}; use base_common_chains::Upgrades; use base_common_consensus::EIP8130_TX_TYPE_ID; use base_common_genesis::RollupConfig; +use base_execution_rpc::SequencerClient; use base_execution_txpool::{ BasePooledTransaction, DEFAULT_MAX_VALIDITY_EXPIRY_SECS, DEFAULT_MAX_VALIDITY_PREDICATES, ValidityPredicate, deserialize_bounded_predicates, @@ -121,6 +122,7 @@ pub struct SendRawTransactionValidityApiImpl { max_validity_predicates: usize, max_validity_expiry_secs: u64, experimental_override: bool, + sequencer_client: Option, transaction_sender: tokio::sync::mpsc::UnboundedSender>, } @@ -170,6 +172,7 @@ impl SendRawTransactionValidityApiImpl { max_validity_predicates, max_validity_expiry_secs, experimental_override: false, + sequencer_client: None, transaction_sender, } } @@ -178,6 +181,11 @@ impl SendRawTransactionValidityApiImpl { self.experimental_override = enabled; self } + /// Proxies validity submissions to the configured sequencer rather than the local pool. + pub fn with_sequencer_client(mut self, client: SequencerClient) -> Self { + self.sequencer_client = Some(client); + self + } } impl SendRawTransactionValidityApiImpl @@ -279,6 +287,13 @@ where tx: Bytes, options: SendRawTransactionValidityOptions, ) -> RpcResult { + if let Some(client) = &self.sequencer_client { + return client + .request("base_sendRawTransactionValidity", (tx, options)) + .await + .map_err(ErrorObjectOwned::from); + } + let latest = self.latest_block_number_and_timestamp()?; if !self.experimental_override { let active = latest.is_some_and(|(_, timestamp)| { @@ -803,6 +818,64 @@ mod tests { assert!(rejected.message().contains("validity predicates must not be empty")); } + #[tokio::test] + async fn proxies_validity_with_predicates_without_local_submission() { + let sequencer = MockServer::start(); + let raw = Bytes::from_static(&[0x02]); + let options = SendRawTransactionValidityOptions { validity: all_predicate_variants() }; + let expected_hash = TxHash::repeat_byte(0x42); + let mock = sequencer.mock(|when, then| { + when.method(POST).path("/").header("x-demo", "forwarded").json_body(json!({ + "jsonrpc": "2.0", "id": 0, "method": "base_sendRawTransactionValidity", + "params": [raw, { "validity": options.validity }], + })); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({"jsonrpc": "2.0", "id": 0, "result": expected_hash})); + }); + let client = SequencerClient::new_http_with_headers( + sequencer.base_url(), + vec!["X-Demo=forwarded".to_string()], + ) + .unwrap(); + let rpc = SendRawTransactionValidityApiImpl::new( + pre_zenith_provider(), + test_transaction_sender(), + ) + .with_sequencer_client(client); + let hash = rpc.send_raw_transaction_validity(raw, options).await.unwrap(); + assert_eq!(hash, expected_hash); + mock.assert(); + } + + #[tokio::test] + async fn proxy_preserves_sequencer_rejection() { + let sequencer = MockServer::start(); + let raw = Bytes::from_static(&[0x02]); + let options = SendRawTransactionValidityOptions { validity: vec![] }; + let mock = sequencer.mock(|when, then| { + when.method(POST).path("/").json_body(json!({ + "jsonrpc": "2.0", "id": 0, "method": "base_sendRawTransactionValidity", + "params": [raw, { "validity": [] }], + })); + then.status(200).header("content-type", "application/json").json_body( + json!({"jsonrpc": "2.0", "id": 0, "error": { + "code": -32602, "message": "upstream validity rejection" + }}), + ); + }); + let client = SequencerClient::new_http_with_headers(sequencer.base_url(), vec![]).unwrap(); + let rpc = SendRawTransactionValidityApiImpl::new( + pre_zenith_provider(), + test_transaction_sender(), + ) + .with_sequencer_client(client); + let error = rpc.send_raw_transaction_validity(raw, options).await.unwrap_err(); + assert_eq!(error.code(), -32602); + assert_eq!(error.message(), "upstream validity rejection"); + mock.assert(); + } + #[test] fn send_raw_transaction_validity_method_is_registered() { let rpc = From 5da1f78113d2f5bef15889eb4e6f8b776df85edd Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Wed, 23 Sep 2026 21:08:35 +0000 Subject: [PATCH 102/299] feat(cli): decouple validity max predicates from experimental flag (#5263) * feat(cli): decouple validity max predicates from experimental flag Rename --experimental-validity-max-predicates to --validity-max-predicates (and --builder.experimental-validity-max-predicates to --builder.validity-max-predicates) and drop the requirement on the experimental validity override, so the limit is configured ahead of Cobalt activation without a restart. * feat(cli): decouple validity max expiry from experimental flag Rename --experimental-validity-max-expiry-secs to --validity-max-expiry-secs and drop the requirement on the experimental validity override. Co-authored-by: Toshi * style: cargo fmt Co-authored-by: Toshi --------- Co-authored-by: Toshi --- bin/base/src/commands/rpc.rs | 4 +- crates/builder/cli/src/args.rs | 49 ++++++--------- crates/execution/cli/src/standard_node.rs | 75 ++++++++++++----------- 3 files changed, 61 insertions(+), 67 deletions(-) diff --git a/bin/base/src/commands/rpc.rs b/bin/base/src/commands/rpc.rs index 1dba4e6e50..45b17b5829 100644 --- a/bin/base/src/commands/rpc.rs +++ b/bin/base/src/commands/rpc.rs @@ -564,7 +564,7 @@ mod tests { "--builder-rpc-urls", "http://localhost:8545", "--enable-experimental-validity-transactions", - "--experimental-validity-max-predicates", + "--validity-max-predicates", "8", ])); @@ -576,7 +576,7 @@ mod tests { assert!(launch_config.standard.rpc.enable_tx_forwarding); assert!(launch_config.standard.rpc.enable_experimental_validity_transactions); - assert_eq!(launch_config.standard.rpc.experimental_validity_max_predicates, 8); + assert_eq!(launch_config.standard.rpc.validity_max_predicates, 8); assert_eq!(launch_config.standard.rpc.builder_rpc_urls.len(), 1); } diff --git a/crates/builder/cli/src/args.rs b/crates/builder/cli/src/args.rs index 90dc9b841e..983ff86d3c 100644 --- a/crates/builder/cli/src/args.rs +++ b/crates/builder/cli/src/args.rs @@ -193,19 +193,18 @@ pub struct Args { #[arg(long = "builder.enable-experimental-validity-transactions", default_value = "false")] pub enable_experimental_validity_transactions: bool, - /// Maximum validity predicates accepted per experimental transaction. + /// Maximum validity predicates accepted per validity transaction. /// /// Capped at [`DEFAULT_MAX_VALIDITY_PREDICATES`], the fixed wire ceiling the /// request deserializer enforces. Values above it can never be honored and /// are rejected at startup rather than silently truncated. #[arg( - long = "builder.experimental-validity-max-predicates", + long = "builder.validity-max-predicates", default_value_t = DEFAULT_MAX_VALIDITY_PREDICATES, value_parser = clap::builder::RangedU64ValueParser::::new() .range(1..=DEFAULT_MAX_VALIDITY_PREDICATES as u64), - requires = "enable_experimental_validity_transactions" )] - pub experimental_validity_max_predicates: usize, + pub validity_max_predicates: usize, /// Decorate sampled ordinary transactions with a behavior-preserving validity predicate. /// @@ -348,7 +347,7 @@ impl Default for Args { extra_block_deadline_secs: 20, enable_resource_metering: false, enable_experimental_validity_transactions: false, - experimental_validity_max_predicates: DEFAULT_MAX_VALIDITY_PREDICATES, + validity_max_predicates: DEFAULT_MAX_VALIDITY_PREDICATES, shadow_validity_injection_enabled: false, shadow_validity_injection_sample_rate_bps: 100, max_uncompressed_block_size: None, @@ -387,7 +386,7 @@ impl Args { }; Ok(BuilderApiExtensionConfig::new( self.enable_experimental_validity_transactions, - self.experimental_validity_max_predicates, + self.validity_max_predicates, ) .with_shadow_validity(shadow_validity)?) } @@ -477,7 +476,7 @@ mod tests { fn default_args_produce_valid_config() { let args = Args::default(); assert!(!args.enable_experimental_validity_transactions); - assert_eq!(args.experimental_validity_max_predicates, DEFAULT_MAX_VALIDITY_PREDICATES); + assert_eq!(args.validity_max_predicates, DEFAULT_MAX_VALIDITY_PREDICATES); assert!(!args.shadow_validity_injection_enabled); assert_eq!(args.shadow_validity_injection_sample_rate_bps, 100); assert!(!args.builder_api_config().unwrap().shadow_validity.is_enabled()); @@ -492,56 +491,48 @@ mod tests { let parsed = CommandParser::parse_from([ "builder", "--builder.enable-experimental-validity-transactions", - "--builder.experimental-validity-max-predicates", + "--builder.validity-max-predicates", "8", ]); assert!(parsed.args.enable_experimental_validity_transactions); - assert_eq!(parsed.args.experimental_validity_max_predicates, 8); + assert_eq!(parsed.args.validity_max_predicates, 8); } #[test] - fn experimental_validity_max_predicates_rejects_values_above_the_wire_ceiling() { + fn validity_max_predicates_rejects_values_above_the_wire_ceiling() { // The request deserializer bounds batches at DEFAULT_MAX_VALIDITY_PREDICATES, // so a larger configured maximum could never be honored. Reject it at // startup instead of silently accepting an unenforceable limit. let error = CommandParser::try_parse_from([ "builder", - "--builder.enable-experimental-validity-transactions", - "--builder.experimental-validity-max-predicates", + "--builder.validity-max-predicates", &(DEFAULT_MAX_VALIDITY_PREDICATES + 1).to_string(), ]) .expect_err("a maximum above the wire ceiling should be rejected"); - assert!(error.to_string().contains("--builder.experimental-validity-max-predicates")); + assert!(error.to_string().contains("--builder.validity-max-predicates")); } #[test] - fn experimental_validity_max_predicates_rejects_zero() { - let error = CommandParser::try_parse_from([ - "builder", - "--builder.enable-experimental-validity-transactions", - "--builder.experimental-validity-max-predicates", - "0", - ]) - .expect_err("a maximum of zero should be rejected"); + fn validity_max_predicates_rejects_zero() { + let error = + CommandParser::try_parse_from(["builder", "--builder.validity-max-predicates", "0"]) + .expect_err("a maximum of zero should be rejected"); - assert!(error.to_string().contains("--builder.experimental-validity-max-predicates")); + assert!(error.to_string().contains("--builder.validity-max-predicates")); } #[test] - fn experimental_validity_max_predicates_accepts_the_wire_ceiling() { + fn validity_max_predicates_accepts_the_wire_ceiling_without_experimental_override() { let parsed = CommandParser::parse_from([ "builder", - "--builder.enable-experimental-validity-transactions", - "--builder.experimental-validity-max-predicates", + "--builder.validity-max-predicates", &DEFAULT_MAX_VALIDITY_PREDICATES.to_string(), ]); - assert_eq!( - parsed.args.experimental_validity_max_predicates, - DEFAULT_MAX_VALIDITY_PREDICATES - ); + assert!(!parsed.args.enable_experimental_validity_transactions); + assert_eq!(parsed.args.validity_max_predicates, DEFAULT_MAX_VALIDITY_PREDICATES); } #[test] diff --git a/crates/execution/cli/src/standard_node.rs b/crates/execution/cli/src/standard_node.rs index e2614fc347..3168599c9a 100644 --- a/crates/execution/cli/src/standard_node.rs +++ b/crates/execution/cli/src/standard_node.rs @@ -352,27 +352,25 @@ pub struct RpcStandardNodeArgs { #[arg(long = "enable-experimental-validity-transactions")] pub enable_experimental_validity_transactions: bool, - /// Maximum validity predicates accepted per experimental transaction. + /// Maximum validity predicates accepted per validity transaction. /// /// Capped at [`DEFAULT_MAX_VALIDITY_PREDICATES`], the fixed wire ceiling the /// request deserializer enforces. Values above it can never be honored and /// are rejected at startup rather than silently truncated. #[arg( - long = "experimental-validity-max-predicates", + long = "validity-max-predicates", default_value_t = DEFAULT_MAX_VALIDITY_PREDICATES, value_parser = clap::builder::RangedU64ValueParser::::new() .range(1..=DEFAULT_MAX_VALIDITY_PREDICATES as u64), - requires = "enable_experimental_validity_transactions" )] - pub experimental_validity_max_predicates: usize, + pub validity_max_predicates: usize, - /// Maximum lifetime, in seconds, for an experimental validity transaction. + /// Maximum lifetime, in seconds, for a validity transaction. #[arg( - long = "experimental-validity-max-expiry-secs", + long = "validity-max-expiry-secs", default_value_t = DEFAULT_MAX_VALIDITY_EXPIRY_SECS, - requires = "enable_experimental_validity_transactions" )] - pub experimental_validity_max_expiry_secs: u64, + pub validity_max_expiry_secs: u64, /// Builder RPC endpoints for transaction forwarding (one forwarder per URL), used by mempool nodes #[arg( @@ -777,8 +775,8 @@ impl StandardBaseRethNode { { runner.install_ext::( SendRawTransactionValidityConfig { - max_validity_predicates: args.rpc.experimental_validity_max_predicates, - max_validity_expiry_secs: args.rpc.experimental_validity_max_expiry_secs, + max_validity_predicates: args.rpc.validity_max_predicates, + max_validity_expiry_secs: args.rpc.validity_max_expiry_secs, experimental_override: args.rpc.enable_experimental_validity_transactions, sequencer_url: args .rpc @@ -986,8 +984,8 @@ mod tests { transaction_event_journal_path: None, enable_tx_forwarding: false, enable_experimental_validity_transactions: false, - experimental_validity_max_predicates: DEFAULT_MAX_VALIDITY_PREDICATES, - experimental_validity_max_expiry_secs: DEFAULT_MAX_VALIDITY_EXPIRY_SECS, + validity_max_predicates: DEFAULT_MAX_VALIDITY_PREDICATES, + validity_max_expiry_secs: DEFAULT_MAX_VALIDITY_EXPIRY_SECS, builder_rpc_urls: Vec::new(), tx_forwarding_resend_after_ms: DEFAULT_RESEND_AFTER_MS, tx_forwarding_batch_size: DEFAULT_MAX_BATCH_SIZE, @@ -1114,14 +1112,8 @@ mod tests { assert_eq!(standard_args.rpc.rollup_args.sequencer, None); assert!(!standard_args.rpc.enable_experimental_validity_transactions); - assert_eq!( - standard_args.rpc.experimental_validity_max_predicates, - DEFAULT_MAX_VALIDITY_PREDICATES - ); - assert_eq!( - standard_args.rpc.experimental_validity_max_expiry_secs, - DEFAULT_MAX_VALIDITY_EXPIRY_SECS - ); + assert_eq!(standard_args.rpc.validity_max_predicates, DEFAULT_MAX_VALIDITY_PREDICATES); + assert_eq!(standard_args.rpc.validity_max_expiry_secs, DEFAULT_MAX_VALIDITY_EXPIRY_SECS); assert!(!config.enabled); assert!(config.builder_urls.is_empty()); assert!(!config.inline_simulation); @@ -1147,60 +1139,71 @@ mod tests { "--builder-rpc-urls", "http://localhost:8545", "--enable-experimental-validity-transactions", - "--experimental-validity-max-predicates", + "--validity-max-predicates", "8", - "--experimental-validity-max-expiry-secs", + "--validity-max-expiry-secs", "45", ]) .args; assert!(args.rpc.enable_tx_forwarding); assert!(args.rpc.enable_experimental_validity_transactions); - assert_eq!(args.rpc.experimental_validity_max_predicates, 8); - assert_eq!(args.rpc.experimental_validity_max_expiry_secs, 45); + assert_eq!(args.rpc.validity_max_predicates, 8); + assert_eq!(args.rpc.validity_max_expiry_secs, 45); assert_eq!(args.rpc.builder_rpc_urls.len(), 1); } #[test] - fn experimental_validity_max_predicates_rejects_values_above_the_wire_ceiling() { + fn validity_max_predicates_rejects_values_above_the_wire_ceiling() { // The request deserializer bounds batches at DEFAULT_MAX_VALIDITY_PREDICATES, // so a larger configured maximum could never be honored. Reject it at // startup instead of silently accepting an unenforceable limit. let error = CommandParser::::try_parse_from([ "base-reth", - "--enable-experimental-validity-transactions", - "--experimental-validity-max-predicates", + "--validity-max-predicates", &(DEFAULT_MAX_VALIDITY_PREDICATES + 1).to_string(), ]) .expect_err("a maximum above the wire ceiling should be rejected"); - assert!(error.to_string().contains("--experimental-validity-max-predicates")); + assert!(error.to_string().contains("--validity-max-predicates")); } #[test] - fn experimental_validity_max_predicates_rejects_zero() { + fn validity_max_predicates_rejects_zero() { let error = CommandParser::::try_parse_from([ "base-reth", - "--enable-experimental-validity-transactions", - "--experimental-validity-max-predicates", + "--validity-max-predicates", "0", ]) .expect_err("a maximum of zero should be rejected"); - assert!(error.to_string().contains("--experimental-validity-max-predicates")); + assert!(error.to_string().contains("--validity-max-predicates")); } #[test] - fn experimental_validity_max_predicates_accepts_the_wire_ceiling() { + fn validity_max_predicates_accepts_the_wire_ceiling_without_experimental_override() { let args = CommandParser::::parse_from([ "base-reth", - "--enable-experimental-validity-transactions", - "--experimental-validity-max-predicates", + "--validity-max-predicates", &DEFAULT_MAX_VALIDITY_PREDICATES.to_string(), ]) .args; - assert_eq!(args.rpc.experimental_validity_max_predicates, DEFAULT_MAX_VALIDITY_PREDICATES); + assert!(!args.rpc.enable_experimental_validity_transactions); + assert_eq!(args.rpc.validity_max_predicates, DEFAULT_MAX_VALIDITY_PREDICATES); + } + + #[test] + fn validity_max_expiry_secs_parses_without_experimental_override() { + let args = CommandParser::::parse_from([ + "base-reth", + "--validity-max-expiry-secs", + "45", + ]) + .args; + + assert!(!args.rpc.enable_experimental_validity_transactions); + assert_eq!(args.rpc.validity_max_expiry_secs, 45); } #[test] From 0d917873d13ee18d572eea7a9a6db4fdd2a184e5 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Wed, 23 Sep 2026 23:31:03 +0000 Subject: [PATCH 103/299] feat(bench): aggregate latest client version (#5266) --- etc/systems/src/benchmark_cli.rs | 125 +++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/etc/systems/src/benchmark_cli.rs b/etc/systems/src/benchmark_cli.rs index 57d50e6522..49aa9b3cc8 100644 --- a/etc/systems/src/benchmark_cli.rs +++ b/etc/systems/src/benchmark_cli.rs @@ -140,6 +140,10 @@ pub struct AggregateBenchmarkArgs { /// One or more self-contained snapshot run output directories to include. #[arg(required = true, value_name = "RUN_OUTPUT_DIR")] pub run_output_dirs: Vec, + /// Emit one report page per client version plus a stable `latest` page for + /// each configured benchmark cohort. + #[arg(long)] + pub versioned_pages: bool, } /// Arguments for one snapshot-backed benchmark case. @@ -748,6 +752,9 @@ impl AggregateBenchmarkArgs { } let mut runs = selected.into_values().collect::>(); + if self.versioned_pages { + runs = Self::versioned_pages(runs)?; + } runs.sort_by(|left, right| { let left_time = Self::parse_created_at(left).expect("selected run was validated"); let right_time = Self::parse_created_at(right).expect("selected run was validated"); @@ -756,6 +763,70 @@ impl AggregateBenchmarkArgs { Self::write_metadata_atomically(&output_dir, &VisualizerMetadata { runs }) } + /// Re-indexes each configured benchmark cohort into stable version pages and + /// a `latest` alias. Sidecar artifacts remain immutable and are referenced + /// by both the version page and its latest alias. + fn versioned_pages(runs: Vec) -> Result> { + let mut by_cohort = BTreeMap::>::new(); + for run in runs { + let cohort = run + .test_config + .get("BenchmarkRun") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| eyre::eyre!("run {} is missing string BenchmarkRun", run.id))? + .to_owned(); + by_cohort.entry(cohort).or_default().push(run); + } + + let mut pages = Vec::new(); + for (cohort, cohort_runs) in by_cohort { + let latest_version = cohort_runs + .iter() + .max_by(|left, right| { + Self::parse_created_at(left) + .expect("selected run was validated") + .cmp(&Self::parse_created_at(right).expect("selected run was validated")) + .then_with(|| left.result.client_version.cmp(&right.result.client_version)) + }) + .map(|run| run.result.client_version.clone()) + .expect("non-empty cohort"); + + for run in cohort_runs.iter().cloned() { + let mut version_page = run; + version_page.test_config.insert( + "BenchmarkRun".to_owned(), + serde_json::Value::String(format!( + "{}--{}", + cohort, + Self::page_component(&version_page.result.client_version) + )), + ); + pages.push(version_page); + } + for run in + cohort_runs.into_iter().filter(|run| run.result.client_version == latest_version) + { + let mut latest_page = run; + latest_page.test_config.insert( + "BenchmarkRun".to_owned(), + serde_json::Value::String(format!("{cohort}--latest")), + ); + pages.push(latest_page); + } + } + Ok(pages) + } + + fn page_component(value: &str) -> String { + value + .chars() + .map(|character| match character { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => character, + _ => '-', + }) + .collect() + } + fn validate_run_artifacts(run_output_dir: &Path) -> Result<()> { for file in [ "metadata.json", @@ -1521,6 +1592,7 @@ real_token_setup: AggregateBenchmarkArgs { output_dir: root.path().to_path_buf(), run_output_dirs: vec![early, latest, distinct_scenario, distinct_version], + versioned_pages: false, } .run() .unwrap(); @@ -1541,6 +1613,58 @@ real_token_setup: assert!(root.path().join("transfer-early").is_dir()); } + #[test] + fn aggregate_versioned_pages_preserve_history_and_add_latest_alias() { + let root = tempfile::tempdir().unwrap(); + let old = write_aggregate_run( + root.path(), + "old", + "transfer-2s", + "base/old", + "2026-09-23T00:00:00.000Z", + ); + let newest_200ms = write_aggregate_run( + root.path(), + "new-200ms", + "transfer-200ms", + "base/new", + "2026-09-23T00:01:00.000Z", + ); + let newest_2s = write_aggregate_run( + root.path(), + "new-2s", + "transfer-2s", + "base/new", + "2026-09-23T00:02:00.000Z", + ); + + AggregateBenchmarkArgs { + output_dir: root.path().to_path_buf(), + run_output_dirs: vec![old, newest_200ms, newest_2s], + versioned_pages: true, + } + .run() + .unwrap(); + + let metadata: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.path().join("metadata.json")).unwrap()) + .unwrap(); + let runs = metadata["runs"].as_array().unwrap(); + assert_eq!(runs.len(), 5); + assert!( + runs.iter() + .any(|run| run["testConfig"]["BenchmarkRun"] == "sepolia-transfer-100mgas--latest") + ); + assert!(runs.iter().any(|run| run["result"]["clientVersion"] == "base/old")); + assert!( + runs.iter() + .filter( + |run| run["testConfig"]["BenchmarkRun"] == "sepolia-transfer-100mgas--latest" + ) + .all(|run| run["result"]["clientVersion"] == "base/new") + ); + } + #[test] fn aggregate_rejects_invalid_artifacts_without_replacing_metadata() { let root = tempfile::tempdir().unwrap(); @@ -1553,6 +1677,7 @@ real_token_setup: let error = AggregateBenchmarkArgs { output_dir: root.path().to_path_buf(), run_output_dirs: vec![invalid], + versioned_pages: false, } .run() .unwrap_err(); From 7c1358fa000a473cf3fb28f7195cc75560d1d4ee Mon Sep 17 00:00:00 2001 From: Francis Li Date: Wed, 23 Sep 2026 23:41:31 +0000 Subject: [PATCH 104/299] fix(gossip): enforce per-height block limit (#5269) * fix(gossip): enforce per-height block limit Amp-Thread-ID: https://ampcode.com/threads/T-01a0d09b-5ec2-703b-8aa4-95ba1b774cba Co-authored-by: Amp * docs(gossip): align block limit comment Amp-Thread-ID: https://ampcode.com/threads/T-01a0d09b-5ec2-703b-8aa4-95ba1b774cba Co-authored-by: Amp --------- Co-authored-by: Amp --- crates/consensus/gossip/src/block_validity.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/consensus/gossip/src/block_validity.rs b/crates/consensus/gossip/src/block_validity.rs index b8eee81bb4..31bd4ae2e2 100644 --- a/crates/consensus/gossip/src/block_validity.rs +++ b/crates/consensus/gossip/src/block_validity.rs @@ -227,9 +227,9 @@ impl BlockHandler { if let Some(seen_hashes_at_height) = self.seen_hashes.get_mut(&envelope.payload.block_number()) { - // CHECK: If more than [`Self::MAX_BLOCKS_TO_KEEP`] different blocks have been received + // CHECK: If [`Self::MAX_BLOCKS_TO_KEEP`] different blocks have already been received // for the same height, reject the block. - if seen_hashes_at_height.len() > Self::MAX_BLOCKS_TO_KEEP { + if seen_hashes_at_height.len() >= Self::MAX_BLOCKS_TO_KEEP { return Err(BlockInvalidError::TooManyBlocks { height: envelope.payload.block_number(), }); @@ -591,7 +591,7 @@ pub(crate) mod tests { assert!(handler.block_valid(&envelope).is_ok()); - let next_payloads = (0..=BlockHandler::MAX_BLOCKS_TO_KEEP) + let next_payloads = (0..BlockHandler::MAX_BLOCKS_TO_KEEP) .map(|_| { let mut block = v1_valid_block(); // The blocks have the same height From 539605ce58aaf02fe5c0382fcd9139c1ee4207e9 Mon Sep 17 00:00:00 2001 From: Mihir Wadekar Date: Thu, 24 Sep 2026 06:40:06 +0000 Subject: [PATCH 105/299] feat(execution): record sequencer block and state seek metrics (#5261) * feat(execution): record sequencer block and state seek metrics Record the block being built on the sequencer transaction span. Account and storage seeks record reads, misses, and duration on their spans and as untagged base_trie.state metrics. Co-authored-by: Cursor * perf(trie): drop per-seek spans from state lookups Per-lookup spans recorded constants and added a span on every account and storage read. The global reads, misses, and seek duration metrics already cover that. Co-authored-by: Cursor * style(trie): format state metric imports Co-authored-by: Cursor * perf(trie): time state seeks only when metrics are enabled Co-authored-by: Cursor * fix(trie): count failed state seeks in read metrics A database error disarmed the timer and returned before incrementing reads, so failed seeks never showed up as load or latency. Co-authored-by: Cursor * feat(trie): label state seek metrics by account or storage Storage lookups outnumber account lookups, so a single counter cannot show which cursor the misses come from. Co-authored-by: Cursor * fix(trie): initialize account and storage seek counters at zero An open kind label left each series absent until the first seek, so a quiet cursor looked missing rather than idle. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- crates/execution/payload/src/builder.rs | 7 ++- crates/execution/trie/src/batch_provider.rs | 27 ++++++---- crates/execution/trie/src/lib.rs | 2 +- crates/execution/trie/src/metrics.rs | 55 +++++++++++++++++++++ crates/execution/trie/src/provider.rs | 27 ++++++---- 5 files changed, 95 insertions(+), 23 deletions(-) diff --git a/crates/execution/payload/src/builder.rs b/crates/execution/payload/src/builder.rs index 426356aaf7..66cf516f13 100644 --- a/crates/execution/payload/src/builder.rs +++ b/crates/execution/payload/src/builder.rs @@ -823,11 +823,15 @@ where /// When `no_tx_pool` is `false` the builder is composing a new block from mempool plus /// attribute pre-includes; pre-includes there may legitimately be skipped on `InvalidTx`, /// so the historical skip-and-continue behavior is preserved. - #[instrument(skip_all, fields(phase = "sequencer_txs"))] + #[instrument( + skip_all, + fields(phase = "sequencer_txs", block_number = tracing::field::Empty) + )] pub fn execute_sequencer_transactions( &self, builder: &mut impl BlockBuilder, ) -> Result { + tracing::Span::current().record("block_number", self.parent().number().saturating_add(1)); let mut info = ExecutionInfo::new(); let no_tx_pool = self.attributes().no_tx_pool(); let resource_metering = &self.builder_config.resource_metering; @@ -901,7 +905,6 @@ where ); } } - Ok(info) } diff --git a/crates/execution/trie/src/batch_provider.rs b/crates/execution/trie/src/batch_provider.rs index 2b7ba0f2dc..1391239f0f 100644 --- a/crates/execution/trie/src/batch_provider.rs +++ b/crates/execution/trie/src/batch_provider.rs @@ -34,6 +34,7 @@ use reth_trie_common::{ use crate::{ BaseProofsBatchHashedAccountCursorFactory, BaseProofsBatchTrieCursorFactory, api::BaseProofsBatchSession, + metrics::{StateMetrics, StateSeekKind}, }; /// State provider that reads through an active [`BaseProofsBatchSession`]'s transaction. @@ -279,26 +280,32 @@ impl HashedPostStateProvider for BaseProofsBatchState impl AccountReader for BaseProofsBatchStateProviderRef<'_, S> { fn basic_account(&self, address: &Address) -> ProviderResult> { let hashed_key = keccak256(address.0); - Ok(self + let mut cursor = self .session .account_hashed_cursor(self.block_number) - .map_err(Into::::into)? - .seek(hashed_key) - .map_err(Into::::into)? - .and_then(|(key, account)| (key == hashed_key).then_some(account))) + .map_err(Into::::into)?; + let found = StateMetrics::record_seek( + StateSeekKind::Account, + || cursor.seek(hashed_key).map_err(Into::::into), + |found| found.as_ref().is_some_and(|(key, _)| *key == hashed_key), + )?; + Ok(found.and_then(|(key, account)| (key == hashed_key).then_some(account))) } } impl StateProvider for BaseProofsBatchStateProviderRef<'_, S> { fn storage(&self, address: Address, storage_key: B256) -> ProviderResult> { let hashed_key = keccak256(storage_key); - Ok(self + let mut cursor = self .session .storage_hashed_cursor(keccak256(address.0), self.block_number) - .map_err(Into::::into)? - .seek(hashed_key) - .map_err(Into::::into)? - .and_then(|(key, storage)| (key == hashed_key).then_some(storage))) + .map_err(Into::::into)?; + let found = StateMetrics::record_seek( + StateSeekKind::Storage, + || cursor.seek(hashed_key).map_err(Into::::into), + |found| found.as_ref().is_some_and(|(key, _)| *key == hashed_key), + )?; + Ok(found.and_then(|(key, storage)| (key == hashed_key).then_some(storage))) } } diff --git a/crates/execution/trie/src/lib.rs b/crates/execution/trie/src/lib.rs index 1b66bb6526..e30afe8669 100644 --- a/crates/execution/trie/src/lib.rs +++ b/crates/execution/trie/src/lib.rs @@ -40,7 +40,7 @@ pub mod metrics; #[cfg(feature = "metrics")] pub use metrics::{ BaseProofsHashedAccountCursor, BaseProofsHashedStorageCursor, BaseProofsStorage, - BaseProofsTrieCursor, StorageMetrics, + BaseProofsTrieCursor, StateMetrics, StateSeekKind, StorageMetrics, }; #[cfg(not(feature = "metrics"))] diff --git a/crates/execution/trie/src/metrics.rs b/crates/execution/trie/src/metrics.rs index 75fb8f3d1a..39360ea663 100644 --- a/crates/execution/trie/src/metrics.rs +++ b/crates/execution/trie/src/metrics.rs @@ -122,6 +122,61 @@ base_metrics::define_metrics! { latest_number: gauge, } +/// Which state cursor a point read used. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum StateSeekKind { + /// Account lookup. + Account, + /// Storage slot lookup. + Storage, +} + +impl StateSeekKind { + /// Metric label for this seek. The only values are `account` and `storage`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Account => "account", + Self::Storage => "storage", + } + } +} + +base_metrics::define_metrics! { + base_trie.state, + struct = StateMetrics, + #[describe("Account and storage seeks")] + #[label(name = "kind", default = ["account", "storage"])] + reads: counter, + #[describe("Seeks whose returned key was not the key requested")] + #[label(name = "kind", default = ["account", "storage"])] + misses: counter, + #[describe("Time spent in account and storage seeks, in seconds")] + #[label(name = "kind", default = ["account", "storage"])] + seek_duration_seconds: histogram, +} + +impl StateMetrics { + /// Times one account or storage seek and counts it, including a seek that returns an error. + /// A successful seek where `hit` returns `false` counts as a miss. + pub fn record_seek( + kind: StateSeekKind, + seek: impl FnOnce() -> Result, + hit: impl FnOnce(&T) -> bool, + ) -> Result { + let label = kind.as_str(); + let result = { + let _timer = base_metrics::timed!(Self::seek_duration_seconds(label)); + seek() + }; + + Self::reads(label).increment(1); + if result.as_ref().is_ok_and(|value| !hit(value)) { + Self::misses(label).increment(1); + } + result + } +} + impl BlockMetrics { /// Record operation durations for the processing of a block. pub fn record_operation_durations(durations: &OperationDurations) { diff --git a/crates/execution/trie/src/provider.rs b/crates/execution/trie/src/provider.rs index 1d905791e0..c4ed979c4c 100644 --- a/crates/execution/trie/src/provider.rs +++ b/crates/execution/trie/src/provider.rs @@ -27,6 +27,7 @@ use reth_trie_common::{ use crate::{ BaseProofsStorage, BaseProofsStorageError, BaseProofsStore, + metrics::{StateMetrics, StateSeekKind}, proof::{ DatabaseProof, DatabaseStateRoot, DatabaseStorageProof, DatabaseStorageRoot, DatabaseTrieWitness, @@ -92,13 +93,16 @@ impl<'a, Storage: BaseProofsStore + Clone> BaseProofsStateProviderRef<'a, Storag hashed_key: B256, ) -> ProviderResult> { let tx = self.ensure_tx()?; - Ok(self + let mut cursor = self .storage .storage_hashed_cursor_with_tx(&tx, keccak256(address.0), self.block_number) - .map_err(Into::::into)? - .seek(hashed_key) - .map_err(Into::::into)? - .and_then(|(key, val)| (key == hashed_key).then_some(val))) + .map_err(Into::::into)?; + let found = StateMetrics::record_seek( + StateSeekKind::Storage, + || cursor.seek(hashed_key).map_err(Into::::into), + |found| found.as_ref().is_some_and(|(key, _)| *key == hashed_key), + )?; + Ok(found.and_then(|(key, value)| (key == hashed_key).then_some(value))) } } @@ -235,13 +239,16 @@ impl<'a, Storage: BaseProofsStore> AccountReader for BaseProofsStateProviderRef< fn basic_account(&self, address: &Address) -> ProviderResult> { let hashed_key = keccak256(address.0); let tx = self.ensure_tx()?; - Ok(self + let mut cursor = self .storage .account_hashed_cursor_with_tx(&tx, self.block_number) - .map_err(Into::::into)? - .seek(hashed_key) - .map_err(Into::::into)? - .and_then(|(key, account)| (key == hashed_key).then_some(account))) + .map_err(Into::::into)?; + let found = StateMetrics::record_seek( + StateSeekKind::Account, + || cursor.seek(hashed_key).map_err(Into::::into), + |found| found.as_ref().is_some_and(|(key, _)| *key == hashed_key), + )?; + Ok(found.and_then(|(key, account)| (key == hashed_key).then_some(account))) } } From 0a60de5429e5c209227d1b2699b597a150622713 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 24 Sep 2026 16:44:55 +0000 Subject: [PATCH 106/299] fix(ci): run the MinIO test containers from a digest-pinned Chainguard image (#5291) --- crates/infra/audit/tests/common/mod.rs | 24 ++++++++++++++++---- crates/infra/snapshotter/tests/common/mod.rs | 24 ++++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/infra/audit/tests/common/mod.rs b/crates/infra/audit/tests/common/mod.rs index 2f238dd125..8f8365907b 100644 --- a/crates/infra/audit/tests/common/mod.rs +++ b/crates/infra/audit/tests/common/mod.rs @@ -1,6 +1,10 @@ //! Common test harness for audit integration tests with S3 fixtures. -use testcontainers::{ImageExt, runners::AsyncRunner}; +use testcontainers::{ + ImageExt, + core::{IntoContainerPort, Mount}, + runners::AsyncRunner, +}; use testcontainers_modules::minio::MinIO; use uuid::Uuid; @@ -12,9 +16,21 @@ pub(crate) struct TestHarness { impl TestHarness { pub(crate) async fn new() -> anyhow::Result { - // MinIO removed the `minio/minio` image from Docker Hub, so pull the - // same tag from quay.io (their current official registry) instead. - let minio_container = MinIO::default().with_name("quay.io/minio/minio").start().await?; + // MinIO no longer publishes community images: Docker Hub dropped them and quay.io + // refuses anonymous pulls. Run Chainguard's build of MinIO RELEASE.2026-09-22T19-25-18Z, + // pinned by digest (`docker buildx imagetools inspect cgr.dev/chainguard/minio:latest` + // prints the current one). Unlike MinIO's image it declares no volume and exposes no + // port, so give `/data` a tmpfs (MinIO cannot rename directories across overlay layers) + // and publish the S3 port. + let minio_container = MinIO::default() + .with_name("cgr.dev/chainguard/minio") + .with_tag( + "latest@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1", + ) + .with_mount(Mount::tmpfs_mount("/data")) + .with_mapped_port(0, 9000.tcp()) + .start() + .await?; let s3_port = minio_container.get_host_port_ipv4(9000).await?; let s3_endpoint = format!("http://127.0.0.1:{s3_port}"); diff --git a/crates/infra/snapshotter/tests/common/mod.rs b/crates/infra/snapshotter/tests/common/mod.rs index 02bb9e39e5..fcf2628575 100644 --- a/crates/infra/snapshotter/tests/common/mod.rs +++ b/crates/infra/snapshotter/tests/common/mod.rs @@ -1,7 +1,11 @@ //! Common test harness for snapshotter integration tests with `MinIO`. use anyhow::Result; -use testcontainers::{ImageExt, runners::AsyncRunner}; +use testcontainers::{ + ImageExt, + core::{IntoContainerPort, Mount}, + runners::AsyncRunner, +}; use testcontainers_modules::minio::MinIO; pub(crate) struct TestHarness { @@ -12,9 +16,21 @@ pub(crate) struct TestHarness { impl TestHarness { pub(crate) async fn new() -> Result { - // MinIO removed the `minio/minio` image from Docker Hub, so pull the - // same tag from quay.io (their current official registry) instead. - let minio_container = MinIO::default().with_name("quay.io/minio/minio").start().await?; + // MinIO no longer publishes community images: Docker Hub dropped them and quay.io + // refuses anonymous pulls. Run Chainguard's build of MinIO RELEASE.2026-09-22T19-25-18Z, + // pinned by digest (`docker buildx imagetools inspect cgr.dev/chainguard/minio:latest` + // prints the current one). Unlike MinIO's image it declares no volume and exposes no + // port, so give `/data` a tmpfs (MinIO cannot rename directories across overlay layers) + // and publish the S3 port. + let minio_container = MinIO::default() + .with_name("cgr.dev/chainguard/minio") + .with_tag( + "latest@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1", + ) + .with_mount(Mount::tmpfs_mount("/data")) + .with_mapped_port(0, 9000.tcp()) + .start() + .await?; let storage_port = minio_container.get_host_port_ipv4(9000).await?; let storage_endpoint = format!("http://127.0.0.1:{storage_port}"); From 632adcbcd43bf0b5b30ea68a7a5f671f9ab9fc55 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 24 Sep 2026 17:04:13 +0000 Subject: [PATCH 107/299] fix(batcher): keep polling the L1 head after the subscription stream ends (#5241) * fix(batcher): keep polling the L1 head after the subscription stream ends * test(batcher): pin the hybrid L1 head dedup across the stream and the poller * test(batcher): drop the hybrid L1 head tests the fallback test covers --- crates/batcher/source/src/l1_hybrid.rs | 91 ++++++++++---------------- 1 file changed, 33 insertions(+), 58 deletions(-) diff --git a/crates/batcher/source/src/l1_hybrid.rs b/crates/batcher/source/src/l1_hybrid.rs index ae0dcbbbe2..983d05f23f 100644 --- a/crates/batcher/source/src/l1_hybrid.rs +++ b/crates/batcher/source/src/l1_hybrid.rs @@ -12,9 +12,12 @@ use crate::{L1HeadEvent, L1HeadPolling, L1HeadSource, L1HeadSubscription, Source /// /// Deduplicates head numbers so that the same block number is only reported once. /// Stale reads (same or lower block number than last reported) are also silently dropped. +/// +/// When the subscription stream ends, the source keeps delivering heads from the poller. #[derive(derive_more::Debug)] pub struct HybridL1HeadSource { - /// The head number stream returned by `S::take_stream`. + /// The head number stream returned by `S::take_stream`, swapped for a pending stream + /// once it ends. /// /// Declared before `_subscription` so it is dropped first, ensuring the /// stream's underlying transport is released before the provider is torn down. @@ -93,7 +96,10 @@ where // Stale or duplicate — loop for next event. } Some(Err(e)) => return Err(e), - None => return Err(SourceError::Closed), + None => { + tracing::warn!("L1 head subscription ended; falling back to polling"); + self.sub = futures::stream::pending().boxed(); + } } } _ = self.interval.next() => { @@ -118,6 +124,8 @@ where #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use async_trait::async_trait; use base_runtime::{Config, Runner}; use futures::{StreamExt, stream::BoxStream}; @@ -132,12 +140,12 @@ mod tests { } } - struct FixedPoller(u64); + struct IncrementingPoller(AtomicU64); #[async_trait] - impl L1HeadPolling for FixedPoller { + impl L1HeadPolling for IncrementingPoller { async fn latest_head(&self) -> Result { - Ok(self.0) + Ok(self.0.fetch_add(1, Ordering::Relaxed)) } } @@ -151,62 +159,44 @@ mod tests { } #[test] - fn test_hybrid_l1_new_head() { - Runner::start(Config::seeded(0), |ctx| async move { - let stream = futures::stream::once(async { Ok(5u64) }); - let mut source = HybridL1HeadSource::new( - ctx, - StreamSub(stream.boxed()), - FixedPoller(5), - Duration::from_secs(100), - ); - - let event = source.next().await.unwrap(); - assert_eq!(event, L1HeadEvent::NewHead(5)); - }); - } - - #[test] - fn test_hybrid_l1_duplicate_skipped() { + fn test_hybrid_l1_stale_and_duplicate_heads_skipped() { Runner::start(Config::seeded(0), |ctx| async move { - let stream = futures::stream::iter(vec![Ok(5u64), Ok(5u64)]); + // Only the stream can produce heads: the poller always fails. + let stream = futures::stream::iter(vec![Ok(10u64), Ok(9u64), Ok(10u64), Ok(11u64)]); let mut source = HybridL1HeadSource::new( ctx, StreamSub(stream.boxed()), - FixedPoller(5), + ProviderErrorPoller, Duration::from_secs(100), ); - let event = source.next().await.unwrap(); - assert_eq!(event, L1HeadEvent::NewHead(5)); - - // Second identical value is skipped; stream exhausted -> Closed. - let err = source.next().await.unwrap_err(); - assert!(matches!(err, SourceError::Closed)); + assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(10)); + // 9 is stale and the second 10 a duplicate: the next head is 11. + assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(11)); }); } #[test] - fn test_hybrid_l1_stale_dropped() { + fn test_hybrid_l1_polls_after_subscription_ends() { Runner::start(Config::seeded(0), |ctx| async move { - // Deliver 10, then 9 (stale), then stream closes. - let stream = futures::stream::iter(vec![Ok(10u64), Ok(9u64)]); + // The stream and the poller both start at head 5; the stream then ends and the + // poller keeps returning a new head on every call. Whichever arm `select!` polls + // first, the other one's 5 is a duplicate and the heads that follow come from the + // poller. + let stream = futures::stream::once(async { Ok(5u64) }); let mut source = HybridL1HeadSource::new( ctx, StreamSub(stream.boxed()), - // The virtual-time interval ticks immediately, and `select!` may poll the - // interval branch before the stream delivers Ok(10). Keep the fallback poller - // from producing a new head so the test only covers stale-drop logic. - ProviderErrorPoller, + IncrementingPoller(AtomicU64::new(5)), Duration::from_secs(100), ); - let event = source.next().await.unwrap(); - assert_eq!(event, L1HeadEvent::NewHead(10)); - - // 9 < 10: stale, skipped. Stream exhausted -> Closed. - let err = source.next().await.unwrap_err(); - assert!(matches!(err, SourceError::Closed)); + let mut heads = Vec::new(); + for _ in 0..3 { + let L1HeadEvent::NewHead(head) = source.next().await.unwrap(); + heads.push(head); + } + assert_eq!(heads, [5, 6, 7]); }); } @@ -229,19 +219,4 @@ mod tests { assert!(matches!(err, SourceError::Provider(_))); }); } - - #[test] - fn test_hybrid_l1_polling_uses_virtual_time() { - Runner::start(Config::seeded(0), |ctx| async move { - let mut source = HybridL1HeadSource::new( - ctx, - StreamSub(futures::stream::pending().boxed()), - FixedPoller(12), - Duration::from_secs(10), - ); - - let event = source.next().await.unwrap(); - assert_eq!(event, L1HeadEvent::NewHead(12)); - }); - } } From 138ec042a5daefa0955119bcca2782a4e67a649d Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 24 Sep 2026 17:49:06 +0000 Subject: [PATCH 108/299] refactor(batcher): hand the hybrid L1 head source a plain stream (#5242) * refactor(batcher): hand the hybrid L1 head source a plain stream * refactor(batcher): let the L1 head stream own the WS provider without an Arc * docs(batcher): drop a redundant intra-doc link target in the service * test(batcher): pin that the L1 head stream outlives its builder * feat(batcher): report whether the L1 head subscription is live --- Cargo.lock | 1 + crates/batcher/encoder/src/metrics.rs | 2 + crates/batcher/service/Cargo.toml | 1 + crates/batcher/service/src/l1_source.rs | 20 +--- crates/batcher/service/src/lib.rs | 2 +- crates/batcher/service/src/service.rs | 92 +++++++++++-------- crates/batcher/source/src/l1_hybrid.rs | 73 ++++----------- crates/batcher/source/src/l1_subscription.rs | 40 -------- crates/batcher/source/src/lib.rs | 6 -- .../batcher/source/src/stream_subscription.rs | 89 ------------------ 10 files changed, 80 insertions(+), 246 deletions(-) delete mode 100644 crates/batcher/source/src/l1_subscription.rs delete mode 100644 crates/batcher/source/src/stream_subscription.rs diff --git a/Cargo.lock b/Cargo.lock index 749f4c181b..c1742c921f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3106,6 +3106,7 @@ dependencies = [ name = "base-batcher-service" version = "0.0.0" dependencies = [ + "alloy-node-bindings", "alloy-primitives", "alloy-provider", "alloy-rpc-types-eth 2.4.1", diff --git a/crates/batcher/encoder/src/metrics.rs b/crates/batcher/encoder/src/metrics.rs index 0964120dee..9a37802a1b 100644 --- a/crates/batcher/encoder/src/metrics.rs +++ b/crates/batcher/encoder/src/metrics.rs @@ -51,6 +51,8 @@ base_metrics::define_metrics! { pending_blocks: gauge, #[describe("Number of L1 transactions currently in-flight")] in_flight_submissions: gauge, + #[describe("Whether the L1 head WebSocket subscription is live (0: polling only)")] + l1_head_subscription_active: gauge, #[describe("Compression ratio for each closed channel")] channel_compression_ratio: histogram, #[describe("Channel lifetime in L1 blocks")] diff --git a/crates/batcher/service/Cargo.toml b/crates/batcher/service/Cargo.toml index d22366c815..f5ddcae806 100644 --- a/crates/batcher/service/Cargo.toml +++ b/crates/batcher/service/Cargo.toml @@ -42,6 +42,7 @@ jsonrpsee = { workspace = true, features = ["http-client", "macros"] } [dev-dependencies] httpmock.workspace = true +alloy-node-bindings.workspace = true base-runtime = { workspace = true, features = ["test-utils"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/batcher/service/src/l1_source.rs b/crates/batcher/service/src/l1_source.rs index 336561485f..7152adcf1e 100644 --- a/crates/batcher/service/src/l1_source.rs +++ b/crates/batcher/service/src/l1_source.rs @@ -1,10 +1,10 @@ -//! L1 head source implementations for the batcher service. +//! L1 head polling source for the batcher service. use std::sync::Arc; use alloy_provider::Provider; use async_trait::async_trait; -use base_batcher_source::{KeepAliveSubscription, L1HeadPolling, PendingSubscription, SourceError}; +use base_batcher_source::{L1HeadPolling, SourceError}; /// Polling source that fetches the latest L1 head block number from an L1 RPC endpoint. #[derive(derive_more::Debug)] @@ -26,19 +26,3 @@ impl L1HeadPolling for RpcL1HeadPollingSource { self.provider.get_block_number().await.map_err(|e| SourceError::Provider(e.to_string())) } } - -/// A WebSocket-backed L1 head subscription. -/// -/// Owns the WS provider so the underlying connection is not dropped when the -/// stream is handed to [`HybridL1HeadSource`]. -/// -/// [`HybridL1HeadSource`]: base_batcher_source::HybridL1HeadSource -pub type WsL1HeadSubscription = KeepAliveSubscription; - -/// A no-op L1 head subscription that never yields head numbers. -/// -/// Used when no L1 WebSocket URL is configured; [`HybridL1HeadSource`] falls -/// back entirely to the polling path. -/// -/// [`HybridL1HeadSource`]: base_batcher_source::HybridL1HeadSource -pub type NullL1HeadSubscription = PendingSubscription; diff --git a/crates/batcher/service/src/lib.rs b/crates/batcher/service/src/lib.rs index 68bec9cbb7..5b8415e3d8 100644 --- a/crates/batcher/service/src/lib.rs +++ b/crates/batcher/service/src/lib.rs @@ -20,7 +20,7 @@ mod source; pub use source::RpcPollingSource; mod l1_source; -pub use l1_source::{NullL1HeadSubscription, RpcL1HeadPollingSource, WsL1HeadSubscription}; +pub use l1_source::RpcL1HeadPollingSource; mod l2_block_parity; pub use l2_block_parity::{ diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index e19e2d19f0..cb51415fa4 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -3,6 +3,7 @@ use std::{ future::{Future, pending}, sync::Arc, + task::Poll, time::Duration, }; @@ -25,7 +26,7 @@ use base_tx_manager::{BaseTxMetrics, SimpleTxManager}; use futures::{ StreamExt, future::BoxFuture, - stream::{BoxStream, FuturesUnordered}, + stream::{self, BoxStream, FuturesUnordered}, }; use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; use tokio::{sync::mpsc, task::JoinHandle}; @@ -35,9 +36,8 @@ use url::Url; use crate::{ BatcherConfig, DerivationStatusPoller, DerivationStatusProvider, L2BlockParityMonitor, - L2BlockParityMonitorConfig, MAX_CHECK_RECENT_TXS_DEPTH, NullL1HeadSubscription, - RecentTxSyncTarget, RpcL1HeadPollingSource, RpcL2BlockProvider, RpcPollingSource, - RpcThrottleClient, WsL1HeadSubscription, + L2BlockParityMonitorConfig, MAX_CHECK_RECENT_TXS_DEPTH, RecentTxSyncTarget, + RpcL1HeadPollingSource, RpcL2BlockProvider, RpcPollingSource, RpcThrottleClient, }; const WEI_PER_ETHER: f64 = 1_000_000_000_000_000_000.0; @@ -64,21 +64,6 @@ impl ThrottleClient for ServiceThrottle { } } -/// Batcher-internal L1 subscription variant: either a live WS subscription or a no-op. -enum L1Subscription { - Ws(WsL1HeadSubscription), - Null(NullL1HeadSubscription), -} - -impl base_batcher_source::L1HeadSubscription for L1Subscription { - fn take_stream(&mut self) -> BoxStream<'static, Result> { - match self { - Self::Ws(ws) => ws.take_stream(), - Self::Null(null) => null.take_stream(), - } - } -} - /// Concrete driver type produced by [`BatcherService::setup`]. /// /// Private — callers interact only through [`ReadyBatcher`]. @@ -88,7 +73,7 @@ type ServiceDriver = BatchDriver< PollingBlockSource, SimpleTxManager, ServiceThrottle, - HybridL1HeadSource, + HybridL1HeadSource, >; /// A fully-initialised batcher ready to run the submission loop. @@ -202,27 +187,32 @@ impl BatcherService { Self { config } } - /// Build an L1 head subscription for the given optional L1 WebSocket URL. + /// Build the live L1 head stream for the given optional L1 WebSocket URL. /// - /// When `url` is `Some`, connects a dedicated WS provider, subscribes to - /// new L1 block headers, and streams their block numbers. The provider is - /// wrapped in a [`WsL1HeadSubscription`] to keep the connection alive. + /// When `url` is `Some`, connects a dedicated WS provider, subscribes to new L1 + /// 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 the WS connection fails, returns a - /// [`NullL1HeadSubscription`] so that [`HybridL1HeadSource`] falls back - /// entirely to polling. + /// When `url` is `None`, or if connecting or subscribing fails, returns a stream that + /// never yields so that [`HybridL1HeadSource`] relies on polling alone. /// - /// [`HybridL1HeadSource`]: base_batcher_source::HybridL1HeadSource - async fn build_l1_subscription(url: Option<&Url>) -> L1Subscription { + /// `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, Result> { + let active = BatcherMetrics::l1_head_subscription_active(); + active.set(0.0); + let Some(url) = url else { - return L1Subscription::Null(NullL1HeadSubscription::new()); + return stream::pending().boxed(); }; let ws_provider = match ProviderBuilder::new().connect(url.as_str()).await { - Ok(p) => Arc::new(p), + Ok(p) => p, Err(e) => { warn!(error = %e, l1_ws = %url, "failed to connect L1 WS provider; falling back to polling"); - return L1Subscription::Null(NullL1HeadSubscription::new()); + return stream::pending().boxed(); } }; @@ -230,12 +220,23 @@ impl BatcherService { Ok(s) => s, Err(e) => { warn!(error = %e, "failed to subscribe to new L1 blocks; falling back to polling"); - return L1Subscription::Null(NullL1HeadSubscription::new()); + return stream::pending().boxed(); } }; - let stream = sub.into_stream().map(|header| Ok(header.number)).boxed(); - L1Subscription::Ws(WsL1HeadSubscription::new(ws_provider, stream)) + active.set(1.0); + sub.into_stream() + .map(move |header| { + // Capture the provider: dropping it closes the connection and ends the stream. + let _keep_alive = &ws_provider; + Ok(header.number) + }) + // Mark the subscription down once alloy gives up reconnecting and the stream ends. + .chain(stream::poll_fn(move |_| { + active.set(0.0); + Poll::Ready(None) + })) + .boxed() } /// Try each URL in order, returning the first that connects. @@ -649,8 +650,7 @@ impl BatcherService { }); // Build the L1 head source: a hybrid of optional WS subscription + polling. - let l1_head_subscription = - Self::build_l1_subscription(self.config.l1_ws_url.as_ref()).await; + let l1_head_stream = Self::build_l1_head_stream(self.config.l1_ws_url.as_ref()).await; let l1_head_poller = RpcL1HeadPollingSource::new(Arc::new( Self::rpc_retry("l1-rpc-poller", retry, rpc_timeout, || { Self::connect_first(&self.config.l1_rpc_url, "l1-rpc-poller", |url| { @@ -667,7 +667,7 @@ impl BatcherService { )); let l1_head_source = HybridL1HeadSource::new( TokioRuntime::new(), - l1_head_subscription, + l1_head_stream, l1_head_poller, self.config.poll_interval, ); @@ -755,6 +755,8 @@ impl BatcherService { mod tests { use std::sync::atomic::{AtomicU8, Ordering}; + use alloy_node_bindings::Anvil; + use super::*; fn test_retry() -> RetryConfig { @@ -804,4 +806,18 @@ mod tests { "error should name the setting, got {error}" ); } + + #[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; + + // The builder has returned: the stream alone must keep the WS provider alive. + let miner = RootProvider::::new_http(anvil.endpoint_url()); + for expected in 1..=2 { + miner.raw_request::<(), String>("evm_mine".into(), ()).await.unwrap(); + let head = tokio::time::timeout(Duration::from_secs(5), heads.next()).await; + assert!(matches!(head.unwrap(), Some(Ok(head)) if head == expected)); + } + } } diff --git a/crates/batcher/source/src/l1_hybrid.rs b/crates/batcher/source/src/l1_hybrid.rs index 983d05f23f..590b1c4f33 100644 --- a/crates/batcher/source/src/l1_hybrid.rs +++ b/crates/batcher/source/src/l1_hybrid.rs @@ -1,12 +1,12 @@ //! Hybrid L1 head source that races a subscription stream against interval-based polling. -use std::{marker::PhantomData, time::Duration}; +use std::time::Duration; use async_trait::async_trait; use base_runtime::Clock; use futures::{StreamExt, stream::BoxStream}; -use crate::{L1HeadEvent, L1HeadPolling, L1HeadSource, L1HeadSubscription, SourceError}; +use crate::{L1HeadEvent, L1HeadPolling, L1HeadSource, SourceError}; /// An L1 head source that races a subscription stream against an interval-based poller. /// @@ -15,53 +15,33 @@ use crate::{L1HeadEvent, L1HeadPolling, L1HeadSource, L1HeadSubscription, Source /// /// When the subscription stream ends, the source keeps delivering heads from the poller. #[derive(derive_more::Debug)] -pub struct HybridL1HeadSource { - /// The head number stream returned by `S::take_stream`, swapped for a pending stream - /// once it ends. - /// - /// Declared before `_subscription` so it is dropped first, ensuring the - /// stream's underlying transport is released before the provider is torn down. +pub struct HybridL1HeadSource

{ + /// Live head numbers pushed by the subscription; pending forever once it ends. #[debug(skip)] sub: BoxStream<'static, Result>, - /// The original subscription, kept alive so its resources remain open. - #[debug(skip)] - _subscription: S, /// Polling source for fetching the latest L1 head block number. #[debug(skip)] poller: P, /// Polling interval timer. #[debug(skip)] interval: BoxStream<'static, ()>, - /// Runtime clock type marker. - #[debug(skip)] - _clock: PhantomData, /// Last reported head number for deduplication. last_head: Option, } -impl HybridL1HeadSource -where - S: L1HeadSubscription, - P: L1HeadPolling, - C: Clock, -{ +impl HybridL1HeadSource

{ /// Create a new hybrid L1 head source. /// - /// Calls [`L1HeadSubscription::take_stream`] once to obtain the live head - /// number stream, then retains the subscription to keep any underlying - /// resources (e.g. a WebSocket provider) alive. Combines the stream with a - /// poller that fires at `poll_interval`. - pub fn new(clock: C, mut subscription: S, poller: P, poll_interval: Duration) -> Self { - let sub = subscription.take_stream(); - let interval = clock.interval(poll_interval); - Self { - sub, - _subscription: subscription, - poller, - interval, - _clock: PhantomData, - last_head: None, - } + /// `sub` carries the live head numbers and must own whatever keeps them flowing + /// (for example a WebSocket provider). `poller` is queried for the latest head on every + /// `poll_interval` tick. + pub fn new( + clock: impl Clock, + sub: BoxStream<'static, Result>, + poller: P, + poll_interval: Duration, + ) -> Self { + Self { sub, poller, interval: clock.interval(poll_interval), last_head: None } } /// Process a received head number, returning an event if it is strictly newer. @@ -78,12 +58,7 @@ where } #[async_trait] -impl L1HeadSource for HybridL1HeadSource -where - S: L1HeadSubscription, - P: L1HeadPolling, - C: Clock, -{ +impl L1HeadSource for HybridL1HeadSource

{ async fn next(&mut self) -> Result { loop { tokio::select! { @@ -126,20 +101,10 @@ where mod tests { use std::sync::atomic::{AtomicU64, Ordering}; - use async_trait::async_trait; use base_runtime::{Config, Runner}; - use futures::{StreamExt, stream::BoxStream}; use super::*; - struct StreamSub(BoxStream<'static, Result>); - - impl L1HeadSubscription for StreamSub { - fn take_stream(&mut self) -> BoxStream<'static, Result> { - std::mem::replace(&mut self.0, futures::stream::pending().boxed()) - } - } - struct IncrementingPoller(AtomicU64); #[async_trait] @@ -165,7 +130,7 @@ mod tests { let stream = futures::stream::iter(vec![Ok(10u64), Ok(9u64), Ok(10u64), Ok(11u64)]); let mut source = HybridL1HeadSource::new( ctx, - StreamSub(stream.boxed()), + stream.boxed(), ProviderErrorPoller, Duration::from_secs(100), ); @@ -186,7 +151,7 @@ mod tests { let stream = futures::stream::once(async { Ok(5u64) }); let mut source = HybridL1HeadSource::new( ctx, - StreamSub(stream.boxed()), + stream.boxed(), IncrementingPoller(AtomicU64::new(5)), Duration::from_secs(100), ); @@ -207,7 +172,7 @@ mod tests { futures::stream::once(async { Err(SourceError::Provider("rpc down".to_string())) }); let mut source = HybridL1HeadSource::new( ctx, - StreamSub(stream.boxed()), + stream.boxed(), // The virtual-time interval ticks immediately, and `select!` may poll this // branch before the stream error. Keep the fallback poller from producing a // head so the test only covers subscription error propagation. diff --git a/crates/batcher/source/src/l1_subscription.rs b/crates/batcher/source/src/l1_subscription.rs deleted file mode 100644 index 9b6b7f0186..0000000000 --- a/crates/batcher/source/src/l1_subscription.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! L1 head subscription trait for keepalive-aware streaming. - -use futures::stream::BoxStream; - -use crate::{KeepAliveSubscription, PendingSubscription, SourceError, StreamSubscription}; - -/// A source of an L1 head number stream that may hold ancillary resources. -/// -/// Unlike a plain [`Stream`], an implementor can own resources (e.g. a WebSocket -/// provider handle) that must remain alive for the duration of the stream. The -/// stream itself is obtained once by calling [`take_stream`]; the implementor is -/// then held by [`HybridL1HeadSource`] to keep those resources live. -/// -/// [`Stream`]: futures::Stream -/// [`take_stream`]: L1HeadSubscription::take_stream -/// [`HybridL1HeadSource`]: crate::HybridL1HeadSource -pub trait L1HeadSubscription: Send { - /// Extract the L1 head number stream from this subscription. - /// - /// Must be called at most once; implementors may panic on a second call. - fn take_stream(&mut self) -> BoxStream<'static, Result>; -} - -impl L1HeadSubscription for StreamSubscription { - fn take_stream(&mut self) -> BoxStream<'static, Result> { - Self::take_stream(self) - } -} - -impl L1HeadSubscription for KeepAliveSubscription { - fn take_stream(&mut self) -> BoxStream<'static, Result> { - Self::take_stream(self) - } -} - -impl L1HeadSubscription for PendingSubscription { - fn take_stream(&mut self) -> BoxStream<'static, Result> { - Self::take_stream(self) - } -} diff --git a/crates/batcher/source/src/lib.rs b/crates/batcher/source/src/lib.rs index 4fc916c956..e68d24a5b6 100644 --- a/crates/batcher/source/src/lib.rs +++ b/crates/batcher/source/src/lib.rs @@ -19,9 +19,6 @@ pub use traits::UnsafeBlockSource; mod polling; pub use polling::{PollingBlockSource, PollingSource}; -mod stream_subscription; -pub use stream_subscription::{KeepAliveSubscription, PendingSubscription, StreamSubscription}; - mod l1_event; pub use l1_event::L1HeadEvent; @@ -31,9 +28,6 @@ pub use l1_source::L1HeadSource; mod l1_polling; pub use l1_polling::L1HeadPolling; -mod l1_subscription; -pub use l1_subscription::L1HeadSubscription; - mod l1_hybrid; pub use l1_hybrid::HybridL1HeadSource; diff --git a/crates/batcher/source/src/stream_subscription.rs b/crates/batcher/source/src/stream_subscription.rs deleted file mode 100644 index 83306edca8..0000000000 --- a/crates/batcher/source/src/stream_subscription.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Generic stream subscription wrappers. - -use std::{any::Any, sync::Arc}; - -use futures::{StreamExt, stream::BoxStream}; - -use crate::SourceError; - -/// A subscription backed directly by a stream. -#[derive(derive_more::Debug)] -pub struct StreamSubscription { - #[debug("{:?}", stream.as_ref().map(|_| ""))] - stream: Option>>, -} - -impl StreamSubscription { - /// Creates a subscription from a stream. - pub fn new(stream: BoxStream<'static, Result>) -> Self { - Self { stream: Some(stream) } - } - - /// Extracts the underlying stream. - /// - /// # Panics - /// - /// Panics if called more than once. - pub fn take_stream(&mut self) -> BoxStream<'static, Result> { - self.stream.take().expect("take_stream called more than once") - } -} - -/// A [`StreamSubscription`] that keeps an ancillary resource alive. -/// -/// This is used for WebSocket subscriptions where the provider connection must -/// outlive the stream handed to a hybrid source. -#[derive(derive_more::Debug)] -pub struct KeepAliveSubscription { - #[debug(skip)] - _resource: Arc, - inner: StreamSubscription, -} - -impl KeepAliveSubscription { - /// Creates a subscription from a resource and stream. - pub fn new( - resource: Arc

, - stream: BoxStream<'static, Result>, - ) -> Self { - Self { _resource: resource, inner: StreamSubscription::new(stream) } - } - - /// Extracts the underlying stream. - /// - /// # Panics - /// - /// Panics if called more than once. - pub fn take_stream(&mut self) -> BoxStream<'static, Result> { - self.inner.take_stream() - } -} - -/// A subscription that never yields items. -#[derive(Debug)] -pub struct PendingSubscription { - _marker: std::marker::PhantomData, -} - -impl PendingSubscription { - /// Creates a pending subscription. - pub const fn new() -> Self { - Self { _marker: std::marker::PhantomData } - } -} - -impl PendingSubscription -where - T: Send + 'static, -{ - /// Returns a stream that never yields. - pub fn take_stream(&mut self) -> BoxStream<'static, Result> { - futures::stream::pending().boxed() - } -} - -impl Default for PendingSubscription { - fn default() -> Self { - Self::new() - } -} From 1bf44f45b3bdf78ca7a2da83e6a6a73b12d0137b Mon Sep 17 00:00:00 2001 From: Francis Li Date: Thu, 24 Sep 2026 18:31:04 +0000 Subject: [PATCH 109/299] fix(proof): use 300-block ZK checkpoint intervals (#5290) * fix(proof): honor requested ZK checkpoint intervals Amp-Thread-ID: https://ampcode.com/threads/T-01a0d431-7dc3-753c-a5e3-971fedee27cf Co-authored-by: Amp * fix(proof): require explicit ZK checkpoint intervals Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0d431-7dc3-753c-a5e3-971fedee27cf * refactor(proof): use u64 for requested checkpoint intervals Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0d431-7dc3-753c-a5e3-971fedee27cf * refactor(proof): preserve checkpoint request field types Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0d431-7dc3-753c-a5e3-971fedee27cf * refactor(proof): use u64 through witness generation Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0d431-7dc3-753c-a5e3-971fedee27cf * refactor(proof): use u64 for checkpoint execution intervals Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0d431-7dc3-753c-a5e3-971fedee27cf * fix(proof): use a fixed 300-block checkpoint interval Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0d431-7dc3-753c-a5e3-971fedee27cf --------- Co-authored-by: Amp --- crates/proof/zk/utils/src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/proof/zk/utils/src/client.rs b/crates/proof/zk/utils/src/client.rs index cf34f370cc..2645379cb7 100644 --- a/crates/proof/zk/utils/src/client.rs +++ b/crates/proof/zk/utils/src/client.rs @@ -16,7 +16,7 @@ use base_protocol::L2BlockInfo; use tracing::{error, info, warn}; /// Fixed interval (in blocks) at which the ZK range program records intermediate output roots. -pub const INTERMEDIATE_ROOT_INTERVAL: u64 = 30; +pub const INTERMEDIATE_ROOT_INTERVAL: u64 = 300; /// Fetches the safe head hash of the L2 chain based on the agreed upon L2 output root in the /// [`BootInfo`]. From cbca9a309bd6703e85e9b2703a259488e87e8172 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 24 Sep 2026 19:29:13 +0000 Subject: [PATCH 110/299] refactor(batcher): make the block and L1 head sources infallible (#5243) * refactor(batcher): make the block and L1 head sources infallible * test(batcher): pin that closed channel sources park and state the source contracts * refactor(batcher): check the hybrid L1 head for staleness in one place --- actions/harness/src/batcher/actor.rs | 17 ++- actions/harness/src/batcher/source.rs | 20 ++-- actions/harness/src/batcher/tx_manager.rs | 34 +++--- actions/harness/tests/batcher/channels.rs | 8 +- actions/harness/tests/batcher/submission.rs | 7 +- crates/batcher/core/src/driver.rs | 93 +++++--------- crates/batcher/core/src/error.rs | 4 - crates/batcher/core/src/test_utils/source.rs | 17 +-- crates/batcher/core/tests/heads.rs | 48 +------- crates/batcher/core/tests/lifecycle.rs | 44 +------ crates/batcher/service/src/service.rs | 10 +- crates/batcher/source/README.md | 3 + crates/batcher/source/src/error.rs | 8 +- crates/batcher/source/src/l1_event.rs | 8 -- crates/batcher/source/src/l1_hybrid.rs | 113 ++++++------------ crates/batcher/source/src/l1_source.rs | 18 +-- crates/batcher/source/src/lib.rs | 3 - crates/batcher/source/src/polling.rs | 15 ++- .../batcher/source/src/test_utils/channel.rs | 42 +++---- .../source/src/test_utils/l1_channel.rs | 61 +++++----- crates/batcher/source/src/traits.rs | 10 +- 21 files changed, 203 insertions(+), 380 deletions(-) delete mode 100644 crates/batcher/source/src/l1_event.rs diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 198ca5966d..5d7ec30550 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -97,7 +97,7 @@ pub enum BatcherError { /// 4. Wait for the driver's next submit pass (a second marker), after which every /// resulting submission has been handed to the tx manager, not just the first. /// 5. Mine one L1 block via the shared [`L1MinerTxManager`], firing all -/// receipt oneshots and delivering an [`L1HeadEvent::NewHead`] to the driver. +/// receipt oneshots and delivering the new L1 head to the driver. /// 6. Yield to let the driver confirm receipts and advance its L1 head. /// /// The driver's [`BatchEncoder`] state is persistent across `advance()` calls. @@ -106,7 +106,6 @@ pub enum BatcherError { /// [`advance`]: Batcher::advance /// [`BatchDriver`]: base_batcher_core::BatchDriver /// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource -/// [`L1HeadEvent::NewHead`]: base_batcher_source::L1HeadEvent pub struct Batcher { /// The L2 block source to drain on each [`advance`](Batcher::advance) cycle. l2_source: S, @@ -147,8 +146,8 @@ impl Batcher { let (source, source_tx) = HarnessBlockSource::new(); let (admin, admin_rx) = AdminHandle::channel(); - // L1 head source: mine_block() sends L1HeadEvent::NewHead; the driver - // calls advance_l1_head() when the channel delivers an event. + // L1 head source: mine_block() sends the mined block number; the driver + // calls advance_l1_head() when the channel delivers it. let (l1_source, l1_head_tx) = ChannelL1HeadSource::new(); let tx_manager = @@ -339,7 +338,7 @@ impl Batcher { } /// Fire receipts for all staged items from `block` and yield to let - /// the driver process confirmations and the L1 head event. + /// the driver process confirmations and the new L1 head. pub async fn confirm_staged(&self, block: &L1Block) { self.tx_manager.confirm_block(block); tokio::task::yield_now().await; @@ -348,8 +347,8 @@ impl Batcher { /// Simulate an L1 reorg back to `block_number`. /// /// Truncates the L1 chain via [`L1Miner::reorg_to`], fires failure - /// receipts for every item in `pending` and `staged`, and publishes - /// [`L1HeadEvent::NewHead`] to the driver. + /// receipts for every item in `pending` and `staged`, and publishes the new + /// L1 head to the driver. /// /// Items already confirmed via [`confirm_staged`] (and thus living in /// the driver's own `in_flight` set) are **not** covered — see @@ -437,10 +436,10 @@ impl Batcher { self.try_encode_only().await?; // Mine one L1 block: submits all pending txs/blobs, fires receipt - // oneshots, and publishes the block number to the L1 head watch. + // oneshots, and sends the new L1 head to the driver. self.tx_manager.mine_block(l1); - // Yield to let the driver process the receipts and the L1 head event. + // Yield to let the driver process the receipts and the new L1 head. tokio::task::yield_now().await; Ok(()) diff --git a/actions/harness/src/batcher/source.rs b/actions/harness/src/batcher/source.rs index 7c325f5fe7..cc4088d8c5 100644 --- a/actions/harness/src/batcher/source.rs +++ b/actions/harness/src/batcher/source.rs @@ -4,7 +4,7 @@ //! test, and the harness must interleave sync markers with the events it feeds in FIFO order. use async_trait::async_trait; -use base_batcher_source::{L2BlockEvent, SourceError, UnsafeBlockSource}; +use base_batcher_source::{L2BlockEvent, UnsafeBlockSource}; use tokio::sync::{mpsc, oneshot}; /// One item queued for the driver's block source. @@ -38,13 +38,14 @@ impl HarnessBlockSource { #[async_trait] impl UnsafeBlockSource for HarnessBlockSource { - async fn next(&mut self) -> Result { + async fn next(&mut self) -> L2BlockEvent { loop { - match self.rx.recv().await.ok_or(SourceError::Closed)? { - BlockSourceItem::Event(event) => return Ok(event), - BlockSourceItem::Marker(reached) => { + match self.rx.recv().await { + Some(BlockSourceItem::Event(event)) => return event, + Some(BlockSourceItem::Marker(reached)) => { let _ = reached.send(()); } + None => std::future::pending().await, } } } @@ -64,10 +65,10 @@ mod tests { tx.send(BlockSourceItem::Marker(reached_tx)).unwrap(); tx.send(BlockSourceItem::Event(L2BlockEvent::Reorg)).unwrap(); - assert!(matches!(source.next().await, Ok(L2BlockEvent::Reorg))); + assert!(matches!(source.next().await, L2BlockEvent::Reorg)); assert!(reached_rx.try_recv().is_err(), "the marker must wait for the next poll"); - assert!(matches!(source.next().await, Ok(L2BlockEvent::Reorg))); + assert!(matches!(source.next().await, L2BlockEvent::Reorg)); assert!(reached_rx.try_recv().is_ok(), "the next poll must answer the marker"); } @@ -84,10 +85,11 @@ mod tests { } #[tokio::test] - async fn closed_channel_closes_the_source() { + async fn parks_once_the_harness_is_gone() { let (mut source, tx) = HarnessBlockSource::new(); drop(tx); - assert!(matches!(source.next().await, Err(SourceError::Closed))); + let next = tokio::time::timeout(Duration::from_millis(10), source.next()); + assert!(next.await.is_err(), "a closed source must park"); } } diff --git a/actions/harness/src/batcher/tx_manager.rs b/actions/harness/src/batcher/tx_manager.rs index 3428809102..31ad7e9b0c 100644 --- a/actions/harness/src/batcher/tx_manager.rs +++ b/actions/harness/src/batcher/tx_manager.rs @@ -9,7 +9,6 @@ use alloy_eips::{eip4844::Blob, eip7594::BlobTransactionSidecarVariant}; use alloy_primitives::{Address, B256, TxKind}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; -use base_batcher_source::L1HeadEvent; use base_tx_manager::{ BlobTxBuilder, SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError, TxManagerResult, @@ -85,8 +84,8 @@ pub struct Inner { /// [`BatchDriver`] and retain the other for [`mine_block`] calls from the test. /// /// When constructed with [`with_l1_head_tx`], [`mine_block`] automatically -/// sends an [`L1HeadEvent::NewHead`] to a paired [`ChannelL1HeadSource`] so -/// that the [`BatchDriver`] observes live L1 head updates. +/// sends the new L1 head to a paired [`ChannelL1HeadSource`] so that the +/// [`BatchDriver`] observes live L1 head updates. /// /// [`send_async`]: L1MinerTxManager::send_async /// [`mine_block`]: L1MinerTxManager::mine_block @@ -100,13 +99,12 @@ pub struct L1MinerTxManager { inbox_address: Address, signer: PrivateKeySigner, chain_id: u64, - /// Optional L1 head channel sender. When set, [`mine_block`] publishes - /// `L1HeadEvent::NewHead(block_number)` so a paired [`ChannelL1HeadSource`] - /// can advance the driver's L1 head. + /// Optional L1 head channel sender. When set, [`mine_block`] publishes the mined + /// block number so a paired [`ChannelL1HeadSource`] can advance the driver's L1 head. /// /// [`mine_block`]: L1MinerTxManager::mine_block /// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource - l1_head_tx: Option>, + l1_head_tx: Option>, } impl L1MinerTxManager { @@ -123,15 +121,14 @@ impl L1MinerTxManager { /// Attach an L1 head channel sender. /// - /// After each [`mine_block`] call, an [`L1HeadEvent::NewHead`] with the - /// mined block number is sent to this channel. A [`BatchDriver`] constructed - /// with the paired [`ChannelL1HeadSource`] will observe the update and advance - /// its pipeline's L1 head accordingly. + /// After each [`mine_block`] call, the mined block number is sent to this channel. + /// A [`BatchDriver`] constructed with the paired [`ChannelL1HeadSource`] will observe + /// the update and advance its pipeline's L1 head accordingly. /// /// [`mine_block`]: L1MinerTxManager::mine_block /// [`BatchDriver`]: base_batcher_core::BatchDriver /// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource - pub fn with_l1_head_tx(mut self, tx: mpsc::UnboundedSender) -> Self { + pub fn with_l1_head_tx(mut self, tx: mpsc::UnboundedSender) -> Self { self.l1_head_tx = Some(tx); self } @@ -216,13 +213,11 @@ impl L1MinerTxManager { } /// Fire receipt oneshots for staged items included in `block` and (if configured) - /// publish an [`L1HeadEvent::NewHead`]. + /// publish the new L1 head. /// /// Staged items without receipts in `block` remain staged. This models the /// production transaction manager's receipt polling: RPC submission can succeed /// before the transaction is included by L1. - /// - /// [`BatchDriver`]: base_batcher_core::BatchDriver pub fn confirm_block(&self, block: &L1Block) { let responses = { let mut inner = self.inner.lock().unwrap(); @@ -249,7 +244,7 @@ impl L1MinerTxManager { let _ = responder.send(response); } if let Some(tx) = &self.l1_head_tx { - let _ = tx.send(L1HeadEvent::NewHead(block.number())); + let _ = tx.send(block.number()); } } @@ -258,8 +253,7 @@ impl L1MinerTxManager { /// Calls [`L1Miner::reorg_to`] to truncate the canonical chain, fires a /// failure receipt for every pending and staged submission (since their /// inclusion block has been discarded or they are no longer valid), and - /// publishes [`L1HeadEvent::NewHead`] so the [`BatchDriver`] observes - /// the reorg. + /// publishes the new L1 head so the [`BatchDriver`] observes the reorg. /// /// Both `pending` (not yet staged) and `staged` (submitted to L1 but not /// yet confirmed) items are drained. This ensures no [`SendHandle`] is @@ -267,7 +261,7 @@ impl L1MinerTxManager { /// /// # Ordering /// - /// Failure receipts are fired *before* `L1HeadEvent::NewHead` is sent. + /// Failure receipts are fired *before* the new L1 head is sent. /// This is intentional: the driver's `select!` loop prioritises receipt /// processing over head events, so firing receipts first ensures the /// driver requeues any failed frames before it advances its L1 head. @@ -297,7 +291,7 @@ impl L1MinerTxManager { let _ = p.responder.send(Err(TxManagerError::Rpc("reorg".to_string()))); } if let Some(tx) = &self.l1_head_tx { - let _ = tx.send(L1HeadEvent::NewHead(block_number)); + let _ = tx.send(block_number); } info!(block_number = %block_number, drained = %drained, "simulated L1 reorg"); } diff --git a/actions/harness/tests/batcher/channels.rs b/actions/harness/tests/batcher/channels.rs index 4366499739..2e98c82b29 100644 --- a/actions/harness/tests/batcher/channels.rs +++ b/actions/harness/tests/batcher/channels.rs @@ -373,11 +373,9 @@ async fn multi_frame_channel_with_empty_l1_gap_derives_correctly() { "incomplete channel after block 1; safe head must stay at genesis" ); - // Mine an empty L1 block 2. The channel was already closed by encode_only() - // (its admin flush), so no staged items are confirmed here. The call to - // confirm_staged is used solely to advance the driver's L1 head to block 2 - // via L1HeadEvent::NewHead — confirm_all fires zero receipts and just sends - // the head event. The remaining frames are already in `pending`. + // Mine an empty L1 block 2 and confirm it: encode_only() already closed the channel + // (admin flush), so this fires no receipt and only advances the driver's L1 head to 2. + // The remaining frames are already in `pending`. h.l1.mine_block(); chain.push(h.l1.tip().clone()); batcher.confirm_staged(h.l1.tip()).await; diff --git a/actions/harness/tests/batcher/submission.rs b/actions/harness/tests/batcher/submission.rs index f233a67ff6..39d2c514bc 100644 --- a/actions/harness/tests/batcher/submission.rs +++ b/actions/harness/tests/batcher/submission.rs @@ -135,10 +135,9 @@ async fn batcher_reorg_during_submission() { chain.push(h.l1.tip().clone()); // --- L1 reorg back to genesis (frames still in staged) --- - // reorg_to fires Err(TxManagerError::Rpc("reorg")) for every staged item - // and sends L1HeadEvent::NewHead(0). The driver's select! loop processes - // each Receipt(id, Failed) → pipeline.requeue(id), rewinding the channel - // cursor without re-encoding. + // reorg_to fires Err(TxManagerError::Rpc("reorg")) for every staged item and + // sends L1 head 0. The driver's select! loop processes each Receipt(id, Failed) + // → pipeline.requeue(id), rewinding the channel cursor without re-encoding. batcher.reorg(0, &mut h.l1); batcher.wait_until_requeued(1).await; diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 115af36082..3c27eb9393 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -5,9 +5,7 @@ use std::time::Duration; use base_batcher_encoder::{ BatchPipeline, BatcherMetrics, DerivationReconciliation, StepError, StepResult, }; -use base_batcher_source::{ - L1HeadEvent, L1HeadSource, L2BlockEvent, SourceError, UnsafeBlockSource, -}; +use base_batcher_source::{L1HeadSource, L2BlockEvent, UnsafeBlockSource}; use base_common_consensus::BaseBlock; use base_protocol::BlockInfo; use base_runtime::Runtime; @@ -82,10 +80,7 @@ where /// DA backlog throttle (controller, client, dedup cache). throttle: DaThrottle, /// L1 head source for chain head advancement. - /// - /// Set to `None` after the source returns [`SourceError::Closed`], causing the - /// driver to park that select arm forever. - l1_head_source: Option, + l1_head_source: L, /// Last trusted L2 safe head. safe_head: Option, /// Ordered derivation-progress snapshots. @@ -144,7 +139,7 @@ where config.max_pending_transactions, ), throttle, - l1_head_source: Some(heads.l1_head_source), + l1_head_source: heads.l1_head_source, safe_head: initial_status.map(|status| status.safe_l2), derivation_status_rx, drain_timeout: config.drain_timeout, @@ -474,10 +469,6 @@ where /// catch up sequentially from the last known safe L2 head. Stopping a stopped /// batcher or starting a running one does nothing. Each command is answered /// once it has been applied. - /// - /// L1 head source errors are handled internally to avoid polluting the return - /// type with a no-op variant: [`SourceError::Closed`] parks the arm for good, - /// any other error is logged and the source polled again. async fn next_event(&mut self) -> Result { loop { let event = tokio::select! { @@ -541,7 +532,7 @@ where } } - event = self.source.next() => match event? { + event = self.source.next() => match event { L2BlockEvent::Block(_) if self.stopped => continue, L2BlockEvent::Block(block) => DriverEvent::Block(block), L2BlockEvent::Reorg => DriverEvent::Reorg, @@ -551,24 +542,7 @@ where DriverEvent::Receipt(ids, outcome) } - l1_event = async { - if let Some(ref mut src) = self.l1_head_source { - src.next().await - } else { - std::future::pending::>().await - } - } => match l1_event { - Ok(L1HeadEvent::NewHead(n)) => DriverEvent::L1Head(n), - Err(SourceError::Closed) => { - warn!("L1 head source closed, L1 head tracking stopped"); - self.l1_head_source = None; - continue; - } - Err(e) => { - warn!(error = %e, "L1 head source error"); - continue; - } - } + head = self.l1_head_source.next() => DriverEvent::L1Head(head), }; return Ok(event); } @@ -606,9 +580,7 @@ mod tests { use base_batcher_encoder::{ BatchSubmission, BlobPayload, FrameEncoder, SubmissionId, SubmissionPayload, }; - use base_batcher_source::{ - L1HeadEvent, L1HeadSource, L2BlockEvent, SourceError, UnsafeBlockSource, - }; + use base_batcher_source::{L1HeadSource, L2BlockEvent, UnsafeBlockSource}; use base_blobs::{BlobDecoder, BlobEncoder}; use base_protocol::{BlockInfo, Frame}; use base_runtime::{ @@ -630,18 +602,18 @@ mod tests { #[derive(Debug)] struct QueuedSource { - events: VecDeque>, + events: VecDeque, } impl QueuedSource { - fn new(events: impl IntoIterator>) -> Self { + fn new(events: impl IntoIterator) -> Self { Self { events: events.into_iter().collect() } } } #[async_trait::async_trait] impl UnsafeBlockSource for QueuedSource { - async fn next(&mut self) -> Result { + async fn next(&mut self) -> L2BlockEvent { match self.events.pop_front() { Some(event) => event, None => std::future::pending().await, @@ -653,20 +625,20 @@ mod tests { #[derive(Debug)] struct QueuedL1HeadSource { - events: VecDeque>, + heads: VecDeque, } impl QueuedL1HeadSource { - fn new(events: impl IntoIterator>) -> Self { - Self { events: events.into_iter().collect() } + fn new(heads: impl IntoIterator) -> Self { + Self { heads: heads.into_iter().collect() } } } #[async_trait::async_trait] impl L1HeadSource for QueuedL1HeadSource { - async fn next(&mut self) -> Result { - match self.events.pop_front() { - Some(event) => event, + async fn next(&mut self) -> u64 { + match self.heads.pop_front() { + Some(head) => head, None => std::future::pending().await, } } @@ -759,8 +731,8 @@ mod tests { fn driver_for_next_event( runtime: R, - source_events: impl IntoIterator>, - l1_events: impl IntoIterator>, + source_events: impl IntoIterator, + l1_heads: impl IntoIterator, tx_manager: TM, ) -> BatchDriver< R, @@ -782,7 +754,7 @@ mod tests { force_blobs_when_throttling: true, }, DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - QueuedL1HeadSource::new(l1_events), + QueuedL1HeadSource::new(l1_heads), ) } @@ -881,8 +853,8 @@ mod tests { let mut driver = driver_for_next_event( ctx.clone(), - [Ok(L2BlockEvent::Block(Box::default()))], - [Ok(L1HeadEvent::NewHead(9))], + [L2BlockEvent::Block(Box::default())], + [9], ImmediateConfirmTxManager { l1_block: 1 }, ) .with_admin_rx(admin_rx); @@ -906,8 +878,8 @@ mod tests { let mut driver = driver_for_next_event( ctx, - [Ok(L2BlockEvent::Block(Box::default()))], - [Ok(L1HeadEvent::NewHead(9))], + [L2BlockEvent::Block(Box::default())], + [9], ImmediateConfirmTxManager { l1_block: 1 }, ) .with_admin_rx(admin_rx); @@ -923,8 +895,8 @@ mod tests { let (_status_tx, status_rx) = mpsc::channel(1); let mut driver = driver_for_next_event( ctx, - [Ok(L2BlockEvent::Block(Box::default()))], - [Ok(L1HeadEvent::NewHead(9))], + [L2BlockEvent::Block(Box::default())], + [9], ImmediateConfirmTxManager { l1_block: 1 }, ) .with_derivation_status_rx(DerivationStatus::from_safe_l2(safe_head(0)), status_rx); @@ -947,8 +919,8 @@ mod tests { let mut driver = driver_for_next_event( ctx, - [Ok(L2BlockEvent::Block(Box::default()))], - [Ok(L1HeadEvent::NewHead(9))], + [L2BlockEvent::Block(Box::default())], + [9], ImmediateConfirmTxManager { l1_block: 42 }, ) .with_derivation_status_rx(DerivationStatus::from_safe_l2(safe_head(0)), status_rx); @@ -972,13 +944,12 @@ mod tests { .await .expect("derivation-status receiver should be open"); - let mut driver = driver_for_next_event( - ctx, - [], - [Ok(L1HeadEvent::NewHead(9))], - ImmediateConfirmTxManager { l1_block: 1 }, - ) - .with_derivation_status_rx(DerivationStatus::from_safe_l2(safe_head(0)), status_rx); + let mut driver = + driver_for_next_event(ctx, [], [9], ImmediateConfirmTxManager { l1_block: 1 }) + .with_derivation_status_rx( + DerivationStatus::from_safe_l2(safe_head(0)), + status_rx, + ); let event = driver.next_event().await.expect("next_event should succeed"); assert!(matches!( diff --git a/crates/batcher/core/src/error.rs b/crates/batcher/core/src/error.rs index 3e4f90a898..7823f3874e 100644 --- a/crates/batcher/core/src/error.rs +++ b/crates/batcher/core/src/error.rs @@ -1,15 +1,11 @@ //! Error types for the batch driver. use base_batcher_encoder::StepError; -use base_batcher_source::SourceError; use base_blobs::BlobEncodeError; /// Errors produced by the [`BatchDriver`](crate::BatchDriver). #[derive(Debug, thiserror::Error)] pub enum BatchDriverError { - /// An error from the L2 block source. - #[error("source error: {0}")] - Source(#[from] SourceError), /// An error encoding frames into blobs. #[error("blob encoding error: {0}")] Blob(#[from] BlobEncodeError), diff --git a/crates/batcher/core/src/test_utils/source.rs b/crates/batcher/core/src/test_utils/source.rs index 1cebdc68ad..1020694079 100644 --- a/crates/batcher/core/src/test_utils/source.rs +++ b/crates/batcher/core/src/test_utils/source.rs @@ -3,9 +3,7 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; -use base_batcher_source::{ - L1HeadEvent, L1HeadSource, L2BlockEvent, SourceError, UnsafeBlockSource, -}; +use base_batcher_source::{L1HeadSource, L2BlockEvent, UnsafeBlockSource}; use base_protocol::BlockInfo; /// [`UnsafeBlockSource`] that parks the select arm forever. @@ -18,7 +16,7 @@ pub struct PendingSource; #[async_trait] impl UnsafeBlockSource for PendingSource { - async fn next(&mut self) -> Result { + async fn next(&mut self) -> L2BlockEvent { std::future::pending().await } } @@ -39,7 +37,7 @@ impl TrackingSource { #[async_trait] impl UnsafeBlockSource for TrackingSource { - async fn next(&mut self) -> Result { + async fn next(&mut self) -> L2BlockEvent { std::future::pending().await } @@ -49,9 +47,6 @@ impl UnsafeBlockSource for TrackingSource { } /// [`UnsafeBlockSource`] that delivers exactly one default block then parks forever. -/// -/// Useful for tests that need a single block ingestion event without the source -/// closing afterwards. #[derive(Debug)] pub struct OneBlockSource { delivered: bool, @@ -72,10 +67,10 @@ impl Default for OneBlockSource { #[async_trait] impl UnsafeBlockSource for OneBlockSource { - async fn next(&mut self) -> Result { + async fn next(&mut self) -> L2BlockEvent { if !self.delivered { self.delivered = true; - Ok(L2BlockEvent::Block(Box::default())) + L2BlockEvent::Block(Box::default()) } else { std::future::pending().await } @@ -91,7 +86,7 @@ pub struct PendingL1HeadSource; #[async_trait] impl L1HeadSource for PendingL1HeadSource { - async fn next(&mut self) -> Result { + async fn next(&mut self) -> u64 { std::future::pending().await } } diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index bb403dc73d..9601beb9ae 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -14,7 +14,7 @@ use base_batcher_core::{ SubmissionStub, TrackingPipeline, TrackingSource, }, }; -use base_batcher_source::{L1HeadEvent, test_utils::ChannelL1HeadSource}; +use base_batcher_source::test_utils::ChannelL1HeadSource; use base_protocol::BlockInfo; use base_runtime::{ Cancellation, Clock, Spawner, @@ -53,7 +53,7 @@ fn test_l1_head_source_advances_pipeline() { let handle = ctx.spawn(driver.run()); // Send a new L1 head via the channel. - l1_tx.send(L1HeadEvent::NewHead(42)).unwrap(); + l1_tx.send(42).unwrap(); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -67,50 +67,6 @@ fn test_l1_head_source_advances_pipeline() { }); } -/// When the L1 head source closes, the driver keeps running without L1 head -/// updates instead of shutting down. The L1 head delivered before the close -/// must be processed normally. -#[test] -fn test_l1_source_closed_driver_continues() { - Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - let (l1_source, l1_tx) = ChannelL1HeadSource::new(); - - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - PendingSource, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - l1_source, - ); - let handle = ctx.spawn(driver.run()); - - l1_tx.send(L1HeadEvent::NewHead(77)).unwrap(); - ctx.sleep(Duration::from_millis(20)).await; - drop(l1_tx); // the source returns Closed on its next poll - - // Driver must still be running after L1 source closes. - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok(), "driver must continue after L1 source closes"); - let r = recorded.lock().unwrap(); - assert!( - r.l1_heads.contains(&77), - "L1 head delivered before close must be processed, got {:?}", - r.l1_heads - ); - }); -} - #[test] fn test_safe_head_conflicts_reset_pipeline_and_source() { Runner::start(Config::seeded(0), |ctx| async move { diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index ba13684194..c086cf690d 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -1,5 +1,5 @@ -//! Integration tests for [`BatchDriver`] lifecycle: source errors, drain, and the order of -//! work and waiting in the loop. +//! Integration tests for [`BatchDriver`] lifecycle: drain and the order of work and waiting +//! in the loop. use std::{ sync::{Arc, Mutex}, @@ -12,49 +12,17 @@ use base_batcher_core::{ BatchDriver, BatchDriverConfig, BatchDriverError, DaThrottle, NoopThrottleClient, ThrottleController, test_utils::{ - DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, NeverConfirmTxManager, - PendingL1HeadSource, Recorded, SubmissionStub, TrackingPipeline, + DriverFixture, ManualConfirmTxManager, NeverConfirmTxManager, PendingL1HeadSource, + Recorded, SubmissionStub, TrackingPipeline, }, }; use base_batcher_encoder::{ChannelLimit, StepError, SubmissionId}; -use base_batcher_source::{ - L2BlockEvent, SourceError, UnsafeBlockSource, test_utils::ChannelBlockSource, -}; +use base_batcher_source::{L2BlockEvent, UnsafeBlockSource}; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, }; -/// An error from the block source is fatal: the driver exits with it instead of running -/// without input. -#[test] -fn test_block_source_closed_is_fatal() { - Runner::start(Config::seeded(0), |ctx| async move { - let (source, source_tx) = ChannelBlockSource::new(); - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))), - source, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - PendingL1HeadSource, - ); - let handle = ctx.spawn(driver.run()); - - // Closing the channel makes the source fail on its next poll. - drop(source_tx); - - let result = handle.await.unwrap(); - assert!(matches!(result, Err(BatchDriverError::Source(SourceError::Closed)))); - }); -} - /// When cancellation fires while a submission is in-flight with a /// `NeverConfirmTxManager`, the drain timeout must fire and the driver must /// exit cleanly. This verifies the `runtime.sleep(drain_timeout)` fix. @@ -129,7 +97,7 @@ struct PollRecorder { #[async_trait] impl UnsafeBlockSource for PollRecorder { - async fn next(&mut self) -> Result { + async fn next(&mut self) -> L2BlockEvent { let dequeued = self.recorded.lock().unwrap().dequeued.len(); self.dequeued_at_poll.lock().unwrap().push(dequeued); std::future::pending().await diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index cb51415fa4..b0e9e791b6 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -16,7 +16,7 @@ use base_batcher_core::{ ThrottleController, ThrottleStrategy, }; use base_batcher_encoder::{BatchEncoder, BatcherMetrics}; -use base_batcher_source::{HybridL1HeadSource, PollingBlockSource, SourceError}; +use base_batcher_source::{HybridL1HeadSource, PollingBlockSource}; use base_common_network::Base; use base_consensus_rpc::RollupNodeApiClient; use base_protocol::BlockInfo; @@ -198,9 +198,7 @@ impl BatcherService { /// /// `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, Result> { + async fn build_l1_head_stream(url: Option<&Url>) -> BoxStream<'static, u64> { let active = BatcherMetrics::l1_head_subscription_active(); active.set(0.0); @@ -229,7 +227,7 @@ impl BatcherService { .map(move |header| { // Capture the provider: dropping it closes the connection and ends the stream. let _keep_alive = &ws_provider; - Ok(header.number) + header.number }) // Mark the subscription down once alloy gives up reconnecting and the stream ends. .chain(stream::poll_fn(move |_| { @@ -817,7 +815,7 @@ mod tests { for expected in 1..=2 { miner.raw_request::<(), String>("evm_mine".into(), ()).await.unwrap(); let head = tokio::time::timeout(Duration::from_secs(5), heads.next()).await; - assert!(matches!(head.unwrap(), Some(Ok(head)) if head == expected)); + assert_eq!(head.unwrap(), Some(expected)); } } } diff --git a/crates/batcher/source/README.md b/crates/batcher/source/README.md index 593c3fa37b..f10dee28fb 100644 --- a/crates/batcher/source/README.md +++ b/crates/batcher/source/README.md @@ -10,5 +10,8 @@ Provides ordered L2 block polling, reorg detection, and L1 head tracking. - **`L2BlockEvent`** — new block or reorg signal - **`PollingBlockSource`** — fetches consecutive L2 blocks above a safe head - **`PollingSource`** — trait for fetching an L2 block by number +- **`L1HeadSource`** — async trait for streaming L1 head block numbers +- **`L1HeadPolling`** — trait for fetching the latest L1 head block number - **`HybridL1HeadSource`** — combines L1 subscription and polling +- **`SourceError`** — errors of the polling adapters, which the sources retry - **`test_utils`** (feature `test-utils`) — channel-backed sources for tests diff --git a/crates/batcher/source/src/error.rs b/crates/batcher/source/src/error.rs index a1a6fc556f..f7b34285fe 100644 --- a/crates/batcher/source/src/error.rs +++ b/crates/batcher/source/src/error.rs @@ -1,6 +1,7 @@ -//! Error type shared by the L2 block and L1 head sources. +//! Error type of the L2 block and L1 head polling adapters. -/// Errors produced by the L2 block and L1 head sources. +/// Errors produced by [`PollingSource`][crate::PollingSource] and +/// [`L1HeadPolling`][crate::L1HeadPolling]. The sources built on them retry. #[derive(Debug, thiserror::Error)] pub enum SourceError { /// Provider or RPC error. @@ -9,7 +10,4 @@ pub enum SourceError { /// A requested block has not become available from the provider yet. #[error("block {0} is not available yet")] BlockUnavailable(u64), - /// The source will deliver nothing more: its channel or stream closed. - #[error("source closed")] - Closed, } diff --git a/crates/batcher/source/src/l1_event.rs b/crates/batcher/source/src/l1_event.rs deleted file mode 100644 index 2288e001d4..0000000000 --- a/crates/batcher/source/src/l1_event.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! L1 head event type. - -/// An event describing a change in the observed L1 chain head. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum L1HeadEvent { - /// A new L1 head block number was observed. - NewHead(u64), -} diff --git a/crates/batcher/source/src/l1_hybrid.rs b/crates/batcher/source/src/l1_hybrid.rs index 590b1c4f33..f8a8ab836f 100644 --- a/crates/batcher/source/src/l1_hybrid.rs +++ b/crates/batcher/source/src/l1_hybrid.rs @@ -6,26 +6,25 @@ use async_trait::async_trait; use base_runtime::Clock; use futures::{StreamExt, stream::BoxStream}; -use crate::{L1HeadEvent, L1HeadPolling, L1HeadSource, SourceError}; +use crate::{L1HeadPolling, L1HeadSource}; /// An L1 head source that races a subscription stream against an interval-based poller. /// -/// Deduplicates head numbers so that the same block number is only reported once. -/// Stale reads (same or lower block number than last reported) are also silently dropped. -/// -/// When the subscription stream ends, the source keeps delivering heads from the poller. +/// Reports only heads strictly above the last one it reported, so a head seen by both the +/// stream and the poller is reported once. When the subscription stream ends, the source +/// keeps delivering heads from the poller. #[derive(derive_more::Debug)] pub struct HybridL1HeadSource

{ /// Live head numbers pushed by the subscription; pending forever once it ends. #[debug(skip)] - sub: BoxStream<'static, Result>, + sub: BoxStream<'static, u64>, /// Polling source for fetching the latest L1 head block number. #[debug(skip)] poller: P, /// Polling interval timer. #[debug(skip)] interval: BoxStream<'static, ()>, - /// Last reported head number for deduplication. + /// Last reported head number. last_head: Option, } @@ -37,62 +36,44 @@ impl HybridL1HeadSource

{ /// `poll_interval` tick. pub fn new( clock: impl Clock, - sub: BoxStream<'static, Result>, + sub: BoxStream<'static, u64>, poller: P, poll_interval: Duration, ) -> Self { Self { sub, poller, interval: clock.interval(poll_interval), last_head: None } } - - /// Process a received head number, returning an event if it is strictly newer. - /// - /// Drops duplicate or stale values (same or lower head number than last emitted). - fn process(&mut self, head: u64) -> Option { - if self.last_head.is_some_and(|last| last >= head) { - tracing::debug!(head, "stale or duplicate L1 head, skipping"); - return None; - } - self.last_head = Some(head); - Some(L1HeadEvent::NewHead(head)) - } } #[async_trait] impl L1HeadSource for HybridL1HeadSource

{ - async fn next(&mut self) -> Result { + async fn next(&mut self) -> u64 { loop { - tokio::select! { - head = self.sub.next() => { - match head { - Some(Ok(n)) => { - if let Some(event) = self.process(n) { - return Ok(event); - } - // Stale or duplicate — loop for next event. - } - Some(Err(e)) => return Err(e), - None => { - tracing::warn!("L1 head subscription ended; falling back to polling"); - self.sub = futures::stream::pending().boxed(); - } + // Take the next head from whichever of the stream and the poller answers first. + let head = tokio::select! { + next = self.sub.next() => match next { + Some(head) => head, + None => { + tracing::warn!("L1 head subscription ended; falling back to polling"); + self.sub = futures::stream::pending().boxed(); + continue; } - } - _ = self.interval.next() => { - match self.poller.latest_head().await { - Ok(n) => { - if let Some(event) = self.process(n) { - return Ok(event); - } - // Stale or duplicate — loop for next event. - } - Err(SourceError::Provider(msg)) => { - tracing::warn!(error = %msg, "L1 head polling error, retrying on next tick"); - // Transient provider error — continue to next tick. - } - Err(e) => return Err(e), + }, + _ = self.interval.next() => match self.poller.latest_head().await { + Ok(head) => head, + Err(error) => { + tracing::warn!(error = %error, "L1 head polling error, retrying on next tick"); + continue; } - } + }, + }; + + // Report it only if it is newer than the last one. + if self.last_head.is_some_and(|last| last >= head) { + tracing::debug!(head, "stale or duplicate L1 head, skipping"); + continue; } + self.last_head = Some(head); + return head; } } } @@ -104,6 +85,7 @@ mod tests { use base_runtime::{Config, Runner}; use super::*; + use crate::SourceError; struct IncrementingPoller(AtomicU64); @@ -127,7 +109,7 @@ mod tests { fn test_hybrid_l1_stale_and_duplicate_heads_skipped() { Runner::start(Config::seeded(0), |ctx| async move { // Only the stream can produce heads: the poller always fails. - let stream = futures::stream::iter(vec![Ok(10u64), Ok(9u64), Ok(10u64), Ok(11u64)]); + let stream = futures::stream::iter(vec![10u64, 9, 10, 11]); let mut source = HybridL1HeadSource::new( ctx, stream.boxed(), @@ -135,9 +117,9 @@ mod tests { Duration::from_secs(100), ); - assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(10)); + assert_eq!(source.next().await, 10); // 9 is stale and the second 10 a duplicate: the next head is 11. - assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(11)); + assert_eq!(source.next().await, 11); }); } @@ -148,7 +130,7 @@ mod tests { // poller keeps returning a new head on every call. Whichever arm `select!` polls // first, the other one's 5 is a duplicate and the heads that follow come from the // poller. - let stream = futures::stream::once(async { Ok(5u64) }); + let stream = futures::stream::once(async { 5u64 }); let mut source = HybridL1HeadSource::new( ctx, stream.boxed(), @@ -158,30 +140,9 @@ mod tests { let mut heads = Vec::new(); for _ in 0..3 { - let L1HeadEvent::NewHead(head) = source.next().await.unwrap(); - heads.push(head); + heads.push(source.next().await); } assert_eq!(heads, [5, 6, 7]); }); } - - #[test] - fn test_hybrid_l1_stream_error() { - Runner::start(Config::seeded(0), |ctx| async move { - let stream = - futures::stream::once(async { Err(SourceError::Provider("rpc down".to_string())) }); - let mut source = HybridL1HeadSource::new( - ctx, - stream.boxed(), - // The virtual-time interval ticks immediately, and `select!` may poll this - // branch before the stream error. Keep the fallback poller from producing a - // head so the test only covers subscription error propagation. - ProviderErrorPoller, - Duration::from_secs(100), - ); - - let err = source.next().await.unwrap_err(); - assert!(matches!(err, SourceError::Provider(_))); - }); - } } diff --git a/crates/batcher/source/src/l1_source.rs b/crates/batcher/source/src/l1_source.rs index 2a36fa9057..d06553575d 100644 --- a/crates/batcher/source/src/l1_source.rs +++ b/crates/batcher/source/src/l1_source.rs @@ -2,22 +2,16 @@ use async_trait::async_trait; -use crate::{L1HeadEvent, SourceError}; - -/// A source of L1 head events, streaming head updates as they arrive. +/// A source of L1 head block numbers, streaming head updates as they arrive. /// /// The batcher driver calls [`next`][L1HeadSource::next] in a loop to track /// L1 chain head advancement, enabling channel timeout detection. #[async_trait] pub trait L1HeadSource: Send { - /// Wait for the next L1 head event. - /// - /// Blocks (asynchronously) until a new L1 head block number is available. - /// Implementations are responsible for deduplicating redundant head updates — - /// if both a subscription and a poller deliver the same block number, only - /// one `NewHead` event is emitted. + /// Wait for the next L1 head block number. /// - /// [`SourceError::Closed`] stops L1 head tracking in the driver for good; any - /// other error is logged and the source polled again. - async fn next(&mut self) -> Result; + /// Blocks (asynchronously) until an L1 head block number is available, and never + /// resolves once nothing more will arrive. A repeated or lower number is harmless: + /// the pipeline ignores heads that do not advance. + async fn next(&mut self) -> u64; } diff --git a/crates/batcher/source/src/lib.rs b/crates/batcher/source/src/lib.rs index e68d24a5b6..910c3ca4a3 100644 --- a/crates/batcher/source/src/lib.rs +++ b/crates/batcher/source/src/lib.rs @@ -19,9 +19,6 @@ pub use traits::UnsafeBlockSource; mod polling; pub use polling::{PollingBlockSource, PollingSource}; -mod l1_event; -pub use l1_event::L1HeadEvent; - mod l1_source; pub use l1_source::L1HeadSource; diff --git a/crates/batcher/source/src/polling.rs b/crates/batcher/source/src/polling.rs index af6dae27bc..be1ec53150 100644 --- a/crates/batcher/source/src/polling.rs +++ b/crates/batcher/source/src/polling.rs @@ -71,7 +71,7 @@ where self.next_poll_at = self.clock.now().saturating_add(self.poll_interval); } - async fn next(&mut self) -> Result { + async fn next(&mut self) -> L2BlockEvent { let delay = self.next_poll_at.saturating_sub(self.clock.now()); if !delay.is_zero() { self.clock.sleep(delay).await; @@ -81,7 +81,7 @@ where let expected = self.tip.number.saturating_add(1); match self.poller.block_by_number(expected).await { Ok(block) if block.header.number == expected => { - return Ok(self.process(block)); + return self.process(block); } Ok(block) => { tracing::warn!( @@ -94,7 +94,6 @@ where Err(SourceError::Provider(error)) => { tracing::warn!(error = %error, "failed to poll next L2 block"); } - Err(error) => return Err(error), } self.next_poll_at = self.clock.now().saturating_add(self.poll_interval); @@ -157,7 +156,7 @@ mod tests { ); for expected in 1..=3 { - let L2BlockEvent::Block(block) = source.next().await.unwrap() else { + let L2BlockEvent::Block(block) = source.next().await else { panic!("expected block"); }; assert_eq!(block.header.number, expected); @@ -179,7 +178,7 @@ mod tests { Duration::from_secs(1), ); - assert!(matches!(source.next().await.unwrap(), L2BlockEvent::Reorg)); + assert!(matches!(source.next().await, L2BlockEvent::Reorg)); }); } @@ -215,7 +214,7 @@ mod tests { Duration::from_secs(1), ); - let L2BlockEvent::Block(block) = source.next().await.unwrap() else { + let L2BlockEvent::Block(block) = source.next().await else { panic!("expected block"); }; assert_eq!(block.header.number, 1); @@ -253,7 +252,7 @@ mod tests { _ = std::future::ready(()) => {} } - let L2BlockEvent::Block(block) = source.next().await.unwrap() else { + let L2BlockEvent::Block(block) = source.next().await else { panic!("expected block"); }; assert_eq!(block.header.number, 1); @@ -276,7 +275,7 @@ mod tests { ); source.reset_catchup(BlockInfo::from(&blocks[5])); - let L2BlockEvent::Block(block) = source.next().await.unwrap() else { + let L2BlockEvent::Block(block) = source.next().await else { panic!("expected block"); }; assert_eq!(block.header.number, 6); diff --git a/crates/batcher/source/src/test_utils/channel.rs b/crates/batcher/source/src/test_utils/channel.rs index 39b4afc0d3..adbdd406fc 100644 --- a/crates/batcher/source/src/test_utils/channel.rs +++ b/crates/batcher/source/src/test_utils/channel.rs @@ -3,14 +3,13 @@ use async_trait::async_trait; use tokio::sync::mpsc; -use crate::{L2BlockEvent, SourceError, UnsafeBlockSource}; +use crate::{L2BlockEvent, UnsafeBlockSource}; /// An [`UnsafeBlockSource`] backed by a `tokio::sync::mpsc` unbounded channel. /// /// Use [`ChannelBlockSource::new`] to obtain a `(source, sender)` pair. /// Events sent on the [`mpsc::UnboundedSender`] side are consumed by -/// [`UnsafeBlockSource::next`]. When all senders are dropped, `next` returns -/// [`SourceError::Closed`]. +/// [`UnsafeBlockSource::next`]. Once all senders are dropped, `next` parks forever. #[derive(Debug)] pub struct ChannelBlockSource { rx: mpsc::UnboundedReceiver, @@ -26,8 +25,11 @@ impl ChannelBlockSource { #[async_trait] impl UnsafeBlockSource for ChannelBlockSource { - async fn next(&mut self) -> Result { - self.rx.recv().await.ok_or(SourceError::Closed) + async fn next(&mut self) -> L2BlockEvent { + match self.rx.recv().await { + Some(event) => event, + None => std::future::pending().await, + } } } @@ -35,6 +37,7 @@ impl UnsafeBlockSource for ChannelBlockSource { mod tests { use alloy_primitives::B256; use base_common_consensus::BaseBlock; + use futures::FutureExt; use super::*; @@ -55,7 +58,7 @@ mod tests { let (mut source, tx) = ChannelBlockSource::new(); tx.send(L2BlockEvent::Block(Box::new(make_block(1)))).unwrap(); - let event = source.next().await.unwrap(); + let event = source.next().await; match event { L2BlockEvent::Block(b) => assert_eq!(b.header.number, 1), _ => panic!("expected Block event"), @@ -67,16 +70,7 @@ mod tests { let (mut source, tx) = ChannelBlockSource::new(); tx.send(L2BlockEvent::Reorg).unwrap(); - assert!(matches!(source.next().await.unwrap(), L2BlockEvent::Reorg)); - } - - #[tokio::test] - async fn closed_when_sender_dropped() { - let (mut source, tx) = ChannelBlockSource::new(); - drop(tx); - - let err = source.next().await.unwrap_err(); - assert!(matches!(err, SourceError::Closed)); + assert!(matches!(source.next().await, L2BlockEvent::Reorg)); } #[tokio::test] @@ -90,7 +84,7 @@ mod tests { tx.send(L2BlockEvent::Block(Box::new(make_block(42)))).unwrap(); }); - let event = source.next().await.unwrap(); + let event = source.next().await; match event { L2BlockEvent::Block(b) => assert_eq!(b.header.number, 42), _ => panic!("expected Block event"), @@ -106,14 +100,22 @@ mod tests { tx.send(L2BlockEvent::Block(Box::new(make_block(2)))).unwrap(); tx.send(L2BlockEvent::Reorg).unwrap(); - match source.next().await.unwrap() { + match source.next().await { L2BlockEvent::Block(b) => assert_eq!(b.header.number, 1), _ => panic!("expected Block(1)"), } - match source.next().await.unwrap() { + match source.next().await { L2BlockEvent::Block(b) => assert_eq!(b.header.number, 2), _ => panic!("expected Block(2)"), } - assert!(matches!(source.next().await.unwrap(), L2BlockEvent::Reorg)); + assert!(matches!(source.next().await, L2BlockEvent::Reorg)); + } + + #[tokio::test] + async fn parks_once_all_senders_are_dropped() { + let (mut source, tx) = ChannelBlockSource::new(); + drop(tx); + + assert!(source.next().now_or_never().is_none(), "a closed source must park"); } } diff --git a/crates/batcher/source/src/test_utils/l1_channel.rs b/crates/batcher/source/src/test_utils/l1_channel.rs index 805d955359..50a441128e 100644 --- a/crates/batcher/source/src/test_utils/l1_channel.rs +++ b/crates/batcher/source/src/test_utils/l1_channel.rs @@ -3,22 +3,21 @@ use async_trait::async_trait; use tokio::sync::mpsc; -use crate::{L1HeadEvent, L1HeadSource, SourceError}; +use crate::L1HeadSource; /// An [`L1HeadSource`] backed by a `tokio::sync::mpsc` unbounded channel. /// /// Use [`ChannelL1HeadSource::new`] to obtain a `(source, sender)` pair. -/// Events sent on the [`mpsc::UnboundedSender`] side are consumed by -/// [`L1HeadSource::next`]. When all senders are dropped, `next` returns -/// [`SourceError::Closed`]. +/// Head numbers sent on the [`mpsc::UnboundedSender`] side are consumed by +/// [`L1HeadSource::next`]. Once all senders are dropped, `next` parks forever. #[derive(Debug)] pub struct ChannelL1HeadSource { - rx: mpsc::UnboundedReceiver, + rx: mpsc::UnboundedReceiver, } impl ChannelL1HeadSource { /// Create a new channel L1 head source and its corresponding sender handle. - pub fn new() -> (Self, mpsc::UnboundedSender) { + pub fn new() -> (Self, mpsc::UnboundedSender) { let (tx, rx) = mpsc::unbounded_channel(); (Self { rx }, tx) } @@ -26,31 +25,26 @@ impl ChannelL1HeadSource { #[async_trait] impl L1HeadSource for ChannelL1HeadSource { - async fn next(&mut self) -> Result { - self.rx.recv().await.ok_or(SourceError::Closed) + async fn next(&mut self) -> u64 { + match self.rx.recv().await { + Some(head) => head, + None => std::future::pending().await, + } } } #[cfg(test)] mod tests { - use super::*; - - #[tokio::test] - async fn receive_l1_head_event() { - let (mut source, tx) = ChannelL1HeadSource::new(); - tx.send(L1HeadEvent::NewHead(42)).unwrap(); + use futures::FutureExt; - let event = source.next().await.unwrap(); - assert_eq!(event, L1HeadEvent::NewHead(42)); - } + use super::*; #[tokio::test] - async fn closed_when_sender_dropped() { + async fn receive_l1_head() { let (mut source, tx) = ChannelL1HeadSource::new(); - drop(tx); + tx.send(42).unwrap(); - let err = source.next().await.unwrap_err(); - assert!(matches!(err, SourceError::Closed)); + assert_eq!(source.next().await, 42); } #[tokio::test] @@ -59,23 +53,30 @@ mod tests { let handle = tokio::spawn(async move { tokio::task::yield_now().await; - tx.send(L1HeadEvent::NewHead(99)).unwrap(); + tx.send(99).unwrap(); }); - let event = source.next().await.unwrap(); - assert_eq!(event, L1HeadEvent::NewHead(99)); + assert_eq!(source.next().await, 99); handle.await.unwrap(); } #[tokio::test] async fn fifo_ordering() { let (mut source, tx) = ChannelL1HeadSource::new(); - tx.send(L1HeadEvent::NewHead(1)).unwrap(); - tx.send(L1HeadEvent::NewHead(2)).unwrap(); - tx.send(L1HeadEvent::NewHead(3)).unwrap(); + tx.send(1).unwrap(); + tx.send(2).unwrap(); + tx.send(3).unwrap(); + + assert_eq!(source.next().await, 1); + assert_eq!(source.next().await, 2); + assert_eq!(source.next().await, 3); + } + + #[tokio::test] + async fn parks_once_all_senders_are_dropped() { + let (mut source, tx) = ChannelL1HeadSource::new(); + drop(tx); - assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(1)); - assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(2)); - assert_eq!(source.next().await.unwrap(), L1HeadEvent::NewHead(3)); + assert!(source.next().now_or_never().is_none(), "a closed source must park"); } } diff --git a/crates/batcher/source/src/traits.rs b/crates/batcher/source/src/traits.rs index 686955df5c..b533d4fafd 100644 --- a/crates/batcher/source/src/traits.rs +++ b/crates/batcher/source/src/traits.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use base_protocol::BlockInfo; -use crate::{L2BlockEvent, SourceError}; +use crate::L2BlockEvent; /// A source of unsafe L2 blocks, streaming events as they arrive. /// @@ -13,10 +13,10 @@ use crate::{L2BlockEvent, SourceError}; pub trait UnsafeBlockSource: Send { /// Wait for the next L2 block event. /// - /// Blocks (asynchronously) until a new block or reorg is available. Any error is - /// fatal to the driver: implementations retry transient provider failures internally - /// and return [`SourceError::Closed`] only once nothing more will ever be delivered. - async fn next(&mut self) -> Result; + /// Blocks (asynchronously) until a new block or reorg is available, and never resolves + /// once nothing more will arrive. Implementations retry transient provider failures + /// internally. + async fn next(&mut self) -> L2BlockEvent; /// Reset the source to begin sequential catchup above `safe_head`. /// From f481d41f92c83e52a0945f724dc2cfacf2d84d07 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 24 Sep 2026 19:54:40 +0000 Subject: [PATCH 111/299] refactor(batcher): make every driver input mandatory (#5244) * refactor(batcher): make every driver input mandatory * fix(batcher): seed the pipeline's L1 head in the driver and give block tests a real block number * refactor(batcher): start the driver from a safe head rather than a derivation status * refactor(harness): start the batcher from BlockInfo::from_l2_genesis and keep the status sender in the driver task --- actions/harness/Cargo.toml | 2 +- actions/harness/src/batcher/actor.rs | 44 +- crates/batcher/core/README.md | 11 +- crates/batcher/core/src/admin.rs | 4 +- crates/batcher/core/src/config.rs | 6 + crates/batcher/core/src/driver.rs | 456 +++++++----------- crates/batcher/core/src/lib.rs | 2 +- crates/batcher/core/src/test_utils/builder.rs | 209 ++++++-- crates/batcher/core/src/test_utils/mod.rs | 2 +- .../batcher/core/src/test_utils/pipeline.rs | 16 +- crates/batcher/core/src/test_utils/source.rs | 6 +- crates/batcher/core/tests/heads.rs | 115 ++--- crates/batcher/core/tests/lifecycle.rs | 32 +- crates/batcher/core/tests/reorg.rs | 70 +-- crates/batcher/core/tests/stop_start.rs | 124 ++--- crates/batcher/core/tests/throttle.rs | 99 +--- crates/batcher/service/src/service.rs | 30 +- crates/consensus/protocol/src/block.rs | 6 + 18 files changed, 559 insertions(+), 675 deletions(-) diff --git a/actions/harness/Cargo.toml b/actions/harness/Cargo.toml index da61b89c87..6c897be9c2 100644 --- a/actions/harness/Cargo.toml +++ b/actions/harness/Cargo.toml @@ -50,7 +50,7 @@ base-node-core.workspace = true base-builder-core = { workspace = true, features = ["test-utils"] } base-test-utils.workspace = true base-tx-manager.workspace = true -base-batcher-core = { workspace = true, features = ["test-utils"] } +base-batcher-core.workspace = true base-common-chains.workspace = true base-execution-evm.workspace = true base-consensus-rpc.workspace = true diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 5d7ec30550..4c8bfe7123 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -3,13 +3,14 @@ use std::{sync::Arc, time::Duration}; use alloy_primitives::B256; use alloy_signer_local::PrivateKeySigner; use base_batcher_core::{ - AdminError, AdminHandle, BatchDriver, BatchDriverConfig, BatchDriverError, DaThrottle, - NoopThrottleClient, ThrottleController, + AdminError, AdminHandle, BatchDriver, BatchDriverConfig, BatchDriverError, BatchDriverInputs, + DaThrottle, NoopThrottleClient, ThrottleController, }; use base_batcher_encoder::{BatchEncoder, EncoderConfig}; use base_batcher_source::{L2BlockEvent, test_utils::ChannelL1HeadSource}; use base_common_consensus::BaseBlock; use base_common_genesis::RollupConfig; +use base_protocol::BlockInfo; use base_runtime::TokioRuntime; use base_tx_manager::TxManager; use tokio::sync::{mpsc, oneshot}; @@ -136,12 +137,16 @@ impl Batcher { /// Spawns the driver immediately. The driver will not process any events /// until the first [`advance`] call. /// + /// # Panics + /// + /// Panics if `config.encoder` is invalid, or if `config.batcher_address` is not the + /// address of `config.l1_signer`. + /// /// [`advance`]: Batcher::advance pub fn new(l2_source: S, rollup_config: &RollupConfig, config: BatcherConfig) -> Self { let l1_chain_id = rollup_config.l1_chain_id; - let rollup_config = Arc::new(rollup_config.clone()); - let pipeline = - BatchEncoder::new(rollup_config, config.encoder.clone()).expect("valid encoder config"); + let pipeline = BatchEncoder::new(Arc::new(rollup_config.clone()), config.encoder.clone()) + .expect("valid encoder config"); let (source, source_tx) = HarnessBlockSource::new(); let (admin, admin_rx) = AdminHandle::channel(); @@ -162,11 +167,11 @@ impl Batcher { let cancel = CancellationToken::new(); let runtime = TokioRuntime::with_token(cancel.clone()); - let throttle = ThrottleController::disabled(); - let driver = BatchDriver::new_without_derivation_status( + let (derivation_status_tx, derivation_status_rx) = mpsc::channel(1); + + let driver = BatchDriver::new( runtime, pipeline, - source, tx_manager.clone(), BatchDriverConfig { inbox: config.inbox_address, @@ -175,13 +180,26 @@ impl Batcher { max_pending_transactions: 16, drain_timeout: Duration::from_secs(10), force_blobs_when_throttling: true, + stopped: false, + }, + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), + BatchDriverInputs { + source, + l1_head_source: l1_source, + // The driver learns the L1 head from the blocks the tests mine. + initial_l1_head: 0, + initial_safe_head: BlockInfo::from_l2_genesis(&rollup_config.genesis), + derivation_status_rx, + admin_rx, }, - DaThrottle::new(throttle, Arc::new(NoopThrottleClient)), - l1_source, - ) - .with_admin_rx(admin_rx); + ); - let driver_task = tokio::spawn(async move { driver.run().await }); + // No action test exercises derivation status: the driver task keeps the sender, so + // the channel stays open, and silent, for as long as the driver runs. + let driver_task = tokio::spawn(async move { + let _derivation_status_tx = derivation_status_tx; + driver.run().await + }); Self { l2_source, source_tx, admin, tx_manager, driver_task, cancel } } diff --git a/crates/batcher/core/README.md b/crates/batcher/core/README.md index 6580a3a2d2..98141d2b30 100644 --- a/crates/batcher/core/README.md +++ b/crates/batcher/core/README.md @@ -5,14 +5,15 @@ 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), a `TxManager` (L1 submission), and a `ThrottleClient` (DA limit -application). Construction takes `BatchDriverHeads`, which seeds the pipeline from the live L1 -tip so channel duration is not measured 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. +application). Construction takes `BatchDriverInputs`: the sources the driver listens to and +the L1 head and safe L2 head 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. `BatchDriverConfig` carries the L1 inbox address, in-flight transaction limit, shutdown drain -timeout, and DA-throttle submission policy. +timeout, DA-throttle submission policy, and whether block ingestion starts stopped. `SubmissionQueue` owns the entire L1 submission lifecycle. It holds the `TxManager`, a `FuturesUnordered` set of in-flight receipt futures, a counting `Semaphore` for backpressure, and diff --git a/crates/batcher/core/src/admin.rs b/crates/batcher/core/src/admin.rs index ce443bcbbe..74df5e5ed2 100644 --- a/crates/batcher/core/src/admin.rs +++ b/crates/batcher/core/src/admin.rs @@ -87,8 +87,8 @@ pub enum AdminCommand { /// Cloneable handle to the driver's admin command channel. /// -/// Create with [`AdminHandle::channel`]; wire the returned -/// [`mpsc::Receiver`] into the driver via [`BatchDriver::with_admin_rx`]. +/// Create with [`AdminHandle::channel`]; hand the returned [`mpsc::Receiver`] to the +/// driver as [`BatchDriverInputs::admin_rx`](crate::BatchDriverInputs::admin_rx). #[derive(Clone, Debug)] pub struct AdminHandle { tx: mpsc::Sender, diff --git a/crates/batcher/core/src/config.rs b/crates/batcher/core/src/config.rs index 5cff3855c2..c7def3c1e6 100644 --- a/crates/batcher/core/src/config.rs +++ b/crates/batcher/core/src/config.rs @@ -21,4 +21,10 @@ pub struct BatchDriverConfig { /// No-op when the encoder is already configured for blob DA. /// Default: `true`. pub force_blobs_when_throttling: bool, + /// Start with block ingestion stopped, as if [`AdminCommand::Stop`] had been + /// received at startup; [`AdminCommand::Start`] resumes it. + /// + /// [`AdminCommand::Stop`]: crate::AdminCommand::Stop + /// [`AdminCommand::Start`]: crate::AdminCommand::Start + pub stopped: bool, } diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 3c27eb9393..7fd913b314 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -18,37 +18,21 @@ use crate::{ DerivationStatus, SubmissionQueue, ThrottleClient, ThrottleController, event::DriverEvent, }; -/// Initial L1 and derivation inputs consumed by a [`BatchDriver`]. +/// The sources a [`BatchDriver`] listens to, and the L1 head and safe L2 head it starts from. #[derive(Debug)] -pub struct BatchDriverHeads { +pub struct BatchDriverInputs { + /// Source of unsafe L2 blocks and reorg signals. + pub source: S, /// Source of live L1 head updates. - l1_head_source: L, - /// Live L1 head used to seed channel deadlines. - initial_l1_head: Option, - /// Initial derivation status and its ordered update stream. - derivation_feed: Option<(DerivationStatus, mpsc::Receiver)>, -} - -impl BatchDriverHeads { - /// Creates production head inputs from independent live and derivation clocks. - pub const fn new( - l1_head_source: L, - initial_l1_head: u64, - initial_status: DerivationStatus, - derivation_status_rx: mpsc::Receiver, - ) -> Self { - Self { - l1_head_source, - initial_l1_head: Some(initial_l1_head), - derivation_feed: Some((initial_status, derivation_status_rx)), - } - } - - /// Creates head inputs without derivation tracking for tests. - #[cfg(any(test, feature = "test-utils"))] - pub const fn without_derivation(l1_head_source: L) -> Self { - Self { l1_head_source, initial_l1_head: None, derivation_feed: None } - } + 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, + /// Ordered derivation-status updates. + pub derivation_status_rx: mpsc::Receiver, + /// Admin commands; see [`AdminHandle::channel`](crate::AdminHandle::channel). + pub admin_rx: mpsc::Receiver, } /// Async orchestration loop for the batcher. @@ -69,7 +53,7 @@ where TC: ThrottleClient, L: L1HeadSource, { - /// Runtime providing cancellation (and future clock/spawn use). + /// Runtime providing cancellation and the shutdown drain timer. runtime: R, /// The encoding pipeline. pipeline: P, @@ -82,16 +66,17 @@ where /// L1 head source for chain head advancement. l1_head_source: L, /// Last trusted L2 safe head. - safe_head: Option, + safe_head: BlockInfo, /// Ordered derivation-progress snapshots. - derivation_status_rx: Option>, + derivation_status_rx: mpsc::Receiver, /// Maximum wall-clock time to wait for in-flight submissions to settle /// when draining on cancellation. drain_timeout: Duration, /// Whether block ingestion is currently stopped (via admin or the `--stopped` flag). stopped: bool, - /// Admin command channel, wired in via [`Self::with_admin_rx`]. - admin_rx: Option>, + /// Admin command channel. Once every [`AdminHandle`](crate::AdminHandle) is dropped, the + /// arm goes quiet. + admin_rx: mpsc::Receiver, /// When `true`, the driver toggles a blob-DA override on the pipeline /// whenever DA-backlog throttling activates. Lifted from /// [`BatchDriverConfig::force_blobs_when_throttling`]. @@ -112,97 +97,39 @@ where /// starving receipt processing and cancellation checks. pub const STEP_BUDGET: usize = 128; - /// Create a [`BatchDriver`] from live L1 and derivation inputs. + /// Create a [`BatchDriver`]. /// - /// Advances the pipeline to the initial L1 tip before the event loop starts - /// so channel duration is measured from that tip, not from block 0. + /// Advances the pipeline to the initial L1 head, so channel duration is measured from + /// the live L1 tip rather than from block 0. pub fn new( runtime: R, mut pipeline: P, - source: S, tx_manager: TM, config: BatchDriverConfig, throttle: DaThrottle, - heads: BatchDriverHeads, + inputs: BatchDriverInputs, ) -> Self { - let (initial_status, derivation_status_rx) = heads.derivation_feed.unzip(); - if let Some(initial_l1_head) = heads.initial_l1_head { - pipeline.advance_l1_head(initial_l1_head); - } + pipeline.advance_l1_head(inputs.initial_l1_head); Self { runtime, pipeline, - source, + source: inputs.source, submissions: SubmissionQueue::new( tx_manager, config.inbox, config.max_pending_transactions, ), throttle, - l1_head_source: heads.l1_head_source, - safe_head: initial_status.map(|status| status.safe_l2), - derivation_status_rx, + l1_head_source: inputs.l1_head_source, + safe_head: inputs.initial_safe_head, + derivation_status_rx: inputs.derivation_status_rx, drain_timeout: config.drain_timeout, - stopped: false, - admin_rx: None, + stopped: config.stopped, + admin_rx: inputs.admin_rx, force_blobs_when_throttling: config.force_blobs_when_throttling, } } - /// Create a driver without derivation-status tracking for tests that do not exercise it. - #[cfg(any(test, feature = "test-utils"))] - pub fn new_without_derivation_status( - runtime: R, - pipeline: P, - source: S, - tx_manager: TM, - config: BatchDriverConfig, - throttle: DaThrottle, - l1_head_source: L, - ) -> Self { - Self::new( - runtime, - pipeline, - source, - tx_manager, - config, - throttle, - BatchDriverHeads::without_derivation(l1_head_source), - ) - } - - /// Attach a derivation-status feed to a test driver created without one. - #[cfg(any(test, feature = "test-utils"))] - pub fn with_derivation_status_rx( - mut self, - initial: DerivationStatus, - rx: mpsc::Receiver, - ) -> Self { - self.safe_head = Some(initial.safe_l2); - self.derivation_status_rx = Some(rx); - self - } - - /// Wire an admin command channel into the driver. - /// - /// When set, the driver processes admin commands as part of its main - /// `select!` loop. When absent, the admin arm is permanently pending and - /// the driver behaves as if no admin server is configured. - pub fn with_admin_rx(mut self, rx: mpsc::Receiver) -> Self { - self.admin_rx = Some(rx); - self - } - - /// Start the driver in a stopped state, deferring block ingestion until - /// [`AdminCommand::Start`] is received via the admin API. - /// - /// Equivalent to the batcher starting normally and immediately receiving - /// a stop command. Use this when the `--stopped` flag is set at startup. - pub const fn with_stopped(mut self, stopped: bool) -> Self { - self.stopped = stopped; - self - } - /// Run the batch driver loop. /// /// Each iteration has two phases: @@ -323,21 +250,18 @@ where /// Reset volatile state and restart delivery above the latest safe head. fn reset_to_safe_head(&mut self, reason: &'static str) { self.reset_pipeline(reason); - - if let Some(safe_head) = self.safe_head { - self.source.reset_catchup(safe_head); - } + self.source.reset_catchup(self.safe_head); } /// Reconcile buffered state with an ordered derivation-progress snapshot. fn on_derivation_status(&mut self, status: DerivationStatus) { let head = status.safe_l2; - let previous = self.safe_head.replace(head); + let previous = self.safe_head; + self.safe_head = head; - if let Some(previous) = previous.filter(|previous| { - head.number < previous.number - || (head.number == previous.number && head.hash != previous.hash) - }) { + if head.number < previous.number + || (head.number == previous.number && head.hash != previous.hash) + { warn!( previous_safe_l2 = %previous.number, previous_safe_hash = %previous.hash, @@ -376,11 +300,7 @@ where /// Apply derivation-status updates that arrived before the next CPU phase. fn apply_pending_derivation_status_updates(&mut self) -> Result<(), BatchDriverError> { loop { - let Some(rx) = self.derivation_status_rx.as_mut() else { - return Ok(()); - }; - - match rx.try_recv() { + match self.derivation_status_rx.try_recv() { Ok(status) => self.on_derivation_status(status), Err(mpsc::error::TryRecvError::Empty) => return Ok(()), Err(mpsc::error::TryRecvError::Disconnected) if self.runtime.is_cancelled() => { @@ -400,7 +320,7 @@ where /// The triggering block will be re-delivered by the sequential poller. fn on_block(&mut self, block: Box) { let number = block.header.number; - if self.safe_head.is_some_and(|safe_head| number <= safe_head.number) { + if number <= self.safe_head.number { return; } @@ -441,16 +361,12 @@ where return; } - if let Some(safe_head) = self.safe_head { - self.source.reset_catchup(safe_head); - info!( - stopped = false, - safe_l2 = %safe_head.number, - "batcher started via admin, catching up from safe head" - ); - } else { - info!(stopped = false, "batcher started via admin"); - } + self.source.reset_catchup(self.safe_head); + info!( + stopped = false, + safe_l2 = %self.safe_head.number, + "batcher started via admin, catching up from safe head" + ); self.stopped = false; } @@ -476,7 +392,7 @@ where _ = self.runtime.cancelled() => DriverEvent::Shutdown, - cmd = Self::next_admin_cmd(&mut self.admin_rx) => { + Some(cmd) = self.admin_rx.recv() => { match cmd { AdminCommand::Flush { reply } if self.stopped => { let _ = reply.send(Err(AdminError::Stopped)); @@ -519,18 +435,10 @@ where continue; } - derivation_status = async { - if let Some(ref mut rx) = self.derivation_status_rx { - rx.recv().await - } else { - std::future::pending::>().await - } - } => { - match derivation_status { - Some(status) => DriverEvent::DerivationStatus(status), - None => return Err(BatchDriverError::DerivationStatusSourceClosed), - } - } + status = self.derivation_status_rx.recv() => match status { + Some(status) => DriverEvent::DerivationStatus(status), + None => return Err(BatchDriverError::DerivationStatusSourceClosed), + }, event = self.source.next() => match event { L2BlockEvent::Block(_) if self.stopped => continue, @@ -547,20 +455,6 @@ where return Ok(event); } } - - /// Returns the next admin command, or parks forever if no channel is wired. - /// - /// Takes only the `Option` to avoid a full `&mut self` borrow - /// conflicting with the other `select!` arms. - async fn next_admin_cmd(rx: &mut Option>) -> AdminCommand { - match rx { - Some(rx) => match rx.recv().await { - Some(cmd) => cmd, - None => std::future::pending().await, - }, - None => std::future::pending().await, - } - } } #[cfg(test)] @@ -591,7 +485,7 @@ mod tests { use tokio::sync::{mpsc, oneshot}; use crate::{ - AdminCommand, BatchDriver, BatchDriverConfig, BatchDriverHeads, DaThrottle, + AdminCommand, BatchDriver, BatchDriverConfig, BatchDriverInputs, DaThrottle, DerivationStatus, NoopThrottleClient, ThrottleController, event::DriverEvent, test_utils::{ @@ -648,32 +542,35 @@ mod tests { BlockInfo { hash: B256::with_last_byte(number as u8), number, ..Default::default() } } + /// The pipeline starts from the live L1 head, so channel duration is not measured from + /// block 0. #[test] fn new_driver_seeds_pipeline_from_live_l1_head() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let (_admin_tx, admin_rx) = mpsc::channel(1); let (_status_tx, status_rx) = mpsc::channel(1); - let status = DerivationStatus::new(safe_head(10), safe_head(42)); let _driver = BatchDriver::new( ctx, - pipeline, - QueuedSource::new(std::iter::empty()), + TrackingPipeline::new(Arc::clone(&recorded)), NeverConfirmTxManager, BatchDriverConfig { inbox: Address::ZERO, max_pending_transactions: 1, drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, + stopped: false, }, DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - BatchDriverHeads::new( - QueuedL1HeadSource::new(std::iter::empty()), - 50, - status, - status_rx, - ), + BatchDriverInputs { + source: QueuedSource::new([]), + l1_head_source: QueuedL1HeadSource::new([]), + initial_l1_head: 50, + initial_safe_head: safe_head(10), + derivation_status_rx: status_rx, + admin_rx, + }, ); assert_eq!(recorded.lock().unwrap().l1_heads, vec![50]); @@ -729,33 +626,52 @@ mod tests { } } - fn driver_for_next_event( - runtime: R, - source_events: impl IntoIterator, - l1_heads: impl IntoIterator, - tx_manager: TM, - ) -> BatchDriver< + /// The channels a [`driver_for_next_event`] driver listens to, kept by the test. + struct EventChannels { + admin_tx: mpsc::Sender, + status_tx: mpsc::Sender, + } + + type EventDriver = BatchDriver< R, TrackingPipeline, QueuedSource, TM, Arc, QueuedL1HeadSource, - > { - BatchDriver::new_without_derivation_status( + >; + + /// A driver whose source and L1 head source deliver the given events, then park. + fn driver_for_next_event( + runtime: R, + source_events: impl IntoIterator, + l1_heads: impl IntoIterator, + tx_manager: TM, + ) -> (EventDriver, EventChannels) { + let (admin_tx, admin_rx) = mpsc::channel(1); + let (status_tx, status_rx) = mpsc::channel(1); + let driver = BatchDriver::new( runtime, TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))), - QueuedSource::new(source_events), tx_manager, BatchDriverConfig { inbox: Address::ZERO, max_pending_transactions: 1, drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, + stopped: false, }, DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - QueuedL1HeadSource::new(l1_heads), - ) + BatchDriverInputs { + source: QueuedSource::new(source_events), + l1_head_source: QueuedL1HeadSource::new(l1_heads), + initial_l1_head: 0, + initial_safe_head: safe_head(0), + derivation_status_rx: status_rx, + admin_rx, + }, + ); + (driver, EventChannels { admin_tx, status_tx }) } #[derive(Debug, Default)] @@ -844,20 +760,18 @@ mod tests { #[test] fn next_event_prioritizes_cancellation_over_ready_admin() { Runner::start(Config::seeded(0), |ctx| async move { - let (admin_tx, admin_rx) = mpsc::channel(1); - let (reply, _reply_rx) = oneshot::channel(); - admin_tx - .send(AdminCommand::Flush { reply }) - .await - .expect("admin receiver should be open"); - - let mut driver = driver_for_next_event( + let (mut driver, channels) = driver_for_next_event( ctx.clone(), [L2BlockEvent::Block(Box::default())], [9], ImmediateConfirmTxManager { l1_block: 1 }, - ) - .with_admin_rx(admin_rx); + ); + let (reply, _reply_rx) = oneshot::channel(); + channels + .admin_tx + .send(AdminCommand::Flush { reply }) + .await + .expect("admin receiver should be open"); ctx.cancel(); @@ -869,20 +783,18 @@ mod tests { #[test] fn next_event_prioritizes_admin_before_source() { Runner::start(Config::seeded(0), |ctx| async move { - let (admin_tx, admin_rx) = mpsc::channel(1); - let (reply, _reply_rx) = oneshot::channel(); - admin_tx - .send(AdminCommand::Flush { reply }) - .await - .expect("admin receiver should be open"); - - let mut driver = driver_for_next_event( + let (mut driver, channels) = driver_for_next_event( ctx, [L2BlockEvent::Block(Box::default())], [9], ImmediateConfirmTxManager { l1_block: 1 }, - ) - .with_admin_rx(admin_rx); + ); + let (reply, _reply_rx) = oneshot::channel(); + channels + .admin_tx + .send(AdminCommand::Flush { reply }) + .await + .expect("admin receiver should be open"); let event = driver.next_event().await.expect("next_event should succeed"); assert!(matches!(event, DriverEvent::Flush(_))); @@ -892,14 +804,12 @@ mod tests { #[test] fn next_event_prioritizes_source_before_receipts_and_heads() { Runner::start(Config::seeded(0), |ctx| async move { - let (_status_tx, status_rx) = mpsc::channel(1); - let mut driver = driver_for_next_event( + let (mut driver, _channels) = driver_for_next_event( ctx, [L2BlockEvent::Block(Box::default())], [9], ImmediateConfirmTxManager { l1_block: 1 }, - ) - .with_derivation_status_rx(DerivationStatus::from_safe_l2(safe_head(0)), status_rx); + ); driver.pipeline.submissions.push_back(SubmissionStub::stub()); driver.submissions.submit_pending(&mut driver.pipeline).await; @@ -911,19 +821,17 @@ mod tests { #[test] fn next_event_prioritizes_derivation_status_before_source_and_receipts() { Runner::start(Config::seeded(0), |ctx| async move { - let (status_tx, status_rx) = mpsc::channel(1); - status_tx - .send(DerivationStatus::from_safe_l2(safe_head(5))) - .await - .expect("derivation-status receiver should be open"); - - let mut driver = driver_for_next_event( + let (mut driver, channels) = driver_for_next_event( ctx, [L2BlockEvent::Block(Box::default())], [9], ImmediateConfirmTxManager { l1_block: 42 }, - ) - .with_derivation_status_rx(DerivationStatus::from_safe_l2(safe_head(0)), status_rx); + ); + channels + .status_tx + .send(DerivationStatus::from_safe_l2(safe_head(5))) + .await + .expect("derivation-status receiver should be open"); driver.pipeline.submissions.push_back(SubmissionStub::stub()); driver.submissions.submit_pending(&mut driver.pipeline).await; @@ -938,19 +846,14 @@ mod tests { #[test] fn next_event_prioritizes_derivation_status_before_l1_head() { Runner::start(Config::seeded(0), |ctx| async move { - let (status_tx, status_rx) = mpsc::channel(1); - status_tx + let (mut driver, channels) = + driver_for_next_event(ctx, [], [9], ImmediateConfirmTxManager { l1_block: 1 }); + channels + .status_tx .send(DerivationStatus::from_safe_l2(safe_head(5))) .await .expect("derivation-status receiver should be open"); - let mut driver = - driver_for_next_event(ctx, [], [9], ImmediateConfirmTxManager { l1_block: 1 }) - .with_derivation_status_rx( - DerivationStatus::from_safe_l2(safe_head(0)), - status_rx, - ); - let event = driver.next_event().await.expect("next_event should succeed"); assert!(matches!( event, @@ -968,14 +871,13 @@ mod tests { let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); pipeline.submissions.push_back(SubmissionStub::stub()); - let handle = ctx.spawn( - DriverFixture::build( - ctx.clone(), - pipeline, - ImmediateConfirmTxManager { l1_block: 42 }, - ) - .run(), - ); + let (driver, _handles) = DriverFixture::new( + ctx.clone(), + pipeline, + ImmediateConfirmTxManager { l1_block: 42 }, + ) + .build(); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -998,8 +900,9 @@ mod tests { let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); pipeline.submissions.push_back(SubmissionStub::stub()); - let handle = ctx - .spawn(DriverFixture::build(ctx.clone(), pipeline, ImmediateFailTxManager).run()); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateFailTxManager).build(); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -1033,14 +936,13 @@ mod tests { })])], )); - let handle = ctx.spawn( - DriverFixture::build( - ctx.clone(), - pipeline, - ImmediateConfirmTxManager { l1_block: 1 }, - ) - .run(), - ); + let (driver, _handles) = DriverFixture::new( + ctx.clone(), + pipeline, + ImmediateConfirmTxManager { l1_block: 1 }, + ) + .build(); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -1071,15 +973,14 @@ mod tests { pipeline.submissions.push_back(SubmissionStub::with_id(0)); pipeline.submissions.push_back(SubmissionStub::with_id(1)); - let handle = ctx.spawn( - DriverFixture::build_with_max_pending( - ctx.clone(), - pipeline, - RecordingConfirmTxManager { l1_block: 10, candidates: Arc::clone(&candidates) }, - 2, - ) - .run(), - ); + let (driver, _handles) = DriverFixture::new( + ctx.clone(), + pipeline, + RecordingConfirmTxManager { l1_block: 10, candidates: Arc::clone(&candidates) }, + ) + .max_pending(2) + .build(); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -1088,8 +989,8 @@ mod tests { let recorded = recorded.lock().unwrap(); assert_eq!(recorded.dequeued.len(), 2, "both submissions must be dequeued"); assert_eq!( - recorded.l1_heads, - vec![10, 10], + recorded.confirmed, + vec![SubmissionId(0), SubmissionId(1)], "each pipeline submission should produce its own confirmation" ); assert_eq!( @@ -1119,14 +1020,13 @@ mod tests { .collect(); pipeline.submissions.push_back(submission); - let handle = ctx.spawn( - DriverFixture::build( - ctx.clone(), - pipeline, - RecordingConfirmTxManager { l1_block: 10, candidates: Arc::clone(&candidates) }, - ) - .run(), - ); + let (driver, _handles) = DriverFixture::new( + ctx.clone(), + pipeline, + RecordingConfirmTxManager { l1_block: 10, candidates: Arc::clone(&candidates) }, + ) + .build(); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -1139,9 +1039,9 @@ mod tests { "multi-frame blob submission must not be requeued by blob encoding" ); assert_eq!( - recorded.l1_heads, - vec![10], - "multi-frame blob submission should confirm in one L1 tx" + recorded.confirmed, + vec![SubmissionId(0)], + "multi-frame blob submission should confirm once" ); let candidates = candidates.lock().unwrap(); assert_eq!(candidates.len(), 1, "multi-frame submission should use one L1 tx"); @@ -1167,15 +1067,11 @@ mod tests { pipeline.submissions.push_back(blob_filling_submission(0)); pipeline.submissions.push_back(blob_filling_submission(1)); - let handle = ctx.spawn( - DriverFixture::build_with_max_pending( - ctx.clone(), - pipeline, - NeverConfirmTxManager, - 1, - ) - .run(), - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager) + .max_pending(1) + .build(); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -1200,23 +1096,22 @@ mod tests { pipeline.submissions.push_back(blob_filling_submission(1)); pipeline.submissions.push_back(blob_filling_submission(2)); - let handle = ctx.spawn( - DriverFixture::build_with_max_pending( - ctx.clone(), - pipeline, - ImmediateConfirmTxManager { l1_block: 7 }, - 1, - ) - .run(), - ); + let (driver, _handles) = DriverFixture::new( + ctx.clone(), + pipeline, + ImmediateConfirmTxManager { l1_block: 7 }, + ) + .max_pending(1) + .build(); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); assert_eq!( - recorded.lock().unwrap().l1_heads, - vec![7, 7, 7], + recorded.lock().unwrap().confirmed, + vec![SubmissionId(0), SubmissionId(1), SubmissionId(2)], "each queued submission must confirm as permits are freed" ); }); @@ -1235,7 +1130,8 @@ mod tests { let state = Arc::new(TxpoolBlockedState::default()); let tx_manager = TxpoolBlockedOnceTxManager { state: Arc::clone(&state) }; - let handle = ctx.spawn(DriverFixture::build(ctx.clone(), pipeline, tx_manager).run()); + let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, tx_manager).build(); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); diff --git a/crates/batcher/core/src/lib.rs b/crates/batcher/core/src/lib.rs index d1bae3537a..7ca14d6fe1 100644 --- a/crates/batcher/core/src/lib.rs +++ b/crates/batcher/core/src/lib.rs @@ -39,7 +39,7 @@ pub use admin::{ }; mod driver; -pub use driver::{BatchDriver, BatchDriverHeads}; +pub use driver::{BatchDriver, BatchDriverInputs}; #[cfg(any(test, feature = "test-utils"))] pub mod test_utils; diff --git a/crates/batcher/core/src/test_utils/builder.rs b/crates/batcher/core/src/test_utils/builder.rs index e4998227a9..5c4b92dd99 100644 --- a/crates/batcher/core/src/test_utils/builder.rs +++ b/crates/batcher/core/src/test_utils/builder.rs @@ -1,18 +1,35 @@ -//! Factory helpers for constructing test [`BatchDriver`] instances and [`BatchSubmission`] stubs. +//! Builder for test [`BatchDriver`] instances, and block and [`BatchSubmission`] stubs. use std::{sync::Arc, time::Duration}; +use alloy_consensus::Header; use alloy_primitives::Address; -use base_batcher_encoder::{BatchSubmission, BlobPayload, SubmissionId}; -use base_protocol::Frame; +use base_batcher_encoder::{BatchPipeline, BatchSubmission, BlobPayload, SubmissionId}; +use base_batcher_source::{L1HeadSource, UnsafeBlockSource}; +use base_common_consensus::BaseBlock; +use base_protocol::{BlockInfo, Frame}; use base_runtime::Runtime; use base_tx_manager::TxManager; +use tokio::sync::mpsc; use crate::{ - BatchDriver, BatchDriverConfig, DaThrottle, NoopThrottleClient, ThrottleController, - test_utils::{PendingL1HeadSource, PendingSource, TrackingPipeline}, + AdminHandle, BatchDriver, BatchDriverConfig, BatchDriverInputs, DaThrottle, DerivationStatus, + NoopThrottleClient, ThrottleClient, ThrottleController, + test_utils::{PendingL1HeadSource, PendingSource}, }; +/// Factory for empty L2 block stubs used in driver tests. +#[derive(Debug)] +pub struct BlockStub; + +impl BlockStub { + /// Returns an empty block with the given number. Pick one above the driver's safe head, + /// or the driver drops it as already safe. + pub fn with_number(number: u64) -> BaseBlock { + BaseBlock { header: Header { number, ..Default::default() }, body: Default::default() } + } +} + /// Factory methods for [`BatchSubmission`] stubs used in driver tests. #[derive(Debug)] pub struct SubmissionStub; @@ -32,55 +49,157 @@ impl SubmissionStub { } } -/// Factory methods for [`BatchDriver`] instances wired with standard test -/// components: [`PendingSource`], [`NoopThrottleClient`], and [`PendingL1HeadSource`]. +/// Builds a [`BatchDriver`] for tests, with a parked source, a parked L1 head source, a +/// disabled throttle and at most one in-flight transaction unless told otherwise. +/// +/// The driver starts from L1 head 0 and, unless [`safe_head`](Self::safe_head) says +/// otherwise, from the L2 genesis (block 0) as safe head, 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: +/// dropping the derivation sender is fatal to the driver, dropping the admin handle silences +/// its admin arm. #[derive(Debug)] -pub struct DriverFixture; - -impl DriverFixture { - /// Build a driver with `max_pending_transactions = 1`. - pub fn build( - runtime: R, - pipeline: TrackingPipeline, - tx_manager: TM, - ) -> BatchDriver< - R, - TrackingPipeline, - PendingSource, - TM, - Arc, - PendingL1HeadSource, - > { - Self::build_with_max_pending(runtime, pipeline, tx_manager, 1) - } +pub struct DriverFixture< + R, + P, + TM, + S = PendingSource, + L = PendingL1HeadSource, + TC = Arc, +> where + TC: ThrottleClient, +{ + runtime: R, + pipeline: P, + tx_manager: TM, + source: S, + l1_head_source: L, + throttle: DaThrottle, + max_pending: usize, + safe_head: BlockInfo, +} - /// Build a driver with a configurable `max_pending_transactions` limit. - pub fn build_with_max_pending( - runtime: R, - pipeline: TrackingPipeline, - tx_manager: TM, - max_pending: usize, - ) -> BatchDriver< - R, - TrackingPipeline, - PendingSource, - TM, - Arc, - PendingL1HeadSource, - > { - BatchDriver::new_without_derivation_status( +/// The sending sides of a fixture-built driver's channels. +#[derive(Debug)] +pub struct DriverHandles { + /// Admin commands. + pub admin: AdminHandle, + /// Derivation-status updates. + pub derivation_status_tx: mpsc::Sender, +} + +impl DriverFixture { + /// Start a fixture around the three components every driver test provides. + pub fn new(runtime: R, pipeline: P, tx_manager: TM) -> Self { + Self { runtime, pipeline, - PendingSource, tx_manager, + source: PendingSource, + l1_head_source: PendingL1HeadSource, + throttle: DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), + max_pending: 1, + safe_head: BlockInfo::default(), + } + } +} + +impl DriverFixture +where + R: Runtime, + P: BatchPipeline, + TM: TxManager, + S: UnsafeBlockSource, + L: L1HeadSource, + TC: ThrottleClient, +{ + /// Replace the L2 block source. + pub fn source(self, source: S2) -> DriverFixture { + DriverFixture { + runtime: self.runtime, + pipeline: self.pipeline, + tx_manager: self.tx_manager, + source, + l1_head_source: self.l1_head_source, + throttle: self.throttle, + max_pending: self.max_pending, + safe_head: self.safe_head, + } + } + + /// Replace the L1 head source. + pub fn l1_head_source( + self, + l1_head_source: L2, + ) -> DriverFixture { + DriverFixture { + runtime: self.runtime, + pipeline: self.pipeline, + tx_manager: self.tx_manager, + source: self.source, + l1_head_source, + throttle: self.throttle, + max_pending: self.max_pending, + safe_head: self.safe_head, + } + } + + /// Replace the DA throttle. + pub fn throttle( + self, + throttle: DaThrottle, + ) -> DriverFixture { + DriverFixture { + runtime: self.runtime, + pipeline: self.pipeline, + tx_manager: self.tx_manager, + source: self.source, + l1_head_source: self.l1_head_source, + throttle, + max_pending: self.max_pending, + safe_head: self.safe_head, + } + } + + /// Set `max_pending_transactions`. + pub const fn max_pending(mut self, max_pending: usize) -> Self { + self.max_pending = max_pending; + 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; + self + } + + /// Build the driver and the handles that feed it. + pub fn build(self) -> (BatchDriver, DriverHandles) { + let (admin, admin_rx) = AdminHandle::channel(); + let (derivation_status_tx, derivation_status_rx) = mpsc::channel(1); + let driver = BatchDriver::new( + self.runtime, + self.pipeline, + self.tx_manager, BatchDriverConfig { inbox: Address::ZERO, - max_pending_transactions: max_pending, + max_pending_transactions: self.max_pending, drain_timeout: Duration::from_millis(10), force_blobs_when_throttling: true, + stopped: false, }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - PendingL1HeadSource, - ) + self.throttle, + BatchDriverInputs { + source: self.source, + l1_head_source: self.l1_head_source, + initial_l1_head: 0, + initial_safe_head: self.safe_head, + derivation_status_rx, + admin_rx, + }, + ); + (driver, DriverHandles { admin, derivation_status_tx }) } } diff --git a/crates/batcher/core/src/test_utils/mod.rs b/crates/batcher/core/src/test_utils/mod.rs index 4d817c2ad0..f21b92348b 100644 --- a/crates/batcher/core/src/test_utils/mod.rs +++ b/crates/batcher/core/src/test_utils/mod.rs @@ -10,7 +10,7 @@ mod source; pub use source::{OneBlockSource, PendingL1HeadSource, PendingSource, TrackingSource}; mod builder; -pub use builder::{DriverFixture, SubmissionStub}; +pub use builder::{BlockStub, DriverFixture, DriverHandles, SubmissionStub}; mod tx_manager; pub use tx_manager::{ diff --git a/crates/batcher/core/src/test_utils/pipeline.rs b/crates/batcher/core/src/test_utils/pipeline.rs index bcaf124d7d..879cc04e74 100644 --- a/crates/batcher/core/src/test_utils/pipeline.rs +++ b/crates/batcher/core/src/test_utils/pipeline.rs @@ -13,8 +13,13 @@ use base_protocol::BlockInfo; /// Shared recording state populated by the test pipeline implementations. #[derive(Debug, Default)] pub struct Recorded { - /// L1 block numbers passed to `advance_l1_head` in order. + /// L1 heads the pipeline advanced to, in order. Like [`BatchEncoder`], the pipeline + /// ignores a head that does not advance. + /// + /// [`BatchEncoder`]: base_batcher_encoder::BatchEncoder pub l1_heads: Vec, + /// Submission IDs passed to `confirm` in order. + pub confirmed: Vec, /// Submission IDs passed to `requeue` in order. pub requeued: Vec, /// Submission IDs dequeued via `next_submission` in order. @@ -100,7 +105,9 @@ impl BatchPipeline for TrackingPipeline { Some(sub) } - fn confirm(&mut self, _: SubmissionId, _: u64) {} + fn confirm(&mut self, id: SubmissionId, _: u64) { + self.recorded.lock().unwrap().confirmed.push(id); + } fn requeue(&mut self, id: SubmissionId) { self.recorded.lock().unwrap().requeued.push(id); @@ -115,7 +122,10 @@ impl BatchPipeline for TrackingPipeline { } fn advance_l1_head(&mut self, l1_block: u64) { - self.recorded.lock().unwrap().l1_heads.push(l1_block); + let mut recorded = self.recorded.lock().unwrap(); + if l1_block > recorded.l1_heads.last().copied().unwrap_or_default() { + recorded.l1_heads.push(l1_block); + } } fn reconcile_derivation( diff --git a/crates/batcher/core/src/test_utils/source.rs b/crates/batcher/core/src/test_utils/source.rs index 1020694079..1efc2bf564 100644 --- a/crates/batcher/core/src/test_utils/source.rs +++ b/crates/batcher/core/src/test_utils/source.rs @@ -6,6 +6,8 @@ use async_trait::async_trait; use base_batcher_source::{L1HeadSource, L2BlockEvent, UnsafeBlockSource}; use base_protocol::BlockInfo; +use crate::test_utils::BlockStub; + /// [`UnsafeBlockSource`] that parks the select arm forever. /// /// Use this in tests that do not exercise the block-delivery path, so that @@ -46,7 +48,7 @@ impl UnsafeBlockSource for TrackingSource { } } -/// [`UnsafeBlockSource`] that delivers exactly one default block then parks forever. +/// [`UnsafeBlockSource`] that delivers exactly one block, numbered 1, then parks forever. #[derive(Debug)] pub struct OneBlockSource { delivered: bool, @@ -70,7 +72,7 @@ impl UnsafeBlockSource for OneBlockSource { async fn next(&mut self) -> L2BlockEvent { if !self.delivered { self.delivered = true; - L2BlockEvent::Block(Box::default()) + L2BlockEvent::Block(Box::new(BlockStub::with_number(1))) } else { std::future::pending().await } diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index 9601beb9ae..90b0ff59df 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -5,13 +5,12 @@ use std::{ time::Duration, }; -use alloy_primitives::{Address, B256}; +use alloy_primitives::B256; use base_batcher_core::{ - BatchDriver, BatchDriverConfig, BatchDriverError, BatchDriverHeads, DaThrottle, - DerivationStatus, NoopThrottleClient, ThrottleController, + BatchDriverError, DerivationStatus, test_utils::{ - DriverFixture, ImmediateConfirmTxManager, PendingL1HeadSource, PendingSource, Recorded, - SubmissionStub, TrackingPipeline, TrackingSource, + DriverFixture, ImmediateConfirmTxManager, Recorded, SubmissionStub, TrackingPipeline, + TrackingSource, }, }; use base_batcher_source::test_utils::ChannelL1HeadSource; @@ -20,7 +19,6 @@ use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, }; -use tokio::sync::mpsc; fn safe_head(number: u64) -> BlockInfo { BlockInfo { hash: B256::with_last_byte(number as u8), number, ..Default::default() } @@ -36,20 +34,10 @@ fn test_l1_head_source_advances_pipeline() { let (l1_source, l1_tx) = ChannelL1HeadSource::new(); - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - PendingSource, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - l1_source, - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .l1_head_source(l1_source) + .build(); let handle = ctx.spawn(driver.run()); // Send a new L1 head via the channel. @@ -72,29 +60,15 @@ fn test_safe_head_conflicts_reset_pipeline_and_source() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_safe_head_match(false); - let (status_tx, status_rx) = mpsc::channel(1); let (source, catchup_heads) = TrackingSource::new(); - let driver = BatchDriver::new( - ctx.clone(), - pipeline, - source, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - BatchDriverHeads::new( - PendingL1HeadSource, - 0, - DerivationStatus::from_safe_l2(safe_head(10)), - status_rx, - ), - ); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .source(source) + .safe_head(safe_head(10)) + .build(); let handle = ctx.spawn(driver.run()); + let status_tx = handles.derivation_status_tx; let regressed = safe_head(5); let replacement = @@ -118,30 +92,16 @@ fn test_derivation_cursor_advance_replays_stalled_channel() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_derivation_stalled(true); - let (status_tx, status_rx) = mpsc::channel(1); let (source, catchup_heads) = TrackingSource::new(); let safe_l2 = safe_head(10); - let driver = BatchDriver::new( - ctx.clone(), - pipeline, - source, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - BatchDriverHeads::new( - PendingL1HeadSource, - 0, - DerivationStatus::from_safe_l2(safe_l2), - status_rx, - ), - ); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .source(source) + .safe_head(safe_l2) + .build(); let handle = ctx.spawn(driver.run()); + let status_tx = handles.derivation_status_tx; status_tx.send(DerivationStatus::new(safe_l2, safe_head(50))).await.unwrap(); ctx.sleep(Duration::from_millis(50)).await; @@ -161,15 +121,16 @@ fn test_queued_safe_head_preempts_submission() { let recorded = Arc::new(Mutex::new(Recorded::default())); let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); pipeline.submissions.push_back(SubmissionStub::stub()); - let (status_tx, status_rx) = mpsc::channel(1); - status_tx.send(DerivationStatus::from_safe_l2(safe_head(5))).await.unwrap(); - - let driver = - DriverFixture::build(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .with_derivation_status_rx( - DerivationStatus::from_safe_l2(safe_head(10)), - status_rx, - ); + + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .safe_head(safe_head(10)) + .build(); + handles + .derivation_status_tx + .send(DerivationStatus::from_safe_l2(safe_head(5))) + .await + .unwrap(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -186,10 +147,9 @@ fn test_derivation_status_sender_drop_is_fatal() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - let (status_tx, status_rx) = mpsc::channel(1); - let driver = DriverFixture::build(ctx, pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .with_derivation_status_rx(DerivationStatus::from_safe_l2(safe_head(0)), status_rx); - drop(status_tx); + let (driver, handles) = + DriverFixture::new(ctx, pipeline, ImmediateConfirmTxManager { l1_block: 1 }).build(); + drop(handles); assert!(matches!(driver.run().await, Err(BatchDriverError::DerivationStatusSourceClosed))); }); @@ -199,13 +159,12 @@ fn test_derivation_status_sender_drop_is_fatal() { fn test_derivation_status_sender_drop_during_shutdown_is_clean() { Runner::start(Config::seeded(0), |ctx| async move { let pipeline = TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))); - let (status_tx, status_rx) = mpsc::channel(1); - let driver = - DriverFixture::build(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .with_derivation_status_rx(DerivationStatus::from_safe_l2(safe_head(0)), status_rx); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .build(); ctx.cancel(); - drop(status_tx); + drop(handles); assert!(driver.run().await.is_ok()); }); } diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index c086cf690d..2aa7685b79 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -6,14 +6,12 @@ use std::{ time::Duration, }; -use alloy_primitives::Address; use async_trait::async_trait; use base_batcher_core::{ - BatchDriver, BatchDriverConfig, BatchDriverError, DaThrottle, NoopThrottleClient, - ThrottleController, + BatchDriverError, test_utils::{ - DriverFixture, ManualConfirmTxManager, NeverConfirmTxManager, PendingL1HeadSource, - Recorded, SubmissionStub, TrackingPipeline, + DriverFixture, ManualConfirmTxManager, NeverConfirmTxManager, Recorded, SubmissionStub, + TrackingPipeline, }, }; use base_batcher_encoder::{ChannelLimit, StepError, SubmissionId}; @@ -25,7 +23,7 @@ use base_runtime::{ /// When cancellation fires while a submission is in-flight with a /// `NeverConfirmTxManager`, the drain timeout must fire and the driver must -/// exit cleanly. This verifies the `runtime.sleep(drain_timeout)` fix. +/// exit cleanly. #[test] fn test_drain_timeout_exits_with_in_flight_submissions() { Runner::start(Config::seeded(0), |ctx| async move { @@ -33,7 +31,8 @@ fn test_drain_timeout_exits_with_in_flight_submissions() { let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); pipeline.submissions.push_back(SubmissionStub::stub()); - let driver = DriverFixture::build(ctx.clone(), pipeline, NeverConfirmTxManager); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(20)).await; @@ -63,7 +62,8 @@ fn test_shutdown_drains_in_flight_before_returning_flush_error() { ); pipeline.submissions.push_back(SubmissionStub::stub()); - let driver = DriverFixture::build(ctx.clone(), pipeline, NeverConfirmTxManager); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(20)).await; @@ -122,20 +122,8 @@ fn test_driver_finishes_pending_work_before_waiting_for_events() { // A single permit: the second submission can only leave the pipeline once the // receipt of the first one has been processed. - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - source, - tx_manager.clone(), - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - PendingL1HeadSource, - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).source(source).build(); let handle = ctx.spawn(driver.run()); // Let the driver submit the first stub and wait on the source, then confirm that stub diff --git a/crates/batcher/core/tests/reorg.rs b/crates/batcher/core/tests/reorg.rs index 79b9cbc890..8b058ada35 100644 --- a/crates/batcher/core/tests/reorg.rs +++ b/crates/batcher/core/tests/reorg.rs @@ -5,14 +5,9 @@ use std::{ time::Duration, }; -use alloy_primitives::Address; -use base_batcher_core::{ - AdminHandle, BatchDriver, BatchDriverConfig, DaThrottle, NoopThrottleClient, - ThrottleController, - test_utils::{ - ImmediateConfirmTxManager, ManualConfirmTxManager, OneBlockSource, PendingL1HeadSource, - Recorded, ReorgPipeline, SubmissionStub, TrackingPipeline, - }, +use base_batcher_core::test_utils::{ + DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, OneBlockSource, Recorded, + ReorgPipeline, SubmissionStub, TrackingPipeline, }; use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; use base_runtime::{ @@ -29,20 +24,10 @@ fn test_add_block_reorg_resets_pipeline_instead_of_fatal_error() { let recorded = Arc::new(Mutex::new(Recorded::default())); let pipeline = ReorgPipeline::new(Arc::clone(&recorded)); - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - OneBlockSource::new(), - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - PendingL1HeadSource, - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .source(OneBlockSource::new()) + .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -68,20 +53,10 @@ fn test_l2_reorg_event_resets_pipeline() { let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); let (source, source_tx) = ChannelBlockSource::new(); - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - source, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - PendingL1HeadSource, - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .source(source) + .build(); let handle = ctx.spawn(driver.run()); source_tx.send(L2BlockEvent::Reorg).unwrap(); @@ -107,23 +82,8 @@ fn test_reorg_keeps_tracking_in_flight_submissions() { pipeline.submissions.push_back(SubmissionStub::stub()); let tx_manager = ManualConfirmTxManager::default(); let (source, source_tx) = ChannelBlockSource::new(); - let (admin_handle, admin_rx) = AdminHandle::channel(); - - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - source, - tx_manager.clone(), - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - PendingL1HeadSource, - ) - .with_admin_rx(admin_rx); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).source(source).build(); let handle = ctx.spawn(driver.run()); // Let the driver submit the stub, then reorg while it is in flight. @@ -133,7 +93,7 @@ fn test_reorg_keeps_tracking_in_flight_submissions() { assert_eq!(recorded.lock().unwrap().resets, 1); assert_eq!( - admin_handle.get_status().await.unwrap().in_flight, + handles.admin.get_status().await.unwrap().in_flight, 1, "the reset must not drop the in-flight submission" ); @@ -142,7 +102,7 @@ fn test_reorg_keeps_tracking_in_flight_submissions() { ctx.sleep(Duration::from_millis(10)).await; assert_eq!( - admin_handle.get_status().await.unwrap().in_flight, + handles.admin.get_status().await.unwrap().in_flight, 0, "the receipt must settle the submission after the reset" ); diff --git a/crates/batcher/core/tests/stop_start.rs b/crates/batcher/core/tests/stop_start.rs index ebeb52d115..37632c5812 100644 --- a/crates/batcher/core/tests/stop_start.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -5,13 +5,11 @@ use std::{ time::Duration, }; -use alloy_primitives::Address; use base_batcher_core::{ - AdminError, AdminHandle, BatchDriver, BatchDriverConfig, DaThrottle, DerivationStatus, - NoopThrottleClient, ThrottleController, + AdminError, test_utils::{ - DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, PendingL1HeadSource, - Recorded, SubmissionStub, TrackingPipeline, TrackingSource, + BlockStub, DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, Recorded, + SubmissionStub, TrackingPipeline, TrackingSource, }, }; use base_batcher_encoder::{ @@ -25,7 +23,6 @@ use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, }; -use tokio::sync::mpsc; /// `AdminCommand::Stop` must immediately reset the pipeline. Stopping a batcher /// that is already stopped succeeds without resetting it again. @@ -34,15 +31,13 @@ fn test_stop_resets_pipeline() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - let (admin_handle, admin_rx) = AdminHandle::channel(); - - let driver = - DriverFixture::build(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .with_admin_rx(admin_rx); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .build(); let handle = ctx.spawn(driver.run()); - admin_handle.stop().await.unwrap(); - admin_handle.stop().await.unwrap(); + handles.admin.stop().await.unwrap(); + handles.admin.stop().await.unwrap(); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); @@ -62,37 +57,24 @@ fn test_stop_resets_pipeline() { fn test_start_triggers_catchup_from_safe_head() { Runner::start(Config::seeded(0), |ctx| async move { let (source, catchup_args) = TrackingSource::new(); - let (admin_handle, admin_rx) = AdminHandle::channel(); - let (derivation_status_tx, derivation_status_rx) = mpsc::channel(1); let safe_head = BlockInfo { number: 42, ..Default::default() }; - let driver = BatchDriver::new_without_derivation_status( + let (driver, handles) = DriverFixture::new( ctx.clone(), TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))), - source, ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - PendingL1HeadSource, ) - .with_admin_rx(admin_rx) - .with_derivation_status_rx(DerivationStatus::from_safe_l2(safe_head), derivation_status_rx); - + .source(source) + .safe_head(safe_head) + .build(); let handle = ctx.spawn(driver.run()); // Stop then start with safe_head = 42; the source will poll 43 next. - admin_handle.stop().await.unwrap(); - admin_handle.start().await.unwrap(); - admin_handle.start().await.unwrap(); + handles.admin.stop().await.unwrap(); + handles.admin.start().await.unwrap(); + handles.admin.start().await.unwrap(); ctx.cancel(); - // Keep the derivation-status channel alive until the driver stops. - drop(derivation_status_tx); assert!(handle.await.unwrap().is_ok()); assert_eq!( *catchup_args.lock().unwrap(), @@ -103,11 +85,10 @@ fn test_start_triggers_catchup_from_safe_head() { } /// While stopped, `Block` source events must be dropped; the pipeline must not -/// receive any blocks. +/// receive any blocks. Once started again, new blocks reach the pipeline. #[test] fn test_stopped_drops_block_events() { Runner::start(Config::seeded(0), |ctx| async move { - let (admin_handle, admin_rx) = AdminHandle::channel(); let (source, source_tx) = ChannelBlockSource::new(); // Use a pipeline variant that counts add_block calls. @@ -159,37 +140,30 @@ fn test_stopped_drops_block_events() { inner: TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))), }; - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - source, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - PendingL1HeadSource, - ) - .with_admin_rx(admin_rx); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .source(source) + .build(); let handle = ctx.spawn(driver.run()); - // Stop, then send a block — it must be dropped. - admin_handle.stop().await.unwrap(); + // Stop, then send a block: it must be dropped. + handles.admin.stop().await.unwrap(); + source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); ctx.sleep(Duration::from_millis(10)).await; - source_tx.send(L2BlockEvent::Block(Box::default())).unwrap(); - ctx.sleep(Duration::from_millis(10)).await; - - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok()); assert_eq!( *add_block_calls.lock().unwrap(), 0, "add_block must not be called while stopped" ); + + // Start, then send the next block: it must reach the pipeline. + handles.admin.start().await.unwrap(); + source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(2)))).unwrap(); + ctx.sleep(Duration::from_millis(10)).await; + assert_eq!(*add_block_calls.lock().unwrap(), 1, "add_block must be called once started"); + + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); }); } @@ -201,22 +175,20 @@ fn test_stop_leaves_in_flight_submissions_to_settle() { let mut pipeline = TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))); pipeline.submissions.push_back(SubmissionStub::stub()); let tx_manager = ManualConfirmTxManager::default(); - let (admin_handle, admin_rx) = AdminHandle::channel(); - - let driver = - DriverFixture::build(ctx.clone(), pipeline, tx_manager.clone()).with_admin_rx(admin_rx); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).build(); let handle = ctx.spawn(driver.run()); // Stop while the stub is in flight. - admin_handle.stop().await.unwrap(); - let status = admin_handle.get_status().await.unwrap(); + handles.admin.stop().await.unwrap(); + let status = handles.admin.get_status().await.unwrap(); assert!(status.stopped); assert_eq!(status.in_flight, 1); // Confirm the submission and let the driver process the receipt. tx_manager.confirm_next(1); ctx.sleep(Duration::from_millis(1)).await; - assert_eq!(admin_handle.get_status().await.unwrap().in_flight, 0); + assert_eq!(handles.admin.get_status().await.unwrap().in_flight, 0); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); @@ -229,14 +201,12 @@ fn test_flush_closes_the_channel_on_a_running_batcher() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - let (admin_handle, admin_rx) = AdminHandle::channel(); - - let driver = - DriverFixture::build(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .with_admin_rx(admin_rx); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .build(); let handle = ctx.spawn(driver.run()); - admin_handle.flush().await.unwrap(); + handles.admin.flush().await.unwrap(); assert_eq!(recorded.lock().unwrap().flush_count, 1); @@ -251,15 +221,13 @@ fn test_flush_is_rejected_while_stopped() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - let (admin_handle, admin_rx) = AdminHandle::channel(); - - let driver = - DriverFixture::build(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .with_admin_rx(admin_rx); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .build(); let handle = ctx.spawn(driver.run()); - admin_handle.stop().await.unwrap(); - let result = admin_handle.flush().await; + handles.admin.stop().await.unwrap(); + let result = handles.admin.flush().await; assert!(matches!(result, Err(AdminError::Stopped))); assert_eq!(recorded.lock().unwrap().flush_count, 0); diff --git a/crates/batcher/core/tests/throttle.rs b/crates/batcher/core/tests/throttle.rs index 03319873de..a9d3cec9fe 100644 --- a/crates/batcher/core/tests/throttle.rs +++ b/crates/batcher/core/tests/throttle.rs @@ -5,12 +5,10 @@ use std::{ time::Duration, }; -use alloy_primitives::Address; use base_batcher_core::{ - BatchDriver, BatchDriverConfig, DaThrottle, ThrottleConfig, ThrottleController, - ThrottleStrategy, + DaThrottle, ThrottleConfig, ThrottleController, ThrottleStrategy, test_utils::{ - ImmediateConfirmTxManager, PendingL1HeadSource, PendingSource, Recorded, TrackingPipeline, + BlockStub, DriverFixture, ImmediateConfirmTxManager, Recorded, TrackingPipeline, TrackingThrottleClient, }, }; @@ -38,20 +36,10 @@ fn test_throttle_client_called_on_high_backlog() { let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - PendingSource, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(throttle, Arc::new(throttle_client)), - PendingL1HeadSource, - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) + .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -83,20 +71,10 @@ fn test_throttle_client_called_with_upper_limits_on_zero_backlog() { let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - PendingSource, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(throttle, Arc::new(throttle_client)), - PendingL1HeadSource, - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) + .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -128,20 +106,10 @@ fn test_throttle_not_called_redundantly() { let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - PendingSource, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(throttle, Arc::new(throttle_client)), - PendingL1HeadSource, - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) + .build(); let handle = ctx.spawn(driver.run()); // Run for 100ms to allow multiple loop iterations. @@ -173,20 +141,10 @@ fn test_step_strategy_full_intensity_applies_lower_limits() { let throttle = ThrottleController::new(config, ThrottleStrategy::Step); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - PendingSource, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(throttle, Arc::new(throttle_client)), - PendingL1HeadSource, - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) + .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -260,20 +218,11 @@ fn test_throttle_transitions_from_active_to_inactive() { let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); - let driver = BatchDriver::new_without_derivation_status( - ctx.clone(), - pipeline, - source, - ImmediateConfirmTxManager { l1_block: 1 }, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - }, - DaThrottle::new(throttle, Arc::new(throttle_client)), - PendingL1HeadSource, - ); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + .source(source) + .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) + .build(); let handle = ctx.spawn(driver.run()); // First iteration fires immediately on startup; give it time to complete. @@ -282,7 +231,7 @@ fn test_throttle_transitions_from_active_to_inactive() { // Drop the backlog to zero, then wake the driver by delivering a dummy // block so the select! arm fires and the loop re-runs the throttle check. *backlog.lock().unwrap() = 0; - source_tx.send(L2BlockEvent::Block(Box::default())).unwrap(); + source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); ctx.sleep(Duration::from_millis(30)).await; ctx.cancel(); diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index b0e9e791b6..08f7345dd6 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -12,7 +12,7 @@ use backon::Retryable; use base_balance_monitor::BalanceMonitorLayer; use base_batcher_admin::AdminServer; use base_batcher_core::{ - AdminHandle, BatchDriver, BatchDriverHeads, DaThrottle, NoopThrottleClient, ThrottleClient, + AdminHandle, BatchDriver, BatchDriverInputs, DaThrottle, NoopThrottleClient, ThrottleClient, ThrottleController, ThrottleStrategy, }; use base_batcher_encoder::{BatchEncoder, BatcherMetrics}; @@ -713,35 +713,37 @@ impl BatcherService { }; background_tasks.push(("derivation status poller", derivation_status_handle)); - // Build the driver — all fallible setup is complete at this point. - let mut driver = BatchDriver::new( + // Build the driver. + let (admin_handle, admin_rx) = AdminHandle::channel(); + let driver = BatchDriver::new( runtime, encoder, - source, tx_manager, base_batcher_core::BatchDriverConfig { inbox: effective_batch_inbox, max_pending_transactions: self.config.max_pending_transactions, drain_timeout, force_blobs_when_throttling: self.config.force_blobs_when_throttling, + stopped: self.config.stopped, }, DaThrottle::new(throttle, throttle_client), - BatchDriverHeads::new( + BatchDriverInputs { + source, l1_head_source, initial_l1_head, - initial_derivation_status, + initial_safe_head: safe_l2, derivation_status_rx, - ), - ) - .with_stopped(self.config.stopped); + admin_rx, + }, + ); + // Without an admin server, drop the handle: the driver's admin arm then stays quiet. let admin_server = match self.config.admin_addr { - Some(addr) => { - let (admin_handle, admin_rx) = AdminHandle::channel(); - driver = driver.with_admin_rx(admin_rx); - Some(AdminServer::spawn(addr, admin_handle).await?) + Some(addr) => Some(AdminServer::spawn(addr, admin_handle).await?), + None => { + drop(admin_handle); + None } - None => None, }; info!("batcher service components initialized"); diff --git a/crates/consensus/protocol/src/block.rs b/crates/consensus/protocol/src/block.rs index c3caa76b71..753f264ff9 100644 --- a/crates/consensus/protocol/src/block.rs +++ b/crates/consensus/protocol/src/block.rs @@ -41,6 +41,12 @@ impl BlockInfo { Self { hash, number, parent_hash, timestamp } } + /// Returns the [`BlockInfo`] of the L2 genesis block described by `genesis`, whose parent + /// hash is zero. + pub const fn from_l2_genesis(genesis: &ChainGenesis) -> Self { + Self::new(genesis.l2.hash, genesis.l2.number, B256::ZERO, genesis.l2_time) + } + /// Returns the block ID. pub const fn id(&self) -> BlockNumHash { BlockNumHash { hash: self.hash, number: self.number } From a85d8719f016efc4458251db7b98cb434556b390 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Thu, 24 Sep 2026 20:16:54 +0000 Subject: [PATCH 112/299] fix(BOP-800): address Denim audit findings (#5288) Co-authored-by: OpenCode --- crates/common/precompiles/src/b20_asset/logic/v3.rs | 2 +- crates/common/precompiles/src/b20_stablecoin/logic/v3.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/common/precompiles/src/b20_asset/logic/v3.rs b/crates/common/precompiles/src/b20_asset/logic/v3.rs index 89d3a00ce3..12f8f34a81 100644 --- a/crates/common/precompiles/src/b20_asset/logic/v3.rs +++ b/crates/common/precompiles/src/b20_asset/logic/v3.rs @@ -925,7 +925,7 @@ impl Asset for AssetV3 { Self::OPERATOR_ROLE } - // --- ERC-8056 scheduled multiplier (introduced at AssetV3, Cobalt) --- + // --- ERC-8056 scheduled multiplier (introduced at AssetV2, Cobalt) --- /// Lazy current multiplier: flips to a matured pending target once `now >= effectiveAt`. fn multiplier(&self, token: &B20AssetToken) -> Result { diff --git a/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs b/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs index 87fbc309f2..5725f20336 100644 --- a/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs +++ b/crates/common/precompiles/src/b20_stablecoin/logic/v3.rs @@ -65,7 +65,9 @@ impl StablecoinV3 { policies.executor, transfer.caller, )?; - if transfer.caller != from || policies.executor != policies.sender { + let should_check_sender_policy = + transfer.caller != from || policies.executor != policies.sender; + if should_check_sender_policy { B20Guards::ensure_authorized_by_id( token, B20PolicyType::TransferSender.id(), From 063e75717513fb651530da8f17684754c256cc56 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 24 Sep 2026 20:36:15 +0000 Subject: [PATCH 113/299] refactor(batcher): fold the driver's event loop into one select (#5248) * refactor(batcher): fold the driver's event loop into one select * refactor(batcher): leave the sources unpolled during an encoding backlog and tidy the loop docs * docs(batcher): state each driver rule once * test(batcher): record pipeline calls as an enum rather than strings --- actions/harness/src/batcher/actor.rs | 6 +- actions/harness/tests/batcher/submission.rs | 8 +- crates/batcher/core/src/driver.rs | 635 ++++++++++-------- crates/batcher/core/src/event.rs | 26 - crates/batcher/core/src/lib.rs | 3 - crates/batcher/core/src/test_utils/mod.rs | 2 +- .../batcher/core/src/test_utils/pipeline.rs | 56 +- crates/batcher/core/tests/heads.rs | 30 +- crates/batcher/core/tests/stop_start.rs | 92 +-- 9 files changed, 419 insertions(+), 439 deletions(-) delete mode 100644 crates/batcher/core/src/event.rs diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 4c8bfe7123..2eeddb6bb7 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -391,9 +391,9 @@ impl Batcher { /// after an L1 reorg. /// /// A reorg requires two driver loop iterations to complete: the first - /// processes each `Receipt(id, Failed)` event and requeues the frames in - /// the encoder pipeline; the second calls `submit_pending()` → - /// [`send_async`] to return them to the pending queue. This method polls + /// handles each failed receipt and requeues the frames in the encoder + /// pipeline; the second calls `submit_pending()` → [`send_async`] to + /// return them to the pending queue. This method polls /// [`pending_count`] until the condition is satisfied. /// /// # Panics diff --git a/actions/harness/tests/batcher/submission.rs b/actions/harness/tests/batcher/submission.rs index 39d2c514bc..23efd33ed1 100644 --- a/actions/harness/tests/batcher/submission.rs +++ b/actions/harness/tests/batcher/submission.rs @@ -96,8 +96,8 @@ async fn batcher_soft_channel_target_derives_exact_blocks() { /// 2. Reorg to genesis **before** calling `confirm_staged` — frames are still /// in `staged`, so `reorg_to` fires `Err(TxManagerError::Rpc("reorg"))` on /// each oneshot responder. -/// 3. The driver processes each `Receipt(id, Failed)` → `pipeline.requeue(id)` -/// rewinds the encoder channel cursor. On the next loop iteration, the driver +/// 3. The driver handles each failed receipt: `pipeline.requeue(id)` rewinds the +/// encoder channel cursor. On the next loop iteration, the driver /// calls `submit_pending()` → `send_async()` and the frames are back in the /// `L1MinerTxManager` pending queue. /// 4. The same batcher stages the requeued frames and mines a new L1 block on @@ -136,8 +136,8 @@ async fn batcher_reorg_during_submission() { // --- L1 reorg back to genesis (frames still in staged) --- // reorg_to fires Err(TxManagerError::Rpc("reorg")) for every staged item and - // sends L1 head 0. The driver's select! loop processes each Receipt(id, Failed) - // → pipeline.requeue(id), rewinding the channel cursor without re-encoding. + // sends L1 head 0. The driver handles each failed receipt with + // pipeline.requeue(id), rewinding the channel cursor without re-encoding. batcher.reorg(0, &mut h.l1); batcher.wait_until_requeued(1).await; diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 7fd913b314..8cf8aca3f8 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -2,9 +2,7 @@ use std::time::Duration; -use base_batcher_encoder::{ - BatchPipeline, BatcherMetrics, DerivationReconciliation, StepError, StepResult, -}; +use base_batcher_encoder::{BatchPipeline, BatcherMetrics, DerivationReconciliation, StepResult}; use base_batcher_source::{L1HeadSource, L2BlockEvent, UnsafeBlockSource}; use base_common_consensus::BaseBlock; use base_protocol::BlockInfo; @@ -15,9 +13,12 @@ use tracing::{debug, error, info, warn}; use crate::{ AdminCommand, AdminError, BatchDriverConfig, BatchDriverError, BatcherStatus, DaThrottle, - DerivationStatus, SubmissionQueue, ThrottleClient, ThrottleController, event::DriverEvent, + DerivationStatus, SubmissionQueue, ThrottleClient, ThrottleController, }; +/// 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. #[derive(Debug)] pub struct BatchDriverInputs { @@ -92,11 +93,6 @@ where TC: ThrottleClient, L: L1HeadSource, { - /// Maximum number of encoding steps to run synchronously per outer loop iteration - /// before yielding to the tokio executor. Prevents a large block backlog from - /// starving receipt processing and cancellation checks. - pub const STEP_BUDGET: usize = 128; - /// Create a [`BatchDriver`]. /// /// Advances the pipeline to the initial L1 head, so channel duration is measured from @@ -133,15 +129,28 @@ where /// Run the batch driver loop. /// /// Each iteration has two phases: - /// 1. **CPU phase**: drain encoding, apply throttle, recover txpool, submit pending frames. - /// 2. **I/O phase**: block on `tokio::select!` until one external event fires. + /// 1. **CPU phase** (`work`): drain encoding, apply throttle, recover txpool, submit + /// ready submissions up to the in-flight limit. + /// 2. **I/O phase**: block on a biased `tokio::select!` until an event fires, and apply it. /// - /// Every event the I/O phase returns is therefore followed by a CPU phase before the - /// driver waits again, so the work an event releases is done before the next one, up to - /// the encoding step budget. + /// Every event is therefore followed by a CPU phase before the driver waits again, so the + /// work an event releases is done before the next one. Encoding is done in slices of + /// `STEP_BUDGET` steps: when a slice is not enough, the I/O phase yields once instead of + /// waiting, serves whatever became ready, then the next CPU phase continues encoding. A + /// large backlog therefore delays no other task by more than a slice, and no event by + /// more than a CPU phase. The two sources are not polled until the backlog is encoded: + /// their next block or head would only add to it, and a poll the next slice abandons + /// would waste an RPC. /// - /// When shutting down after cancellation, the I/O phase is replaced by a bounded drain - /// of all in-flight receipts. + /// The I/O phase polls its arms in priority order: cancellation, admin commands, + /// derivation status, L2 blocks, receipts, L1 heads. Admin commands come before the + /// source so control-plane operations (stop, start, flush) are never starved by sustained + /// block throughput; derivation-status changes come before unsafe blocks so pruning and + /// recovery cannot be starved by sequential catchup. A stopped batcher does not poll its + /// source at all. + /// + /// Cancellation ends the loop with a bounded drain of the in-flight submissions; see + /// `shutdown`. pub async fn run(mut self) -> Result<(), BatchDriverError> { if self.stopped { info!( @@ -150,95 +159,108 @@ where ); } - let mut shutting_down = false; - let mut shutdown_flush_error: Option = None; loop { - if !shutting_down { - self.apply_pending_derivation_status_updates()?; - } + let encoding_left = self.work().await?; - self.drain_encoding()?; - let is_throttling = self.throttle.apply(self.pipeline.da_backlog_bytes()).await; - if self.force_blobs_when_throttling { - self.pipeline.set_blob_override(is_throttling); - } - self.submissions.recover_txpool().await; - self.submissions.submit_pending(&mut self.pipeline).await; - - if shutting_down { - self.submissions - .drain(&mut self.pipeline, self.runtime.sleep(self.drain_timeout)) - .await; - if let Some(error) = shutdown_flush_error { - return Err(error.into()); - } - return Ok(()); - } + tokio::select! { + biased; + + _ = self.runtime.cancelled() => break, - match self.next_event().await? { - DriverEvent::Shutdown => { - info!( - in_flight = %self.submissions.in_flight_count(), - "batcher shutting down, draining in-flight submissions" - ); - if let Err(error) = self.pipeline.flush() { - warn!(error = %error, "flush failed during shutdown"); - shutdown_flush_error = Some(error); + Some(cmd) = self.admin_rx.recv() => self.on_admin(cmd)?, + + status = self.derivation_status_rx.recv() => match status { + Some(status) => self.on_derivation_status(status), + None => return Err(BatchDriverError::DerivationStatusSourceClosed), + }, + + event = self.source.next(), if !self.stopped && !encoding_left => match event { + L2BlockEvent::Block(block) => self.on_block(block), + L2BlockEvent::Reorg => { + warn!("L2 reorg detected, resetting pipeline and catching up from safe head"); + self.reset_to_safe_head(BatcherMetrics::RESET_SOURCE_REORG); } - shutting_down = true; - } - DriverEvent::Block(b) => { - self.on_block(b); - } - DriverEvent::Flush(reply) => { - self.pipeline.flush()?; - let _ = reply.send(Ok(())); - debug!("admin flush applied, released channel artifacts"); - } - DriverEvent::Reorg => { - warn!("L2 reorg detected, resetting pipeline and catching up from safe head"); - self.reset_to_safe_head(BatcherMetrics::RESET_SOURCE_REORG); - } - DriverEvent::Receipt(ids, o) => { - self.submissions.handle_outcome(&mut self.pipeline, ids, o); - } - DriverEvent::L1Head(n) => { - self.pipeline.advance_l1_head(n); - debug!(l1_head = %n, "L1 head advanced via source"); + }, + + Some((ids, outcome)) = self.submissions.next_settled() => { + self.submissions.handle_outcome(&mut self.pipeline, ids, outcome); } - DriverEvent::DerivationStatus(status) => { - self.on_derivation_status(status); + + head = self.l1_head_source.next(), if !encoding_left => { + self.pipeline.advance_l1_head(head); + debug!(l1_head = %head, "L1 head advanced via source"); } + + // Yield once, so other tasks run and the arms above get another look, then + // continue encoding. + () = tokio::task::yield_now(), if encoding_left => {} } } + + self.shutdown().await } - /// Drain encoding steps synchronously up to [`Self::STEP_BUDGET`]. + /// The CPU phase: encode what is buffered, apply the DA throttle, recover the txpool and + /// submit ready submissions up to the in-flight limit. /// - /// Stops at [`StepResult::Idle`] (nothing left to encode) or when the step budget runs - /// out. Returns `Err` on a fatal [`StepError`]. - fn drain_encoding(&mut self) -> Result<(), BatchDriverError> { - let mut steps = 0usize; - loop { + /// Returns `true` when the encoding step budget ran out, so encoding must continue. + async fn work(&mut self) -> Result { + let encoding_left = self.drain_encoding()?; + + let is_throttling = self.throttle.apply(self.pipeline.da_backlog_bytes()).await; + if self.force_blobs_when_throttling { + self.pipeline.set_blob_override(is_throttling); + } + + self.submissions.recover_txpool().await; + self.submissions.submit_pending(&mut self.pipeline).await; + Ok(encoding_left) + } + + /// Flush the current channel, submit what it released, then wait for the in-flight + /// submissions to settle, up to the drain timeout. + /// + /// The drain always runs; a flush or encoding error is reported afterwards. + async fn shutdown(mut self) -> Result<(), BatchDriverError> { + info!( + in_flight = %self.submissions.in_flight_count(), + "batcher shutting down, draining in-flight submissions" + ); + + let flushed = self.pipeline.flush().inspect_err(|error| { + warn!(error = %error, "flush failed during shutdown"); + }); + let worked = self.work().await; + + self.submissions.drain(&mut self.pipeline, self.runtime.sleep(self.drain_timeout)).await; + + flushed?; + worked?; + Ok(()) + } + + /// Run up to `STEP_BUDGET` encoding steps. + /// + /// Returns `Ok(true)` when the budget ran out before [`StepResult::Idle`], `Err` on a + /// fatal [`StepError`](base_batcher_encoder::StepError). + fn drain_encoding(&mut self) -> Result { + for encoded in 0..STEP_BUDGET { match self.pipeline.step() { - Ok(StepResult::Idle) => break, - Ok(StepResult::BlockEncoded | StepResult::ChannelClosed) => { - steps += 1; - if steps == Self::STEP_BUDGET { - debug!(steps = %steps, "encoding step budget exhausted, yielding"); - break; + Ok(StepResult::Idle) => { + if encoded > 0 { + debug!(steps = %encoded, "completed encoding drain"); } + return Ok(false); } + Ok(StepResult::BlockEncoded | StepResult::ChannelClosed) => {} Err(e) => { error!(error = %e, "fatal encoding step error, batcher halting"); return Err(e.into()); } } } - if steps > 0 { - debug!(steps = %steps, "completed encoding drain"); - } - Ok(()) + debug!(steps = %STEP_BUDGET, "encoding step budget exhausted"); + Ok(true) } /// Drop buffered pipeline state, recording why it was dropped. @@ -297,22 +319,6 @@ where } } - /// Apply derivation-status updates that arrived before the next CPU phase. - fn apply_pending_derivation_status_updates(&mut self) -> Result<(), BatchDriverError> { - loop { - match self.derivation_status_rx.try_recv() { - Ok(status) => self.on_derivation_status(status), - Err(mpsc::error::TryRecvError::Empty) => return Ok(()), - Err(mpsc::error::TryRecvError::Disconnected) if self.runtime.is_cancelled() => { - return Ok(()); - } - Err(mpsc::error::TryRecvError::Disconnected) => { - return Err(BatchDriverError::DerivationStatusSourceClosed); - } - } - } - } - /// Ingest a new L2 block into the pipeline. /// /// If the pipeline signals a reorg via `add_block` (parent-hash mismatch), @@ -370,90 +376,45 @@ where self.stopped = false; } - /// Block on the next external event using a biased `tokio::select!`. - /// - /// Admin commands are handled inline in the loop. Only a flush on a running - /// batcher is returned to the caller, as [`DriverEvent::Flush`]. Admin - /// commands are placed before the source arm so control-plane operations - /// (stop, start, flush) are never starved by sustained block throughput. - /// Derivation-status changes are also handled before unsafe blocks so pruning and - /// recovery cannot be starved by sequential catchup. - /// - /// [`AdminCommand::Stop`] immediately resets the pipeline, then drops `Block` - /// source events until [`AdminCommand::Start`] is received. Reorg events - /// propagate regardless of the stopped state. On start the source is reset to - /// catch up sequentially from the last known safe L2 head. Stopping a stopped - /// batcher or starting a running one does nothing. Each command is answered - /// once it has been applied. - async fn next_event(&mut self) -> Result { - loop { - let event = tokio::select! { - biased; - - _ = self.runtime.cancelled() => DriverEvent::Shutdown, - - Some(cmd) = self.admin_rx.recv() => { - match cmd { - AdminCommand::Flush { reply } if self.stopped => { - let _ = reply.send(Err(AdminError::Stopped)); - } - AdminCommand::Flush { reply } => { - return Ok(DriverEvent::Flush(reply)); - } - AdminCommand::Stop { reply } => { - self.on_admin_stop(); - let _ = reply.send(Ok(())); - } - AdminCommand::Start { reply } => { - self.on_admin_start(); - let _ = reply.send(Ok(())); - } - AdminCommand::SetThrottle { strategy, config } => { - self.throttle.set_controller( - ThrottleController::new(config, strategy) - ); - info!("throttle controller replaced via admin"); - } - AdminCommand::ResetThrottle => { - self.throttle.reset(); - info!("throttle controller reset via admin"); - } - AdminCommand::GetThrottleInfo { reply } => { - let _ = reply.send( - self.throttle.snapshot(self.pipeline.da_backlog_bytes()) - ); - } - AdminCommand::GetStatus { reply } => { - let _ = reply.send(BatcherStatus { - stopped: self.stopped, - in_flight: self.submissions.in_flight_count(), - da_backlog_bytes: self.pipeline.da_backlog_bytes(), - }); - } - } - // Await the next real event. Only a flush on a running batcher returns above. - continue; - } - - status = self.derivation_status_rx.recv() => match status { - Some(status) => DriverEvent::DerivationStatus(status), - None => return Err(BatchDriverError::DerivationStatusSourceClosed), - }, - - event = self.source.next() => match event { - L2BlockEvent::Block(_) if self.stopped => continue, - L2BlockEvent::Block(block) => DriverEvent::Block(block), - L2BlockEvent::Reorg => DriverEvent::Reorg, - }, - - Some((ids, outcome)) = self.submissions.next_settled() => { - DriverEvent::Receipt(ids, outcome) - } - - head = self.l1_head_source.next() => DriverEvent::L1Head(head), - }; - return Ok(event); + /// Apply an admin command, and answer it when it carries a reply. + fn on_admin(&mut self, cmd: AdminCommand) -> Result<(), BatchDriverError> { + match cmd { + AdminCommand::Flush { reply } if self.stopped => { + let _ = reply.send(Err(AdminError::Stopped)); + } + AdminCommand::Flush { reply } => { + self.pipeline.flush()?; + let _ = reply.send(Ok(())); + debug!("admin flush applied, released channel artifacts"); + } + AdminCommand::Stop { reply } => { + self.on_admin_stop(); + let _ = reply.send(Ok(())); + } + AdminCommand::Start { reply } => { + self.on_admin_start(); + let _ = reply.send(Ok(())); + } + AdminCommand::SetThrottle { strategy, config } => { + self.throttle.set_controller(ThrottleController::new(config, strategy)); + info!("throttle controller replaced via admin"); + } + AdminCommand::ResetThrottle => { + self.throttle.reset(); + info!("throttle controller reset via admin"); + } + AdminCommand::GetThrottleInfo { reply } => { + let _ = reply.send(self.throttle.snapshot(self.pipeline.da_backlog_bytes())); + } + AdminCommand::GetStatus { reply } => { + let _ = reply.send(BatcherStatus { + stopped: self.stopped, + in_flight: self.submissions.in_flight_count(), + da_backlog_bytes: self.pipeline.da_backlog_bytes(), + }); + } } + Ok(()) } } @@ -484,13 +445,13 @@ mod tests { use base_tx_manager::{SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError}; use tokio::sync::{mpsc, oneshot}; + use super::STEP_BUDGET; use crate::{ AdminCommand, BatchDriver, BatchDriverConfig, BatchDriverInputs, DaThrottle, DerivationStatus, NoopThrottleClient, ThrottleController, - event::DriverEvent, test_utils::{ - DriverFixture, ImmediateConfirmTxManager, ImmediateFailTxManager, - NeverConfirmTxManager, Recorded, SubmissionStub, TrackingPipeline, + BlockStub, DriverFixture, ImmediateConfirmTxManager, ImmediateFailTxManager, + NeverConfirmTxManager, PipelineCall, Recorded, SubmissionStub, TrackingPipeline, }, }; @@ -626,52 +587,56 @@ mod tests { } } - /// The channels a [`driver_for_next_event`] driver listens to, kept by the test. - struct EventChannels { + /// A driver whose source and L1 head source deliver the given events then park, with + /// the handles the test keeps to feed it and observe it. + struct QueuedDriver { + driver: BatchDriver< + R, + TrackingPipeline, + QueuedSource, + TM, + Arc, + QueuedL1HeadSource, + >, + recorded: Arc>, admin_tx: mpsc::Sender, status_tx: mpsc::Sender, } - type EventDriver = BatchDriver< - R, - TrackingPipeline, - QueuedSource, - TM, - Arc, - QueuedL1HeadSource, - >; - - /// A driver whose source and L1 head source deliver the given events, then park. - fn driver_for_next_event( + fn queued_driver( runtime: R, - source_events: impl IntoIterator, + blocks: impl IntoIterator, l1_heads: impl IntoIterator, tx_manager: TM, - ) -> (EventDriver, EventChannels) { + ) -> QueuedDriver { + let recorded = Arc::new(Mutex::new(Recorded::default())); let (admin_tx, admin_rx) = mpsc::channel(1); let (status_tx, status_rx) = mpsc::channel(1); - let driver = BatchDriver::new( - runtime, - TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))), - tx_manager, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - stopped: false, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - BatchDriverInputs { - source: QueuedSource::new(source_events), - l1_head_source: QueuedL1HeadSource::new(l1_heads), - initial_l1_head: 0, - initial_safe_head: safe_head(0), - derivation_status_rx: status_rx, - admin_rx, - }, - ); - (driver, EventChannels { admin_tx, status_tx }) + let driver = + BatchDriver::new( + runtime, + TrackingPipeline::new(Arc::clone(&recorded)), + tx_manager, + BatchDriverConfig { + inbox: Address::ZERO, + max_pending_transactions: 1, + drain_timeout: Duration::from_millis(10), + force_blobs_when_throttling: true, + stopped: false, + }, + DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), + BatchDriverInputs { + source: QueuedSource::new(blocks.into_iter().map(|number| { + L2BlockEvent::Block(Box::new(BlockStub::with_number(number))) + })), + l1_head_source: QueuedL1HeadSource::new(l1_heads), + initial_l1_head: 0, + initial_safe_head: safe_head(0), + derivation_status_rx: status_rx, + admin_rx, + }, + ); + QueuedDriver { driver, recorded, admin_tx, status_tx } } #[derive(Debug, Default)] @@ -757,108 +722,184 @@ mod tests { } } + // The loop polls its arms in priority order; each test below makes several arms ready at + // once and checks which one the driver serves first. + #[test] - fn next_event_prioritizes_cancellation_over_ready_admin() { + fn run_prioritizes_cancellation_over_ready_admin() { Runner::start(Config::seeded(0), |ctx| async move { - let (mut driver, channels) = driver_for_next_event( - ctx.clone(), - [L2BlockEvent::Block(Box::default())], - [9], - ImmediateConfirmTxManager { l1_block: 1 }, - ); + let queued = + queued_driver(ctx.clone(), [1], [9], ImmediateConfirmTxManager { l1_block: 1 }); + let (reply, reply_rx) = oneshot::channel(); + queued.admin_tx.send(AdminCommand::Flush { reply }).await.unwrap(); + ctx.cancel(); + + assert!(queued.driver.run().await.is_ok()); + assert!(reply_rx.await.is_err(), "a cancelled driver must not serve the flush"); + assert!(!queued.recorded.lock().unwrap().calls.contains(&PipelineCall::AddBlock)); + }); + } + + #[test] + fn run_prioritizes_admin_before_source() { + Runner::start(Config::seeded(0), |ctx| async move { + let queued = + queued_driver(ctx.clone(), [1], [], ImmediateConfirmTxManager { l1_block: 1 }); let (reply, _reply_rx) = oneshot::channel(); - channels - .admin_tx - .send(AdminCommand::Flush { reply }) - .await - .expect("admin receiver should be open"); + queued.admin_tx.send(AdminCommand::Flush { reply }).await.unwrap(); + let handle = ctx.spawn(queued.driver.run()); + ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); - let event = driver.next_event().await.expect("next_event should succeed"); - assert!(matches!(event, DriverEvent::Shutdown)); + let recorded = queued.recorded.lock().unwrap(); + assert!( + recorded.calls.starts_with(&[PipelineCall::Flush, PipelineCall::AddBlock]), + "{:?}", + recorded.calls + ); }); } #[test] - fn next_event_prioritizes_admin_before_source() { + fn run_prioritizes_source_before_receipts_and_heads() { Runner::start(Config::seeded(0), |ctx| async move { - let (mut driver, channels) = driver_for_next_event( - ctx, - [L2BlockEvent::Block(Box::default())], - [9], - ImmediateConfirmTxManager { l1_block: 1 }, + let mut queued = + queued_driver(ctx.clone(), [1], [9], ImmediateConfirmTxManager { l1_block: 1 }); + // Submitted by the first CPU phase, so its receipt is ready at the first wait. + queued.driver.pipeline.submissions.push_back(SubmissionStub::stub()); + + let handle = ctx.spawn(queued.driver.run()); + ctx.sleep(Duration::from_millis(10)).await; + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + + let recorded = queued.recorded.lock().unwrap(); + assert_eq!( + recorded.calls.first(), + Some(&PipelineCall::AddBlock), + "{:?}", + recorded.calls ); - let (reply, _reply_rx) = oneshot::channel(); - channels - .admin_tx - .send(AdminCommand::Flush { reply }) - .await - .expect("admin receiver should be open"); - - let event = driver.next_event().await.expect("next_event should succeed"); - assert!(matches!(event, DriverEvent::Flush(_))); + // The receipt confirms at L1 block 1 before the source's head 9 arrives; the other + // way round, head 1 would not advance past 9. + assert_eq!(recorded.l1_heads, [1, 9]); }); } #[test] - fn next_event_prioritizes_source_before_receipts_and_heads() { + fn run_prioritizes_derivation_status_before_source_and_receipts() { Runner::start(Config::seeded(0), |ctx| async move { - let (mut driver, _channels) = driver_for_next_event( - ctx, - [L2BlockEvent::Block(Box::default())], - [9], - ImmediateConfirmTxManager { l1_block: 1 }, - ); - driver.pipeline.submissions.push_back(SubmissionStub::stub()); - driver.submissions.submit_pending(&mut driver.pipeline).await; + let mut queued = + queued_driver(ctx.clone(), [6], [], ImmediateConfirmTxManager { l1_block: 42 }); + queued.driver.pipeline.submissions.push_back(SubmissionStub::stub()); + queued.status_tx.send(DerivationStatus::from_safe_l2(safe_head(5))).await.unwrap(); + + let handle = ctx.spawn(queued.driver.run()); + ctx.sleep(Duration::from_millis(10)).await; + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); - let event = driver.next_event().await.expect("next_event should succeed"); - assert!(matches!(event, DriverEvent::Block(_))); + let recorded = queued.recorded.lock().unwrap(); + assert!( + recorded + .calls + .starts_with(&[PipelineCall::ReconcileDerivation, PipelineCall::AddBlock]), + "{:?}", + recorded.calls + ); + assert!(recorded.calls.contains(&PipelineCall::Confirm)); }); } #[test] - fn next_event_prioritizes_derivation_status_before_source_and_receipts() { + fn run_prioritizes_derivation_status_before_l1_head() { Runner::start(Config::seeded(0), |ctx| async move { - let (mut driver, channels) = driver_for_next_event( - ctx, - [L2BlockEvent::Block(Box::default())], - [9], - ImmediateConfirmTxManager { l1_block: 42 }, + let queued = + queued_driver(ctx.clone(), [], [9], ImmediateConfirmTxManager { l1_block: 1 }); + queued.status_tx.send(DerivationStatus::from_safe_l2(safe_head(5))).await.unwrap(); + + let handle = ctx.spawn(queued.driver.run()); + ctx.sleep(Duration::from_millis(10)).await; + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + + let recorded = queued.recorded.lock().unwrap(); + assert!( + recorded + .calls + .starts_with(&[PipelineCall::ReconcileDerivation, PipelineCall::AdvanceL1Head]), + "{:?}", + recorded.calls ); - channels - .status_tx - .send(DerivationStatus::from_safe_l2(safe_head(5))) - .await - .expect("derivation-status receiver should be open"); - driver.pipeline.submissions.push_back(SubmissionStub::stub()); - driver.submissions.submit_pending(&mut driver.pipeline).await; - - let event = driver.next_event().await.expect("next_event should succeed"); - assert!(matches!( - event, - DriverEvent::DerivationStatus(status) if status.safe_l2.number == 5 - )); }); } + /// A backlog larger than one encoding slice is finished without any external event. #[test] - fn next_event_prioritizes_derivation_status_before_l1_head() { + fn run_finishes_a_backlog_larger_than_one_slice_without_events() { Runner::start(Config::seeded(0), |ctx| async move { - let (mut driver, channels) = - driver_for_next_event(ctx, [], [9], ImmediateConfirmTxManager { l1_block: 1 }); - channels - .status_tx - .send(DerivationStatus::from_safe_l2(safe_head(5))) - .await - .expect("derivation-status receiver should be open"); - - let event = driver.next_event().await.expect("next_event should succeed"); - assert!(matches!( - event, - DriverEvent::DerivationStatus(status) if status.safe_l2.number == 5 - )); + let blocks = 2 * STEP_BUDGET + 5; + let recorded = Arc::new(Mutex::new(Recorded::default())); + let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_encoding_steps(blocks); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); + + let handle = ctx.spawn(driver.run()); + ctx.sleep(Duration::from_millis(10)).await; + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + + assert_eq!(recorded.lock().unwrap().encoded_steps, blocks); + }); + } + + /// A ready admin command is served between two encoding slices, not after the whole + /// backlog. + #[test] + fn run_serves_admin_between_encoding_slices() { + Runner::start(Config::seeded(0), |ctx| async move { + let recorded = Arc::new(Mutex::new(Recorded::default())); + let pipeline = TrackingPipeline::new(Arc::clone(&recorded)) + .with_encoding_steps(2 * STEP_BUDGET + 5); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); + + let handle = ctx.spawn(driver.run()); + // Stop resets the pipeline, which drops whatever was still to encode. + handles.admin.stop().await.unwrap(); + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + + assert_eq!(recorded.lock().unwrap().encoded_steps, STEP_BUDGET); + }); + } + + /// The driver yields to other tasks between encoding slices: a task can send an admin + /// command in the middle of a backlog and have it served before the backlog is done. + #[test] + fn run_yields_to_other_tasks_between_encoding_slices() { + let config = Config { cycle_limit: Some(1_000_000), ..Config::seeded(0) }; + Runner::start(config, |ctx| async move { + let recorded = Arc::new(Mutex::new(Recorded::default())); + let pipeline = + TrackingPipeline::new(Arc::clone(&recorded)).with_encoding_steps(3 * STEP_BUDGET); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); + + let handle = ctx.spawn(driver.run()); + // Send the stop once the first slice is done: the driver must yield for this task + // to observe that. + while recorded.lock().unwrap().encoded_steps < STEP_BUDGET { + tokio::task::yield_now().await; + } + handles.admin.stop().await.unwrap(); + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + + let encoded = recorded.lock().unwrap().encoded_steps; + assert!(encoded < 3 * STEP_BUDGET, "the stop must not wait for the whole backlog"); }); } diff --git a/crates/batcher/core/src/event.rs b/crates/batcher/core/src/event.rs deleted file mode 100644 index 47aa6e736d..0000000000 --- a/crates/batcher/core/src/event.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Internal driver event type produced by the `tokio::select!` I/O phase. - -use base_batcher_encoder::SubmissionId; -use base_common_consensus::BaseBlock; -use tokio::sync::oneshot; - -use crate::{AdminResult, DerivationStatus, TxOutcome}; - -/// Events the driver can receive from external sources during the I/O phase. -#[derive(Debug)] -pub enum DriverEvent { - /// Cancellation token fired. - Shutdown, - /// New L2 unsafe block from the source. - Block(Box), - /// Admin requested a flush of the current channel; answered once the pipeline is flushed. - Flush(oneshot::Sender>), - /// L2 reorganisation detected. - Reorg, - /// An in-flight L1 transaction settled, carrying one or more submissions. - Receipt(Vec, TxOutcome), - /// L1 chain head advanced. - L1Head(u64), - /// Derivation progress changed. - DerivationStatus(DerivationStatus), -} diff --git a/crates/batcher/core/src/lib.rs b/crates/batcher/core/src/lib.rs index 7ca14d6fe1..bfd49e53a2 100644 --- a/crates/batcher/core/src/lib.rs +++ b/crates/batcher/core/src/lib.rs @@ -27,9 +27,6 @@ pub use submissions::{BatchTxCandidateBuilder, BatchTxCandidateError, Submission mod config; pub use config::BatchDriverConfig; -mod event; -pub use event::DriverEvent; - mod derivation_status; pub use derivation_status::DerivationStatus; diff --git a/crates/batcher/core/src/test_utils/mod.rs b/crates/batcher/core/src/test_utils/mod.rs index f21b92348b..631aadb23c 100644 --- a/crates/batcher/core/src/test_utils/mod.rs +++ b/crates/batcher/core/src/test_utils/mod.rs @@ -4,7 +4,7 @@ mod throttle; pub use throttle::{ThrottleCallLog, TrackingThrottleClient}; mod pipeline; -pub use pipeline::{Recorded, ReorgPipeline, TrackingPipeline}; +pub use pipeline::{PipelineCall, Recorded, ReorgPipeline, TrackingPipeline}; mod source; pub use source::{OneBlockSource, PendingL1HeadSource, PendingSource, TrackingSource}; diff --git a/crates/batcher/core/src/test_utils/pipeline.rs b/crates/batcher/core/src/test_utils/pipeline.rs index 879cc04e74..70ac568213 100644 --- a/crates/batcher/core/src/test_utils/pipeline.rs +++ b/crates/batcher/core/src/test_utils/pipeline.rs @@ -1,4 +1,8 @@ //! Test [`BatchPipeline`] implementations for action-testing the batch driver. +//! +//! Hand-rolled rather than mocked: the driver ordering tests need one call log ordered across +//! several trait methods, and the pipelines hold state (queued submissions, steps to encode) +//! that the driver consumes while it runs. use std::sync::{Arc, Mutex}; @@ -10,6 +14,21 @@ use base_batcher_encoder::{ use base_common_consensus::BaseBlock; use base_protocol::BlockInfo; +/// A [`BatchPipeline`] call recorded by [`TrackingPipeline`] for the ordering tests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PipelineCall { + /// `add_block`. + AddBlock, + /// `confirm`. + Confirm, + /// `flush`. + Flush, + /// `advance_l1_head`, recorded only when the head advances. + AdvanceL1Head, + /// `reconcile_derivation`. + ReconcileDerivation, +} + /// Shared recording state populated by the test pipeline implementations. #[derive(Debug, Default)] pub struct Recorded { @@ -30,6 +49,10 @@ pub struct Recorded { pub safe_numbers: Vec, /// Number of times `flush()` was called. pub flush_count: usize, + /// The calls the ordering tests look at, in call order. + pub calls: Vec, + /// Number of `step()` calls that encoded a block. + pub encoded_steps: usize, } /// [`BatchPipeline`] that records every significant method call into a shared [`Recorded`]. @@ -50,6 +73,8 @@ pub struct TrackingPipeline { derivation_stalled: bool, /// When set, `flush` records the call then returns this error. flush_error: Option, + /// Blocks left to encode: `step` reports one encoded block per call while above zero. + encoding_steps: usize, } impl TrackingPipeline { @@ -62,9 +87,16 @@ impl TrackingPipeline { safe_head_matches: true, derivation_stalled: false, flush_error: None, + encoding_steps: 0, } } + /// Make `step` report `steps` encoded blocks before reporting idle. + pub const fn with_encoding_steps(mut self, steps: usize) -> Self { + self.encoding_steps = steps; + self + } + /// Set the value returned by `da_backlog_bytes`. pub const fn with_da_backlog(mut self, value: u64) -> Self { self.da_backlog_bytes_value = value; @@ -92,11 +124,17 @@ impl TrackingPipeline { impl BatchPipeline for TrackingPipeline { fn add_block(&mut self, _: BaseBlock) -> Result<(), (ReorgError, Box)> { + self.recorded.lock().unwrap().calls.push(PipelineCall::AddBlock); Ok(()) } fn step(&mut self) -> Result { - Ok(StepResult::Idle) + if self.encoding_steps == 0 { + return Ok(StepResult::Idle); + } + self.encoding_steps -= 1; + self.recorded.lock().unwrap().encoded_steps += 1; + Ok(StepResult::BlockEncoded) } fn next_submission(&mut self) -> Option { @@ -106,7 +144,9 @@ impl BatchPipeline for TrackingPipeline { } fn confirm(&mut self, id: SubmissionId, _: u64) { - self.recorded.lock().unwrap().confirmed.push(id); + let mut recorded = self.recorded.lock().unwrap(); + recorded.confirmed.push(id); + recorded.calls.push(PipelineCall::Confirm); } fn requeue(&mut self, id: SubmissionId) { @@ -114,7 +154,10 @@ impl BatchPipeline for TrackingPipeline { } fn flush(&mut self) -> Result<(), StepError> { - self.recorded.lock().unwrap().flush_count += 1; + let mut recorded = self.recorded.lock().unwrap(); + recorded.flush_count += 1; + recorded.calls.push(PipelineCall::Flush); + drop(recorded); if let Some(error) = self.flush_error.take() { return Err(error); } @@ -125,6 +168,7 @@ impl BatchPipeline for TrackingPipeline { let mut recorded = self.recorded.lock().unwrap(); if l1_block > recorded.l1_heads.last().copied().unwrap_or_default() { recorded.l1_heads.push(l1_block); + recorded.calls.push(PipelineCall::AdvanceL1Head); } } @@ -133,7 +177,10 @@ impl BatchPipeline for TrackingPipeline { safe_l2: BlockInfo, _: Option, ) -> DerivationReconciliation { - self.recorded.lock().unwrap().safe_numbers.push(safe_l2.number); + let mut recorded = self.recorded.lock().unwrap(); + recorded.safe_numbers.push(safe_l2.number); + recorded.calls.push(PipelineCall::ReconcileDerivation); + drop(recorded); if !self.safe_head_matches { return DerivationReconciliation::SafeHeadMismatch; } @@ -145,6 +192,7 @@ impl BatchPipeline for TrackingPipeline { fn reset(&mut self) { self.submissions.clear(); + self.encoding_steps = 0; self.recorded.lock().unwrap().resets += 1; } diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index 90b0ff59df..1d02f44123 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -9,8 +9,7 @@ use alloy_primitives::B256; use base_batcher_core::{ BatchDriverError, DerivationStatus, test_utils::{ - DriverFixture, ImmediateConfirmTxManager, Recorded, SubmissionStub, TrackingPipeline, - TrackingSource, + DriverFixture, ImmediateConfirmTxManager, Recorded, TrackingPipeline, TrackingSource, }, }; use base_batcher_source::test_utils::ChannelL1HeadSource; @@ -115,33 +114,6 @@ fn test_derivation_cursor_advance_replays_stalled_channel() { }); } -#[test] -fn test_queued_safe_head_preempts_submission() { - Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - pipeline.submissions.push_back(SubmissionStub::stub()); - - let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .safe_head(safe_head(10)) - .build(); - handles - .derivation_status_tx - .send(DerivationStatus::from_safe_l2(safe_head(5))) - .await - .unwrap(); - let handle = ctx.spawn(driver.run()); - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok()); - let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.resets, 1); - assert!(recorded.dequeued.is_empty()); - }); -} - #[test] fn test_derivation_status_sender_drop_is_fatal() { Runner::start(Config::seeded(0), |ctx| async move { diff --git a/crates/batcher/core/tests/stop_start.rs b/crates/batcher/core/tests/stop_start.rs index 37632c5812..11c388fed8 100644 --- a/crates/batcher/core/tests/stop_start.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -8,16 +8,11 @@ use std::{ use base_batcher_core::{ AdminError, test_utils::{ - BlockStub, DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, Recorded, - SubmissionStub, TrackingPipeline, TrackingSource, + BlockStub, DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, PipelineCall, + Recorded, SubmissionStub, TrackingPipeline, TrackingSource, }, }; -use base_batcher_encoder::{ - BatchPipeline, BatchSubmission, DerivationReconciliation, ReorgError, StepError, StepResult, - SubmissionId, -}; use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; -use base_common_consensus::BaseBlock; use base_protocol::BlockInfo; use base_runtime::{ Cancellation, Clock, Spawner, @@ -84,83 +79,36 @@ fn test_start_triggers_catchup_from_safe_head() { }); } -/// While stopped, `Block` source events must be dropped; the pipeline must not -/// receive any blocks. Once started again, new blocks reach the pipeline. +/// While stopped, the batcher does not read its source: the pipeline receives no blocks. +/// Once started again, the blocks queued meanwhile reach the pipeline. #[test] -fn test_stopped_drops_block_events() { +fn test_stopped_leaves_the_source_unread() { Runner::start(Config::seeded(0), |ctx| async move { + let recorded = Arc::new(Mutex::new(Recorded::default())); let (source, source_tx) = ChannelBlockSource::new(); - - // Use a pipeline variant that counts add_block calls. - let add_block_calls = Arc::new(Mutex::new(0usize)); - struct CountingPipeline { - calls: Arc>, - inner: TrackingPipeline, - } - impl BatchPipeline for CountingPipeline { - fn add_block(&mut self, block: BaseBlock) -> Result<(), (ReorgError, Box)> { - *self.calls.lock().unwrap() += 1; - self.inner.add_block(block) - } - fn step(&mut self) -> Result { - self.inner.step() - } - fn next_submission(&mut self) -> Option { - self.inner.next_submission() - } - fn confirm(&mut self, id: SubmissionId, n: u64) { - self.inner.confirm(id, n); - } - fn requeue(&mut self, id: SubmissionId) { - self.inner.requeue(id); - } - fn flush(&mut self) -> Result<(), StepError> { - self.inner.flush() - } - fn advance_l1_head(&mut self, n: u64) { - self.inner.advance_l1_head(n); - } - fn reconcile_derivation( - &mut self, - safe_l2: BlockInfo, - current_l1: Option, - ) -> DerivationReconciliation { - self.inner.reconcile_derivation(safe_l2, current_l1) - } - fn reset(&mut self) { - self.inner.reset(); - } - fn da_backlog_bytes(&self) -> u64 { - self.inner.da_backlog_bytes() - } - } - - let pipeline = CountingPipeline { - calls: Arc::clone(&add_block_calls), - inner: TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))), - }; - - let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .source(source) - .build(); + let (driver, handles) = DriverFixture::new( + ctx.clone(), + TrackingPipeline::new(Arc::clone(&recorded)), + ImmediateConfirmTxManager { l1_block: 1 }, + ) + .source(source) + .build(); let handle = ctx.spawn(driver.run()); + let blocks_added = || { + recorded.lock().unwrap().calls.iter().filter(|&&c| c == PipelineCall::AddBlock).count() + }; - // Stop, then send a block: it must be dropped. + // Stop, then send a block: it stays in the source. handles.admin.stop().await.unwrap(); source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); ctx.sleep(Duration::from_millis(10)).await; - assert_eq!( - *add_block_calls.lock().unwrap(), - 0, - "add_block must not be called while stopped" - ); + assert_eq!(blocks_added(), 0, "a stopped batcher must not ingest blocks"); - // Start, then send the next block: it must reach the pipeline. + // Start: the queued block and the next one reach the pipeline. handles.admin.start().await.unwrap(); source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(2)))).unwrap(); ctx.sleep(Duration::from_millis(10)).await; - assert_eq!(*add_block_calls.lock().unwrap(), 1, "add_block must be called once started"); + assert_eq!(blocks_added(), 2, "a started batcher must ingest the queued blocks"); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); From 47028412bc39023a16002883c8e7f67290b1e201 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 24 Sep 2026 20:52:02 +0000 Subject: [PATCH 114/299] refactor(batcher): track in-flight submissions by id and drop the semaphore (#5253) * refactor(batcher): track in-flight submissions by id and drop the semaphore * docs(batcher): keep the fatal blob rationale on the error variant --- crates/batcher/core/README.md | 25 +- crates/batcher/core/src/driver.rs | 78 +++--- crates/batcher/core/src/error.rs | 10 +- crates/batcher/core/src/submissions.rs | 245 +++++++----------- .../batcher/core/src/test_utils/tx_manager.rs | 5 +- crates/batcher/core/tests/lifecycle.rs | 5 +- 6 files changed, 149 insertions(+), 219 deletions(-) diff --git a/crates/batcher/core/README.md b/crates/batcher/core/README.md index 98141d2b30..1103d5437c 100644 --- a/crates/batcher/core/README.md +++ b/crates/batcher/core/README.md @@ -16,20 +16,21 @@ Each arm advances the pipeline or adjusts submission pressure without blocking t timeout, DA-throttle submission policy, and whether block ingestion starts stopped. `SubmissionQueue` owns the entire L1 submission lifecycle. It holds the `TxManager`, a -`FuturesUnordered` set of in-flight receipt futures, a counting `Semaphore` for backpressure, and -a boolean txpool-blocked flag. When the driver calls `submit_pending`, the queue loops: it tries -to acquire a semaphore permit, asks the pipeline for the next ready submission, encodes frames as -blobs or calldata depending on the `DaType`, and hands the resulting `TxCandidate` to the -`TxManager`. Each submission spawns a permit-holding future that resolves to a `(SubmissionId, -TxOutcome)` pair when the transaction settles. Confirmed receipts call `pipeline.confirm` and -`pipeline.advance_l1_head`. Failed submissions are requeued. A `TxpoolBlocked` outcome sets a -sticky flag that prevents further submissions until `recover_txpool` successfully cancels the -stuck transaction. In-flight futures survive a pipeline reset and keep their permits until they -settle; the reset pipeline ignores the stale ids they report. +`FuturesUnordered` set of in-flight receipt futures, and a txpool-blocked flag. When the driver +calls `submit_pending`, the queue sends one L1 transaction per ready submission, as blobs or +calldata depending on its `DaType`, until `max_pending_transactions` are in flight. Each +transaction becomes a receipt future that resolves to a `(SubmissionId, TxOutcome)` pair when +it settles. Confirmed receipts call `pipeline.confirm` and `pipeline.advance_l1_head`. Failed +submissions are requeued. A `TxpoolBlocked` outcome sets a sticky flag that prevents further +submissions until `recover_txpool` successfully cancels the stuck transaction. A blob +submission that cannot be built into a transaction is fatal: the encoder packs blobs within +protocol limits, so a retry would fail the same way. In-flight transactions survive a pipeline +reset and keep counting against the limit until they settle; the reset pipeline ignores the +stale ids they report. `TxOutcome` represents the three terminal states of an L1 submission: `Confirmed { l1_block }`, -`Failed`, and `TxpoolBlocked`. During normal operation, failed frames are requeued for retry; -txpool-blocked frames are also requeued but submission is suspended until the nonce slot is freed. +`Failed`, and `TxpoolBlocked`. Failed frames are requeued for retry; txpool-blocked frames are +also requeued but submission is suspended until the nonce slot is freed. The throttle subsystem controls how much DA data the sequencer may include per block and per transaction based on the L1 DA backlog. `ThrottleController` takes a `ThrottleConfig` and a diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 8cf8aca3f8..f1840fc31c 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -42,8 +42,8 @@ pub struct BatchDriverInputs { /// an [`L1HeadSource`] (L1 chain head tracking), ordered [`DerivationStatus`] updates, /// and a [`TxManager`] (L1 submission) into a single `tokio::select!` task. /// -/// Uses [`SubmissionQueue`] for concurrent receipt tracking and semaphore backpressure, -/// and [`DaThrottle`] for DA backlog throttle management. +/// Uses [`SubmissionQueue`] to send submissions and track their receipts, and +/// [`DaThrottle`] for DA backlog throttle management. #[derive(Debug)] pub struct BatchDriver where @@ -60,7 +60,7 @@ where pipeline: P, /// The L2 block source. source: S, - /// Submission lifecycle manager (tx manager, in-flight tracking, semaphore, txpool state). + /// Submission lifecycle manager (tx manager, in-flight tracking, txpool state). submissions: SubmissionQueue, /// DA backlog throttle (controller, client, dedup cache). throttle: DaThrottle, @@ -182,8 +182,8 @@ where } }, - Some((ids, outcome)) = self.submissions.next_settled() => { - self.submissions.handle_outcome(&mut self.pipeline, ids, outcome); + Some((id, outcome)) = self.submissions.next_settled() => { + self.submissions.handle_outcome(&mut self.pipeline, id, outcome); } head = self.l1_head_source.next(), if !encoding_left => { @@ -203,7 +203,8 @@ where /// The CPU phase: encode what is buffered, apply the DA throttle, recover the txpool and /// submit ready submissions up to the in-flight limit. /// - /// Returns `true` when the encoding step budget ran out, so encoding must continue. + /// Returns `true` when the encoding step budget ran out, so encoding must continue. Fails + /// on a fatal encoding error or a blob submission that cannot be built. async fn work(&mut self) -> Result { let encoding_left = self.drain_encoding()?; @@ -213,14 +214,15 @@ where } self.submissions.recover_txpool().await; - self.submissions.submit_pending(&mut self.pipeline).await; + self.submissions.submit_pending(&mut self.pipeline).await?; Ok(encoding_left) } /// Flush the current channel, submit what it released, then wait for the in-flight /// submissions to settle, up to the drain timeout. /// - /// The drain always runs; a flush or encoding error is reported afterwards. + /// The drain always runs; an error from the flush or the final CPU phase is reported + /// afterwards. async fn shutdown(mut self) -> Result<(), BatchDriverError> { info!( in_flight = %self.submissions.in_flight_count(), @@ -447,8 +449,8 @@ mod tests { use super::STEP_BUDGET; use crate::{ - AdminCommand, BatchDriver, BatchDriverConfig, BatchDriverInputs, DaThrottle, - DerivationStatus, NoopThrottleClient, ThrottleController, + AdminCommand, BatchDriver, BatchDriverConfig, BatchDriverError, BatchDriverInputs, + DaThrottle, DerivationStatus, NoopThrottleClient, ThrottleController, test_utils::{ BlockStub, DriverFixture, ImmediateConfirmTxManager, ImmediateFailTxManager, NeverConfirmTxManager, PipelineCall, Recorded, SubmissionStub, TrackingPipeline, @@ -956,23 +958,17 @@ mod tests { }); } - /// When blob encoding fails, the submission has already been dequeued and its frames marked - /// pending. Without a requeue those frames never become ready again, so the driver must - /// requeue the submission before retrying. + /// A blob submission that cannot be built into a transaction is fatal: a retry would fail + /// the same way and hold back every submission behind it. #[test] - fn test_blob_encoding_failure_requeues_submission() { - // Blob submission encoding feeds DERIVATION_VERSION_0 (1) + frame.encode() - // (23 + data.len()) into BlobEncoder::encode. It fails when > BLOB_MAX_DATA_SIZE - // (130_044), so data.len() >= 130_021 guarantees DataTooLarge. - const OVERSIZED: usize = 130_021; - + fn test_blob_encoding_failure_is_fatal() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let mut pipeline = TrackingPipeline::new(Arc::default()); + // A frame as large as a whole blob no longer fits once framed. pipeline.submissions.push_back(BatchSubmission::blobs( SubmissionId(0), vec![BlobPayload::new(vec![Arc::new(Frame { - data: vec![0u8; OVERSIZED], + data: vec![0u8; BlobEncoder::BLOB_MAX_DATA_SIZE], ..Frame::default() })])], )); @@ -983,23 +979,9 @@ mod tests { ImmediateConfirmTxManager { l1_block: 1 }, ) .build(); - let handle = ctx.spawn(driver.run()); - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - - let recorded = recorded.lock().unwrap(); - assert_eq!( - recorded.requeued, - vec![SubmissionId(0)], - "requeue must be called when blob encoding fails so the channel is not stuck" - ); - assert!( - recorded.l1_heads.is_empty(), - "advance_l1_head must not be called when blob encoding fails" - ); + let result = ctx.spawn(driver.run()).await.unwrap(); + assert!(matches!(result, Err(BatchDriverError::Blob(_))), "got {result:?}"); }); } @@ -1097,11 +1079,10 @@ mod tests { }); } - /// The semaphore must prevent more concurrent in-flight L1 txs than - /// `max_pending_transactions`. With max=1 and two submissions, the second - /// submission must not be dequeued while the first tx still holds the permit. + /// No more than `max_pending_transactions` L1 txs are in flight. With max=1 and two + /// submissions, the second submission must not be dequeued while the first tx is pending. #[test] - fn test_semaphore_prevents_excess_concurrent_submissions() { + fn test_in_flight_limit_holds_back_further_submissions() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); @@ -1119,17 +1100,16 @@ mod tests { assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.dequeued, vec![SubmissionId(0)], "only one permit is available"); - assert!(recorded.requeued.is_empty(), "blocked submissions must not be dequeued"); - // The semaphore (max=1) is occupied by blob 1 — no second tx was submitted. - assert!(recorded.l1_heads.is_empty(), "no confirmation while semaphore is full"); + assert_eq!(recorded.dequeued, vec![SubmissionId(0)], "only one tx may be in flight"); + assert!(recorded.requeued.is_empty(), "a held-back submission is not requeued"); + assert!(recorded.l1_heads.is_empty(), "the tx in flight never confirms"); }); } /// With `max_pending_transactions`=1 and blob-filling submissions, the second - /// blob tx is only submitted once the first is confirmed (freeing the permit). + /// blob tx is only submitted once the first is confirmed. #[test] - fn test_second_blob_tx_submitted_after_permit_freed() { + fn test_next_blob_tx_submitted_once_the_previous_settles() { Runner::start(Config::seeded(0), |ctx| async move { let recorded = Arc::new(Mutex::new(Recorded::default())); let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); @@ -1153,7 +1133,7 @@ mod tests { assert_eq!( recorded.lock().unwrap().confirmed, vec![SubmissionId(0), SubmissionId(1), SubmissionId(2)], - "each queued submission must confirm as permits are freed" + "each queued submission must confirm as the tx before it settles" ); }); } diff --git a/crates/batcher/core/src/error.rs b/crates/batcher/core/src/error.rs index 7823f3874e..40312a9b4c 100644 --- a/crates/batcher/core/src/error.rs +++ b/crates/batcher/core/src/error.rs @@ -1,14 +1,16 @@ //! Error types for the batch driver. use base_batcher_encoder::StepError; -use base_blobs::BlobEncodeError; + +use crate::BatchTxCandidateError; /// Errors produced by the [`BatchDriver`](crate::BatchDriver). #[derive(Debug, thiserror::Error)] pub enum BatchDriverError { - /// An error encoding frames into blobs. - #[error("blob encoding error: {0}")] - Blob(#[from] BlobEncodeError), + /// A blob submission could not be built into a transaction. The encoder packs blobs + /// within protocol limits, so a retry would fail the same way. + #[error("blob transaction could not be built: {0}")] + Blob(#[from] BatchTxCandidateError), /// A block could not be composed into a batch; continuing would produce a gap /// in the submitted L2 block sequence. #[error("fatal pipeline step error: {0}")] diff --git a/crates/batcher/core/src/submissions.rs b/crates/batcher/core/src/submissions.rs index f91ac574d0..9c1ba42db7 100644 --- a/crates/batcher/core/src/submissions.rs +++ b/crates/batcher/core/src/submissions.rs @@ -8,16 +8,16 @@ use base_batcher_encoder::{ SubmissionId, SubmissionPayload, }; use base_blobs::{BlobEncodeError, BlobEncoder}; +use base_protocol::Frame; use base_tx_manager::{TxCandidate, TxManager, TxManagerError}; use futures::stream::{FuturesUnordered, StreamExt}; -use tokio::sync::Semaphore; use tracing::{info, warn}; use crate::TxOutcome; -/// Type alias for the in-flight receipt future collection. -type InFlight = - FuturesUnordered, TxOutcome)> + Send>>>; +/// Receipt futures of the transactions in flight, each resolving to the submission it +/// carries and how its transaction settled. +type InFlight = FuturesUnordered + Send>>>; /// Builds L1 transaction candidates for batch submissions. #[derive(Debug)] @@ -76,18 +76,30 @@ impl BatchTxCandidateBuilder { payload_size as u64, )) } + + /// Build a calldata transaction candidate carrying one version-prefixed frame. + pub fn calldata_tx_candidate(inbox: Address, frame: &Frame) -> TxCandidate { + TxCandidate { + to: Some(inbox), + tx_data: FrameEncoder::to_calldata(frame), + value: U256::ZERO, + gas_limit: 0, + blobs: Arc::from([]), + } + } } -/// Manages the full submission lifecycle for the batch driver. +/// Sends ready submissions to L1, one transaction each, and tracks those transactions until +/// they settle. /// -/// Owns capacity management (semaphore), in-flight receipt tracking -/// ([`FuturesUnordered`]), txpool blockage state, the [`TxManager`], and the -/// batcher inbox address. These were previously loose fields on [`BatchDriver`]. +/// At most `max_pending` transactions are in flight at once. A +/// [`TxOutcome::TxpoolBlocked`] outcome suspends sending until +/// [`recover_txpool`](Self::recover_txpool) cancels the transaction holding the nonce slot. #[derive(Debug)] pub struct SubmissionQueue { tx_manager: TM, in_flight: InFlight, - semaphore: Arc, + max_pending: usize, inbox: Address, txpool_blocked: bool, } @@ -98,78 +110,50 @@ impl SubmissionQueue { Self { tx_manager, in_flight: FuturesUnordered::new(), - semaphore: Arc::new(Semaphore::new(max_pending)), + max_pending, inbox, txpool_blocked: false, } } - /// Submit all ready frames that fit within semaphore capacity. + /// Send ready submissions, one L1 transaction each, until `max_pending` transactions are + /// in flight, the pipeline has nothing ready, or the txpool is blocked. /// - /// For each available semaphore permit (= one L1 transaction), dequeues one - /// ready submission and encodes it as a blob or calldata transaction. Each - /// blob payload may contain frames from multiple channels. - /// Loops until the semaphore is exhausted, the pipeline has no ready submissions, - /// or the txpool is blocked. - pub async fn submit_pending(&mut self, pipeline: &mut P) { - loop { - if self.txpool_blocked { - return; - } - - let Ok(permit) = Arc::clone(&self.semaphore).try_acquire_owned() else { - return; - }; - + /// Fails when a blob submission cannot be built into a transaction. + pub async fn submit_pending( + &mut self, + pipeline: &mut P, + ) -> Result<(), BatchTxCandidateError> { + while !self.txpool_blocked && self.in_flight.len() < self.max_pending { let Some(sub) = pipeline.next_submission() else { - drop(permit); - return; + return Ok(()); }; - // Convert the submission into its final L1 transaction payload before - // handing ownership to the asynchronous transaction manager. - let da_type = sub.da_type(); - let da_type_label = match da_type { + // Build the transaction. + let da_type_label = match sub.da_type() { DaType::Blob => BatcherMetrics::DA_TYPE_BLOB, DaType::Calldata => BatcherMetrics::DA_TYPE_CALLDATA, }; - let blob_payload_bytes; - let candidate = match sub.payload() { + let (candidate, blob_payload_bytes) = match sub.payload() { SubmissionPayload::Blobs(payloads) => { - match BatchTxCandidateBuilder::blob_tx_candidate(self.inbox, payloads) { - Ok((candidate, payload_size)) => { - blob_payload_bytes = Some(payload_size); - BatcherMetrics::blobs_per_tx().record(payloads.len() as f64); - for payload in payloads { - BatcherMetrics::blob_fill_ratio().record( - payload.frame_bytes() as f64 / DaEgress::BLOB_CAPACITY as f64, - ); - } - candidate - } - Err(e) => { - warn!(error = %e, "failed to encode frames to blob, requeueing"); - pipeline.requeue(sub.id); - drop(permit); - return; - } + let (candidate, payload_size) = + BatchTxCandidateBuilder::blob_tx_candidate(self.inbox, payloads)?; + BatcherMetrics::blobs_per_tx().record(payloads.len() as f64); + for payload in payloads { + BatcherMetrics::blob_fill_ratio() + .record(payload.frame_bytes() as f64 / DaEgress::BLOB_CAPACITY as f64); } + (candidate, Some(payload_size)) } SubmissionPayload::Calldata(frame) => { - blob_payload_bytes = None; - TxCandidate { - to: Some(self.inbox), - tx_data: FrameEncoder::to_calldata(frame), - value: U256::ZERO, - gas_limit: 0, - blobs: vec![].into(), - } + (BatchTxCandidateBuilder::calldata_tx_candidate(self.inbox, frame), None) } }; + let id = sub.id; let frame_bytes = sub.frame_bytes(); info!( - submissions = 1, + id = ?id, da_type = %da_type_label, frame_bytes = %frame_bytes, "submitting batch frames to L1" @@ -178,37 +162,34 @@ impl SubmissionQueue { BatcherMetrics::da_bytes_submitted_total(da_type_label).increment(frame_bytes as u64); BatcherMetrics::in_flight_submissions().increment(1.0); - // Capture for the post-confirm metric: blob_used_bytes_total counts - // payload bytes that actually landed on L1, not bytes attempted, so - // we only increment after the tx confirms. + // Send it, and track its receipt until the transaction settles. let handle = self.tx_manager.send_async(candidate).await; - let fut: Pin, TxOutcome)> + Send>> = - Box::pin(async move { - let outcome = match handle.await { - Ok(receipt) => { - let l1_block = receipt.block_number.unwrap_or_else(|| { - warn!("confirmed receipt missing block number; l1_head will not advance"); - 0 - }); - if let Some(bytes) = blob_payload_bytes { - BatcherMetrics::blob_used_bytes_total().increment(bytes); - } - TxOutcome::Confirmed { l1_block } - } - Err(TxManagerError::AlreadyReserved) => { - warn!("txpool nonce slot already reserved"); - TxOutcome::TxpoolBlocked + self.in_flight.push(Box::pin(async move { + let outcome = match handle.await { + Ok(receipt) => { + let l1_block = receipt.block_number.unwrap_or_else(|| { + warn!(id = ?id, "confirmed receipt missing block number; l1_head will not advance"); + 0 + }); + // Count blob bytes only once they land on L1, not when attempted. + if let Some(bytes) = blob_payload_bytes { + BatcherMetrics::blob_used_bytes_total().increment(bytes); } - Err(e) => { - warn!(error = %e, "submission failed"); - TxOutcome::Failed - } - }; - drop(permit); - (vec![sub.id], outcome) - }); - self.in_flight.push(fut); + TxOutcome::Confirmed { l1_block } + } + Err(TxManagerError::AlreadyReserved) => { + warn!(id = ?id, "txpool nonce slot already reserved"); + TxOutcome::TxpoolBlocked + } + Err(e) => { + warn!(id = ?id, error = %e, "submission failed"); + TxOutcome::Failed + } + }; + (id, outcome) + })); } + Ok(()) } /// Attempt to clear a txpool blockage by cancelling the stuck transaction. @@ -230,101 +211,67 @@ impl SubmissionQueue { } } - /// Handle a settled in-flight receipt. + /// Report a settled transaction to the pipeline. /// - /// On confirmation, calls `pipeline.confirm` for each submitted id and - /// `pipeline.advance_l1_head` once. On failure, requeues all. On txpool - /// blockage, requeues all and sets the blocked flag. + /// A confirmation confirms the submission and advances the pipeline's L1 head to the + /// inclusion block. A failure requeues the submission. A txpool blockage requeues it too, + /// and suspends sending until [`recover_txpool`](Self::recover_txpool) succeeds. pub fn handle_outcome( &mut self, pipeline: &mut P, - ids: Vec, + id: SubmissionId, outcome: TxOutcome, ) { BatcherMetrics::in_flight_submissions().decrement(1.0); match outcome { TxOutcome::Confirmed { l1_block } => { - for id in &ids { - pipeline.confirm(*id, l1_block); - } + pipeline.confirm(id, l1_block); pipeline.advance_l1_head(l1_block); - BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_CONFIRMED) - .increment(ids.len() as u64); - info!(submissions = %ids.len(), l1_block = %l1_block, "submission confirmed on L1"); + BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_CONFIRMED).increment(1); + info!(id = ?id, l1_block = %l1_block, "submission confirmed on L1"); } TxOutcome::Failed => { - let count = ids.len(); - for id in ids { - pipeline.requeue(id); - } - BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_FAILED) - .increment(count as u64); - warn!(submissions = %count, "submission failed"); + pipeline.requeue(id); + BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_FAILED).increment(1); } TxOutcome::TxpoolBlocked => { - let count = ids.len(); - for id in ids { - pipeline.requeue(id); - } + pipeline.requeue(id); self.txpool_blocked = true; - BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_REQUEUED) - .increment(count as u64); - warn!(submissions = %count, "submission blocked by txpool nonce slot"); + BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_REQUEUED).increment(1); } } } - /// Drain all in-flight futures up to the given deadline. + /// Report the in-flight transactions to the pipeline as they settle, until none is left + /// or `timeout` fires. /// - /// Confirmed receipts call `pipeline.confirm` + `pipeline.advance_l1_head`. - /// Failed or txpool-blocked submissions are logged and abandoned — no requeue - /// because the process is shutting down. + /// Used on shutdown: the transactions still in flight at the timeout are abandoned. pub async fn drain( &mut self, pipeline: &mut P, - mut timeout_fut: Pin + Send>>, + mut timeout: impl Future + Unpin, ) { - loop { - if self.in_flight.is_empty() { - break; - } + while !self.in_flight.is_empty() { tokio::select! { - _ = &mut timeout_fut => { - warn!(remaining = %self.in_flight.len(), "drain timeout reached, abandoning in-flight submissions"); - break; + () = &mut timeout => { + warn!( + remaining = %self.in_flight.len(), + "drain timeout reached, abandoning in-flight submissions" + ); + return; } - Some((ids, outcome)) = self.in_flight.next() => { - BatcherMetrics::in_flight_submissions().decrement(1.0); - match outcome { - TxOutcome::Confirmed { l1_block } => { - for id in &ids { - pipeline.confirm(*id, l1_block); - } - pipeline.advance_l1_head(l1_block); - BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_CONFIRMED).increment(ids.len() as u64); - info!(submissions = %ids.len(), l1_block = %l1_block, "submission confirmed on L1 during drain"); - } - TxOutcome::Failed => { - BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_FAILED).increment(ids.len() as u64); - warn!(submissions = %ids.len(), "submission failed during drain, abandoning"); - } - TxOutcome::TxpoolBlocked => { - BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_REQUEUED).increment(ids.len() as u64); - warn!(submissions = %ids.len(), "submission txpool-blocked during drain, abandoning"); - } - } + Some((id, outcome)) = self.in_flight.next() => { + self.handle_outcome(pipeline, id, outcome); } } } } - /// Returns a future for the next settled `(ids, outcome)` pair. + /// Returns a future for the next settled `(id, outcome)` pair. /// /// Resolves immediately to `None` when in-flight is empty; safe to use as /// a `select!` arm with a `Some(...)` pattern guard. - pub fn next_settled( - &mut self, - ) -> impl Future, TxOutcome)>> + '_ { + pub fn next_settled(&mut self) -> impl Future> + '_ { self.in_flight.next() } diff --git a/crates/batcher/core/src/test_utils/tx_manager.rs b/crates/batcher/core/src/test_utils/tx_manager.rs index 7ddcca2a7a..0e9823e78f 100644 --- a/crates/batcher/core/src/test_utils/tx_manager.rs +++ b/crates/batcher/core/src/test_utils/tx_manager.rs @@ -81,9 +81,8 @@ impl TxManager for ImmediateFailTxManager { /// [`TxManager`] that never confirms any submission — the in-flight future parks forever. /// -/// Used to test semaphore backpressure: permits are consumed but never released, -/// so `try_acquire_owned` fails once the limit is reached and no further -/// submissions are dequeued. +/// Used to test the in-flight limit: a transaction sent never settles, so once the limit +/// is reached no further submissions are dequeued. #[derive(Debug)] pub struct NeverConfirmTxManager; diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index 2aa7685b79..7463485478 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -105,7 +105,8 @@ impl UnsafeBlockSource for PollRecorder { } /// The driver does all the work it can before waiting for the next event: a receipt that -/// frees a permit gets the next ready submission sent before any source is waited on again. +/// frees an in-flight slot gets the next ready submission sent before any source is waited on +/// again. #[test] fn test_driver_finishes_pending_work_before_waiting_for_events() { Runner::start(Config::seeded(0), |ctx| async move { @@ -120,7 +121,7 @@ fn test_driver_finishes_pending_work_before_waiting_for_events() { dequeued_at_poll: Arc::clone(&dequeued_at_poll), }; - // A single permit: the second submission can only leave the pipeline once the + // One tx in flight at most: the second submission can only leave the pipeline once the // receipt of the first one has been processed. let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).source(source).build(); From 783c3819da988f469a63d876b0de0876b808752c Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 24 Sep 2026 21:54:14 +0000 Subject: [PATCH 115/299] test(cli): avoid node teardown in OTLP export test (#5259) --- bin/base/src/cli.rs | 68 ++++----------------------------------------- 1 file changed, 6 insertions(+), 62 deletions(-) diff --git a/bin/base/src/cli.rs b/bin/base/src/cli.rs index 77ef3ae42b..e6fe60ddb3 100644 --- a/bin/base/src/cli.rs +++ b/bin/base/src/cli.rs @@ -81,18 +81,12 @@ impl BaseCli { #[cfg(test)] mod tests { - use std::{ffi::OsStr, process::Command}; - #[cfg(unix)] - use std::{thread, time::Duration}; + use std::{ffi::OsStr, process::Command, time::Duration}; - #[cfg(unix)] use axum::{Router, body::Bytes, routing::post}; use clap::{CommandFactory, Parser}; - #[cfg(unix)] use tokio::{ net::TcpListener, - process::Command as AsyncCommand, - signal::unix::{SignalKind, signal}, sync::{mpsc, oneshot}, }; @@ -140,7 +134,6 @@ mod tests { } } - #[cfg(unix)] #[test] fn exports_otlp_spans_with_independent_filter() { // Each exporter needs its own process because the tracing subscriber is global. @@ -154,7 +147,6 @@ mod tests { let output = command .args(["--exact", "cli::tests::exports_otlp_child", "--ignored", "--nocapture"]) .env("BASE_OTLP_TEST_PROTOCOL", protocol) - .env("BASE_NODE_METRICS_ENABLED", "false") .env("OTEL_BSP_SCHEDULE_DELAY", "50") .env("OTEL_SERVICE_NAME", "unified-otlp-test") .output() @@ -168,14 +160,10 @@ mod tests { } } - #[cfg(unix)] #[tokio::test] #[ignore = "spawned by exports_otlp_spans_with_independent_filter"] async fn exports_otlp_child() { let protocol = std::env::var("BASE_OTLP_TEST_PROTOCOL").unwrap(); - // Install before node startup so an early shutdown request cannot terminate the - // process before the CLI has registered its own SIGTERM listener. - let _sigterm = signal(SignalKind::terminate()).unwrap(); let collector = TcpListener::bind("127.0.0.1:0").await.unwrap(); let endpoint = format!("http://{}", collector.local_addr().unwrap()); let (sender, mut requests) = mpsc::unbounded_channel(); @@ -211,35 +199,16 @@ mod tests { .unwrap(); }); - // Keep the unified RPC command running without any external network dependency. - let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let upstream_url = format!("http://{}", upstream.local_addr().unwrap()); - let dir = tempfile::tempdir().unwrap(); let otlp_arg = format!("--tracing-otlp={endpoint}"); let cli = BaseCli::try_parse_from([ "base", "--chain", "dev", "rpc", - "--datadir", - dir.path().to_str().unwrap(), - "--ipcpath", - dir.path().join("rpc.ipc").to_str().unwrap(), - "--port", - "0", - "--authrpc.port", - "0", - "--rpc.port", - "0", - "--p2p.listen.tcp", - "0", - "--p2p.listen.udp", - "0", - "--disable-discovery", "--l1-eth-rpc", - &upstream_url, + "http://localhost:8545", "--l1-beacon", - &upstream_url, + "http://localhost:5052", "-q", &otlp_arg, "--tracing-otlp-protocol", @@ -248,13 +217,9 @@ mod tests { "off,base_otlp_test=debug", ]) .unwrap(); - let node = - thread::Builder::new().stack_size(32 * 1024 * 1024).spawn(move || cli.run()).unwrap(); + LogConfig::from(cli.logging).init_with_trace_args(&cli.traces, &[]).unwrap(); let received = tokio::time::timeout(Duration::from_secs(30), async { loop { - if node.is_finished() { - break None; - } { let _span = tracing::debug_span!(target: "base_otlp_test", "included_span").entered(); @@ -264,39 +229,18 @@ mod tests { tracing::error_span!(target: "excluded_target", "excluded_span").entered(); } tokio::select! { - Some(body) = requests.recv() => break Some(body), + Some(body) = requests.recv() => break body, _ = tokio::time::sleep(Duration::from_millis(50)) => {} } } }) - .await; - - // Export may complete before the CLI starts listening for shutdown. Retry the signal - // until it exits, keeping the collector, upstream and data directory alive throughout. - // Never let the test process exit with a detached node using RocksDB's global mutexes. - tokio::time::timeout(Duration::from_secs(30), async { - while !node.is_finished() { - let status = AsyncCommand::new("kill") - .args(["-TERM", &std::process::id().to_string()]) - .status() - .await - .unwrap(); - assert!(status.success(), "failed to request CLI shutdown"); - tokio::time::sleep(Duration::from_millis(50)).await; - } - }) .await - .expect("timed out waiting for CLI shutdown"); - let node_result = node.join(); + .expect("timed out waiting for OTLP export"); stop_collector.send(()).unwrap(); tokio::time::timeout(Duration::from_secs(5), collector) .await .expect("timed out waiting for collector shutdown") .unwrap(); - node_result.expect("CLI thread panicked").expect("CLI shutdown failed"); - let received = received - .expect("timed out waiting for OTLP export") - .expect("node exited before OTLP export"); // Protobuf string fields retain their UTF-8 bytes in both transport encodings. let payload = String::from_utf8_lossy(&received); From 320529ae84771ba9e91749849543b769a9c0d128 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 24 Sep 2026 21:56:36 +0000 Subject: [PATCH 116/299] fix(consensus): fetch system config by L2 hash (#5274) * fix(consensus): fetch system config by L2 hash * fix(consensus): preserve system config ancestry by hash * fix(consensus): address system config lookup feedback --- actions/harness/src/providers/l2.rs | 113 ++++++++-- .../derive/src/attributes/stateful.rs | 53 +++-- crates/consensus/derive/src/pipeline/core.rs | 47 +++- .../derive/src/stages/batch/batch_queue.rs | 4 +- .../src/stages/batch/batch_validator.rs | 6 +- .../derive/src/test_utils/chain_providers.rs | 17 +- .../src/test_utils/sys_config_fetcher.rs | 22 +- .../consensus/derive/src/traits/pipeline.rs | 7 +- .../consensus/derive/src/traits/providers.rs | 6 +- .../providers/src/l2_chain_provider.rs | 202 ++++++++++-------- crates/consensus/providers/src/pipeline.rs | 10 +- crates/proof/proof/src/l1/pipeline.rs | 10 +- crates/proof/proof/src/l2/chain_provider.rs | 27 ++- crates/proof/zk/utils/src/client.rs | 2 +- 14 files changed, 342 insertions(+), 184 deletions(-) diff --git a/actions/harness/src/providers/l2.rs b/actions/harness/src/providers/l2.rs index b44a3dc5a9..864f060c59 100644 --- a/actions/harness/src/providers/l2.rs +++ b/actions/harness/src/providers/l2.rs @@ -1,7 +1,10 @@ -use std::sync::{Arc, Mutex}; +use std::{ + collections::HashSet, + sync::{Arc, Mutex}, +}; use alloy_eips::BlockNumHash; -use alloy_primitives::map::HashMap; +use alloy_primitives::{B256, map::HashMap}; use async_trait::async_trait; use base_common_consensus::BaseBlock; use base_common_genesis::{RollupConfig, SystemConfig}; @@ -16,7 +19,7 @@ pub enum L2ProviderError { BlockNotFound(u64), /// System config not found. #[error("system config not found for L2 block {0}")] - SystemConfigNotFound(u64), + SystemConfigNotFound(B256), } impl From for PipelineErrorKind { @@ -28,7 +31,8 @@ impl From for PipelineErrorKind { /// In-memory L2 chain provider for action tests. /// /// Implements [`L2ChainProvider`] and [`BatchValidationProvider`] using -/// maps keyed by block number. Tests pre-populate it via [`insert_block`] and +/// blocks keyed by number and system configs keyed by L2 block hash. Tests pre-populate it via +/// [`insert_block`] and /// [`insert_system_config`]. /// /// The genesis L2 block and its system config must be inserted before the @@ -41,10 +45,12 @@ impl From for PipelineErrorKind { pub struct ActionL2ChainProvider { /// L2 blocks by block number. blocks: Arc>>, + /// L2 blocks by block hash, used to walk a particular fork's ancestry. + blocks_by_hash: Arc>>, /// Base blocks (headers + txs) by block number, needed for batch validation. base_blocks: Arc>>, - /// System configs by L2 block number. - system_configs: Arc>>, + /// System configs by L2 block hash. + system_configs: Arc>>, } impl ActionL2ChainProvider { @@ -81,13 +87,17 @@ impl ActionL2ChainProvider { .unwrap_or_else(|| SystemConfig { gas_limit: 30_000_000, ..Default::default() }); provider.insert_block(genesis_l2); - provider.insert_system_config(rollup_config.genesis.l2.number, genesis_config); + provider.insert_system_config(rollup_config.genesis.l2.hash, genesis_config); provider } /// Insert a known L2 block into the provider. pub fn insert_block(&self, block: L2BlockInfo) { self.blocks.lock().expect("L2 blocks lock poisoned").insert(block.block_info.number, block); + self.blocks_by_hash + .lock() + .expect("L2 blocks by hash lock poisoned") + .insert(block.block_info.hash, block); } /// Insert a known L2 block with transactions into the provider. @@ -95,9 +105,9 @@ impl ActionL2ChainProvider { self.base_blocks.lock().expect("L2 base blocks lock poisoned").insert(number, block); } - /// Insert a system config for the given L2 block number. - pub fn insert_system_config(&self, number: u64, config: SystemConfig) { - self.system_configs.lock().expect("L2 system configs lock poisoned").insert(number, config); + /// Insert a system config for the given L2 block hash. + pub fn insert_system_config(&self, hash: B256, config: SystemConfig) { + self.system_configs.lock().expect("L2 system configs lock poisoned").insert(hash, config); } } @@ -131,14 +141,85 @@ impl BatchValidationProvider for ActionL2ChainProvider { impl L2ChainProvider for ActionL2ChainProvider { type Error = L2ProviderError; - async fn system_config_by_number( + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, _rollup_config: Arc, ) -> Result { - // Walk back from `number` to find the nearest config at or before this block. - let system_configs = self.system_configs.lock().expect("L2 system configs lock poisoned"); - let config = (0..=number).rev().find_map(|n| system_configs.get(&n).copied()); - config.ok_or(L2ProviderError::SystemConfigNotFound(number)) + let mut current_hash = hash; + let mut visited = HashSet::new(); + + loop { + if let Some(config) = self + .system_configs + .lock() + .expect("L2 system configs lock poisoned") + .get(¤t_hash) + .copied() + { + return Ok(config); + } + + if !visited.insert(current_hash) { + return Err(L2ProviderError::SystemConfigNotFound(hash)); + } + + current_hash = self + .blocks_by_hash + .lock() + .expect("L2 blocks by hash lock poisoned") + .get(¤t_hash) + .ok_or(L2ProviderError::SystemConfigNotFound(hash))? + .block_info + .parent_hash; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn system_config_lookup_walks_the_requested_fork_by_hash() { + let provider = ActionL2ChainProvider::default(); + let genesis_hash = B256::left_padding_from(&[1]); + let child_hash = B256::left_padding_from(&[2]); + provider.insert_system_config( + genesis_hash, + SystemConfig { gas_limit: 123, ..Default::default() }, + ); + provider.insert_block(L2BlockInfo { + block_info: BlockInfo { + hash: child_hash, + parent_hash: genesis_hash, + number: 1, + ..Default::default() + }, + ..Default::default() + }); + + let mut provider = provider; + let config = provider + .system_config_by_l2_hash(child_hash, Arc::new(RollupConfig::default())) + .await + .unwrap(); + assert_eq!(config.gas_limit, 123); + } + + #[tokio::test] + async fn system_config_lookup_rejects_cyclic_test_ancestry() { + let provider = ActionL2ChainProvider::default(); + let hash = B256::left_padding_from(&[1]); + provider.insert_block(L2BlockInfo { + block_info: BlockInfo { hash, parent_hash: hash, number: 1, ..Default::default() }, + ..Default::default() + }); + + let mut provider = provider; + assert!(matches!( + provider.system_config_by_l2_hash(hash, Arc::new(RollupConfig::default())).await, + Err(L2ProviderError::SystemConfigNotFound(actual)) if actual == hash + )); } } diff --git a/crates/consensus/derive/src/attributes/stateful.rs b/crates/consensus/derive/src/attributes/stateful.rs index df2cba5b13..6cb8a2b4ef 100644 --- a/crates/consensus/derive/src/attributes/stateful.rs +++ b/crates/consensus/derive/src/attributes/stateful.rs @@ -103,7 +103,7 @@ where } _ => self .config_fetcher - .system_config_by_number(l2_parent.block_info.number, Arc::clone(&self.rollup_cfg)) + .system_config_by_l2_hash(l2_parent.block_info.hash, Arc::clone(&self.rollup_cfg)) .await .map_err(Into::into)?, }; @@ -506,10 +506,8 @@ mod tests { assert_eq!(payload.gas_limit, Some(123)); let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert( - parent.block_info.number, - SystemConfig { gas_limit: 456, ..Default::default() }, - ); + fetcher + .insert(parent.block_info.hash, SystemConfig { gas_limit: 456, ..Default::default() }); let (mut builder, parent, epoch) = system_config_test_builder(fetcher); let activation = builder.rollup_cfg.l2_block_timestamp(parent.block_info.number + 1); Arc::make_mut(&mut builder.rollup_cfg).upgrades.fjord_time = Some(activation); @@ -522,10 +520,8 @@ mod tests { assert_eq!(payload.gas_limit, Some(456)); let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert( - parent.block_info.number, - SystemConfig { gas_limit: 456, ..Default::default() }, - ); + fetcher + .insert(parent.block_info.hash, SystemConfig { gas_limit: 456, ..Default::default() }); let (mut builder, parent, epoch) = system_config_test_builder(fetcher); let mut mismatched_parent = parent; mismatched_parent.block_info.hash = B256::left_padding_from(&[2]); @@ -539,6 +535,23 @@ mod tests { assert_eq!(builder.parent_system_config.unwrap().0, mismatched_parent); } + #[tokio::test] + async fn test_prepare_payload_uses_system_config_for_exact_parent_hash() { + let canonical_hash = B256::left_padding_from(&[1]); + let reorged_hash = B256::left_padding_from(&[2]); + let mut fetcher = TestSystemConfigL2Fetcher::default(); + fetcher.insert(canonical_hash, SystemConfig { gas_limit: 123, ..Default::default() }); + fetcher.insert(reorged_hash, SystemConfig { gas_limit: 456, ..Default::default() }); + let (mut builder, mut parent, epoch) = system_config_test_builder(fetcher); + assert_eq!(parent.block_info.hash, canonical_hash); + + // Both blocks have the same height but represent different forks. The requested parent + // must select the config attached to its hash, never a stale config at that height. + parent.block_info.hash = reorged_hash; + let payload = builder.prepare_payload_attributes(parent, epoch).await.unwrap(); + assert_eq!(payload.gas_limit, Some(456)); + } + #[tokio::test] async fn test_epoch_update_applies_to_parent_system_config_seed() { let system_config_address = address!("1111111111111111111111111111111111111111"); @@ -606,7 +619,7 @@ mod tests { let l1_cfg = Arc::new(Sepolia::l1_config()); let l2_number = 1; let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert(l2_number, SystemConfig::default()); + fetcher.insert(B256::ZERO, SystemConfig::default()); let mut provider = TestChainProvider::default(); let header = Header::default(); let hash = header.hash_slow(); @@ -633,7 +646,7 @@ mod tests { let l1_cfg = Arc::new(Sepolia::l1_config()); let l2_number = 1; let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert(l2_number, SystemConfig::default()); + fetcher.insert(B256::ZERO, SystemConfig::default()); let mut provider = TestChainProvider::default(); let header = Header::default(); let hash = header.hash_slow(); @@ -661,7 +674,7 @@ mod tests { let l1_cfg = Arc::new(Sepolia::l1_config()); let l2_number = 1; let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert(l2_number, SystemConfig::default()); + fetcher.insert(B256::ZERO, SystemConfig::default()); let mut provider = TestChainProvider::default(); let header = Header { timestamp, ..Default::default() }; let hash = header.hash_slow(); @@ -701,7 +714,7 @@ mod tests { let l1_cfg = Arc::new(Sepolia::l1_config()); let l2_number = 1; let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert(l2_number, SystemConfig::default()); + fetcher.insert(B256::ZERO, SystemConfig::default()); let mut provider = TestChainProvider::default(); let header = Header { timestamp, ..Default::default() }; let prev_randao = header.mix_hash; @@ -766,7 +779,7 @@ mod tests { let l1_cfg = Arc::new(Sepolia::l1_config()); let l2_number = 1; let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert(l2_number, SystemConfig::default()); + fetcher.insert(B256::ZERO, SystemConfig::default()); let mut provider = TestChainProvider::default(); let header = Header { timestamp, ..Default::default() }; let hash = header.hash_slow(); @@ -829,7 +842,7 @@ mod tests { let l2_number = 2; let mut fetcher = TestSystemConfigL2Fetcher::default(); fetcher.insert( - l2_number, + B256::ZERO, SystemConfig { gas_limit: 30_000_000, eip1559_denominator: Some(500), @@ -896,7 +909,7 @@ mod tests { let mut fetcher = TestSystemConfigL2Fetcher::default(); fetcher.insert( - l2_number, + B256::ZERO, SystemConfig { gas_limit: 300_000_000, eip1559_denominator: Some(50), @@ -975,7 +988,7 @@ mod tests { let l1_cfg = Arc::new(Sepolia::l1_config()); let l2_number = 1; let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert(l2_number, SystemConfig::default()); + fetcher.insert(B256::ZERO, SystemConfig::default()); let mut provider = TestChainProvider::default(); let header = Header { timestamp, ..Default::default() }; let prev_randao = header.mix_hash; @@ -1034,7 +1047,7 @@ mod tests { let l1_cfg = Arc::new(Sepolia::l1_config()); let l2_number = 1; let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert(l2_number, SystemConfig::default()); + fetcher.insert(B256::ZERO, SystemConfig::default()); let mut provider = TestChainProvider::default(); let header = Header { timestamp, ..Default::default() }; let parent_beacon_block_root = Some(header.parent_beacon_block_root.unwrap_or_default()); @@ -1094,7 +1107,7 @@ mod tests { let l1_cfg = Arc::new(Sepolia::l1_config()); let l2_number = 1; let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert(l2_number, SystemConfig::default()); + fetcher.insert(B256::ZERO, SystemConfig::default()); let mut provider = TestChainProvider::default(); let header = Header { timestamp, ..Default::default() }; let prev_randao = header.mix_hash; @@ -1149,7 +1162,7 @@ mod tests { let l1_cfg = Arc::new(Sepolia::l1_config()); let l2_number = 1; let mut fetcher = TestSystemConfigL2Fetcher::default(); - fetcher.insert(l2_number, SystemConfig::default()); + fetcher.insert(B256::ZERO, SystemConfig::default()); let mut provider = TestChainProvider::default(); // The epoch header's parent_hash must match l2_parent.l1_origin.hash. diff --git a/crates/consensus/derive/src/pipeline/core.rs b/crates/consensus/derive/src/pipeline/core.rs index 8b9c657ddf..5ac950465f 100644 --- a/crates/consensus/derive/src/pipeline/core.rs +++ b/crates/consensus/derive/src/pipeline/core.rs @@ -4,6 +4,7 @@ use alloc::{boxed::Box, collections::VecDeque, string::ToString, sync::Arc}; use core::fmt::Debug; use alloy_eips::BlockNumHash; +use alloy_primitives::B256; use async_trait::async_trait; use base_common_genesis::{RollupConfig, SystemConfig}; use base_protocol::{AttributesWithParent, BatchValidationProvider, BlockInfo, L2BlockInfo}; @@ -92,7 +93,7 @@ where let system_config = self .l2_chain_provider - .system_config_by_number(current.block_info.number, Arc::clone(&self.rollup_config)) + .system_config_by_l2_hash(current.block_info.hash, Arc::clone(&self.rollup_config)) .await .map_err(Into::into)?; @@ -195,13 +196,13 @@ where &self.rollup_config } - /// Returns the [`SystemConfig`] by L2 number. - async fn system_config_by_number( + /// Returns the [`SystemConfig`] for the L2 block with the given hash. + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, ) -> Result { self.l2_chain_provider - .system_config_by_number(number, Arc::clone(&self.rollup_config)) + .system_config_by_l2_hash(hash, Arc::clone(&self.rollup_config)) .await .map_err(Into::into) } @@ -362,7 +363,7 @@ mod tests { async fn test_derivation_pipeline_signal_activation() { let rollup_config = Arc::new(RollupConfig::default()); let mut l2_chain_provider = TestL2ChainProvider::default(); - l2_chain_provider.system_configs.insert(0, SystemConfig::default()); + l2_chain_provider.system_configs.insert(B256::ZERO, SystemConfig::default()); let attributes = TestNextAttributes::default(); let mut pipeline = DerivationPipeline::new(attributes, rollup_config, l2_chain_provider); @@ -399,7 +400,7 @@ mod tests { async fn test_derivation_pipeline_signal_reset_ok() { let rollup_config = Arc::new(RollupConfig::default()); let mut l2_chain_provider = TestL2ChainProvider::default(); - l2_chain_provider.system_configs.insert(0, SystemConfig::default()); + l2_chain_provider.system_configs.insert(B256::ZERO, SystemConfig::default()); let attributes = TestNextAttributes::default(); let mut pipeline = DerivationPipeline::new(attributes, rollup_config, l2_chain_provider); @@ -415,7 +416,7 @@ mod tests { ..Default::default() }); let mut l2_chain_provider = TestL2ChainProvider::default(); - l2_chain_provider.system_configs.insert(0, SystemConfig::default()); + l2_chain_provider.system_configs.insert(B256::ZERO, SystemConfig::default()); let attributes = TestNextAttributes::default(); let mut pipeline = DerivationPipeline::new(attributes, rollup_config, l2_chain_provider); @@ -428,6 +429,32 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_initial_reset_uses_system_config_for_exact_block_hash() { + let canonical_hash = B256::left_padding_from(&[1]); + let reorged_hash = B256::left_padding_from(&[2]); + let mut l2_chain_provider = TestL2ChainProvider::default(); + l2_chain_provider + .system_configs + .insert(canonical_hash, SystemConfig { gas_limit: 123, ..Default::default() }); + l2_chain_provider + .system_configs + .insert(reorged_hash, SystemConfig { gas_limit: 456, ..Default::default() }); + let mut pipeline = DerivationPipeline::new( + TestNextAttributes::default(), + Arc::new(RollupConfig::default()), + l2_chain_provider, + ); + + // The reset target is the reorged block at the same height as the canonical block. + let safe_head = L2BlockInfo { + block_info: BlockInfo { hash: reorged_hash, ..Default::default() }, + ..Default::default() + }; + let (_, system_config) = pipeline.initial_reset(safe_head).await.unwrap(); + assert_eq!(system_config.gas_limit, 456); + } + /// On a Granite-straddle safe head — L2 timestamp post-Granite while its L1 origin /// timestamp is pre-Granite, possible because `max_sequencer_drift` allows the L2 to lead /// its L1 origin by up to 1800s post-Fjord — the Granite-vs-pre-Granite `channel_timeout` @@ -479,11 +506,11 @@ mod tests { }); } l2_chain_provider.system_configs.insert( - SPEC_STOP_L1_ORIGIN, + B256::with_last_byte((SPEC_STOP_L1_ORIGIN & 0xff) as u8), SystemConfig { batcher_address: BATCHER_AT_SPEC_STOP, ..Default::default() }, ); l2_chain_provider.system_configs.insert( - CODE_STOP_L1_ORIGIN, + B256::with_last_byte((CODE_STOP_L1_ORIGIN & 0xff) as u8), SystemConfig { batcher_address: BATCHER_AT_CODE_STOP, ..Default::default() }, ); diff --git a/crates/consensus/derive/src/stages/batch/batch_queue.rs b/crates/consensus/derive/src/stages/batch/batch_queue.rs index cd9466661c..70a7032518 100644 --- a/crates/consensus/derive/src/stages/batch/batch_queue.rs +++ b/crates/consensus/derive/src/stages/batch/batch_queue.rs @@ -545,9 +545,9 @@ mod tests { impl L2ChainProvider for PendingL2ChainProvider { type Error = TestProviderError; - async fn system_config_by_number( + async fn system_config_by_l2_hash( &mut self, - _: u64, + _: alloy_primitives::B256, _: Arc, ) -> Result::Error> { unreachable!() diff --git a/crates/consensus/derive/src/stages/batch/batch_validator.rs b/crates/consensus/derive/src/stages/batch/batch_validator.rs index aa4d8f9ed7..eacef27e97 100644 --- a/crates/consensus/derive/src/stages/batch/batch_validator.rs +++ b/crates/consensus/derive/src/stages/batch/batch_validator.rs @@ -416,12 +416,12 @@ mod tests { impl L2ChainProvider for ResettingL2ChainProvider { type Error = ResetError; - async fn system_config_by_number( + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: alloy_primitives::B256, _: Arc, ) -> Result::Error> { - Err(ResetError::BlockNotFound(BlockId::Number(number.into()))) + Err(ResetError::BlockNotFound(BlockId::Hash(hash.into()))) } } diff --git a/crates/consensus/derive/src/test_utils/chain_providers.rs b/crates/consensus/derive/src/test_utils/chain_providers.rs index 5baf191de1..3579125352 100644 --- a/crates/consensus/derive/src/test_utils/chain_providers.rs +++ b/crates/consensus/derive/src/test_utils/chain_providers.rs @@ -95,7 +95,7 @@ pub enum TestProviderError { L2BlockNotFound, /// The system config was not found. #[error("System config not found")] - SystemConfigNotFound(u64), + SystemConfigNotFound(B256), } impl From for PipelineErrorKind { @@ -161,8 +161,8 @@ pub struct TestL2ChainProvider { pub short_circuit: bool, /// Blocks pub base_blocks: Vec, - /// System configs - pub system_configs: HashMap, + /// System configs, keyed by L2 block hash. + pub system_configs: HashMap, } impl TestL2ChainProvider { @@ -170,7 +170,7 @@ impl TestL2ChainProvider { pub const fn new( blocks: Vec, base_blocks: Vec, - system_configs: HashMap, + system_configs: HashMap, ) -> Self { Self { blocks, short_circuit: false, base_blocks, system_configs } } @@ -204,14 +204,11 @@ impl BatchValidationProvider for TestL2ChainProvider { impl L2ChainProvider for TestL2ChainProvider { type Error = TestProviderError; - async fn system_config_by_number( + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, _: Arc, ) -> Result::Error> { - self.system_configs - .get(&number) - .ok_or_else(|| TestProviderError::SystemConfigNotFound(number)) - .cloned() + self.system_configs.get(&hash).ok_or(TestProviderError::SystemConfigNotFound(hash)).cloned() } } diff --git a/crates/consensus/derive/src/test_utils/sys_config_fetcher.rs b/crates/consensus/derive/src/test_utils/sys_config_fetcher.rs index 4874c2f233..eb61ec13d1 100644 --- a/crates/consensus/derive/src/test_utils/sys_config_fetcher.rs +++ b/crates/consensus/derive/src/test_utils/sys_config_fetcher.rs @@ -2,7 +2,7 @@ use alloc::{boxed::Box, string::ToString, sync::Arc}; -use alloy_primitives::map::HashMap; +use alloy_primitives::{B256, map::HashMap}; use async_trait::async_trait; use base_common_consensus::BaseBlock; use base_common_genesis::{RollupConfig, SystemConfig}; @@ -17,14 +17,14 @@ use crate::{ /// A mock implementation of the [`L2ChainProvider`] and [`BatchValidationProvider`] for testing. #[derive(Debug, Default)] pub struct TestSystemConfigL2Fetcher { - /// A map from [u64] block number to a [`SystemConfig`]. - pub system_configs: HashMap, + /// A map from L2 block hash to a [`SystemConfig`]. + pub system_configs: HashMap, } impl TestSystemConfigL2Fetcher { - /// Inserts a new system config into the mock fetcher with the given block number. - pub fn insert(&mut self, number: u64, config: SystemConfig) { - self.system_configs.insert(number, config); + /// Inserts a new system config into the mock fetcher with the given block hash. + pub fn insert(&mut self, hash: B256, config: SystemConfig) { + self.system_configs.insert(hash, config); } /// Clears all system configs from the mock fetcher. @@ -38,7 +38,7 @@ impl TestSystemConfigL2Fetcher { pub enum TestSystemConfigL2FetcherError { /// The system config was not found. #[error("system config not found: {0}")] - NotFound(u64), + NotFound(B256), } impl From for PipelineErrorKind { @@ -64,14 +64,14 @@ impl BatchValidationProvider for TestSystemConfigL2Fetcher { impl L2ChainProvider for TestSystemConfigL2Fetcher { type Error = TestSystemConfigL2FetcherError; - async fn system_config_by_number( + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, _: Arc, ) -> Result::Error> { self.system_configs - .get(&number) + .get(&hash) .copied() - .ok_or_else(|| TestSystemConfigL2FetcherError::NotFound(number)) + .ok_or(TestSystemConfigL2FetcherError::NotFound(hash)) } } diff --git a/crates/consensus/derive/src/traits/pipeline.rs b/crates/consensus/derive/src/traits/pipeline.rs index 8c1cb0faa6..9563704e29 100644 --- a/crates/consensus/derive/src/traits/pipeline.rs +++ b/crates/consensus/derive/src/traits/pipeline.rs @@ -3,6 +3,7 @@ use alloc::boxed::Box; use core::iter::Iterator; +use alloy_primitives::B256; use async_trait::async_trait; use base_common_genesis::{RollupConfig, SystemConfig}; use base_protocol::{AttributesWithParent, L2BlockInfo}; @@ -21,9 +22,9 @@ pub trait Pipeline: OriginProvider + Iterator { /// Returns the rollup config. fn rollup_config(&self) -> &RollupConfig; - /// Returns the [`SystemConfig`] by L2 number. - async fn system_config_by_number( + /// Returns the [`SystemConfig`] for the L2 block with the given hash. + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, ) -> Result; } diff --git a/crates/consensus/derive/src/traits/providers.rs b/crates/consensus/derive/src/traits/providers.rs index ddf2f22364..9384d871a3 100644 --- a/crates/consensus/derive/src/traits/providers.rs +++ b/crates/consensus/derive/src/traits/providers.rs @@ -41,10 +41,10 @@ pub trait L2ChainProvider: BatchValidationProviderDerive { /// The error type for the [`L2ChainProvider`]. type Error: Display + Into; - /// Returns the [`SystemConfig`] by L2 number. - async fn system_config_by_number( + /// Returns the [`SystemConfig`] for the L2 block with the given hash. + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, rollup_config: Arc, ) -> Result::Error>; } diff --git a/crates/consensus/providers/src/l2_chain_provider.rs b/crates/consensus/providers/src/l2_chain_provider.rs index f5f0e4c59d..51312f088d 100644 --- a/crates/consensus/providers/src/l2_chain_provider.rs +++ b/crates/consensus/providers/src/l2_chain_provider.rs @@ -2,7 +2,7 @@ use std::{num::NonZeroUsize, sync::Arc, time::Duration}; -use alloy_eips::BlockId; +use alloy_consensus::Header; use alloy_primitives::{B256, Bytes}; use alloy_provider::{Provider, RootProvider}; use alloy_rpc_client::RpcClient; @@ -25,6 +25,7 @@ use tower::ServiceBuilder; use crate::Metrics; const L2_BLOCK_REF_BY_NUMBER_METHOD: &str = "l2_block_ref_by_number"; +const L2_BLOCK_REF_BY_HASH_METHOD: &str = "l2_block_ref_by_hash"; const L2_BLOCK_VISIBILITY_RETRY_ATTEMPTS: usize = 5; const L2_BLOCK_VISIBILITY_RETRY_DELAY: Duration = Duration::from_millis(20); @@ -38,8 +39,10 @@ pub struct AlloyL2ChainProvider { trust_rpc: bool, /// The rollup configuration. rollup_config: Arc, - /// The `block_by_number` LRU cache. - block_by_number_cache: LruCache, + /// The `block_by_number` LRU cache. Shares blocks with `block_by_hash_cache`. + block_by_number_cache: LruCache>, + /// The `block_by_hash` LRU cache. Shares blocks with `block_by_number_cache`. + block_by_hash_cache: LruCache>, } impl AlloyL2ChainProvider { @@ -71,6 +74,7 @@ impl AlloyL2ChainProvider { trust_rpc, rollup_config, block_by_number_cache: LruCache::new(NonZeroUsize::new(cache_size).unwrap()), + block_by_hash_cache: LruCache::new(NonZeroUsize::new(cache_size).unwrap()), } } @@ -84,90 +88,82 @@ impl AlloyL2ChainProvider { self.inner.get_block_number().await } - /// Verifies that a block's hash matches the expected hash when `trust_rpc` is false. + /// Fetches the [`BaseBlock`] with the given hash, caching the result. + async fn block_by_hash( + &mut self, + hash: B256, + ) -> Result, AlloyL2ChainProviderError> { + if let Some(block) = self.block_by_hash_cache.get(&hash) { + return Ok(Arc::clone(block)); + } + + for attempt in 1..=L2_BLOCK_VISIBILITY_RETRY_ATTEMPTS { + Metrics::l2_chain_requests(L2_BLOCK_REF_BY_HASH_METHOD).increment(1); + let block = + base_metrics::time!(Metrics::request_duration(L2_BLOCK_REF_BY_HASH_METHOD), { + self.inner.get_block_by_hash(hash).full().await + }) + .map_err(|e| { + Metrics::l2_chain_errors(L2_BLOCK_REF_BY_HASH_METHOD).increment(1); + AlloyL2ChainProviderError::Transport(e) + })?; + + if let Some(block) = block { + let block = block + .map_header(|header| header.into_inner()) + .into_consensus() + .map_transactions(|t| t.inner.inner.into_inner()); + self.verify_block_hash(&block.header, hash)?; + let block = Arc::new(block); + // A block found by hash carries no claim to being canonical at its height, so do + // not seed the number-keyed cache from this lookup. + self.block_by_hash_cache.put(hash, Arc::clone(&block)); + return Ok(block); + } + + if attempt < L2_BLOCK_VISIBILITY_RETRY_ATTEMPTS { + Metrics::l2_block_visibility_retries().increment(1); + tracing::debug!( + target: "l2_chain_provider", + ?hash, + attempt, + attempts = L2_BLOCK_VISIBILITY_RETRY_ATTEMPTS, + delay = ?L2_BLOCK_VISIBILITY_RETRY_DELAY, + "L2 block not visible yet; retrying" + ); + tokio::time::sleep(L2_BLOCK_VISIBILITY_RETRY_DELAY).await; + } + } + + tracing::warn!( + target: "l2_chain_provider", + ?hash, + attempts = L2_BLOCK_VISIBILITY_RETRY_ATTEMPTS, + "L2 block not visible after exhausting retry budget" + ); + Err(AlloyL2ChainProviderError::BlockHashNotFound(hash)) + } + + /// Verifies that a header hashes to the expected hash when `trust_rpc` is false. fn verify_block_hash( &self, - block_hash: B256, + header: &Header, expected_hash: B256, ) -> Result<(), RpcError> { if self.trust_rpc { return Ok(()); } - if block_hash != expected_hash { + let actual_hash = header.hash_slow(); + if actual_hash != expected_hash { return Err(RpcError::local_usage_str(&format!( - "Block hash mismatch: expected {expected_hash:?}, got {block_hash:?}" + "Block hash mismatch: expected {expected_hash:?}, got {actual_hash:?}" ))); } Ok(()) } - /// Returns the [`L2BlockInfo`] for the given [`BlockId`]. [None] is returned if the block - /// does not exist. - pub async fn block_info_by_id( - &mut self, - id: BlockId, - ) -> Result, RpcError> { - let method_name = match id { - BlockId::Number(_) => "l2_block_ref_by_number", - BlockId::Hash(_) => "l2_block_ref_by_hash", - }; - - Metrics::l2_chain_requests(method_name).increment(1); - - let raw_block = base_metrics::time!(Metrics::request_duration(method_name), { - match &id { - BlockId::Number(num) => self.inner.get_block_by_number(*num).full().await, - BlockId::Hash(hash) => self.inner.get_block_by_hash(hash.block_hash).full().await, - } - }); - - let result = async { - let block = match id { - BlockId::Number(_) => raw_block?, - BlockId::Hash(hash) => { - let block = raw_block?; - - // Verify block hash matches if we fetched by hash - if let Some(ref b) = block { - self.verify_block_hash(b.header.hash, hash.block_hash)?; - } - - block - } - }; - - match block { - Some(block) => { - let consensus_block = block - .map_header(|header| header.into_inner()) - .into_consensus() - .map_transactions(|t| t.inner.inner); - - let l2_block = L2BlockInfo::from_block_and_genesis( - &consensus_block, - &self.rollup_config.genesis, - ) - .map_err(|_| { - RpcError::local_usage_str( - "failed to construct L2BlockInfo from block and genesis", - ) - })?; - Ok(Some(l2_block)) - } - None => Ok(None), - } - } - .await; - - if result.is_err() { - Metrics::l2_chain_errors(method_name).increment(1); - } - - result - } - /// Creates a new [`AlloyL2ChainProvider`] from the provided [`url::Url`]. pub fn new_http( url: url::Url, @@ -198,12 +194,15 @@ pub enum AlloyL2ChainProviderError { /// Failed to find a block. #[error("Failed to fetch block {0}")] BlockNotFound(u64), + /// Failed to find a block by hash. + #[error("Failed to fetch block {0}")] + BlockHashNotFound(B256), /// Failed to construct [`L2BlockInfo`] from the block and genesis. #[error("Failed to construct L2BlockInfo from block {0} and genesis")] L2BlockInfoConstruction(u64), /// Failed to convert the block into a [`SystemConfig`]. #[error("Failed to convert block {0} into SystemConfig")] - SystemConfigConversion(u64), + SystemConfigConversion(B256), } impl From for PipelineErrorKind { @@ -215,6 +214,10 @@ impl From for PipelineErrorKind { AlloyL2ChainProviderError::BlockNotFound(number) => { ResetError::BlockNotFound(alloy_eips::BlockId::Number(number.into())).reset() } + // A missing hash was reorged out; retrying the same lookup cannot succeed. + AlloyL2ChainProviderError::BlockHashNotFound(hash) => { + ResetError::BlockNotFound(hash.into()).reset() + } AlloyL2ChainProviderError::L2BlockInfoConstruction(_) => Self::Temporary( PipelineError::Provider("L2 block info construction failed".to_string()), ), @@ -237,7 +240,7 @@ impl BatchValidationProvider for AlloyL2ChainProvider { async fn block_by_number(&mut self, number: u64) -> Result { if let Some(block) = self.block_by_number_cache.get(&number) { - return Ok(block.clone()); + return Ok((**block).clone()); } for attempt in 1..=L2_BLOCK_VISIBILITY_RETRY_ATTEMPTS { @@ -253,12 +256,17 @@ impl BatchValidationProvider for AlloyL2ChainProvider { })?; if let Some(block) = block { - let block = block - .map_header(|header| header.into_inner()) - .into_consensus() - .map_transactions(|t| t.inner.inner.into_inner()); - self.block_by_number_cache.put(number, block.clone()); - return Ok(block); + let block_hash = block.header.hash; + let block = Arc::new( + block + .map_header(|header| header.into_inner()) + .into_consensus() + .map_transactions(|t| t.inner.inner.into_inner()), + ); + self.verify_block_hash(&block.header, block_hash)?; + self.block_by_hash_cache.put(block_hash, Arc::clone(&block)); + self.block_by_number_cache.put(number, Arc::clone(&block)); + return Ok((*block).clone()); } if attempt < L2_BLOCK_VISIBILITY_RETRY_ATTEMPTS { @@ -289,14 +297,14 @@ impl BatchValidationProvider for AlloyL2ChainProvider { impl L2ChainProvider for AlloyL2ChainProvider { type Error = AlloyL2ChainProviderError; - async fn system_config_by_number( + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, rollup_config: Arc, ) -> Result::Error> { - let block = self.block_by_number(number).await?; + let block = self.block_by_hash(hash).await?; to_system_config(&block, &rollup_config) - .map_err(|_| AlloyL2ChainProviderError::SystemConfigConversion(number)) + .map_err(|_| AlloyL2ChainProviderError::SystemConfigConversion(hash)) } } @@ -328,7 +336,8 @@ mod tests { assert!(matches!(kind, PipelineErrorKind::Temporary(_))); // SystemConfigConversion is a decode failure — transient. - let kind: PipelineErrorKind = AlloyL2ChainProviderError::SystemConfigConversion(0).into(); + let kind: PipelineErrorKind = + AlloyL2ChainProviderError::SystemConfigConversion(B256::ZERO).into(); assert!(matches!(kind, PipelineErrorKind::Temporary(_))); // L2 BlockNotFound: the pipeline only requests blocks that should exist on the @@ -479,9 +488,9 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn test_block_by_number_callers_preserve_transport_errors() { + async fn test_l2_chain_provider_callers_preserve_transport_errors() { let server = MockServer::start_async().await; - let mock = server + let number_mock = server .mock_async(|when, then| { when.method(POST) .path("/") @@ -495,17 +504,32 @@ mod tests { }); }) .await; + let hash_mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/") + .json_body_includes(r#"{"method":"eth_getBlockByHash"}"#); + then.respond_with(|req| { + HttpMockResponse::builder() + .status(200) + .header("content-type", "application/json") + .body(json_rpc_error_response(req)) + .build() + }); + }) + .await; let mut provider = l2_provider(&server); let err = provider.l2_block_info_by_number(42).await.unwrap_err(); assert!(matches!(err, AlloyL2ChainProviderError::Transport(_))); let err = provider - .system_config_by_number(42, Arc::new(RollupConfig::default())) + .system_config_by_l2_hash(B256::ZERO, Arc::new(RollupConfig::default())) .await .unwrap_err(); assert!(matches!(err, AlloyL2ChainProviderError::Transport(_))); - mock.assert_calls_async(2).await; + number_mock.assert_calls_async(1).await; + hash_mock.assert_calls_async(1).await; } } diff --git a/crates/consensus/providers/src/pipeline.rs b/crates/consensus/providers/src/pipeline.rs index 51287d5cde..415559b6ad 100644 --- a/crates/consensus/providers/src/pipeline.rs +++ b/crates/consensus/providers/src/pipeline.rs @@ -4,7 +4,7 @@ use core::fmt::Debug; use std::sync::Arc; use alloy_genesis::ChainConfig; -use alloy_primitives::Address; +use alloy_primitives::{Address, B256}; use async_trait::async_trait; use base_common_genesis::{RollupConfig, SystemConfig}; use base_consensus_derive::{ @@ -181,11 +181,11 @@ impl Pipeline for OnlinePipeline { self.inner.rollup_config() } - /// Returns the [`SystemConfig`] by L2 number. - async fn system_config_by_number( + /// Returns the [`SystemConfig`] for the L2 block with the given hash. + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, ) -> Result { - self.inner.system_config_by_number(number).await + self.inner.system_config_by_l2_hash(hash).await } } diff --git a/crates/proof/proof/src/l1/pipeline.rs b/crates/proof/proof/src/l1/pipeline.rs index cb91b595b6..9fe7fedad1 100644 --- a/crates/proof/proof/src/l1/pipeline.rs +++ b/crates/proof/proof/src/l1/pipeline.rs @@ -4,7 +4,7 @@ use alloc::{boxed::Box, sync::Arc}; use core::fmt::Debug; use alloy_genesis::ChainConfig; -use alloy_primitives::Sealable; +use alloy_primitives::{B256, Sealable}; use async_trait::async_trait; use base_common_genesis::{RollupConfig, SystemConfig}; use base_consensus_derive::{ @@ -232,11 +232,11 @@ where self.pipeline.rollup_config() } - /// Returns the [`SystemConfig`] by L2 number. - async fn system_config_by_number( + /// Returns the [`SystemConfig`] for the L2 block with the given hash. + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, ) -> Result { - self.pipeline.system_config_by_number(number).await + self.pipeline.system_config_by_l2_hash(hash).await } } diff --git a/crates/proof/proof/src/l2/chain_provider.rs b/crates/proof/proof/src/l2/chain_provider.rs index 8654abe6da..081af72330 100644 --- a/crates/proof/proof/src/l2/chain_provider.rs +++ b/crates/proof/proof/src/l2/chain_provider.rs @@ -114,9 +114,23 @@ impl BatchValidationProvider for OracleL2ChainProv async fn block_by_number(&mut self, number: u64) -> Result { // Fetch the header for the given block number. - let header @ Header { transactions_root, timestamp, .. } = - self.header_by_number(number).await?; + let header = self.header_by_number(number).await?; let header_hash = header.hash_slow(); + self.block_from_header(header, header_hash).await + } +} + +impl OracleL2ChainProvider { + /// Hydrates a [`Header`] into a full [`BaseBlock`] by walking its transactions trie. + /// + /// `header_hash` must be the hash of `header`; it is passed separately because callers that + /// looked the header up by hash already have it. + async fn block_from_header( + &self, + header: Header, + header_hash: B256, + ) -> Result { + let Header { transactions_root, timestamp, .. } = header; // Fetch the transactions in the block. HintType::L2Transactions @@ -156,13 +170,14 @@ impl BatchValidationProvider for OracleL2ChainProv impl L2ChainProvider for OracleL2ChainProvider { type Error = OracleProviderError; - async fn system_config_by_number( + async fn system_config_by_l2_hash( &mut self, - number: u64, + hash: B256, rollup_config: Arc, ) -> Result::Error> { - // Get the block at the given number. - let block = self.block_by_number(number).await?; + // A hash addresses the header directly, avoiding the walk back from the safe head that a + // lookup by number requires. + let block = self.block_from_header(self.header_by_hash(hash)?, hash).await?; // Construct the system config from the payload. to_system_config(&block, rollup_config.as_ref()) diff --git a/crates/proof/zk/utils/src/client.rs b/crates/proof/zk/utils/src/client.rs index 2645379cb7..07c29f0e14 100644 --- a/crates/proof/zk/utils/src/client.rs +++ b/crates/proof/zk/utils/src/client.rs @@ -41,7 +41,7 @@ where // Sourced from kona/crates/driver/src/core.rs with modifications to use the L2 provider's caching // system. After each block execution, we update the L2 provider's caches (header_by_number, -// block_by_number, system_config_by_number, l2_block_info_by_number) with the new block data. This +// block_by_number, system_config_by_l2_hash, l2_block_info_by_number) with the new block data. This // ensures subsequent lookups for this block number can be served directly from cache rather than // requiring oracle queries. /// Advances the derivation pipeline to the target block number. From 30df2e00077f4ed3fcfd304e4c5fa403de8ae252 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 24 Sep 2026 23:09:05 +0000 Subject: [PATCH 117/299] perf(trie): reuse RocksDB iterators across versioned cursor steps (#5299) --- crates/execution/trie/Cargo.toml | 4 + .../trie/benches/cursor_iteration.rs | 314 ++++++++++++ crates/execution/trie/src/db/mod.rs | 8 +- crates/execution/trie/src/db/rocksdb.rs | 337 ++++++++++--- crates/execution/trie/src/lib.rs | 7 +- .../trie/tests/cursor_differential/mod.rs | 477 ++++++++++++++++++ crates/execution/trie/tests/lib.rs | 1 + 7 files changed, 1071 insertions(+), 77 deletions(-) create mode 100644 crates/execution/trie/benches/cursor_iteration.rs create mode 100644 crates/execution/trie/tests/cursor_differential/mod.rs diff --git a/crates/execution/trie/Cargo.toml b/crates/execution/trie/Cargo.toml index 9919414328..f7c01396f2 100644 --- a/crates/execution/trie/Cargo.toml +++ b/crates/execution/trie/Cargo.toml @@ -93,6 +93,10 @@ harness = false name = "deep_history_reads" harness = false +[[bench]] +name = "cursor_iteration" +harness = false + [features] serde-bincode-compat = [ "alloy-consensus/serde-bincode-compat", diff --git a/crates/execution/trie/benches/cursor_iteration.rs b/crates/execution/trie/benches/cursor_iteration.rs new file mode 100644 index 0000000000..170834c18f --- /dev/null +++ b/crates/execution/trie/benches/cursor_iteration.rs @@ -0,0 +1,314 @@ +//! Benchmarks sequential `RocksDB` versioned-cursor iteration, the access pattern reth's trie +//! walkers and proof/witness generation drive through the proofs-history store. +//! +//! The fixture seeds accounts, one account's storage, the account trie, and one storage trie, +//! then applies several blocks of random updates so keys carry multiple versions, with some +//! tombstoned accounts and trie nodes and some zero-valued storage slots. Two layouts are +//! measured: everything flushed and compacted into SST files, and a compacted base with the +//! later blocks still in the memtable (reads merge both sources). +//! +//! Each benchmark opens a fresh cursor, then either walks `WALK_LEN` consecutive live keys via +//! `next()` or performs `SEEKS` random seeks each followed by `NEXTS_PER_SEEK` `next()` calls. +//! Both a latest and a historical `max_block_number` are measured; the historical bound makes +//! the newest row of many keys invisible, exercising the bounded-version lookup. + +use std::{hint::black_box, sync::Arc}; + +use alloy_eips::{BlockNumHash, NumHash, eip1898::BlockWithParent}; +use alloy_primitives::{B256, U256}; +use base_execution_trie::{ + BaseProofsInitialStateStore, BaseProofsStore, BlockStateDiff, RocksdbProofsStorage, +}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use rand_08::{Rng, SeedableRng, rngs::StdRng}; +use reth_primitives_traits::Account; +use reth_trie::{ + BranchNodeCompact, HashedPostState, HashedStorage, Nibbles, + hashed_cursor::HashedCursor, + trie_cursor::TrieCursor, + updates::{StorageTrieUpdates, TrieUpdates}, +}; +use tempfile::TempDir; + +const ACCOUNTS: usize = 20_000; +const SLOTS: usize = 8_192; +const TRIE_PATHS: usize = 8_192; +const BLOCKS: u64 = 6; +const UPDATE_PROBABILITY: f64 = 0.5; +const TOMBSTONE_PROBABILITY: f64 = 0.03; +const ZERO_SLOT_PROBABILITY: f64 = 0.1; +const WALK_LEN: usize = 1_000; +const SEEKS: usize = 64; +const NEXTS_PER_SEEK: usize = 16; + +struct Fixture { + _dir: TempDir, + storage: Arc, + storage_address: B256, + accounts: Vec, + slots: Vec, + account_paths: Vec, + storage_paths: Vec, +} + +fn random_hash(rng: &mut StdRng) -> B256 { + B256::from(rng.r#gen::<[u8; 32]>()) +} + +fn random_paths(rng: &mut StdRng, count: usize) -> Vec { + let mut paths: Vec = (0..count) + .map(|_| { + let len = rng.gen_range(1..=6); + Nibbles::from_nibbles((0..len).map(|_| rng.gen_range(0..16u8)).collect::>()) + }) + .collect(); + paths.sort(); + paths.dedup(); + paths +} + +fn account(rng: &mut StdRng, nonce: u64) -> Account { + Account { nonce, balance: U256::from(rng.r#gen::()), bytecode_hash: None } +} + +fn branch(rng: &mut StdRng) -> BranchNodeCompact { + BranchNodeCompact::new(rng.r#gen::() | 1, 0, 0, vec![], Some(random_hash(rng))) +} + +fn slot_value(rng: &mut StdRng) -> U256 { + if rng.gen_bool(ZERO_SLOT_PROBABILITY) { + U256::ZERO + } else { + U256::from(rng.r#gen::() | 1) + } +} + +fn block_hash(number: u64) -> B256 { + B256::left_padding_from(&number.to_be_bytes()) +} + +fn block_diff(rng: &mut StdRng, fixture: &Fixture, block: u64) -> BlockStateDiff { + let mut post_state = HashedPostState::default(); + for key in &fixture.accounts { + if rng.gen_bool(UPDATE_PROBABILITY) { + let value = (!rng.gen_bool(TOMBSTONE_PROBABILITY)).then(|| account(rng, block)); + post_state.accounts.insert(*key, value); + } + } + let mut storage = HashedStorage::new(false); + for slot in &fixture.slots { + if rng.gen_bool(UPDATE_PROBABILITY) { + storage.storage.insert(*slot, slot_value(rng)); + } + } + post_state.storages.insert(fixture.storage_address, storage); + + let mut trie_updates = TrieUpdates::default(); + for path in &fixture.account_paths { + if rng.gen_bool(UPDATE_PROBABILITY) { + if rng.gen_bool(TOMBSTONE_PROBABILITY) { + trie_updates.removed_nodes.insert(*path); + } else { + trie_updates.account_nodes.insert(*path, branch(rng)); + } + } + } + let mut storage_trie = StorageTrieUpdates::default(); + for path in &fixture.storage_paths { + if rng.gen_bool(UPDATE_PROBABILITY) { + if rng.gen_bool(TOMBSTONE_PROBABILITY) { + storage_trie.removed_nodes.insert(*path); + } else { + storage_trie.storage_nodes.insert(*path, branch(rng)); + } + } + } + trie_updates.storage_tries.insert(fixture.storage_address, storage_trie); + + BlockStateDiff { + sorted_trie_updates: trie_updates.into_sorted(), + sorted_post_state: post_state.into_sorted(), + } +} + +/// Builds the fixture. With `recent_in_memtable`, only the initial state and the first half of +/// the blocks are compacted into SST files; the remaining blocks stay in the memtable. +fn create_fixture(recent_in_memtable: bool) -> Fixture { + let mut rng = StdRng::seed_from_u64(7); + let dir = TempDir::new().expect("create temp dir"); + let storage = Arc::new(RocksdbProofsStorage::new(dir.path()).expect("open RocksDB")); + let mut accounts: Vec = (0..ACCOUNTS).map(|_| random_hash(&mut rng)).collect(); + accounts.sort(); + let mut slots: Vec = (0..SLOTS).map(|_| random_hash(&mut rng)).collect(); + slots.sort(); + let fixture = Fixture { + _dir: dir, + storage, + storage_address: random_hash(&mut rng), + accounts, + slots, + account_paths: random_paths(&mut rng, TRIE_PATHS), + storage_paths: random_paths(&mut rng, TRIE_PATHS), + }; + + let store = &fixture.storage; + store.set_initial_state_anchor(BlockNumHash::new(0, block_hash(0))).expect("set anchor"); + store + .store_hashed_accounts( + fixture.accounts.iter().map(|key| (*key, Some(account(&mut rng, 0)))).collect(), + ) + .expect("store accounts"); + store + .store_hashed_storages( + fixture.storage_address, + fixture.slots.iter().map(|slot| (*slot, slot_value(&mut rng))).collect(), + ) + .expect("store slots"); + store + .store_account_branches( + fixture.account_paths.iter().map(|path| (*path, Some(branch(&mut rng)))).collect(), + ) + .expect("store account branches"); + store + .store_storage_branches( + fixture.storage_address, + fixture.storage_paths.iter().map(|path| (*path, Some(branch(&mut rng)))).collect(), + ) + .expect("store storage branches"); + store.commit_initial_state().expect("commit initial state"); + + for number in 1..=BLOCKS { + let block_ref = + BlockWithParent::new(block_hash(number - 1), NumHash::new(number, block_hash(number))); + let diff = block_diff(&mut rng, &fixture, number); + store.store_trie_updates(block_ref, diff).expect("store block"); + if recent_in_memtable && number == BLOCKS / 2 { + store.flush_and_compact().expect("flush and compact"); + } + } + if !recent_in_memtable { + store.flush_and_compact().expect("flush and compact"); + } + fixture +} + +fn walk_hashed(mut cursor: C) -> usize { + let mut count = usize::from(cursor.seek(B256::ZERO).expect("seek").is_some()); + while count < WALK_LEN { + let Some(entry) = cursor.next().expect("next") else { break }; + black_box(entry); + count += 1; + } + count +} + +fn seek_next_hashed(mut cursor: C, targets: &[B256]) -> usize { + let mut count = 0; + for target in targets { + if cursor.seek(*target).expect("seek").is_none() { + continue; + } + for _ in 0..NEXTS_PER_SEEK { + let Some(entry) = cursor.next().expect("next") else { break }; + black_box(entry); + count += 1; + } + } + count +} + +fn walk_trie(mut cursor: C) -> usize { + let mut count = usize::from(cursor.seek(Nibbles::default()).expect("seek").is_some()); + while count < WALK_LEN { + let Some(entry) = cursor.next().expect("next") else { break }; + black_box(entry); + count += 1; + } + count +} + +fn seek_next_trie(mut cursor: C, targets: &[Nibbles]) -> usize { + let mut count = 0; + for target in targets { + if cursor.seek(*target).expect("seek").is_none() { + continue; + } + for _ in 0..NEXTS_PER_SEEK { + let Some(entry) = cursor.next().expect("next") else { break }; + black_box(entry); + count += 1; + } + } + count +} + +fn cursor_iteration_benches(c: &mut Criterion) { + for (layout, recent_in_memtable) in [("compacted", false), ("memtable_overlay", true)] { + let fixture = create_fixture(recent_in_memtable); + let store = &fixture.storage; + let mut rng = StdRng::seed_from_u64(11); + let hashed_targets: Vec = (0..SEEKS).map(|_| random_hash(&mut rng)).collect(); + let path_targets = random_paths(&mut rng, SEEKS); + + let mut group = c.benchmark_group(format!("cursor_iteration/{layout}")); + group.sample_size(20); + + for (bound, max_block) in [("latest", BLOCKS), ("historical", BLOCKS / 2)] { + let id = |name: &str| BenchmarkId::new(name, bound); + group.bench_function(id("account_next_1000"), |b| { + b.iter(|| walk_hashed(store.account_hashed_cursor(max_block).expect("cursor"))) + }); + group.bench_function(id("storage_next_1000"), |b| { + b.iter(|| { + walk_hashed( + store + .storage_hashed_cursor(fixture.storage_address, max_block) + .expect("cursor"), + ) + }) + }); + group.bench_function(id("account_trie_next_1000"), |b| { + b.iter(|| walk_trie(store.account_trie_cursor(max_block).expect("cursor"))) + }); + group.bench_function(id("storage_trie_next_1000"), |b| { + b.iter(|| { + walk_trie( + store + .storage_trie_cursor(fixture.storage_address, max_block) + .expect("cursor"), + ) + }) + }); + group.bench_function(id("account_seek_next16_x64"), |b| { + b.iter(|| { + seek_next_hashed( + store.account_hashed_cursor(max_block).expect("cursor"), + &hashed_targets, + ) + }) + }); + group.bench_function(id("storage_seek_next16_x64"), |b| { + b.iter(|| { + seek_next_hashed( + store + .storage_hashed_cursor(fixture.storage_address, max_block) + .expect("cursor"), + &hashed_targets, + ) + }) + }); + group.bench_function(id("account_trie_seek_next16_x64"), |b| { + b.iter(|| { + seek_next_trie( + store.account_trie_cursor(max_block).expect("cursor"), + &path_targets, + ) + }) + }); + } + group.finish(); + } +} + +criterion_group!(benches, cursor_iteration_benches); +criterion_main!(benches); diff --git a/crates/execution/trie/src/db/mod.rs b/crates/execution/trie/src/db/mod.rs index ad3aa9f9f0..b7c4e5c62e 100644 --- a/crates/execution/trie/src/db/mod.rs +++ b/crates/execution/trie/src/db/mod.rs @@ -12,10 +12,10 @@ pub use store::{MdbxProofsStorage, MdbxProofsStorageOptions}; mod rocksdb; pub use rocksdb::{ ProofWindowValue, RocksDbHistoryTable, RocksDbLatestVersionResult, RocksdbAccountCursor, - RocksdbBatchSession, RocksdbHistoryDeleteBatch, RocksdbPreparedHistoryDeletes, - RocksdbPreparedPrune, RocksdbProofsStorage, RocksdbProofsStorageOptions, RocksdbPrunePlan, - RocksdbReadSnapshot, RocksdbReplacementState, RocksdbStorageCursor, RocksdbTrieCursor, - RocksdbVersionedCursor, + RocksdbBatchSession, RocksdbHistoryDeleteBatch, RocksdbHistoryIterator, + RocksdbPreparedHistoryDeletes, RocksdbPreparedPrune, RocksdbProofsStorage, + RocksdbProofsStorageOptions, RocksdbPrunePlan, RocksdbReadSnapshot, RocksdbReplacementState, + RocksdbStorageCursor, RocksdbTrieCursor, RocksdbVersionedCursor, }; mod cursor; diff --git a/crates/execution/trie/src/db/rocksdb.rs b/crates/execution/trie/src/db/rocksdb.rs index 5970884b41..d1aabd6eac 100644 --- a/crates/execution/trie/src/db/rocksdb.rs +++ b/crates/execution/trie/src/db/rocksdb.rs @@ -29,8 +29,9 @@ use reth_trie_common::{ }; use rocksdb::{ BlockBasedIndexType, BlockBasedOptions, BoundColumnFamily, Cache, ColumnFamilyDescriptor, - CompactionPri, DBCompressionType, DBWithThreadMode, Direction, IteratorMode, MultiThreaded, - Options, ReadOptions, SliceTransform, SnapshotWithThreadMode, WriteBatch, WriteOptions, + CompactionPri, DBCompressionType, DBRawIteratorWithThreadMode, DBWithThreadMode, Direction, + IteratorMode, MultiThreaded, Options, ReadOptions, SliceTransform, SnapshotWithThreadMode, + WriteBatch, WriteOptions, }; use tracing::info; @@ -255,6 +256,26 @@ pub struct RocksdbReadSnapshot<'db> { pub snapshot: SnapshotWithThreadMode<'db, RocksDb>, } +/// Raw `RocksDB` iterator over one history column family, pinned to a cursor's read snapshot. +/// +/// A versioned cursor keeps one of these for its whole lifetime and steps it across +/// `seek`/`next` calls instead of opening a fresh iterator (and paying a full multi-level +/// seek) per call. +/// +/// The fields are private because they encode a lifetime invariant the type system does not +/// check: the iterator's read options reference the `RocksDB` snapshot owned by `_snapshot` +/// (`ReadOptions::set_snapshot` stores a raw pointer), so the snapshot must be released only +/// after the iterator is destroyed. Keeping both in one struct, with `iter` declared first so it +/// is dropped first, guarantees that ordering. +pub struct RocksdbHistoryIterator<'db> { + /// Iterator reading `_snapshot`; must be dropped before `_snapshot`. + iter: DBRawIteratorWithThreadMode<'db, RocksDb>, + /// Key prefix bounding the iterator, or `None` for an unbounded total-order iterator. + prefix: Option>, + /// Keeps the snapshot referenced by `iter` alive. + _snapshot: Arc>, +} + /// Cursor over `RocksDB` versioned history rows. pub struct RocksdbVersionedCursor<'db, T: Table + DupSort> { /// Shared read snapshot used for cursor lookups. @@ -263,6 +284,9 @@ pub struct RocksdbVersionedCursor<'db, T: Table + DupSort> { pub max_block_number: u64, /// Current logical key position in the cursor. pub current_key: Option, + /// Iterator reused across `seek`/`next` calls; created lazily and recreated when the + /// requested prefix bound changes. + pub iter: Option>, /// Marker for the table type parameter. pub _table: PhantomData, } @@ -341,6 +365,14 @@ impl fmt::Debug for RocksdbReadSnapshot<'_> { } } +impl fmt::Debug for RocksdbHistoryIterator<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RocksdbHistoryIterator") + .field("prefix", &self.prefix) + .finish_non_exhaustive() + } +} + impl fmt::Debug for RocksdbVersionedCursor<'_, T> where T: Table + DupSort, @@ -2391,6 +2423,151 @@ impl reth_db::database_metrics::DatabaseMetrics for RocksdbProofsStorage { #[cfg(not(feature = "metrics"))] impl reth_db::database_metrics::DatabaseMetrics for RocksdbProofsStorage {} +impl<'db> RocksdbHistoryIterator<'db> { + /// Maximum number of `next()` steps used to walk past a key's older versions before + /// falling back to a seek, mirroring `RocksDB`'s default + /// `max_sequential_skip_in_iterations`. Keys with few versions are skipped by stepping + /// within the current data block; deeply versioned keys cost one extra seek. + pub const MAX_SEQUENTIAL_VERSION_SKIPS: usize = 8; + + /// Opens an iterator over `cf_name` reading `snapshot`, bounded to `prefix` when given and + /// otherwise in total order. + pub fn new( + snapshot: Arc>, + cf_name: &'static str, + prefix: Option<&[u8]>, + ) -> Result { + let db: &'db RocksDb = snapshot.db; + let cf = db.cf_handle(cf_name).ok_or_else(|| { + DatabaseError::Other(format!("missing RocksDB column family {cf_name}")) + })?; + let mut read_options = prefix.map_or_else(total_order_read_options, prefix_read_options); + // The snapshot outlives the iterator: both are owned by `Self` and `iter` drops first. + read_options.set_snapshot(&snapshot.snapshot); + let iter = db.raw_iterator_cf_opt(&cf, read_options); + Ok(Self { iter, prefix: prefix.map(<[u8]>::to_vec), _snapshot: snapshot }) + } + + /// Seeks to the first row of the first key at or after `key` and resolves the first live + /// key from there. + pub fn seek_live( + &mut self, + key: &T::Key, + max_block_number: u64, + ) -> Result, DatabaseError> + where + T: Table> + RocksDbHistoryTable, + T::Value: Decompress, + { + // Under the reversed block-suffix encoding a key's newest row (block `u64::MAX`) sorts + // first, so this lands on the first row of the first key `>= key`. + self.iter.seek(encode_history_key::(key, u64::MAX)?); + self.resolve_live::(max_block_number) + } + + /// Resolves the first live key strictly after `key`. + /// + /// When the iterator is still on one of `key`'s rows (the common case after a previous + /// `seek_live`/`next_live_after` returned `key`) it steps forward from there; otherwise + /// (e.g. after a point lookup or an interleaved seek) it seeks past `key`'s rows. + pub fn next_live_after( + &mut self, + key: &T::Key, + max_block_number: u64, + ) -> Result, DatabaseError> + where + T: Table> + RocksDbHistoryTable, + T::Value: Decompress, + { + let key_prefix = encode_history_key_prefix::(key)?; + if self.is_on_key(&key_prefix) { + self.skip_key_versions(&key_prefix); + } else { + self.seek_past_key(&key_prefix); + } + self.resolve_live::(max_block_number) + } + + /// Resolves the first live key starting from the iterator's current row, which must be the + /// first (newest) row of its key. + fn resolve_live( + &mut self, + max_block_number: u64, + ) -> Result, DatabaseError> + where + T: Table> + RocksDbHistoryTable, + T::Value: Decompress, + { + loop { + let Some(raw_key) = self.iter.key() else { + // `key()` returns `None` both on exhaustion and on I/O error, so an error here + // would otherwise be silently treated as "no more entries". This path feeds + // state-root computation, so a swallowed error must be surfaced rather than + // resolved as an absent trie node. + self.iter.status().map_err(rocksdb_error)?; + return Ok(None); + }; + if raw_key.len() != T::KEY_LEN + BLOCK_NUMBER_KEY_LEN { + return Err(DatabaseError::Decode); + } + let key_prefix = raw_key[..T::KEY_LEN].to_vec(); + let block_number = decode_history_block_suffix(&raw_key[T::KEY_LEN..])?; + + if block_number > max_block_number { + // The newest row is above the bound; the newest version at or below it is the + // first row `>= (key, max_block_number)`, if it still belongs to this key. + let mut target = key_prefix.clone(); + target.extend_from_slice(&encode_history_block_suffix(max_block_number)); + self.iter.seek(&target); + if !self.is_on_key(&key_prefix) { + // No version at or below the bound: the key does not exist at + // `max_block_number`, and the iterator already sits on the next key's + // first row. + continue; + } + } + + let raw_value = self.iter.value().ok_or(DatabaseError::Decode)?; + if let MaybeDeleted(Some(value)) = T::Value::decompress(raw_value)?.value { + return Ok(Some((T::decode_history_key_prefix(&key_prefix)?, value))); + } + self.skip_key_versions(&key_prefix); + } + } + + /// Returns whether the iterator is on a row of the key encoded as `key_prefix`. + fn is_on_key(&self, key_prefix: &[u8]) -> bool { + self.iter.key().is_some_and(|raw_key| { + raw_key.len() == key_prefix.len() + BLOCK_NUMBER_KEY_LEN + && raw_key.starts_with(key_prefix) + }) + } + + /// Moves from one of `key_prefix`'s rows to the first row of the next key. + fn skip_key_versions(&mut self, key_prefix: &[u8]) { + for _ in 0..Self::MAX_SEQUENTIAL_VERSION_SKIPS { + self.iter.next(); + if !self.is_on_key(key_prefix) { + return; + } + } + self.seek_past_key(key_prefix); + } + + /// Seeks to the first row of the first key after `key_prefix`. + fn seek_past_key(&mut self, key_prefix: &[u8]) { + // A key's oldest possible row (block `0`) has the largest suffix, so seeking there and + // stepping over that row if it exists lands on the next key. + let mut last_row = Vec::with_capacity(key_prefix.len() + BLOCK_NUMBER_KEY_LEN); + last_row.extend_from_slice(key_prefix); + last_row.extend_from_slice(&encode_history_block_suffix(0)); + self.iter.seek(&last_row); + if self.iter.key() == Some(last_row.as_slice()) { + self.iter.next(); + } + } +} + impl<'db, T, V> RocksdbVersionedCursor<'db, T> where T: Table> + DupSort, @@ -2408,7 +2585,7 @@ where snapshot: Arc>, max_block_number: u64, ) -> Self { - Self { snapshot, max_block_number, current_key: None, _table: PhantomData } + Self { snapshot, max_block_number, current_key: None, iter: None, _table: PhantomData } } fn cf(&self) -> Result>, DatabaseError> { @@ -2467,23 +2644,11 @@ where } fn seek(&mut self, start_key: T::Key) -> Result, DatabaseError> { - self.next_live_from(start_key) + self.seek_live(start_key, None) } fn next(&mut self) -> Result, DatabaseError> { - if let Some(key) = self.current_key.clone() { - self.next_live_after(key) - } else { - self.next_live_from(T::Key::default()) - } - } - - fn next_live_from(&mut self, key: T::Key) -> Result, DatabaseError> { - self.next_live_candidate(key, false, total_order_read_options()) - } - - fn next_live_after(&mut self, key: T::Key) -> Result, DatabaseError> { - self.next_live_candidate(key, true, total_order_read_options()) + self.next_live(None) } fn seek_with_prefix( @@ -2491,69 +2656,49 @@ where key: T::Key, prefix: &[u8], ) -> Result, DatabaseError> { - self.next_live_candidate(key, false, prefix_read_options(prefix)) + self.seek_live(key, Some(prefix)) } fn next_with_prefix(&mut self, prefix: &[u8]) -> Result, DatabaseError> { - if let Some(key) = self.current_key.clone() { - self.next_live_candidate(key, true, prefix_read_options(prefix)) - } else { - self.next_live_candidate(T::Key::default(), false, prefix_read_options(prefix)) - } + self.next_live(Some(prefix)) + } + + /// Returns the cursor's persistent iterator, (re)creating it when it does not exist yet or + /// was opened with a different prefix bound. + fn history_iter( + &mut self, + prefix: Option<&[u8]>, + ) -> Result<&mut RocksdbHistoryIterator<'db>, DatabaseError> { + let iter = match self.iter.take() { + Some(iter) if iter.prefix.as_deref() == prefix => iter, + _ => RocksdbHistoryIterator::new(Arc::clone(&self.snapshot), T::NAME, prefix)?, + }; + Ok(self.iter.insert(iter)) } - fn next_live_candidate( + /// Positions the cursor on the first live key at or after `key`. + fn seek_live( &mut self, key: T::Key, - exclusive: bool, - read_options: ReadOptions, + prefix: Option<&[u8]>, ) -> Result, DatabaseError> { - let found = { - let cf = self.cf()?; - // Under the reversed block-suffix encoding, within a given key's - // prefix the SMALLEST encoded suffix is `u64::MAX - u64::MAX = 0` - // (i.e. block `u64::MAX`) and the LARGEST is `u64::MAX - 0 = - // u64::MAX` (i.e. block `0`). So to start a forward scan at the - // first encoded row of `key`, use block `u64::MAX`; to start - // strictly after all of `key`'s rows, use block `0` (the - // `before_start` filter below then skips the equal-key row). - let start_block = if exclusive { 0 } else { u64::MAX }; - let start_key = encode_history_key::(&key, start_block)?; - let iter = self.snapshot.snapshot().iterator_cf_opt( - &cf, - read_options, - IteratorMode::From(&start_key, Direction::Forward), - ); - let mut last_candidate = None; - let mut found = None; - - for item in iter { - let (raw_key, _) = item.map_err(rocksdb_error)?; - let (candidate, _) = decode_history_key::(&raw_key)?; - let before_start = if exclusive { candidate <= key } else { candidate < key }; - if before_start || last_candidate.as_ref() == Some(&candidate) { - continue; - } - - last_candidate = Some(candidate.clone()); - if let Some((live_key, latest_value)) = self.latest_version_for_key(&candidate)? - && let MaybeDeleted(Some(value)) = latest_value.value - { - found = Some((live_key, value)); - break; - } - } + let max_block_number = self.max_block_number; + let found = self.history_iter(prefix)?.seek_live::(&key, max_block_number)?; + self.current_key = found.as_ref().map(|(key, _)| key.clone()); + Ok(found) + } - found + /// Advances the cursor to the first live key after the current key, or to the first live key + /// when unpositioned. + fn next_live(&mut self, prefix: Option<&[u8]>) -> Result, DatabaseError> { + let Some(current_key) = self.current_key.clone() else { + return self.seek_live(T::Key::default(), prefix); }; - - if let Some((key, value)) = found { - self.current_key = Some(key.clone()); - return Ok(Some((key, value))); - } - - self.current_key = None; - Ok(None) + let max_block_number = self.max_block_number; + let found = + self.history_iter(prefix)?.next_live_after::(¤t_key, max_block_number)?; + self.current_key = found.as_ref().map(|(key, _)| key.clone()); + Ok(found) } const fn is_positioned(&self) -> bool { @@ -3223,6 +3368,58 @@ mod tests { assert_eq!(acc.nonce, 1); } + #[test] + fn cursor_skips_heavily_versioned_tombstoned_key_to_next_live_key() { + const DEAD_VERSIONS: u64 = 256; + + let (storage, _dir) = temp_storage(); + storage.set_earliest_block_number_hash(0, B256::ZERO).unwrap(); + + let dead_key = B256::repeat_byte(0x01); + let live_key = B256::repeat_byte(0x02); + + // Give `dead_key` a long version chain so a naive one-row-at-a-time + // skip would have to walk every one of these before reaching + // `live_key`. + let mut parent = B256::ZERO; + for version in 1..=DEAD_VERSIONS { + let block_ref = block(version, parent); + storage.store_trie_updates(block_ref, account_update(dead_key, version)).unwrap(); + parent = block_ref.block.hash; + } + + // Tombstone `dead_key` so its latest version (at or below `max_block`) + // resolves to "not live". + let tombstone_block = DEAD_VERSIONS + 1; + let block_ref = block(tombstone_block, parent); + let mut post_state = HashedPostState::default(); + post_state.accounts.insert(dead_key, None); + storage + .store_trie_updates( + block_ref, + BlockStateDiff { + sorted_trie_updates: TrieUpdates::default().into_sorted(), + sorted_post_state: post_state.into_sorted(), + }, + ) + .unwrap(); + parent = block_ref.block.hash; + + // `live_key` sorts after `dead_key`, so a forward walk must skip past + // the entire dead version chain (and the tombstone row) to reach it. + let live_block = tombstone_block + 1; + let block_ref = block(live_block, parent); + storage.store_trie_updates(block_ref, account_update(live_key, 1)).unwrap(); + + let mut cursor = storage.account_hashed_cursor(live_block).unwrap(); + let (key, account) = cursor.next().unwrap().expect("live key found"); + assert_eq!(key, live_key); + assert_eq!(account.nonce, 1); + + // The tombstoned key must never be surfaced, and the walk terminates. + assert!(cursor.next().unwrap().is_none()); + } + fn block(number: u64, parent: B256) -> BlockWithParent { BlockWithParent { parent, diff --git a/crates/execution/trie/src/lib.rs b/crates/execution/trie/src/lib.rs index e30afe8669..af65ff1203 100644 --- a/crates/execution/trie/src/lib.rs +++ b/crates/execution/trie/src/lib.rs @@ -31,9 +31,10 @@ pub use db::{ MdbxAccountCursor, MdbxBatchSession, MdbxProofsStorage, MdbxProofsStorageOptions, MdbxStorageCursor, MdbxTrieCursor, ProofWindowValue, RocksDbHistoryTable, RocksDbLatestVersionResult, RocksdbAccountCursor, RocksdbBatchSession, - RocksdbHistoryDeleteBatch, RocksdbPreparedHistoryDeletes, RocksdbPreparedPrune, - RocksdbProofsStorage, RocksdbProofsStorageOptions, RocksdbPrunePlan, RocksdbReadSnapshot, - RocksdbReplacementState, RocksdbStorageCursor, RocksdbTrieCursor, RocksdbVersionedCursor, + RocksdbHistoryDeleteBatch, RocksdbHistoryIterator, RocksdbPreparedHistoryDeletes, + RocksdbPreparedPrune, RocksdbProofsStorage, RocksdbProofsStorageOptions, RocksdbPrunePlan, + RocksdbReadSnapshot, RocksdbReplacementState, RocksdbStorageCursor, RocksdbTrieCursor, + RocksdbVersionedCursor, }; pub mod metrics; diff --git a/crates/execution/trie/tests/cursor_differential/mod.rs b/crates/execution/trie/tests/cursor_differential/mod.rs new file mode 100644 index 0000000000..853c06be96 --- /dev/null +++ b/crates/execution/trie/tests/cursor_differential/mod.rs @@ -0,0 +1,477 @@ +//! Differential cursor tests: `RocksDB` cursors must return exactly what the MDBX store (the +//! previous production backend) returns for random multi-block histories and random seek/next +//! sequences. MDBX is the reference because, unlike the in-memory store, it models storage-trie +//! wipes (`StorageTrieUpdates::is_deleted`). + +use rand_08::{Rng, SeedableRng, rngs::StdRng}; +use reth_trie::{ + hashed_cursor::HashedStorageCursor, trie_cursor::TrieStorageCursor, updates::StorageTrieUpdates, +}; + +use super::*; + +/// Number of independent random histories checked. +const SEEDS: u64 = 24; +/// Blocks written after the initial state in each history. +const BLOCKS: u64 = 24; +/// Cursor operations driven per cursor and `max_block` value. +const OPS_PER_RUN: usize = 80; +/// Distinct `max_block` values checked per history. +const MAX_BLOCK_SAMPLES: usize = 6; + +/// Keyspace shared by the reference and `RocksDB` stores for one history. +struct Keyspace { + accounts: Vec, + storage_addresses: Vec, + slots: Vec, + account_paths: Vec, + storage_paths: Vec, +} + +impl Keyspace { + fn random(rng: &mut StdRng) -> Self { + // Few distinct high bytes so keys share long prefixes and interleave with seek targets. + let hash = |rng: &mut StdRng| { + let mut bytes: [u8; 32] = rng.r#gen(); + bytes[0] = rng.gen_range(0..4); + B256::from(bytes) + }; + let accounts = (0..16).map(|_| hash(rng)).collect(); + let storage_addresses = (0..4).map(|_| hash(rng)).collect(); + let slots = (0..12).map(|_| hash(rng)).collect(); + let paths = |rng: &mut StdRng| { + let mut paths: Vec = (0..14).map(|_| random_path(rng)).collect(); + paths.sort(); + paths.dedup(); + paths + }; + let account_paths = paths(rng); + let storage_paths = paths(rng); + Self { accounts, storage_addresses, slots, account_paths, storage_paths } + } + + fn pick(rng: &mut StdRng, items: &[T]) -> T { + items[rng.gen_range(0..items.len())] + } + + /// Seek target: usually a known key, sometimes an arbitrary one. + fn hashed_target(rng: &mut StdRng, known: &[B256]) -> B256 { + match rng.gen_range(0..8) { + 0 => B256::ZERO, + 1 => B256::repeat_byte(0xff), + 2 | 3 => { + let mut bytes: [u8; 32] = rng.r#gen(); + bytes[0] = rng.gen_range(0..5); + B256::from(bytes) + } + _ => Self::pick(rng, known), + } + } + + fn path_target(rng: &mut StdRng, known: &[Nibbles]) -> Nibbles { + if rng.gen_bool(0.3) { random_path(rng) } else { known[rng.gen_range(0..known.len())] } + } +} + +/// Short nibble path over a small alphabet so paths share prefixes. +fn random_path(rng: &mut StdRng) -> Nibbles { + let len = rng.gen_range(0..=5); + Nibbles::from_nibbles( + (0..len).map(|_| [0u8, 1, 0xe, 0xf][rng.gen_range(0..4)]).collect::>(), + ) +} + +fn account(rng: &mut StdRng, block: u64) -> Account { + Account { nonce: block, balance: U256::from(rng.r#gen::()), bytecode_hash: None } +} + +fn storage_value(rng: &mut StdRng) -> U256 { + if rng.gen_bool(0.2) { U256::ZERO } else { U256::from(rng.gen_range(1..u64::MAX)) } +} + +fn branch(rng: &mut StdRng) -> BranchNodeCompact { + BranchNodeCompact::new( + rng.r#gen::() | 1, + 0, + 0, + vec![], + Some(B256::from(rng.r#gen::<[u8; 32]>())), + ) +} + +fn block_hash(number: u64) -> B256 { + B256::left_padding_from(&number.to_be_bytes()) +} + +/// Random per-block diff with inserts, updates, zero values, tombstones, and wipes. A small set of +/// "hot" keys is updated almost every block so some keys carry long version chains. +fn random_block_diff(rng: &mut StdRng, keys: &Keyspace, block: u64) -> BlockStateDiff { + let mut post_state = HashedPostState::default(); + for (index, key) in keys.accounts.iter().enumerate() { + let touch = if index < 3 { 0.9 } else { 0.25 }; + if rng.gen_bool(touch) { + let value = (!rng.gen_bool(0.25)).then(|| account(rng, block)); + post_state.accounts.insert(*key, value); + } + } + for address in &keys.storage_addresses { + if !rng.gen_bool(0.7) { + continue; + } + let mut storage = HashedStorage::new(rng.gen_bool(0.08)); + for (index, slot) in keys.slots.iter().enumerate() { + let touch = if index < 2 { 0.9 } else { 0.25 }; + if rng.gen_bool(touch) { + storage.storage.insert(*slot, storage_value(rng)); + } + } + post_state.storages.insert(*address, storage); + } + + let mut trie_updates = TrieUpdates::default(); + for (index, path) in keys.account_paths.iter().enumerate() { + let touch = if index < 2 { 0.9 } else { 0.25 }; + if rng.gen_bool(touch) { + if rng.gen_bool(0.25) { + trie_updates.removed_nodes.insert(*path); + } else { + trie_updates.account_nodes.insert(*path, branch(rng)); + } + } + } + for address in &keys.storage_addresses { + if !rng.gen_bool(0.6) { + continue; + } + let mut storage_trie = + StorageTrieUpdates { is_deleted: rng.gen_bool(0.08), ..Default::default() }; + for (index, path) in keys.storage_paths.iter().enumerate() { + let touch = if index < 2 { 0.9 } else { 0.25 }; + if rng.gen_bool(touch) { + if rng.gen_bool(0.25) { + storage_trie.removed_nodes.insert(*path); + } else { + storage_trie.storage_nodes.insert(*path, branch(rng)); + } + } + } + trie_updates.storage_tries.insert(*address, storage_trie); + } + + BlockStateDiff { + sorted_trie_updates: trie_updates.into_sorted(), + sorted_post_state: post_state.into_sorted(), + } +} + +/// Writes the same random history into both stores. `RocksDB` is flushed and compacted halfway +/// so reads merge SST files with memtable data. +fn build_history( + rng: &mut StdRng, + keys: &Keyspace, + reference: &MdbxProofsStorage, + rocksdb: &TestRocksdbProofsStorage, +) -> Result<(), BaseProofsStorageError> { + let initial_accounts: Vec<_> = + keys.accounts.iter().map(|key| (*key, Some(account(rng, 0)))).collect(); + let initial_account_nodes: Vec<_> = + keys.account_paths.iter().map(|path| (*path, Some(branch(rng)))).collect(); + let mut initial_storages = Vec::new(); + let mut initial_storage_nodes = Vec::new(); + for address in &keys.storage_addresses { + let slots: Vec<_> = keys.slots.iter().map(|slot| (*slot, storage_value(rng))).collect(); + let nodes: Vec<_> = + keys.storage_paths.iter().map(|path| (*path, Some(branch(rng)))).collect(); + initial_storages.push((*address, slots)); + initial_storage_nodes.push((*address, nodes)); + } + + let mut blocks = Vec::new(); + for number in 1..=BLOCKS { + blocks.push(( + BlockWithParent::new(block_hash(number - 1), NumHash::new(number, block_hash(number))), + random_block_diff(rng, keys, number), + )); + } + + let initial = InitialState { + accounts: initial_accounts, + account_nodes: initial_account_nodes, + storages: initial_storages, + storage_nodes: initial_storage_nodes, + }; + let rocksdb_store = &rocksdb.storage; + initial.write(reference)?; + initial.write(rocksdb_store)?; + + for (index, (block_ref, diff)) in blocks.into_iter().enumerate() { + reference.store_trie_updates(block_ref, diff.clone())?; + rocksdb_store.store_trie_updates(block_ref, diff)?; + if index as u64 == BLOCKS / 2 { + rocksdb_store.flush_and_compact()?; + } + } + Ok(()) +} + +/// Trie branch nodes keyed by path, as accepted by the initial-state store APIs. +type BranchNodes = Vec<(Nibbles, Option)>; + +/// Initial (block 0) state written identically into both stores. +struct InitialState { + accounts: Vec<(B256, Option)>, + account_nodes: BranchNodes, + storages: Vec<(B256, Vec<(B256, U256)>)>, + storage_nodes: Vec<(B256, BranchNodes)>, +} + +impl InitialState { + fn write( + &self, + store: &S, + ) -> Result<(), BaseProofsStorageError> { + store.set_initial_state_anchor(BlockNumHash::new(0, block_hash(0)))?; + store.store_hashed_accounts(self.accounts.clone())?; + store.store_account_branches(self.account_nodes.clone())?; + for (address, slots) in &self.storages { + store.store_hashed_storages(*address, slots.clone())?; + } + for (address, nodes) in &self.storage_nodes { + store.store_storage_branches(*address, nodes.clone())?; + } + store.commit_initial_state()?; + Ok(()) + } +} + +/// Drives the same random op sequence on two hashed cursors and asserts identical results. +/// +/// Per the cursor contracts, a seek is issued first, after `reset`/`set_hashed_address`, and after +/// any exhausted (`None`) result. +fn drive_hashed( + rng: &mut StdRng, + context: &str, + expected: &mut A, + actual: &mut B, + targets: &[B256], + addresses: Option<&[B256]>, +) where + V: PartialEq + std::fmt::Debug, + A: HashedStorageCursor, + B: HashedStorageCursor, +{ + let mut must_seek = true; + for op_index in 0..OPS_PER_RUN { + let roll = rng.gen_range(0..10); + let (op, want, got) = if must_seek || roll < 3 { + let target = Keyspace::hashed_target(rng, targets); + ( + format!("seek({target})"), + expected.seek(target).unwrap(), + actual.seek(target).unwrap(), + ) + } else if roll == 9 { + if let Some(addresses) = addresses { + let address = Keyspace::pick(rng, addresses); + expected.set_hashed_address(address); + actual.set_hashed_address(address); + must_seek = true; + assert_eq!( + expected.is_storage_empty().unwrap(), + actual.is_storage_empty().unwrap(), + "{context} op {op_index}: is_storage_empty after set_hashed_address({address})" + ); + } else { + expected.reset(); + actual.reset(); + must_seek = true; + } + continue; + } else { + ("next".to_string(), expected.next().unwrap(), actual.next().unwrap()) + }; + assert_eq!(want, got, "{context} op {op_index}: {op}"); + must_seek = want.is_none(); + } +} + +/// Wrapper giving account cursors a no-op [`HashedStorageCursor`] impl. +struct AccountCursor(C); + +impl HashedCursor for AccountCursor { + type Value = C::Value; + + fn seek(&mut self, key: B256) -> Result, reth_db::DatabaseError> { + self.0.seek(key) + } + + fn next(&mut self) -> Result, reth_db::DatabaseError> { + self.0.next() + } + + fn reset(&mut self) { + self.0.reset(); + } +} + +impl HashedStorageCursor for AccountCursor { + fn is_storage_empty(&mut self) -> Result { + Ok(false) + } + + fn set_hashed_address(&mut self, _hashed_address: B256) {} +} + +/// Drives the same random op sequence on two trie cursors and asserts identical results. +fn drive_trie( + rng: &mut StdRng, + context: &str, + expected: &mut A, + actual: &mut B, + targets: &[Nibbles], + addresses: Option<&[B256]>, +) where + A: TrieStorageCursor, + B: TrieStorageCursor, +{ + let mut must_seek = true; + for op_index in 0..OPS_PER_RUN { + let roll = rng.gen_range(0..10); + let (op, want, got) = if must_seek || roll < 2 { + let target = Keyspace::path_target(rng, targets); + ( + format!("seek({target:?})"), + expected.seek(target).unwrap(), + actual.seek(target).unwrap(), + ) + } else if roll == 2 { + let target = Keyspace::path_target(rng, targets); + ( + format!("seek_exact({target:?})"), + expected.seek_exact(target).unwrap(), + actual.seek_exact(target).unwrap(), + ) + } else if roll == 9 { + if let Some(address) = addresses.map(|addresses| Keyspace::pick(rng, addresses)) { + expected.set_hashed_address(address); + actual.set_hashed_address(address); + } else { + expected.reset(); + actual.reset(); + } + must_seek = true; + continue; + } else { + ("next".to_string(), expected.next().unwrap(), actual.next().unwrap()) + }; + assert_eq!(want, got, "{context} op {op_index}: {op}"); + must_seek = want.is_none(); + if must_seek { + // Stores differ in where an unsuccessful lookup leaves the cursor; only the + // positioned state is part of the shared contract. + continue; + } + assert_eq!( + expected.current().unwrap(), + actual.current().unwrap(), + "{context} op {op_index}: current() after {op}" + ); + } +} + +/// Wrapper giving account trie cursors a no-op [`TrieStorageCursor`] impl. +struct AccountTrie(C); + +impl TrieCursor for AccountTrie { + fn seek_exact( + &mut self, + key: Nibbles, + ) -> Result, reth_db::DatabaseError> { + self.0.seek_exact(key) + } + + fn seek( + &mut self, + key: Nibbles, + ) -> Result, reth_db::DatabaseError> { + self.0.seek(key) + } + + fn next(&mut self) -> Result, reth_db::DatabaseError> { + self.0.next() + } + + fn current(&mut self) -> Result, reth_db::DatabaseError> { + self.0.current() + } + + fn reset(&mut self) { + self.0.reset(); + } +} + +impl TrieStorageCursor for AccountTrie { + fn set_hashed_address(&mut self, _hashed_address: B256) {} +} + +/// Random histories with inserts, updates, zero slots, tombstones, and wipes; random seek/next +/// sequences on every cursor kind at random `max_block` values must match the reference store. +#[test] +#[serial_test::serial] +fn rocksdb_cursors_match_reference_store() -> Result<(), BaseProofsStorageError> { + for seed in 0..SEEDS { + let mut rng = StdRng::seed_from_u64(seed); + let keys = Keyspace::random(&mut rng); + let reference = create_mdbx_proofs_storage(); + let rocksdb = create_rocksdb_proofs_storage(); + build_history(&mut rng, &keys, &reference, &rocksdb)?; + + for sample in 0..MAX_BLOCK_SAMPLES { + let max_block = match sample { + 0 => 0, + 1 => BLOCKS, + _ => rng.gen_range(0..=BLOCKS + 1), + }; + let context = format!("seed {seed} max_block {max_block}"); + + drive_hashed( + &mut rng, + &format!("{context} accounts"), + &mut AccountCursor(reference.account_hashed_cursor(max_block)?), + &mut AccountCursor(rocksdb.account_hashed_cursor(max_block)?), + &keys.accounts, + None, + ); + + let address = Keyspace::pick(&mut rng, &keys.storage_addresses); + drive_hashed( + &mut rng, + &format!("{context} storage"), + &mut reference.storage_hashed_cursor(address, max_block)?, + &mut rocksdb.storage_hashed_cursor(address, max_block)?, + &keys.slots, + Some(&keys.storage_addresses), + ); + + drive_trie( + &mut rng, + &format!("{context} account trie"), + &mut AccountTrie(reference.account_trie_cursor(max_block)?), + &mut AccountTrie(rocksdb.account_trie_cursor(max_block)?), + &keys.account_paths, + None, + ); + + let address = Keyspace::pick(&mut rng, &keys.storage_addresses); + drive_trie( + &mut rng, + &format!("{context} storage trie"), + &mut reference.storage_trie_cursor(address, max_block)?, + &mut rocksdb.storage_trie_cursor(address, max_block)?, + &keys.storage_paths, + Some(&keys.storage_addresses), + ); + } + } + Ok(()) +} diff --git a/crates/execution/trie/tests/lib.rs b/crates/execution/trie/tests/lib.rs index 2c4b6f49df..6f004e9a5b 100644 --- a/crates/execution/trie/tests/lib.rs +++ b/crates/execution/trie/tests/lib.rs @@ -1,6 +1,7 @@ //! Common test suite for [`BaseProofsStore`] implementations. mod branch_cursors; +mod cursor_differential; mod hashed_cursors; mod history; mod proof_window; From 98b1b85fe73cd901197ef4c7e93631ccd855ed2d Mon Sep 17 00:00:00 2001 From: xenoliss Date: Fri, 25 Sep 2026 08:32:49 +0000 Subject: [PATCH 118/299] refactor(batcher): answer every admin command once the driver has applied it (#5283) * refactor(batcher): answer every admin command once the driver has applied it * docs(batcher): leave setLogLevel out of the answered-once-applied rule --- crates/batcher/admin/README.md | 9 ++++- crates/batcher/core/src/admin.rs | 66 ++++++++++++++++--------------- crates/batcher/core/src/driver.rs | 12 +++--- 3 files changed, 49 insertions(+), 38 deletions(-) diff --git a/crates/batcher/admin/README.md b/crates/batcher/admin/README.md index dfe341aaeb..14656049eb 100644 --- a/crates/batcher/admin/README.md +++ b/crates/batcher/admin/README.md @@ -1,6 +1,7 @@ Admin JSON-RPC API server for the Base Batcher. -Lifecycle methods answer once the driver has applied them, not when the command is queued. +Every method except `admin_setLogLevel` answers once the driver has applied it, not when the +command is queued. | Method | Behavior | |---|---| @@ -8,6 +9,10 @@ Lifecycle methods answer once the driver has applied them, not when the command | `admin_startBatcher` | Starts ingestion again from the safe L2 head. Does nothing if the batcher is already running. | | `admin_flushBatcher` | Closes the current channel so its frames become eligible for submission; it does not wait for L1 inclusion. Fails if the batcher is stopped. | | `admin_getBatcherStatus` | Returns `stopped`, `in_flight` and `da_backlog_bytes`. | +| `admin_setThrottleController` | Replaces the DA throttle strategy and its full configuration. The new limits are pushed to the block builder right after. | +| `admin_resetThrottleController` | Pushes the current DA limits to the block builder again, even if they have not changed. | +| `admin_getThrottleController` | Returns the throttle strategy, its threshold and maximum intensity, and the current intensity and limits. | +| `admin_setLogLevel` | Not supported yet; always fails with `-32601`. | Error codes: `-32001` the driver has shut down, `-32002` the batcher is in the wrong state for the -request. +request, `-32601` the method is not supported. diff --git a/crates/batcher/core/src/admin.rs b/crates/batcher/core/src/admin.rs index 74df5e5ed2..61d679b76a 100644 --- a/crates/batcher/core/src/admin.rs +++ b/crates/batcher/core/src/admin.rs @@ -41,19 +41,21 @@ pub enum AdminError { pub type AdminResult = Result; /// Commands the admin HTTP server can send to the running driver task. +/// +/// Every command carries a reply channel, answered once the driver has applied it. #[derive(derive_more::Debug)] pub enum AdminCommand { /// Start block ingestion again after a [`Stop`](Self::Stop). Start { - /// Answered once the driver has applied the command. + /// Answered once ingestion is running. #[debug(skip)] - reply: oneshot::Sender>, + reply: oneshot::Sender<()>, }, /// Stop block ingestion; the driver task keeps running. Stop { - /// Answered once the driver has applied the command. + /// Answered once ingestion is stopped. #[debug(skip)] - reply: oneshot::Sender>, + reply: oneshot::Sender<()>, }, /// Flush the current encoding channel. Flush { @@ -68,18 +70,25 @@ pub enum AdminCommand { /// The new throttle configuration to apply. #[debug(skip)] config: ThrottleConfig, + /// Answered once the new controller is in place. + #[debug(skip)] + reply: oneshot::Sender<()>, }, /// Clear the throttle dedup cache so limits are re-applied unconditionally. - ResetThrottle, - /// Read current throttle state; reply sent via the embedded oneshot sender. + ResetThrottle { + /// Answered once the cache is cleared. + #[debug(skip)] + reply: oneshot::Sender<()>, + }, + /// Read the current throttle state. GetThrottleInfo { - /// Channel to send the throttle info snapshot back on. + /// Answered with a snapshot of the throttle state. #[debug(skip)] reply: oneshot::Sender, }, - /// Read current driver runtime state; reply sent via the embedded oneshot sender. + /// Read the current driver runtime state. GetStatus { - /// Channel to send the batcher status back on. + /// Answered with the batcher status. #[debug(skip)] reply: oneshot::Sender, }, @@ -88,7 +97,9 @@ pub enum AdminCommand { /// Cloneable handle to the driver's admin command channel. /// /// Create with [`AdminHandle::channel`]; hand the returned [`mpsc::Receiver`] to the -/// driver as [`BatchDriverInputs::admin_rx`](crate::BatchDriverInputs::admin_rx). +/// driver as [`BatchDriverInputs::admin_rx`](crate::BatchDriverInputs::admin_rx). Every +/// method that sends a command returns once the driver has applied it, or +/// [`AdminError::ChannelClosed`] once the driver is gone. #[derive(Clone, Debug)] pub struct AdminHandle { tx: mpsc::Sender, @@ -119,7 +130,7 @@ impl AdminHandle { /// Answered once the channel is closed, before its frames are submitted; it does not /// wait for L1 inclusion. Returns [`AdminError::Stopped`] if the batcher is stopped. pub async fn flush(&self) -> AdminResult<()> { - self.request(|reply| AdminCommand::Flush { reply }).await + self.request(|reply| AdminCommand::Flush { reply }).await? } /// Replace the throttle strategy and configuration. @@ -128,32 +139,29 @@ impl AdminHandle { /// supported. Callers that want to change only one field should call /// [`get_throttle_info`](Self::get_throttle_info) first to read the /// current config, adjust the desired field, and pass the result here. + /// The new limits are pushed to the block builder right after. pub async fn set_throttle( &self, strategy: ThrottleStrategy, config: ThrottleConfig, ) -> AdminResult<()> { - self.send(AdminCommand::SetThrottle { strategy, config }).await + self.request(|reply| AdminCommand::SetThrottle { strategy, config, reply }).await } - /// Clear the throttle dedup cache so limits are re-applied unconditionally - /// on the next driver iteration. + /// Clear the throttle dedup cache, so the current limits are pushed to the block + /// builder again right after, even if they have not changed. pub async fn reset_throttle(&self) -> AdminResult<()> { - self.send(AdminCommand::ResetThrottle).await + self.request(|reply| AdminCommand::ResetThrottle { reply }).await } /// Read the current throttle controller state. pub async fn get_throttle_info(&self) -> AdminResult { - let (tx, rx) = oneshot::channel(); - self.send(AdminCommand::GetThrottleInfo { reply: tx }).await?; - rx.await.map_err(|_| AdminError::ChannelClosed) + self.request(|reply| AdminCommand::GetThrottleInfo { reply }).await } /// Read the current driver runtime state. pub async fn get_status(&self) -> AdminResult { - let (tx, rx) = oneshot::channel(); - self.send(AdminCommand::GetStatus { reply: tx }).await?; - rx.await.map_err(|_| AdminError::ChannelClosed) + self.request(|reply| AdminCommand::GetStatus { reply }).await } /// Dynamic log level changes require a `tracing-subscriber` reload handle @@ -166,18 +174,14 @@ impl AdminHandle { Err(AdminError::NotSupported("set_log_level")) } - async fn send(&self, cmd: AdminCommand) -> AdminResult<()> { - self.tx.send(cmd).await.map_err(|_| AdminError::ChannelClosed) - } - - /// Send a command carrying a reply channel and wait for the driver's answer. - async fn request( + /// Send a command and wait for the driver's answer. + async fn request( &self, - command: impl FnOnce(oneshot::Sender>) -> AdminCommand, - ) -> AdminResult<()> { + command: impl FnOnce(oneshot::Sender) -> AdminCommand, + ) -> AdminResult { let (reply, rx) = oneshot::channel(); - self.send(command(reply)).await?; - rx.await.map_err(|_| AdminError::ChannelClosed)? + self.tx.send(command(reply)).await.map_err(|_| AdminError::ChannelClosed)?; + rx.await.map_err(|_| AdminError::ChannelClosed) } } diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index f1840fc31c..3f6b09b8d4 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -378,7 +378,7 @@ where self.stopped = false; } - /// Apply an admin command, and answer it when it carries a reply. + /// Apply an admin command and answer it. fn on_admin(&mut self, cmd: AdminCommand) -> Result<(), BatchDriverError> { match cmd { AdminCommand::Flush { reply } if self.stopped => { @@ -391,18 +391,20 @@ where } AdminCommand::Stop { reply } => { self.on_admin_stop(); - let _ = reply.send(Ok(())); + let _ = reply.send(()); } AdminCommand::Start { reply } => { self.on_admin_start(); - let _ = reply.send(Ok(())); + let _ = reply.send(()); } - AdminCommand::SetThrottle { strategy, config } => { + AdminCommand::SetThrottle { strategy, config, reply } => { self.throttle.set_controller(ThrottleController::new(config, strategy)); + let _ = reply.send(()); info!("throttle controller replaced via admin"); } - AdminCommand::ResetThrottle => { + AdminCommand::ResetThrottle { reply } => { self.throttle.reset(); + let _ = reply.send(()); info!("throttle controller reset via admin"); } AdminCommand::GetThrottleInfo { reply } => { From 615cf0f6ffb2083cad83037dcd788cda07628e0a Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 25 Sep 2026 13:28:25 +0000 Subject: [PATCH 119/299] feat(upgrades): add the Everest upgrade (#5247) * feat(upgrades): add the Everest upgrade between Denim and Zenith Everest is a contract-backed execution upgrade that follows Denim. It is appended to CONTRACT_VARIANTS, so the L1 ProtocolVersions contract must register "everest" at the next registration id. No built-in chain schedules it and nothing is gated on it yet. * test(upgrades): schedule Everest in test_upgrades_iter fixture Everest was appended to CONTRACT_VARIANTS but the iter() fixture still left it unscheduled, so the expected list (index+1 per variant) diverged from the actual output under --all-features (serde). Give Everest a timestamp so the fixture matches the 15-entry contract ladder. * fix(upgrades): keep Denim and Everest timestamps on the execution schedule to_chain_upgrades stopped at Cobalt, so a configured timestamp never became an EL hardfork. basectl now reads those timestamps from the chain config. --- actions/harness/src/engine.rs | 5 +++ actions/harness/src/test_rollup_config.rs | 8 +++++ crates/common/chains/src/config.rs | 7 ++++ crates/common/chains/src/upgrade.rs | 33 ++++++++++++++----- crates/common/chains/src/upgrades.rs | 11 +++++++ crates/common/evm2/src/spec.rs | 2 +- crates/common/genesis/src/chain/upgrade.rs | 26 +++++++++++++-- crates/common/genesis/src/rollup.rs | 12 +++++++ crates/common/precompiles/src/provider.rs | 7 ++-- crates/common/rpc-types/src/genesis.rs | 5 +++ crates/consensus/cli/src/metrics.rs | 3 ++ crates/consensus/engine/src/versions.rs | 1 + crates/consensus/protocol/src/batch/single.rs | 1 + crates/execution/chainspec/src/spec.rs | 11 +++++-- crates/execution/chainspec/src/upgrades.rs | 24 ++++++++++++++ .../infra/basectl/src/app/views/upgrades.rs | 11 ++++++- .../proof/primitives/src/per_chain_config.rs | 1 + crates/proof/proof/src/boot.rs | 2 ++ crates/proof/zk/utils/src/precompiles/mod.rs | 1 + crates/utilities/test-utils/src/genesis.rs | 3 +- etc/scripts/devnet/anvil-nitro-local.sh | 2 +- 21 files changed, 157 insertions(+), 19 deletions(-) diff --git a/actions/harness/src/engine.rs b/actions/harness/src/engine.rs index e1306472a8..7bf30fd3fe 100644 --- a/actions/harness/src/engine.rs +++ b/actions/harness/src/engine.rs @@ -231,6 +231,9 @@ impl ActionEngineClient { if let Some(ts) = hf.base.denim { base.insert("denim".to_string(), serde_json::json!(ts)); } + if let Some(ts) = hf.base.everest { + base.insert("everest".to_string(), serde_json::json!(ts)); + } if let Some(ts) = hf.base.zenith { base.insert("zenith".to_string(), serde_json::json!(ts)); } @@ -1081,6 +1084,7 @@ mod tests { beryl: Some(42), cobalt: Some(42), denim: Some(42), + everest: Some(42), zenith: Some(42), }, ..Default::default() @@ -1091,6 +1095,7 @@ mod tests { let genesis = ActionEngineClient::build_genesis_for_rollup(&config); assert_eq!(genesis.config.extra_fields["base"]["denim"], serde_json::json!(42)); + assert_eq!(genesis.config.extra_fields["base"]["everest"], serde_json::json!(42)); assert_eq!(genesis.config.extra_fields["base"]["zenith"], serde_json::json!(42)); } diff --git a/actions/harness/src/test_rollup_config.rs b/actions/harness/src/test_rollup_config.rs index 804d459a46..3da27bebbb 100644 --- a/actions/harness/src/test_rollup_config.rs +++ b/actions/harness/src/test_rollup_config.rs @@ -177,6 +177,14 @@ impl TestRollupConfigBuilder { self } + /// Sets the Everest activation timestamp. + /// + /// Everest is a standalone Base-specific fork, independent of the inherited fork cascade. + pub const fn with_everest_at(mut self, t: u64) -> Self { + self.config.upgrades.base.everest = Some(t); + self + } + /// Sets the Zenith activation timestamp. /// /// Zenith is a standalone Base-specific fork, independent of the inherited fork cascade. diff --git a/crates/common/chains/src/config.rs b/crates/common/chains/src/config.rs index 99126440c5..dfb9c44560 100644 --- a/crates/common/chains/src/config.rs +++ b/crates/common/chains/src/config.rs @@ -59,6 +59,8 @@ pub struct ChainConfig { pub cobalt_timestamp: Option, /// Denim activation timestamp (optional). pub denim_timestamp: Option, + /// Everest activation timestamp (optional). + pub everest_timestamp: Option, // Genesis /// L1 genesis block hash. @@ -373,6 +375,7 @@ impl ChainConfig { beryl: self.beryl_timestamp, cobalt: self.cobalt_timestamp, denim: self.denim_timestamp, + everest: self.everest_timestamp, // Zenith is a genesis-only testing gate and must never enter a canonical chain // schedule. Devnets and tests opt in through custom genesis config instead. zenith: None, @@ -473,6 +476,7 @@ const MAINNET: ChainConfig = ChainConfig { beryl_timestamp: Some(1_782_410_400), cobalt_timestamp: Some(1_790_791_200), denim_timestamp: None, + everest_timestamp: None, genesis_l1_hash: b256!("5c13d307623a926cd31415036c8b7fa14572f9dac64528e857a470511fc30771"), genesis_l1_number: 17_481_768, @@ -547,6 +551,7 @@ const SEPOLIA: ChainConfig = ChainConfig { beryl_timestamp: Some(1_781_805_600), cobalt_timestamp: Some(1_790_186_400), denim_timestamp: None, + everest_timestamp: None, genesis_l1_hash: b256!("cac9a83291d4dec146d6f7f69ab2304f23f5be87b1789119a0c5b1e4482444ed"), genesis_l1_number: 4_370_868, @@ -612,6 +617,7 @@ const DEVNET: ChainConfig = ChainConfig { beryl_timestamp: None, cobalt_timestamp: None, denim_timestamp: None, + everest_timestamp: None, genesis_l1_hash: B256::ZERO, genesis_l1_number: 0, @@ -666,6 +672,7 @@ const ZERONET: ChainConfig = ChainConfig { beryl_timestamp: Some(1_782_349_188), cobalt_timestamp: None, denim_timestamp: None, + everest_timestamp: None, genesis_l1_hash: b256!("acb2c60e3887888b5111b05c8d8f32e2761c7d4a0f10562d199253ab072c3a71"), genesis_l1_number: 3_083_762, diff --git a/crates/common/chains/src/upgrade.rs b/crates/common/chains/src/upgrade.rs index a3b7ef117b..cf0e147173 100644 --- a/crates/common/chains/src/upgrade.rs +++ b/crates/common/chains/src/upgrade.rs @@ -17,25 +17,25 @@ pub trait BaseUpgradeExt: Sized { fn into_eth_spec(self) -> SpecId; /// Returns the execution fork ladder with activation conditions for the given chain config. - fn forks_for(cfg: &ChainConfig) -> [(BaseUpgrade, ForkCondition); 13]; + fn forks_for(cfg: &ChainConfig) -> [(BaseUpgrade, ForkCondition); 14]; /// Base mainnet list of execution upgrades. - fn mainnet() -> [(BaseUpgrade, ForkCondition); 13] { + fn mainnet() -> [(BaseUpgrade, ForkCondition); 14] { Self::forks_for(ChainConfig::mainnet()) } /// Base Sepolia list of execution upgrades. - fn sepolia() -> [(BaseUpgrade, ForkCondition); 13] { + fn sepolia() -> [(BaseUpgrade, ForkCondition); 14] { Self::forks_for(ChainConfig::sepolia()) } /// Devnet list of execution upgrades. - fn devnet() -> [(BaseUpgrade, ForkCondition); 13] { + fn devnet() -> [(BaseUpgrade, ForkCondition); 14] { Self::forks_for(ChainConfig::devnet()) } /// Base Zeronet list of execution upgrades. - fn zeronet() -> [(BaseUpgrade, ForkCondition); 13] { + fn zeronet() -> [(BaseUpgrade, ForkCondition); 14] { Self::forks_for(ChainConfig::zeronet()) } @@ -56,17 +56,18 @@ impl BaseUpgradeExt for BaseUpgrade { | Self::Holocene | Self::PectraBlobSchedule => SpecId::CANCUN, Self::Isthmus | Self::Jovian => SpecId::PRAGUE, - // Azul, Beryl, Cobalt, Denim, Zenith, and newer Base upgrades inherit the latest + // Azul, Beryl, Cobalt, Denim, Everest, Zenith, and newer Base upgrades inherit the latest // known Ethereum spec until explicitly mapped. _ => SpecId::OSAKA, } } - fn forks_for(cfg: &ChainConfig) -> [(BaseUpgrade, ForkCondition); 13] { + fn forks_for(cfg: &ChainConfig) -> [(BaseUpgrade, ForkCondition); 14] { let azul = cfg.azul_timestamp.map_or(ForkCondition::Never, ForkCondition::Timestamp); let beryl = cfg.beryl_timestamp.map_or(ForkCondition::Never, ForkCondition::Timestamp); let cobalt = cfg.cobalt_timestamp.map_or(ForkCondition::Never, ForkCondition::Timestamp); let denim = cfg.denim_timestamp.map_or(ForkCondition::Never, ForkCondition::Timestamp); + let everest = cfg.everest_timestamp.map_or(ForkCondition::Never, ForkCondition::Timestamp); [ (Self::Bedrock, ForkCondition::Block(cfg.bedrock_block)), (Self::Regolith, ForkCondition::Timestamp(cfg.regolith_timestamp)), @@ -81,11 +82,14 @@ impl BaseUpgradeExt for BaseUpgrade { (Self::Beryl, beryl), (Self::Cobalt, cobalt), (Self::Denim, denim), + (Self::Everest, everest), ] } fn from_timestamp(chain_spec: impl Upgrades, timestamp: u64) -> BaseUpgrade { - if chain_spec.is_denim_active_at_timestamp(timestamp) { + if chain_spec.is_everest_active_at_timestamp(timestamp) { + Self::Everest + } else if chain_spec.is_denim_active_at_timestamp(timestamp) { Self::Denim } else if chain_spec.is_cobalt_active_at_timestamp(timestamp) { Self::Cobalt @@ -130,7 +134,7 @@ mod tests { fn check_base_upgrade_from_str() { let upgrade_str = [ "beDrOck", "rEgOlITH", "cAnYoN", "eCoToNe", "FJorD", "GRaNiTe", "hOlOcEnE", "isthMUS", - "jOvIaN", "aZuL", "bErYl", "cObAlT", "dEnIm", "zEnItH", + "jOvIaN", "aZuL", "bErYl", "cObAlT", "dEnIm", "eVeReSt", "zEnItH", ]; let expected_upgrades = [ BaseUpgrade::Bedrock, @@ -146,6 +150,7 @@ mod tests { BaseUpgrade::Beryl, BaseUpgrade::Cobalt, BaseUpgrade::Denim, + BaseUpgrade::Everest, BaseUpgrade::Zenith, ]; @@ -230,6 +235,7 @@ mod tests { (BaseUpgrade::Beryl, SpecId::OSAKA), (BaseUpgrade::Cobalt, SpecId::OSAKA), (BaseUpgrade::Denim, SpecId::OSAKA), + (BaseUpgrade::Everest, SpecId::OSAKA), (BaseUpgrade::Zenith, SpecId::OSAKA), ]; @@ -267,6 +273,15 @@ mod tests { assert!(BaseUpgrade::EXECUTION_VARIANTS.contains(&BaseUpgrade::Denim)); } + #[test] + fn everest_is_contract_backed_and_follows_denim_in_both_ladders() { + assert!(BaseUpgrade::Everest.is_contract_backed()); + assert!(BaseUpgrade::Everest.is_execution()); + assert_eq!(BaseUpgrade::from_contract_fork_name("everest"), Some(BaseUpgrade::Everest)); + assert_eq!(BaseUpgrade::EXECUTION_VARIANTS.last(), Some(&BaseUpgrade::Everest)); + assert_eq!(BaseUpgrade::CONTRACT_VARIANTS.last(), Some(&BaseUpgrade::Everest)); + } + #[test] fn zenith_is_genesis_only_not_l1_signal_backed() { // Zenith activates via genesis config only: it is not contract-backed, not signalable diff --git a/crates/common/chains/src/upgrades.rs b/crates/common/chains/src/upgrades.rs index 02ee8c7aad..3af1239f75 100644 --- a/crates/common/chains/src/upgrades.rs +++ b/crates/common/chains/src/upgrades.rs @@ -86,6 +86,13 @@ pub trait Upgrades: EthereumHardforks { self.fork_condition(BaseUpgrade::Denim).active_at_timestamp(timestamp) } + /// Returns `true` if [`Everest`](BaseUpgrade::Everest) is active at given block timestamp. + /// Everest is unscheduled by default, so this returns `false` until an activation time is + /// configured via genesis or the L1 upgrade signal. + fn is_everest_active_at_timestamp(&self, timestamp: u64) -> bool { + self.fork_condition(BaseUpgrade::Everest).active_at_timestamp(timestamp) + } + /// Returns `true` if the [`Zenith`](BaseUpgrade::Zenith) gate is active at the given block /// timestamp. Zenith is the permanently unscheduled gate for future hardfork feature /// testing: it is never contract-backed, so it can only be activated through genesis @@ -147,6 +154,10 @@ impl Upgrades for RollupConfig { .upgrade_activation_timestamp(BaseUpgrade::Denim) .map(ForkCondition::Timestamp) .unwrap_or(ForkCondition::Never), + BaseUpgrade::Everest => self + .upgrade_activation_timestamp(BaseUpgrade::Everest) + .map(ForkCondition::Timestamp) + .unwrap_or(ForkCondition::Never), // Zenith is the genesis-only gate for future hardfork feature testing: the runtime // registry drops Zenith writes, so only a genesis-configured timestamp can appear // here. diff --git a/crates/common/evm2/src/spec.rs b/crates/common/evm2/src/spec.rs index 006af1015b..a961933cfd 100644 --- a/crates/common/evm2/src/spec.rs +++ b/crates/common/evm2/src/spec.rs @@ -47,7 +47,7 @@ impl From for SpecId { | BaseUpgrade::Holocene | BaseUpgrade::PectraBlobSchedule => Self::CANCUN, BaseUpgrade::Isthmus | BaseUpgrade::Jovian => Self::PRAGUE, - // Azul, Beryl, Cobalt, Denim, Zenith, and newer upgrades inherit the latest known + // Azul, Beryl, Cobalt, Denim, Everest, Zenith, and newer upgrades inherit the latest known // Ethereum spec until explicitly mapped. _ => Self::OSAKA, } diff --git a/crates/common/genesis/src/chain/upgrade.rs b/crates/common/genesis/src/chain/upgrade.rs index 441145d345..52148222fd 100644 --- a/crates/common/genesis/src/chain/upgrade.rs +++ b/crates/common/genesis/src/chain/upgrade.rs @@ -31,6 +31,10 @@ pub struct BaseUpgradeConfig { /// Active if `denim` != None && L2 block timestamp >= `Some(denim)`, inactive otherwise. #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub denim: Option, + /// `everest` sets the activation time for the Everest network upgrade. + /// Active if `everest` != None && L2 block timestamp >= `Some(everest)`, inactive otherwise. + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub everest: Option, /// `zenith` sets the activation time for the Zenith network upgrade. /// Active if `zenith` != None && L2 block timestamp >= `Some(zenith)`, inactive otherwise. #[cfg_attr( @@ -47,6 +51,7 @@ impl BaseUpgradeConfig { && self.beryl.is_none() && self.cobalt.is_none() && self.denim.is_none() + && self.everest.is_none() && self.zenith.is_none() } } @@ -72,6 +77,10 @@ hardfork!( /// 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. Like + /// [`Denim`](BaseUpgrade::Denim) it is unscheduled for now, contract-backed, and part of the + /// execution fork ladder. + /// /// [`Zenith`](BaseUpgrade::Zenith) is a hardfork for future experimental features. It is /// genesis-configurable but not contract-backed, since the L1 upgrade-signal contract does /// not yet recognise it. @@ -113,6 +122,8 @@ hardfork!( Cobalt, /// Denim: Fourth Base-specific network upgrade. Unscheduled for now. Denim, + /// Everest: Fifth Base-specific network upgrade. Unscheduled for now. + Everest, /// Zenith: hardfork for future experimental features. Zenith, } @@ -127,7 +138,7 @@ impl BaseUpgrade { /// These are the upgrades that participate in the reth/revm hardfork schedule. Excludes the /// contract-only [`Delta`](Self::Delta) and [`PectraBlobSchedule`](Self::PectraBlobSchedule) /// upgrades, and [`Zenith`](Self::Zenith), which does not yet change EVM execution. - pub const EXECUTION_VARIANTS: [Self; 13] = [ + pub const EXECUTION_VARIANTS: [Self; 14] = [ Self::Bedrock, Self::Regolith, Self::Canyon, @@ -141,6 +152,7 @@ impl BaseUpgrade { Self::Beryl, Self::Cobalt, Self::Denim, + Self::Everest, ]; /// The contract-backed upgrade set, in activation order. @@ -155,7 +167,7 @@ impl BaseUpgrade { /// upgrades by ascending append-only registration id with names kept offchain. This order MUST /// match the contract's registration order — reordering silently misattributes every /// activation timestamp. Only ever append. - pub const CONTRACT_VARIANTS: [Self; 14] = [ + pub const CONTRACT_VARIANTS: [Self; 15] = [ Self::Regolith, Self::Canyon, Self::Delta, @@ -170,6 +182,7 @@ impl BaseUpgrade { Self::Beryl, Self::Cobalt, Self::Denim, + Self::Everest, ]; /// Returns true if this upgrade participates in the execution fork ladder. @@ -202,6 +215,7 @@ impl BaseUpgrade { Self::Beryl => 10, Self::Cobalt => 11, Self::Denim => 12, + Self::Everest => 13, Self::Delta | Self::PectraBlobSchedule | Self::Zenith => return None, }) } @@ -228,6 +242,7 @@ impl BaseUpgrade { Self::Beryl => "beryl", Self::Cobalt => "cobalt", Self::Denim => "denim", + Self::Everest => "everest", Self::Zenith => "zenith", } } @@ -272,6 +287,7 @@ impl BaseUpgrade { "beryl" | "baseberyl" | "v2" => Self::Beryl, "cobalt" | "basecobalt" | "v3" => Self::Cobalt, "denim" | "basedenim" => Self::Denim, + "everest" | "baseeverest" => Self::Everest, // Zenith is not contract-backed: even though `contract_id` emits "zenith", it is // deliberately not resolvable here, so the L1 upgrade signal can never address it. _ => return None, @@ -669,6 +685,7 @@ impl UpgradeConfig { beryl: Some(1_782_410_400), cobalt: Some(1_790_791_200), denim: None, + everest: None, zenith: None, }, }; @@ -693,6 +710,7 @@ impl UpgradeConfig { beryl: Some(1_781_805_600), cobalt: Some(1_790_186_400), denim: None, + everest: None, zenith: None, }, }; @@ -743,6 +761,7 @@ impl UpgradeConfig { BaseUpgrade::Beryl => self.base.beryl = None, BaseUpgrade::Cobalt => self.base.cobalt = None, BaseUpgrade::Denim => self.base.denim = None, + BaseUpgrade::Everest => self.base.everest = None, BaseUpgrade::Zenith => self.base.zenith = None, } } @@ -783,6 +802,7 @@ impl UpgradeConfig { BaseUpgrade::Beryl => self.base.beryl, BaseUpgrade::Cobalt => self.base.cobalt, BaseUpgrade::Denim => self.base.denim, + BaseUpgrade::Everest => self.base.everest, BaseUpgrade::Zenith => self.base.zenith, }; @@ -813,6 +833,7 @@ impl UpgradeConfig { BaseUpgrade::Beryl => self.base.beryl = Some(timestamp), BaseUpgrade::Cobalt => self.base.cobalt = Some(timestamp), BaseUpgrade::Denim => self.base.denim = Some(timestamp), + BaseUpgrade::Everest => self.base.everest = Some(timestamp), BaseUpgrade::Zenith => self.base.zenith = Some(timestamp), } } @@ -945,6 +966,7 @@ mod tests { beryl: Some(12), cobalt: Some(13), denim: Some(14), + everest: Some(15), zenith: None, }, }; diff --git a/crates/common/genesis/src/rollup.rs b/crates/common/genesis/src/rollup.rs index 61dd67095c..28623aa27c 100644 --- a/crates/common/genesis/src/rollup.rs +++ b/crates/common/genesis/src/rollup.rs @@ -330,6 +330,11 @@ impl RollupConfig { [upgrade_activation_timestamp(BaseUpgrade::Denim)], "Denim"; + is_everest_active, + is_first_everest_block, + [upgrade_activation_timestamp(BaseUpgrade::Everest)], + "Everest"; + is_zenith_active, is_first_zenith_block, [upgrade_activation_timestamp(BaseUpgrade::Zenith)], @@ -522,6 +527,8 @@ impl RollupConfig { Some(BaseUpgrade::Cobalt) } else if self.is_first_denim_block(timestamp, parent_timestamp) { Some(BaseUpgrade::Denim) + } else if self.is_first_everest_block(timestamp, parent_timestamp) { + Some(BaseUpgrade::Everest) } else { None }; @@ -617,6 +624,7 @@ mod tests { beryl: Some(120), cobalt: Some(130), denim: None, + everest: None, zenith: None, }, }, @@ -699,6 +707,7 @@ mod tests { beryl: Some(120), cobalt: None, denim: None, + everest: None, zenith: None, }, ..Default::default() @@ -981,6 +990,7 @@ mod tests { beryl: None, cobalt: None, denim: None, + everest: None, zenith: None, }; assert_eq!( @@ -995,6 +1005,7 @@ mod tests { beryl: Some(800), cobalt: None, denim: None, + everest: None, zenith: None, }; assert_eq!( @@ -1011,6 +1022,7 @@ mod tests { beryl: Some(800), cobalt: None, denim: None, + everest: None, zenith: None, }; assert_eq!(cfg.ethereum_fork_activation(EthereumHardfork::Osaka), ForkCondition::Never); diff --git a/crates/common/precompiles/src/provider.rs b/crates/common/precompiles/src/provider.rs index 7ef90bb9e7..a207d32336 100644 --- a/crates/common/precompiles/src/provider.rs +++ b/crates/common/precompiles/src/provider.rs @@ -51,11 +51,14 @@ impl BasePrecompiles { BaseUpgrade::Jovian => Self::jovian(), BaseUpgrade::Azul => Self::azul(), BaseUpgrade::Beryl => Self::beryl(), - // Denim and Zenith introduce no new precompiles, so they inherit the Cobalt mapping + // Denim, Everest, and Zenith introduce no new precompiles, so they inherit the Cobalt mapping // and evolve with the newest hardfork (keep them grouped with the latest arm). // Activation is governed separately by the genesis upgrade schedule and // execution-code gates — this arm only describes the precompile set. - BaseUpgrade::Cobalt | BaseUpgrade::Denim | BaseUpgrade::Zenith => Self::cobalt(), + BaseUpgrade::Cobalt + | BaseUpgrade::Denim + | BaseUpgrade::Everest + | BaseUpgrade::Zenith => Self::cobalt(), upgrade => panic!("unsupported Base precompile upgrade: {upgrade}"), }; diff --git a/crates/common/rpc-types/src/genesis.rs b/crates/common/rpc-types/src/genesis.rs index 7c267c9a07..ec51318ec9 100644 --- a/crates/common/rpc-types/src/genesis.rs +++ b/crates/common/rpc-types/src/genesis.rs @@ -48,6 +48,8 @@ pub struct UpgradeInfo { pub cobalt: Option, /// Denim upgrade timestamp. pub denim: Option, + /// Everest upgrade timestamp. + pub everest: Option, /// Zenith upgrade timestamp. #[serde(alias = "future")] pub zenith: Option, @@ -169,6 +171,7 @@ mod tests { beryl: Some(16), cobalt: None, denim: None, + everest: None, zenith: Some(1_000_000), }, activation_admin_address: None, @@ -248,6 +251,7 @@ mod tests { beryl: Some(16), cobalt: None, denim: None, + everest: None, zenith: None, }, activation_admin_address: None, @@ -280,6 +284,7 @@ mod tests { beryl: Some(16), cobalt: None, denim: None, + everest: None, zenith: None, }, activation_admin_address: None, diff --git a/crates/consensus/cli/src/metrics.rs b/crates/consensus/cli/src/metrics.rs index d3686456b7..43a8b1bac0 100644 --- a/crates/consensus/cli/src/metrics.rs +++ b/crates/consensus/cli/src/metrics.rs @@ -258,6 +258,7 @@ const UPGRADE_METRIC_LABELS: [(BaseUpgrade, &str); BaseUpgrade::CONTRACT_VARIANT (BaseUpgrade::Beryl, "Beryl"), (BaseUpgrade::Cobalt, "Cobalt"), (BaseUpgrade::Denim, "Denim"), + (BaseUpgrade::Everest, "Everest"), ]; fn upgrade_metric_label(upgrade: BaseUpgrade) -> Option<&'static str> { @@ -317,6 +318,7 @@ mod tests { beryl: Some(2_000), cobalt: Some(3_000), denim: None, + everest: None, zenith: None, }, ..Default::default() @@ -372,6 +374,7 @@ mod tests { beryl: Some(1_000), cobalt: Some(2_000), denim: None, + everest: None, zenith: None, }, ..Default::default() diff --git a/crates/consensus/engine/src/versions.rs b/crates/consensus/engine/src/versions.rs index 97c0e7ce15..269052927d 100644 --- a/crates/consensus/engine/src/versions.rs +++ b/crates/consensus/engine/src/versions.rs @@ -124,6 +124,7 @@ mod tests { beryl: Some(50), cobalt: Some(60), denim: None, + everest: None, zenith: None, }, ..Default::default() diff --git a/crates/consensus/protocol/src/batch/single.rs b/crates/consensus/protocol/src/batch/single.rs index 147771b688..b58843bfef 100644 --- a/crates/consensus/protocol/src/batch/single.rs +++ b/crates/consensus/protocol/src/batch/single.rs @@ -668,6 +668,7 @@ mod tests { base: BaseUpgradeConfig { cobalt: Some(0), denim: Some(0), + everest: None, zenith: Some(0), ..Default::default() }, diff --git a/crates/execution/chainspec/src/spec.rs b/crates/execution/chainspec/src/spec.rs index e7916ebdc9..6894a4a2f0 100644 --- a/crates/execution/chainspec/src/spec.rs +++ b/crates/execution/chainspec/src/spec.rs @@ -177,6 +177,7 @@ impl BaseChainSpec { let beryl_time = genesis_info.base.beryl; let cobalt_time = genesis_info.base.cobalt; let denim_time = genesis_info.base.denim; + let everest_time = genesis_info.base.everest; let zenith_time = genesis_info.base.zenith; let time_upgrade_opts = [ (BaseUpgrade::Regolith.boxed(), genesis_info.regolith_time), @@ -195,6 +196,7 @@ impl BaseChainSpec { (BaseUpgrade::Beryl.boxed(), beryl_time), (BaseUpgrade::Cobalt.boxed(), cobalt_time), (BaseUpgrade::Denim.boxed(), denim_time), + (BaseUpgrade::Everest.boxed(), everest_time), (BaseUpgrade::Zenith.boxed(), zenith_time), ]; @@ -1051,12 +1053,14 @@ mod tests { } #[test] - fn builtin_chain_specs_never_activate_denim_or_zenith() { - // Built-in production schedules do not configure Denim or genesis-only Zenith. + fn builtin_chain_specs_never_activate_denim_everest_or_zenith() { + // Built-in production schedules do not configure Denim, Everest, or genesis-only Zenith. for spec in [BaseChainSpec::mainnet(), BaseChainSpec::sepolia(), 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)); + assert_eq!(spec.fork(BaseUpgrade::Everest), ForkCondition::Never); + assert!(!spec.is_fork_active_at_timestamp(BaseUpgrade::Everest, u64::MAX)); assert_eq!(spec.fork(BaseUpgrade::Zenith), ForkCondition::Never); assert!(!spec.is_fork_active_at_timestamp(BaseUpgrade::Zenith, 0)); assert!(!spec.is_fork_active_at_timestamp(BaseUpgrade::Zenith, u64::MAX)); @@ -1355,6 +1359,7 @@ mod tests { "v2": 60, "v3": 65, "denim": 900000, + "everest": 950000, "zenith": 1000000 }, "activationAdminAddress": "0xcb00000000000000000000000000000000000000", @@ -1400,6 +1405,8 @@ mod tests { assert!(chain_spec.is_fork_active_at_timestamp(BaseUpgrade::Cobalt, 65)); assert!(!chain_spec.is_fork_active_at_timestamp(BaseUpgrade::Denim, 899_999)); assert!(chain_spec.is_fork_active_at_timestamp(BaseUpgrade::Denim, 900_000)); + assert!(!chain_spec.is_fork_active_at_timestamp(BaseUpgrade::Everest, 949_999)); + assert!(chain_spec.is_fork_active_at_timestamp(BaseUpgrade::Everest, 950_000)); assert!(!chain_spec.is_fork_active_at_timestamp(BaseUpgrade::Zenith, 999_999)); assert!(chain_spec.is_fork_active_at_timestamp(BaseUpgrade::Zenith, 1_000_000)); } diff --git a/crates/execution/chainspec/src/upgrades.rs b/crates/execution/chainspec/src/upgrades.rs index 2505dd5f34..1a2ccda2cf 100644 --- a/crates/execution/chainspec/src/upgrades.rs +++ b/crates/execution/chainspec/src/upgrades.rs @@ -83,6 +83,16 @@ impl ChainUpgradesExt for ChainUpgrades { forks.push((BaseUpgrade::Cobalt.boxed(), cobalt)); } + let denim = self[BaseUpgrade::Denim]; + if !matches!(denim, ForkCondition::Never) { + forks.push((BaseUpgrade::Denim.boxed(), denim)); + } + + let everest = self[BaseUpgrade::Everest]; + if !matches!(everest, ForkCondition::Never) { + forks.push((BaseUpgrade::Everest.boxed(), everest)); + } + ChainHardforks::new(forks) } } @@ -106,4 +116,18 @@ mod tests { assert_eq!(upgrades.get(BaseUpgrade::Azul), Some(ForkCondition::Timestamp(1_000_000))); assert_eq!(upgrades.get(EthereumHardfork::Osaka), upgrades.get(BaseUpgrade::Azul)); } + + #[test] + fn denim_and_everest_timestamps_reach_the_execution_schedule() { + let upgrades = + ChainUpgrades::new(BaseUpgrade::devnet().into_iter().map(|(fork, cond)| match fork { + BaseUpgrade::Denim => (fork, ForkCondition::Timestamp(900_000)), + BaseUpgrade::Everest => (fork, ForkCondition::Timestamp(950_000)), + _ => (fork, cond), + })) + .to_chain_upgrades(); + + assert_eq!(upgrades.get(BaseUpgrade::Denim), Some(ForkCondition::Timestamp(900_000))); + assert_eq!(upgrades.get(BaseUpgrade::Everest), Some(ForkCondition::Timestamp(950_000))); + } } diff --git a/crates/infra/basectl/src/app/views/upgrades.rs b/crates/infra/basectl/src/app/views/upgrades.rs index b227e5d031..0be14bb9e9 100644 --- a/crates/infra/basectl/src/app/views/upgrades.rs +++ b/crates/infra/basectl/src/app/views/upgrades.rs @@ -108,6 +108,7 @@ impl ChainUpgrades { self.set_timestamp(BaseUpgrade::Beryl, upgrades.base.beryl); self.set_timestamp(BaseUpgrade::Cobalt, upgrades.base.cobalt); self.set_timestamp(BaseUpgrade::Denim, upgrades.base.denim); + self.set_timestamp(BaseUpgrade::Everest, upgrades.base.everest); self.set_timestamp(BaseUpgrade::Zenith, upgrades.base.zenith); } @@ -192,7 +193,12 @@ fn specs_from_config(cfg: &ChainConfig) -> Vec { name: "Cobalt", timestamp: cfg.cobalt_timestamp, }, - UpgradeSpec { upgrade: BaseUpgrade::Denim, name: "Denim", timestamp: None }, + UpgradeSpec { upgrade: BaseUpgrade::Denim, name: "Denim", timestamp: cfg.denim_timestamp }, + UpgradeSpec { + upgrade: BaseUpgrade::Everest, + name: "Everest", + timestamp: cfg.everest_timestamp, + }, UpgradeSpec { upgrade: BaseUpgrade::Zenith, name: "Zenith", timestamp: None }, ] } @@ -2814,6 +2820,7 @@ mod tests { beryl: Some(12), cobalt: None, denim: None, + everest: None, zenith: None, }, ..UpgradeConfig::default() @@ -2839,6 +2846,7 @@ mod tests { beryl: None, cobalt: None, denim: None, + everest: None, zenith: None, }, ..UpgradeConfig::default() @@ -2884,6 +2892,7 @@ mod tests { beryl: None, cobalt: None, denim: None, + everest: None, zenith: None, }, ..UpgradeConfig::default() diff --git a/crates/proof/primitives/src/per_chain_config.rs b/crates/proof/primitives/src/per_chain_config.rs index ae686d0d3b..7391c9b199 100644 --- a/crates/proof/primitives/src/per_chain_config.rs +++ b/crates/proof/primitives/src/per_chain_config.rs @@ -243,6 +243,7 @@ impl PerChainConfig { beryl: None, cobalt: None, denim: None, + everest: None, zenith: None, }, }, diff --git a/crates/proof/proof/src/boot.rs b/crates/proof/proof/src/boot.rs index 8aae5a697c..4ed1fe1cc5 100644 --- a/crates/proof/proof/src/boot.rs +++ b/crates/proof/proof/src/boot.rs @@ -615,6 +615,7 @@ mod tests { beryl: None, cobalt: None, denim: None, + everest: None, zenith: None, }, ..Default::default() @@ -1022,6 +1023,7 @@ mod tests { rollup_config.upgrades = UpgradeConfig { base: BaseUpgradeConfig { denim: Some(1_004), + everest: None, zenith: Some(1_005), ..Default::default() }, diff --git a/crates/proof/zk/utils/src/precompiles/mod.rs b/crates/proof/zk/utils/src/precompiles/mod.rs index 62cf8326c1..32c53be47d 100644 --- a/crates/proof/zk/utils/src/precompiles/mod.rs +++ b/crates/proof/zk/utils/src/precompiles/mod.rs @@ -462,6 +462,7 @@ mod tests { (BaseUpgrade::Beryl, true), (BaseUpgrade::Cobalt, true), (BaseUpgrade::Denim, true), + (BaseUpgrade::Everest, true), (BaseUpgrade::Zenith, true), ] { let spec = BaseSpecId::new(upgrade); diff --git a/crates/utilities/test-utils/src/genesis.rs b/crates/utilities/test-utils/src/genesis.rs index c9c63392f1..671c74fe83 100644 --- a/crates/utilities/test-utils/src/genesis.rs +++ b/crates/utilities/test-utils/src/genesis.rs @@ -153,7 +153,7 @@ pub fn build_test_genesis_cobalt() -> Genesis { genesis } -/// Builds a test genesis with Denim and Zenith enabled at timestamp 0. +/// Builds a test genesis with Denim, Everest, and Zenith enabled at timestamp 0. pub fn build_test_genesis_zenith() -> Genesis { let mut genesis = build_test_genesis_cobalt(); genesis.config.extra_fields.insert( @@ -163,6 +163,7 @@ pub fn build_test_genesis_zenith() -> Genesis { "beryl": 0, "cobalt": 0, "denim": 0, + "everest": 0, "zenith": 0 }), ); diff --git a/etc/scripts/devnet/anvil-nitro-local.sh b/etc/scripts/devnet/anvil-nitro-local.sh index fc3bb4e7b9..7eda3f7a5c 100755 --- a/etc/scripts/devnet/anvil-nitro-local.sh +++ b/etc/scripts/devnet/anvil-nitro-local.sh @@ -309,7 +309,7 @@ load_fork_names() { rollup_timestamp() { local name="$1" path case "$name" in - azul | beryl | cobalt | denim) path=".base.${name}" ;; + azul | beryl | cobalt | denim | everest) path=".base.${name}" ;; *) path=".${name}_time" ;; esac jq -r --arg path "$path" ' From b3e2639a84007900498638595f4cef8721010135 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Fri, 25 Sep 2026 14:45:09 +0000 Subject: [PATCH 120/299] fix(consensus): unblock conductor takeover without gossip (#5289) * fix(consensus): unblock conductor takeover without gossip Amp-Thread-ID: https://ampcode.com/threads/T-01a0d0c5-bda8-70d8-a508-f778aae22991 Co-authored-by: Amp * refactor(consensus): flatten sequencer start preparation Amp-Thread-ID: https://ampcode.com/threads/T-01a0d0c5-bda8-70d8-a508-f778aae22991 Co-authored-by: Amp * refactor(consensus): centralize sequencer start readiness checks Amp-Thread-ID: https://ampcode.com/threads/T-01a0d0c5-bda8-70d8-a508-f778aae22991 Co-authored-by: Amp * refactor(consensus): retain original conductor leadership match Amp-Thread-ID: https://ampcode.com/threads/T-01a0d0c5-bda8-70d8-a508-f778aae22991 Co-authored-by: Amp * fix(builder): separate test runtime ownership from executor clones Amp-Thread-ID: https://ampcode.com/threads/T-01a0d0c5-bda8-70d8-a508-f778aae22991 Co-authored-by: Amp * chore: split builder runtime fix out of conductor PR Amp-Thread-ID: https://ampcode.com/threads/T-01a0d0c5-bda8-70d8-a508-f778aae22991 Co-authored-by: Amp --------- Co-authored-by: Amp --- actions/harness/src/engine.rs | 11 ++ .../harness/src/sequencer/builder_backend.rs | 9 + .../harness/src/sequencer/engine_client.rs | 8 + .../actors/engine/engine_request_processor.rs | 119 ++++++++++- .../service/src/actors/engine/request.rs | 8 + .../validator_engine_request_handler.rs | 12 ++ .../service/src/actors/sequencer/actor.rs | 182 ++++++++++++++++- .../src/actors/sequencer/admin_api_impl.rs | 21 +- .../src/actors/sequencer/engine_client.rs | 20 ++ .../sequencer/engine_request_coordinator.rs | 185 +++++++++++++++++- .../actors/sequencer/shadow_reconciliation.rs | 5 + .../src/actors/sequencer/tests/actor_test.rs | 7 +- .../sequencer/tests/admin_api_impl_test.rs | 54 ++--- 13 files changed, 587 insertions(+), 54 deletions(-) diff --git a/actions/harness/src/engine.rs b/actions/harness/src/engine.rs index 7bf30fd3fe..7872360c70 100644 --- a/actions/harness/src/engine.rs +++ b/actions/harness/src/engine.rs @@ -985,6 +985,17 @@ impl BaseEngineApi for ActionEngineClient { #[async_trait] impl SequencerEngineClient for ActionEngineClient { + async fn prepare_sequencer_start( + &self, + expected_hash: B256, + ) -> Result<(), NodeEngineClientError> { + let head = self.inner.lock().expect("action engine inner lock poisoned").canonical_head; + if expected_hash == B256::ZERO || expected_hash != head.block_info.hash { + return Err(NodeEngineClientError::RequestError("unsafe head mismatch".to_string())); + } + Ok(()) + } + async fn reset_engine_forkchoice( &self, _reason: ResetReason, diff --git a/actions/harness/src/sequencer/builder_backend.rs b/actions/harness/src/sequencer/builder_backend.rs index 633223a672..aeff40a7e3 100644 --- a/actions/harness/src/sequencer/builder_backend.rs +++ b/actions/harness/src/sequencer/builder_backend.rs @@ -20,6 +20,7 @@ use std::{ use alloy_consensus::transaction::SignerRecoverable; use alloy_eips::{eip2718::Encodable2718, eip7685::Requests}; +use alloy_primitives::B256; use alloy_provider::{Identity, ProviderBuilder}; use alloy_rpc_types_engine::PayloadId; use async_trait::async_trait; @@ -131,6 +132,14 @@ impl BuilderBackedEngineClient { /// `newPayload` + canonical `forkchoiceUpdated` to import it. #[async_trait] impl SequencerEngineClient for BuilderBackedEngineClient { + async fn prepare_sequencer_start(&self, expected_hash: B256) -> EngineClientResult<()> { + let head = self.head.lock().expect("head lock").block_info.hash; + if expected_hash == B256::ZERO || expected_hash != head { + return Err(EngineClientError::RequestError("unsafe head mismatch".to_string())); + } + Ok(()) + } + async fn reset_engine_forkchoice(&self, _reason: ResetReason) -> EngineClientResult<()> { let head = self.head.lock().expect("head lock").block_info.hash; self.engine() diff --git a/actions/harness/src/sequencer/engine_client.rs b/actions/harness/src/sequencer/engine_client.rs index dd014ca5ed..49260d9686 100644 --- a/actions/harness/src/sequencer/engine_client.rs +++ b/actions/harness/src/sequencer/engine_client.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use alloy_primitives::B256; use alloy_rpc_types_engine::PayloadId; use async_trait::async_trait; use base_common_consensus::BaseBlock; @@ -40,6 +41,13 @@ impl ActionSequencerEngineClient { #[async_trait] impl SequencerEngineClient for ActionSequencerEngineClient { + async fn prepare_sequencer_start( + &self, + expected_hash: B256, + ) -> Result<(), base_consensus_node::EngineClientError> { + self.inner.prepare_sequencer_start(expected_hash).await + } + async fn reset_engine_forkchoice( &self, reason: ResetReason, diff --git a/crates/consensus/service/src/actors/engine/engine_request_processor.rs b/crates/consensus/service/src/actors/engine/engine_request_processor.rs index 58106c5641..51dd55023d 100644 --- a/crates/consensus/service/src/actors/engine/engine_request_processor.rs +++ b/crates/consensus/service/src/actors/engine/engine_request_processor.rs @@ -703,7 +703,7 @@ where #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{sync::Arc, time::Duration}; use alloy_consensus::transaction::Recovered; use alloy_eips::{BlockId, BlockNumHash, BlockNumberOrTag, NumHash, eip2718::Encodable2718}; @@ -912,6 +912,123 @@ mod tests { ); } + #[rstest] + #[case::conductor_follower(false, true)] + #[case::shadow_follower(true, false)] + #[tokio::test] + async fn admin_payload_advances_catching_up_conductor_only_when_not_shadow( + #[case] shadow: bool, + #[case] should_advance: bool, + ) { + let parent = L2BlockInfo { + block_info: BlockInfo { + number: 10, + hash: B256::with_last_byte(10), + timestamp: 9, + ..Default::default() + }, + ..Default::default() + }; + let mut child = unsafe_payload_with_l1_info(11, parent.block_info.hash, B256::ZERO); + let child_hash = + child.execution_payload.clone().try_into_block::().unwrap().hash_slow(); + if let BaseExecutionPayload::V1(payload) = &mut child.execution_payload { + payload.block_hash = child_hash; + } + let client = Arc::new( + test_engine_client_builder() + .with_block_info_by_tag(BlockNumberOrTag::Latest, parent) + .with_block_info_by_tag(BlockNumberOrTag::Safe, parent) + .with_block_info_by_tag(BlockNumberOrTag::Finalized, parent) + .with_new_payload_v2_response(PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: Some(child_hash), + }) + .with_fork_choice_updated_v3_response(valid_fcu()) + .build(), + ); + let mut derivation = MockEngineDerivationClient::new(); + derivation.expect_send_new_engine_safe_head().returning(|_| Ok(())); + derivation.expect_notify_sync_completed().returning(|_| Ok(())); + let initial_state = TestEngineStateBuilder::new() + .with_unsafe_head(parent) + .with_safe_head(parent) + .with_finalized_head(parent) + .with_el_sync_finished(true) + .build(); + let (state_tx, _) = watch::channel(initial_state); + let (queue_tx, _) = watch::channel(0usize); + let processor = EngineProcessor::new( + Arc::clone(&client), + Arc::new(RollupConfig::default()), + derivation, + Engine::new(initial_state, state_tx, queue_tx), + ); + let (unsafe_head_tx, mut unsafe_head_rx) = watch::channel(L2BlockInfo::default()); + let (request_tx, request_rx) = mpsc::channel(4); + let mut coordinator = + SequencerEngineRequestCoordinator::new(processor, shadow, None, shadow, unsafe_head_tx); + *coordinator.sequencer_state_mut() = + SequencerEngineState::CatchingUp { shadow, catchup: Default::default() }; + let handle = coordinator.start(request_rx); + + tokio::time::timeout( + Duration::from_secs(1), + unsafe_head_rx.wait_for(|head| *head == parent), + ) + .await + .expect("timed out waiting for coordinator bootstrap") + .expect("unsafe head watch closed during bootstrap"); + request_tx + .send(EngineActorRequest::ProcessAdminUnsafeL2BlockRequest(Box::new(child))) + .await + .expect("failed to send admin payload"); + let (result_tx, mut result_rx) = mpsc::channel(1); + request_tx + .send(EngineActorRequest::PrepareSequencerStart { + expected_hash: parent.block_info.hash, + result_tx, + }) + .await + .expect("failed to send barrier request"); + let barrier = tokio::time::timeout(Duration::from_secs(1), result_rx.recv()) + .await + .expect("timed out waiting for barrier response") + .expect("barrier response channel closed"); + + if should_advance { + assert_eq!(unsafe_head_rx.borrow().block_info.number, 11); + assert_eq!(unsafe_head_rx.borrow().block_info.hash, child_hash); + assert!(client.last_new_payload_v2().await.is_some()); + assert!(matches!(barrier, Err(EngineClientError::RequestError(_)))); + + let (result_tx, mut result_rx) = mpsc::channel(1); + request_tx + .send(EngineActorRequest::PrepareSequencerStart { + expected_hash: child_hash, + result_tx, + }) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), result_rx.recv()) + .await + .unwrap() + .unwrap() + .expect("takeover must accept the inserted committed head"); + } else { + barrier.expect("shadow preparation must accept the unchanged head"); + assert_eq!(*unsafe_head_rx.borrow(), parent); + assert!(client.last_new_payload_v2().await.is_none()); + } + + drop(request_tx); + let result = tokio::time::timeout(Duration::from_secs(1), handle) + .await + .expect("timed out shutting down coordinator") + .expect("coordinator task panicked"); + assert!(matches!(result, Err(crate::EngineError::ChannelClosed))); + } + #[rstest] #[case::sequencer_enqueues_contiguous_external_payload( NodeMode::Sequencer, diff --git a/crates/consensus/service/src/actors/engine/request.rs b/crates/consensus/service/src/actors/engine/request.rs index 66e6afa878..1dcdc65191 100644 --- a/crates/consensus/service/src/actors/engine/request.rs +++ b/crates/consensus/service/src/actors/engine/request.rs @@ -1,3 +1,4 @@ +use alloy_primitives::B256; use alloy_rpc_types_engine::PayloadId; use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; use base_consensus_engine::{ @@ -63,6 +64,13 @@ pub enum EngineClientError { /// Inbound requests that the [`crate::EngineActor`] can process. #[derive(Debug)] pub enum EngineActorRequest { + /// Request to atomically validate and prepare a stopped sequencer for activation. + PrepareSequencerStart { + /// Unsafe head hash supplied by the administrator. + expected_hash: B256, + /// Channel on which readiness or rejection is returned. + result_tx: mpsc::Sender>, + }, /// Request to build. BuildRequest(Box), /// Request to get the sealed payload without inserting it. diff --git a/crates/consensus/service/src/actors/engine/validator_engine_request_handler.rs b/crates/consensus/service/src/actors/engine/validator_engine_request_handler.rs index 56bc800a6e..d775238e37 100644 --- a/crates/consensus/service/src/actors/engine/validator_engine_request_handler.rs +++ b/crates/consensus/service/src/actors/engine/validator_engine_request_handler.rs @@ -75,6 +75,18 @@ where }; match request { + EngineActorRequest::PrepareSequencerStart { result_tx, .. } => { + if result_tx + .send(Err(EngineClientError::RequestError( + "sequencer start is unsupported by validator engine handler" + .to_string(), + ))) + .await + .is_err() + { + warn!(target: "engine", "Sequencer start response receiver dropped"); + } + } EngineActorRequest::BuildRequest(request) => { let BuildRequest { attributes, result_tx, otel_cx } = *request; let client = Arc::clone(self.processor.client()); diff --git a/crates/consensus/service/src/actors/sequencer/actor.rs b/crates/consensus/service/src/actors/sequencer/actor.rs index 70b57e489c..28f0435a5e 100644 --- a/crates/consensus/service/src/actors/sequencer/actor.rs +++ b/crates/consensus/service/src/actors/sequencer/actor.rs @@ -221,6 +221,8 @@ where /// /// Admin API queries are serviced throughout — both during reset attempts and during the /// backoff sleep — so that control can reach the sequencer while EL sync is in progress. + /// A successful non-shadow admin start already validated the engine's takeover head; do not + /// follow it with a startup reset that could replace that head. async fn schedule_initial_reset( &mut self, next_payload: &mut Option, @@ -232,7 +234,11 @@ where biased; _ = self.cancellation_token.cancelled() => return Ok(()), Some(query) = self.admin_api_rx.recv() => { + let was_active = self.is_active; self.handle_admin_query(next_payload, query).await; + if !was_active && self.is_active && !shadow_cycle_coordinated { + return Ok(()); + } } result = async { if shadow_cycle_coordinated { @@ -264,7 +270,11 @@ where biased; _ = self.cancellation_token.cancelled() => return Ok(()), Some(query) = self.admin_api_rx.recv() => { + let was_active = self.is_active; self.handle_admin_query(next_payload, query).await; + if !was_active && self.is_active && !shadow_cycle_coordinated { + return Ok(()); + } } _ = &mut sleep => break, } @@ -781,14 +791,180 @@ where #[cfg(test)] mod tests { + use alloy_eips::BlockNumberOrTag; use alloy_primitives::{B256, Sealed}; + use alloy_rpc_types_engine::{ForkchoiceUpdated, PayloadId, PayloadStatus, PayloadStatusEnum}; use base_common_consensus::{BaseBlock, BaseTxEnvelope, TxDeposit}; - use base_common_genesis::{RollupConfig, SystemConfig}; - use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; + use base_common_genesis::{ChainGenesis, RollupConfig, SystemConfig, UpgradeConfig}; + use base_common_rpc_types_engine::{ + BaseExecutionPayload, BaseExecutionPayloadEnvelope, BasePayloadAttributes, + }; + use base_consensus_derive::test_utils::TestAttributesBuilder; + use base_consensus_engine::{Engine, test_utils::TestEngineStateBuilder}; use base_protocol::{BlockInfo, L1BlockInfoBedrock}; + use rstest::rstest; + use tokio::sync::watch; use super::*; - use crate::actors::sequencer::tests::test_actor; + use crate::{ + EngineProcessor, EngineRequestReceiver, MockConductor, MockEngineDerivationClient, + MockOriginSelector, MockUnsafePayloadGossipClient, QueuedSequencerEngineClient, + SequencerEngineRequestCoordinator, + actors::sequencer::tests::test_actor, + test_utils::{EngineClientCall, FakeEngineClient, ScriptedForkchoiceResponse}, + }; + + #[rstest] + #[tokio::test(start_paused = true)] + async fn conductor_takeover_without_gossip_starts_building( + #[values(false, true)] shadow: bool, + #[values(false, true)] during_backoff: bool, + ) { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let head = L2BlockInfo { + block_info: BlockInfo { + number: 100, + hash: B256::with_last_byte(100), + timestamp: now, + ..Default::default() + }, + ..Default::default() + }; + let config = Arc::new(RollupConfig { + block_time: 2, + genesis: ChainGenesis { l2_time: now - 200, ..Default::default() }, + upgrades: UpgradeConfig { ecotone_time: Some(0), ..Default::default() }, + ..Default::default() + }); + let el = Arc::new(FakeEngineClient::new(Arc::clone(&config))); + el.set_l2_block_info_by_label(BlockNumberOrTag::Latest, head); + let el_handle = el.handle(); + el_handle.push_scripted_fcu_v3([None, Some(PayloadId::new([1; 8]))].map(|payload_id| { + ScriptedForkchoiceResponse::Ok(ForkchoiceUpdated { + payload_status: PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: Some(head.block_info.hash), + }, + payload_id, + }) + })); + let safe = L2BlockInfo { + block_info: BlockInfo { + number: 90, + hash: B256::with_last_byte(90), + ..Default::default() + }, + ..Default::default() + }; + let state = TestEngineStateBuilder::new() + .with_unsafe_head(head) + .with_safe_head(safe) + .with_finalized_head(safe) + .build(); + let (state_tx, state_rx) = watch::channel(state); + let (queue_tx, _) = watch::channel(0usize); + let mut derivation = MockEngineDerivationClient::new(); + derivation.expect_send_new_engine_safe_head().returning(|_| Ok(())); + derivation.expect_notify_sync_completed().returning(|_| Ok(())); + derivation.expect_send_signal().returning(|_| Ok(())); + let processor = EngineProcessor::new( + el, + Arc::clone(&config), + derivation, + Engine::new(state, state_tx, queue_tx), + ); + let (request_tx, request_rx) = mpsc::channel(8); + let (head_tx, mut head_rx) = watch::channel(L2BlockInfo::default()); + let coordinator = + SequencerEngineRequestCoordinator::new(processor, shadow, None, true, head_tx) + .start(request_rx); + tokio::time::timeout(Duration::from_secs(1), head_rx.wait_for(|value| *value == head)) + .await + .unwrap() + .unwrap(); + let engine_client = + Arc::new(QueuedSequencerEngineClient::new(request_tx, head_rx, state_rx)); + assert!(matches!( + engine_client.reset_engine_forkchoice(ResetReason::SequencerStartup).await, + Err(EngineClientError::ELSyncing) + )); + + let mut conductor = MockConductor::new(); + conductor.expect_leader().once().returning(|| Ok(true)); + let mut origin_selector = MockOriginSelector::new(); + origin_selector.expect_next_l1_origin().returning(|_| Ok(BlockInfo::default())); + let mut gossip = MockUnsafePayloadGossipClient::new(); + gossip.expect_seals_privately().return_const(false); + let mut attributes = BasePayloadAttributes::default(); + attributes.payload_attributes.timestamp = now + 2; + let recovery_mode = RecoveryModeGuard::new(false); + let cancellation = CancellationToken::new(); + let (admin_tx, admin_rx) = mpsc::channel(8); + let actor = SequencerActor { + admin_api_rx: admin_rx, + builder: PayloadBuilder { + attributes_builder: TestAttributesBuilder { + attributes: vec![Ok(attributes)], + ..Default::default() + }, + engine_client: Arc::clone(&engine_client), + origin_selector, + recovery_mode: recovery_mode.clone(), + rollup_config: Arc::clone(&config), + }, + cancellation_token: cancellation.clone(), + conductor: Some(conductor), + engine_client: Arc::clone(&engine_client), + is_active: false, + shadow_blocks_per_cycle: shadow.then(|| NonZeroU64::new(1).unwrap()), + shadow_funding: None, + recovery_mode, + rollup_config: config, + seal_offset: base_protocol::DEFAULT_SEAL_OFFSET, + unsafe_payload_gossip_client: gossip, + sealer: None, + pending_stop: None, + }; + let actor = tokio::spawn(actor.start(())); + if during_backoff { + tokio::time::sleep(Duration::from_millis(100)).await; + } + let (result_tx, result_rx) = oneshot::channel(); + admin_tx + .send(SequencerAdminQuery::StartSequencer(head.block_info.hash, result_tx)) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), result_rx).await.unwrap().unwrap().unwrap(); + + let built = tokio::time::timeout(Duration::from_secs(6), async { + loop { + if let Some(fcs) = el_handle.calls().into_iter().find_map(|call| match call { + EngineClientCall::ForkChoiceUpdatedV3 { fcs, payload_attributes } + if payload_attributes.is_some() => + { + Some(fcs) + } + _ => None, + }) { + break fcs; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + cancellation.cancel(); + actor.await.unwrap().unwrap(); + drop(engine_client); + assert!(matches!(coordinator.await.unwrap(), Err(crate::EngineError::ChannelClosed))); + if shadow { + assert!(built.is_err(), "admin start must not bypass shadow catch-up"); + } else { + assert_eq!( + built.expect("takeover must build without gossip").head_block_hash, + head.block_info.hash + ); + } + } fn valid_sealer() -> (PayloadSealer, L2BlockInfo, SystemConfig) { let block = BaseBlock { diff --git a/crates/consensus/service/src/actors/sequencer/admin_api_impl.rs b/crates/consensus/service/src/actors/sequencer/admin_api_impl.rs index c1f5db4618..4ec51046c5 100644 --- a/crates/consensus/service/src/actors/sequencer/admin_api_impl.rs +++ b/crates/consensus/service/src/actors/sequencer/admin_api_impl.rs @@ -128,6 +128,7 @@ where /// /// - The engine has not yet received a forkchoice update (`unsafe_head == B256::ZERO`). /// - `unsafe_head` does not match the engine's current unsafe head hash. + /// - A non-shadow sequencer is still EL-syncing or has unresolved canonical observations. /// /// When a conductor is configured, this checks `conductor_leader` before activating, /// matching the reference node's `Start()` behavior. If the node is not the leader the call returns @@ -157,25 +158,11 @@ where } } - let engine_head = self.engine_client.get_unsafe_head().await.map_err(|e| { - error!(target: "sequencer", error = %e, "Failed to fetch engine unsafe head"); - SequencerAdminAPIError::RequestError(e.to_string()) + self.engine_client.prepare_sequencer_start(unsafe_head).await.map_err(|err| { + error!(target: "sequencer", error = %err, "Engine rejected sequencer start"); + SequencerAdminAPIError::RequestError(err.to_string()) })?; - if engine_head.block_info.hash == B256::ZERO { - return Err(SequencerAdminAPIError::RequestError( - "no prestate: engine unsafe head is uninitialized, cannot safely start sequencer" - .to_string(), - )); - } - - if unsafe_head != engine_head.block_info.hash { - return Err(SequencerAdminAPIError::RequestError(format!( - "block hash mismatch: engine unsafe head is {}, caller requested {}", - engine_head.block_info.hash, unsafe_head, - ))); - } - info!(target: "sequencer", unsafe_head = %unsafe_head, "Starting sequencer"); self.is_active = true; diff --git a/crates/consensus/service/src/actors/sequencer/engine_client.rs b/crates/consensus/service/src/actors/sequencer/engine_client.rs index 163fc7a5f2..be12eaad6b 100644 --- a/crates/consensus/service/src/actors/sequencer/engine_client.rs +++ b/crates/consensus/service/src/actors/sequencer/engine_client.rs @@ -1,5 +1,6 @@ use std::{fmt::Debug, sync::Arc}; +use alloy_primitives::B256; use alloy_rpc_types_engine::PayloadId; use async_trait::async_trait; use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; @@ -21,6 +22,10 @@ use crate::{ #[cfg_attr(test, mockall::automock)] #[async_trait] pub trait SequencerEngineClient: Debug + Send + Sync { + /// Atomically validates the expected engine head and prepares sequencer request routing. + /// Shadow starts validate the head without changing catch-up or reconciliation state. + async fn prepare_sequencer_start(&self, expected_hash: B256) -> EngineClientResult<()>; + /// Resets the engine's forkchoice, awaiting confirmation that it succeeded or returning the /// error in performing the reset. async fn reset_engine_forkchoice(&self, reason: ResetReason) -> EngineClientResult<()>; @@ -82,6 +87,10 @@ pub trait SequencerEngineClient: Debug + Send + Sync { /// methods without any additional wrapping. #[async_trait] impl SequencerEngineClient for Arc { + async fn prepare_sequencer_start(&self, expected_hash: B256) -> EngineClientResult<()> { + (**self).prepare_sequencer_start(expected_hash).await + } + async fn reset_engine_forkchoice(&self, reason: ResetReason) -> EngineClientResult<()> { (**self).reset_engine_forkchoice(reason).await } @@ -170,6 +179,17 @@ impl QueuedSequencerEngineClient { #[async_trait] impl SequencerEngineClient for QueuedSequencerEngineClient { + async fn prepare_sequencer_start(&self, expected_hash: B256) -> EngineClientResult<()> { + let (result_tx, mut result_rx) = mpsc::channel(1); + self.engine_actor_request_tx + .send(EngineActorRequest::PrepareSequencerStart { expected_hash, result_tx }) + .await + .map_err(|_| EngineClientError::RequestError("request channel closed.".to_string()))?; + result_rx.recv().await.ok_or_else(|| { + EngineClientError::ResponseError("response channel closed.".to_string()) + })? + } + async fn get_unsafe_head(&self) -> EngineClientResult { Ok(*self.unsafe_head_rx.borrow()) } diff --git a/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs b/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs index c0cb2b156f..961b8366de 100644 --- a/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs +++ b/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs @@ -6,6 +6,7 @@ use std::{ }; use alloy_eips::BlockNumberOrTag; +use alloy_primitives::B256; use base_consensus_engine::{ ConsolidateTask, EngineClient, EngineTask, EngineTaskError, EngineTaskErrorSeverity, EngineTaskErrors, FinalizeTask, Metrics as EngineMetrics, SealTaskError, @@ -85,6 +86,53 @@ where &mut self.sequencer_state } + /// Validates an authorized admin start against the serialized engine state. + /// + /// Conductor supplies its committed head to admin start even when it has no new payload to + /// post. That head can release an unobserved non-shadow catch-up, but cannot override known + /// gaps or conflicts. The caller must check conductor leadership before requesting activation. + /// Shadow starts validate the head without changing catch-up or reconciliation state. + pub fn prepare_sequencer_start( + &mut self, + expected_hash: B256, + ) -> Result<(), EngineClientError> { + let sync_state = self.processor.engine_state().sync_state; + let head = sync_state.unsafe_head(); + if expected_hash == B256::ZERO || head.block_info.hash == B256::ZERO { + return Err(EngineClientError::RequestError( + "no prestate: unsafe head is uninitialized".to_string(), + )); + } + if expected_hash != head.block_info.hash { + return Err(EngineClientError::RequestError(format!( + "block hash mismatch: engine unsafe head is {}, caller requested {}", + head.block_info.hash, expected_hash + ))); + } + + // Shadow activation must not release its catch-up or reconciliation gate. + if self.is_shadow_sequencer() { + return Ok(()); + } + + if !self.processor.engine_state().el_sync_finished { + return Err(EngineClientError::ELSyncing); + } + + if let SequencerEngineState::CatchingUp { catchup, .. } = &self.sequencer_state { + if catchup.is_faulted() { + return Err(EngineClientError::ShadowBufferFaulted); + } + + if catchup.has_observations() && !catchup.is_complete(head, sync_state.safe_head()) { + return Err(EngineClientError::ELSyncing); + } + } + + self.sequencer_state = SequencerEngineState::Regular; + Ok(()) + } + /// Returns whether this handler is configured as a shadow sequencer. const fn is_shadow_sequencer(&self) -> bool { matches!( @@ -301,6 +349,12 @@ where }; match request { + EngineActorRequest::PrepareSequencerStart { expected_hash, result_tx } => { + let result = self.prepare_sequencer_start(expected_hash); + if result_tx.send(result).await.is_err() { + warn!(target: "engine", "Sequencer start response receiver dropped"); + } + } EngineActorRequest::BuildRequest(build_request) => { let BuildRequest { attributes, result_tx, otel_cx } = *build_request; let client = Arc::clone(self.processor.client()); @@ -454,13 +508,14 @@ where } EngineActorRequest::ProcessAdminUnsafeL2BlockRequest(envelope) => { match self.sequencer_state { - SequencerEngineState::CatchingUp { .. } => { + SequencerEngineState::CatchingUp { shadow: true, .. } => { warn!(target: "engine", "Ignoring admin unsafe payload during canonical catch-up"); } SequencerEngineState::ShadowActive(_) => { warn!(target: "engine", "Ignoring admin unsafe payload on shadow sequencer"); } - SequencerEngineState::Regular => { + SequencerEngineState::Regular + | SequencerEngineState::CatchingUp { shadow: false, .. } => { self.processor.handle_admin_unsafe_l2_block(*envelope); } } @@ -705,18 +760,23 @@ where mod tests { use std::sync::Arc; + use alloy_primitives::B256; + use alloy_rpc_types_engine::ExecutionPayloadV1; use base_common_genesis::RollupConfig; + use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; use base_consensus_engine::{ Engine, EngineState, - test_utils::{MockEngineClient, test_engine_client_builder}, + test_utils::{MockEngineClient, TestEngineStateBuilder, test_engine_client_builder}, }; - use base_protocol::L2BlockInfo; + use base_protocol::{BlockInfo, L2BlockInfo}; use jsonrpsee::core::ClientError; + use rstest::rstest; use tokio::sync::watch; use super::{BootstrapRole, SequencerEngineRequestCoordinator, SequencerEngineState}; use crate::{ - Conductor, ConductorError, EngineProcessor, MockConductor, MockEngineDerivationClient, + CanonicalUnsafeCatchup, Conductor, ConductorError, EngineProcessor, MockConductor, + MockEngineDerivationClient, ShadowReconciliationGate, }; fn coordinator( @@ -792,4 +852,119 @@ mod tests { let coordinator = coordinator(false, false, Some(Arc::new(conductor))); assert_eq!(coordinator.resolve_bootstrap_role().await, BootstrapRole::ConductorFollower); } + + #[rstest] + #[case::no_observations(100, true, &[], false, true)] + #[case::caught_up(100, true, &[(100, 100)], false, true)] + #[case::wrong_hash(99, true, &[], false, false)] + #[case::zero_hash(0, true, &[], false, false)] + #[case::el_syncing(100, false, &[], false, false)] + #[case::gap(100, true, &[(102, 102)], false, false)] + #[case::different_branch(100, true, &[(100, 99)], false, false)] + #[case::private_tail(100, true, &[(99, 99)], false, false)] + #[case::faulted(100, true, &[(100, 100), (100, 99)], false, false)] + #[case::shadow_catchup(100, true, &[], true, true)] + #[case::shadow_caught_up(100, true, &[(100, 100)], true, true)] + #[case::shadow_el_syncing(100, false, &[], true, true)] + #[case::shadow_gap(100, true, &[(102, 102)], true, true)] + #[case::shadow_faulted(100, true, &[(100, 100), (100, 99)], true, true)] + #[case::shadow_wrong_hash(99, true, &[], true, false)] + #[case::shadow_zero_hash(0, true, &[], true, false)] + fn prepare_start_preserves_catchup_safety( + #[case] requested_hash: u8, + #[case] el_synced: bool, + #[case] observations: &[(u64, u8)], + #[case] shadow: bool, + #[case] accepted: bool, + ) { + let head = L2BlockInfo { + block_info: BlockInfo { + number: 100, + hash: B256::with_last_byte(100), + ..Default::default() + }, + ..Default::default() + }; + let state = TestEngineStateBuilder::new() + .with_unsafe_head(head) + .with_safe_head(L2BlockInfo::default()) + .with_el_sync_finished(el_synced) + .build(); + let (state_tx, _) = watch::channel(state); + let (queue_tx, _) = watch::channel(0usize); + let processor = EngineProcessor::new( + Arc::new(test_engine_client_builder().build()), + Arc::new(RollupConfig::default()), + MockEngineDerivationClient::new(), + Engine::new(state, state_tx, queue_tx), + ); + let (head_tx, _) = watch::channel(head); + let mut coordinator = + SequencerEngineRequestCoordinator::new(processor, shadow, None, true, head_tx); + let mut catchup = CanonicalUnsafeCatchup::default(); + for &(number, hash) in observations { + catchup.buffer_payload(BaseExecutionPayloadEnvelope { + execution_payload: BaseExecutionPayload::V1(ExecutionPayloadV1 { + block_number: number, + block_hash: B256::with_last_byte(hash), + parent_hash: B256::ZERO, + fee_recipient: Default::default(), + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Default::default(), + prev_randao: B256::ZERO, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: number, + extra_data: Default::default(), + base_fee_per_gas: Default::default(), + transactions: vec![], + }), + parent_beacon_block_root: None, + }); + } + catchup.commit(head); + *coordinator.sequencer_state_mut() = SequencerEngineState::CatchingUp { shadow, catchup }; + + let result = coordinator.prepare_sequencer_start(B256::with_last_byte(requested_hash)); + assert_eq!(result.is_ok(), accepted, "{result:?}"); + assert_eq!( + matches!(coordinator.sequencer_state(), SequencerEngineState::Regular), + accepted && !shadow + ); + assert_eq!( + matches!( + coordinator.sequencer_state(), + SequencerEngineState::CatchingUp { shadow: true, .. } + ), + shadow + ); + // Acceptance must remain idempotent; refusal must not discard the blocking evidence. + assert_eq!( + coordinator.prepare_sequencer_start(B256::with_last_byte(requested_hash)).is_ok(), + accepted + ); + + *coordinator.sequencer_state_mut() = + SequencerEngineState::ShadowActive(Box::new(ShadowReconciliationGate::new(head))); + assert_eq!( + coordinator.prepare_sequencer_start(B256::with_last_byte(requested_hash)).is_ok(), + requested_hash == 100 + ); + assert!(matches!(coordinator.sequencer_state(), SequencerEngineState::ShadowActive(_))); + } + + #[rstest] + #[case(B256::ZERO)] + #[case(B256::with_last_byte(100))] + fn prepare_start_rejects_uninitialized_head( + #[case] requested_hash: B256, + #[values(false, true)] shadow: bool, + ) { + let mut coordinator = coordinator(shadow, true, None); + assert!(matches!( + coordinator.prepare_sequencer_start(requested_hash), + Err(crate::EngineClientError::RequestError(message)) if message.contains("no prestate") + )); + } } diff --git a/crates/consensus/service/src/actors/sequencer/shadow_reconciliation.rs b/crates/consensus/service/src/actors/sequencer/shadow_reconciliation.rs index cd4931d14f..068cd51d04 100644 --- a/crates/consensus/service/src/actors/sequencer/shadow_reconciliation.rs +++ b/crates/consensus/service/src/actors/sequencer/shadow_reconciliation.rs @@ -99,6 +99,11 @@ impl CanonicalUnsafeCatchup { self.faulted } + /// Returns whether any canonical unsafe payload has ever been observed. + pub const fn has_observations(&self) -> bool { + self.highest_observed.is_some() + } + /// Returns whether the engine has reached or safely overtaken the latest observed payload. /// Buffered payloads across a gap intentionally prevent completion until every observation is /// either applied in order or overtaken by a fully safe-derived head. diff --git a/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs b/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs index f532e1172a..4a33e1dec9 100644 --- a/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs +++ b/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs @@ -379,7 +379,12 @@ async fn test_stop_discards_queued_parent_and_restart_builds_immediately_on_fres let mut client = MockSequencerEngineClient::new(); client.expect_reset_engine_forkchoice().times(1).return_once(|_| Ok(())); - client.expect_get_unsafe_head().times(5).returning({ + client + .expect_prepare_sequencer_start() + .with(mockall::predicate::eq(restart_head.block_info.hash)) + .once() + .return_once(|_| Ok(())); + client.expect_get_unsafe_head().times(4).returning({ let get_head_calls = Arc::clone(&get_head_calls); move || { let call = get_head_calls.fetch_add(1, Ordering::Relaxed); diff --git a/crates/consensus/service/src/actors/sequencer/tests/admin_api_impl_test.rs b/crates/consensus/service/src/actors/sequencer/tests/admin_api_impl_test.rs index e266293027..bf38aea902 100644 --- a/crates/consensus/service/src/actors/sequencer/tests/admin_api_impl_test.rs +++ b/crates/consensus/service/src/actors/sequencer/tests/admin_api_impl_test.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{num::NonZeroU64, sync::Arc}; use alloy_primitives::B256; use base_consensus_rpc::SequencerAdminAPIError; @@ -101,15 +101,8 @@ async fn test_start_sequencer_no_conductor( #[values(true, false)] via_channel: bool, ) { let test_hash = B256::from([1u8; 32]); - let engine_head = L2BlockInfo { - block_info: BlockInfo { hash: test_hash, ..Default::default() }, - ..Default::default() - }; - let mut client = MockSequencerEngineClient::new(); - // .returning() (not .return_once()) allows 0 or 1 calls: the already-started case - // returns early before reaching the engine check. - client.expect_get_unsafe_head().returning(move || Ok(engine_head)); + client.expect_prepare_sequencer_start().returning(|_| Ok(())); let mut actor = test_actor(); actor.engine_client = Arc::new(client); @@ -139,23 +132,26 @@ async fn test_start_sequencer_no_conductor( /// Conductor confirms leadership: sequencer activates. #[rstest] #[tokio::test] -async fn test_start_sequencer_conductor_is_leader(#[values(true, false)] via_channel: bool) { +async fn test_start_sequencer_conductor_is_leader( + #[values(true, false)] via_channel: bool, + #[values(true, false)] shadow: bool, +) { let test_hash = B256::from([1u8; 32]); - let engine_head = L2BlockInfo { - block_info: BlockInfo { hash: test_hash, ..Default::default() }, - ..Default::default() - }; - let mut conductor = MockConductor::new(); conductor.expect_leader().times(1).return_once(|| Ok(true)); let mut client = MockSequencerEngineClient::new(); - client.expect_get_unsafe_head().times(1).return_once(move || Ok(engine_head)); + client + .expect_prepare_sequencer_start() + .with(mockall::predicate::eq(test_hash)) + .times(1) + .return_once(|_| Ok(())); let mut actor = test_actor(); actor.conductor = Some(conductor); actor.engine_client = Arc::new(client); actor.is_active = false; + actor.shadow_blocks_per_cycle = shadow.then(|| NonZeroU64::new(1).unwrap()); let result = async { match via_channel { @@ -285,7 +281,10 @@ async fn test_start_sequencer_already_active_skips_leader_check( #[tokio::test] async fn test_start_sequencer_engine_not_initialized(#[values(true, false)] via_channel: bool) { let mut client = MockSequencerEngineClient::new(); - client.expect_get_unsafe_head().times(1).return_once(|| Ok(L2BlockInfo::default())); // hash == B256::ZERO + client + .expect_prepare_sequencer_start() + .times(1) + .return_once(|_| Err(EngineClientError::RequestError("no prestate".to_string()))); let mut actor = test_actor(); actor.engine_client = Arc::new(client); @@ -315,20 +314,21 @@ async fn test_start_sequencer_engine_not_initialized(#[values(true, false)] via_ /// Caller's `unsafe_head` does not match the engine's current unsafe head: sequencer refuses. #[rstest] #[tokio::test] -async fn test_start_sequencer_unsafe_head_mismatch(#[values(true, false)] via_channel: bool) { +async fn test_start_sequencer_unsafe_head_mismatch( + #[values(true, false)] via_channel: bool, + #[values(true, false)] shadow: bool, +) { let requested_hash = B256::from([1u8; 32]); - let engine_hash = B256::from([2u8; 32]); - let engine_head = L2BlockInfo { - block_info: BlockInfo { hash: engine_hash, ..Default::default() }, - ..Default::default() - }; - let mut client = MockSequencerEngineClient::new(); - client.expect_get_unsafe_head().times(1).return_once(move || Ok(engine_head)); + client + .expect_prepare_sequencer_start() + .times(1) + .return_once(|_| Err(EngineClientError::RequestError("block hash mismatch".to_string()))); let mut actor = test_actor(); actor.engine_client = Arc::new(client); actor.is_active = false; + actor.shadow_blocks_per_cycle = shadow.then(|| NonZeroU64::new(1).unwrap()); let result = async { match via_channel { @@ -357,9 +357,9 @@ async fn test_start_sequencer_unsafe_head_mismatch(#[values(true, false)] via_ch async fn test_start_sequencer_engine_client_error(#[values(true, false)] via_channel: bool) { let mut client = MockSequencerEngineClient::new(); client - .expect_get_unsafe_head() + .expect_prepare_sequencer_start() .times(1) - .return_once(|| Err(EngineClientError::RequestError("rpc failure".to_string()))); + .return_once(|_| Err(EngineClientError::RequestError("rpc failure".to_string()))); let mut actor = test_actor(); actor.engine_client = Arc::new(client); From 28b98c6a970c66f82f0b3ad519231630304a7309 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Fri, 25 Sep 2026 15:08:18 +0000 Subject: [PATCH 121/299] test(challenger-e2e): cover full path 2 (#5249) * test(challenger-e2e): cover full path 2 Co-authored-by: Codex * test(challenger-e2e): trim run_path2's argument list Clippy's too_many_arguments fired at 9/7 and CI runs with -D warnings. The Path 1 outcome check moves to the caller, where the scenario branch it guards already lives, and the fork URL and driver key collapse into the ForkConfig they were only ever used to build. Generated with Claude Code Co-Authored-By: Claude --------- Co-authored-by: Codex Co-authored-by: Claude --- crates/infra/challenger-e2e/README.md | 12 ++- .../challenger-e2e/src/challenger_e2e.rs | 90 +++++++++++++++++-- crates/infra/challenger-e2e/src/config.rs | 26 +++++- crates/infra/challenger-e2e/src/lib.rs | 2 +- .../infra/zk-fork-dispute/src/checkpoint.rs | 32 ++++++- 5 files changed, 146 insertions(+), 16 deletions(-) diff --git a/crates/infra/challenger-e2e/README.md b/crates/infra/challenger-e2e/README.md index 02df7438eb..3ce2f68de9 100644 --- a/crates/infra/challenger-e2e/README.md +++ b/crates/infra/challenger-e2e/README.md @@ -86,10 +86,13 @@ disputes games it was never given would pass the run. `counteredIndex` stay set, and a challenger that "defends" a legitimate challenge of a wrong TEE root fails here. If it was a TEE nullify there is no challenge to leave standing, and the same window proves **idempotence**: - the challenger must not dispute a game it has already nullified. Path 2 - *dispute* (fraudulent ZK against a correct TEE root) is not staged: the real - prover cannot produce a wrong-root proof the real verifier accepts. That - half stays in the mock driver tests. + the challenger must not dispute a game it has already nullified. + + With `CHALLENGER_E2E_SCENARIO=path1-path2`, the driver then restores the + canonical root while preserving the recorded challenge. That makes the + challenge fraudulent without mocking proof verification. The challenger + must detect **Path 2 dispute**, clear `zkProver` and `counteredIndex`, and + move B's nonce again. 5. **Path 4 `InvalidDualProposal`.** Game B was staged *before* the challenger was released: A requested a real SNARK of B's canonical roots from `BASE_CHALLENGER_ZK_RPC_URL` (not the fork) and submitted @@ -142,6 +145,7 @@ is pointed at and talks to the same prover-service. | `BASE_CHALLENGER_GAME_TYPE` | Yes | `AggregateVerifier` game type | | `BASE_CHALLENGER_ANCHOR_STATE_REGISTRY_ADDR` | Yes | `AnchorStateRegistry` on L1; read to find the scanner's lower bound | | `CHALLENGER_E2E_ANVIL_PORT` | No (default `18545`) | Fork port; not 8545, which the production challenger reserves for its signer sidecar | +| `CHALLENGER_E2E_SCENARIO` | No (default `all`) | `all` for the existing combined run, or `path1-path2` for complete Path 2 coverage | | `CHALLENGER_E2E_CHALLENGER_METRICS_URL` | No (default `http://127.0.0.1:7300/metrics`) | Prometheus endpoint of the challenger under test | | `CHALLENGER_E2E_GAME_LOOKBACK` | No (default `50`) | Factory indices searched for two games to corrupt | | `CHALLENGER_E2E_STARTUP_TIMEOUT` | No (default `5m`) | Budget for the fork and the first scan | diff --git a/crates/infra/challenger-e2e/src/challenger_e2e.rs b/crates/infra/challenger-e2e/src/challenger_e2e.rs index 847f628e8c..58830bffd6 100644 --- a/crates/infra/challenger-e2e/src/challenger_e2e.rs +++ b/crates/infra/challenger-e2e/src/challenger_e2e.rs @@ -21,7 +21,10 @@ use eyre::{Context, Result, bail, ensure, eyre}; use tracing::{info, warn}; use url::Url; -use crate::{config::Config, metrics::Scrape}; +use crate::{ + config::{Config, Scenario}, + metrics::Scrape, +}; /// Wei granted to each throwaway account on the fork. Orders of magnitude more /// than a dispute costs, and worthless outside the pod. @@ -130,18 +133,39 @@ impl ChallengerE2e { ) .await?; - Self::stage_dual_proof(&config, &fork_url, &verifier, &provider, &driver, game_b).await?; + if config.scenario == Scenario::All { + Self::stage_dual_proof(&config, &fork_url, &verifier, &provider, &driver, game_b) + .await?; + } Self::release_challenger(&fork_url, &challenger)?; Self::await_first_scan(&config).await?; Self::assert_quiet_on_valid_games(&config).await?; - let path1 = + let (path1, checkpoint) = Self::run_path1(&config, &fork_url, &verifier, &provider, &driver, &challenger, game_a) .await?; Self::assert_game_a_settled(&config, &verifier, &provider, &challenger, game_a, path1) .await?; + if config.scenario == Scenario::Path1Path2 { + ensure!( + matches!(path1, Path1Outcome::ZkChallenge), + "Path 2 dispute requires Path 1 to land as a ZK challenge" + ); + Self::run_path2( + &config, + Self::fork_config(&config, &fork_url, &driver, game_a), + &verifier, + &provider, + &challenger, + game_a, + checkpoint, + ) + .await?; + Self::assert_bystanders_untouched(&verifier, &bystanders).await?; + return Ok(()); + } Self::run_path4(&config, &fork_url, &verifier, &provider, &driver, &challenger, game_b) .await?; @@ -467,7 +491,7 @@ impl ChallengerE2e { driver: &PrivateKeySigner, challenger: &PrivateKeySigner, game: Candidate, - ) -> Result { + ) -> Result<(Path1Outcome, Checkpoint)> { let fork_config = Self::fork_config(config, fork_url, driver, game); // Before the patch, not after. `Checkpoint::patch` mutates the game and // then reads it back several times; a challenger that scans during @@ -485,7 +509,7 @@ impl ChallengerE2e { "corrupted intermediate output root; waiting for the challenger to dispute" ); - Self::await_dispute( + let outcome = Self::await_dispute( config, verifier, provider, @@ -494,7 +518,8 @@ impl ChallengerE2e { nonce, checkpoint.index + 1, ) - .await + .await?; + Ok((outcome, checkpoint)) } /// The challenger must leave game A alone once it has acted on it. @@ -511,9 +536,8 @@ impl ChallengerE2e { /// legitimate challenge, or re-nullifying an already-nullified game, is /// invisible to the state comparison alone. /// - /// Path 2 *dispute* (fraudulent ZK against a correct TEE root) is not - /// staged: the real prover cannot produce a wrong-root proof the real - /// verifier accepts. + /// The `path1-path2` scenario restores the canonical root after this + /// window, turning the recorded challenge into Path 2's fraudulent case. async fn assert_game_a_settled( config: &Config, verifier: &AggregateVerifierContractClient, @@ -564,6 +588,54 @@ impl ChallengerE2e { Ok(()) } + /// Path 2 dispute: restore the correct TEE root underneath the recorded + /// Path 1 challenge, then require the challenger to nullify that challenge. + async fn run_path2( + config: &Config, + fork_config: ForkConfig, + verifier: &AggregateVerifierContractClient, + provider: &RootProvider, + challenger: &PrivateKeySigner, + game: Candidate, + checkpoint: Checkpoint, + ) -> Result<()> { + let nonce = provider.get_transaction_count(challenger.address()).await?; + checkpoint + .restore(&fork_config, verifier) + .await + .context("failed to restore the canonical root for Path 2")?; + info!( + game = %game.address, + challenged_index = checkpoint.index, + "restored the correct root; waiting for Path 2" + ); + + let state = Self::poll_until( + config, + config.dispute_timeout, + "the challenger to nullify the fraudulent ZK challenge", + || async { + let state = Self::read_game_state(verifier, game.address).await?; + Ok((state.zk_prover == Address::ZERO && state.countered_index == 0) + .then_some(state)) + }, + ) + .await?; + ensure!( + state.tee_prover != Address::ZERO, + "Path 2 cleared the TEE proof instead of the fraudulent ZK challenge" + ); + Self::assert_challenger_acted( + provider, + challenger, + nonce, + "Path 2 fraudulent challenge nullified", + ) + .await?; + info!(game = %game.address, "Path 2: fraudulent ZK challenge nullified"); + Ok(()) + } + /// Reads everything the challenger is able to change about a game. async fn read_game_state( verifier: &AggregateVerifierContractClient, diff --git a/crates/infra/challenger-e2e/src/config.rs b/crates/infra/challenger-e2e/src/config.rs index b71710b832..0932e4dad2 100644 --- a/crates/infra/challenger-e2e/src/config.rs +++ b/crates/infra/challenger-e2e/src/config.rs @@ -8,13 +8,27 @@ use std::time::Duration; use alloy_primitives::Address; -use clap::Parser; +use clap::{Parser, ValueEnum}; use url::Url; +/// E2E scenario to execute. +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, ValueEnum)] +pub enum Scenario { + /// Existing combined Path 1, Path 2 skip, and Path 4 run. + #[default] + All, + /// Path 1 followed by both the skip and dispute halves of Path 2. + Path1Path2, +} + /// Runtime configuration for [`crate::ChallengerE2e`]. #[derive(Debug, Parser)] #[command(name = "challenger-e2e", version, about, long_about = None)] pub struct Config { + /// E2E scenario to execute. + #[arg(long, env = "CHALLENGER_E2E_SCENARIO", default_value = "all")] + pub scenario: Scenario, + /// L1 RPC that Anvil forks. Only ever read from. #[arg(long = "l1-eth-rpc", env = "BASE_CHALLENGER_L1_ETH_RPC")] pub l1_eth_rpc: Url, @@ -104,3 +118,13 @@ pub struct Config { )] pub poll_interval: Duration, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_path1_path2_scenario() { + assert_eq!(Scenario::from_str("path1-path2", false), Ok(Scenario::Path1Path2)); + } +} diff --git a/crates/infra/challenger-e2e/src/lib.rs b/crates/infra/challenger-e2e/src/lib.rs index c12f3e7218..eead2a1e86 100644 --- a/crates/infra/challenger-e2e/src/lib.rs +++ b/crates/infra/challenger-e2e/src/lib.rs @@ -1,7 +1,7 @@ #![doc = include_str!("../README.md")] mod config; -pub use config::Config; +pub use config::{Config, Scenario}; mod metrics; pub use metrics::Scrape; diff --git a/crates/infra/zk-fork-dispute/src/checkpoint.rs b/crates/infra/zk-fork-dispute/src/checkpoint.rs index 55f3a45905..2370183896 100644 --- a/crates/infra/zk-fork-dispute/src/checkpoint.rs +++ b/crates/infra/zk-fork-dispute/src/checkpoint.rs @@ -86,6 +86,16 @@ impl Checkpoint { Ok(checkpoint) } + /// Restores this checkpoint's canonical root on an Anvil fork. + pub async fn restore( + self, + config: &Config, + verifier: &AggregateVerifierContractClient, + ) -> Result<()> { + let roots = verifier.intermediate_output_roots(config.game_address).await?; + AnvilPatch::apply(config, verifier, &roots, self.index, self.expected_root).await + } + /// Finds an already-invalid intermediate root by comparing on-chain vs canonical. pub async fn find(config: &Config, verifier: &AggregateVerifierContractClient) -> Result { let roots = verifier.intermediate_output_roots(config.game_address).await?; @@ -387,7 +397,7 @@ impl AnvilPatch { invalid_index = index, from = %original_root, to = %patched_root, - "patched invalid intermediate root on fork" + "updated intermediate output root on fork" ); Ok(()) } @@ -725,6 +735,26 @@ mod tests { roots[2].as_slice(), "rootClaim must remain unchanged" ); + + let restored = patch_cwia_root_in_bytecode( + after.as_ref(), + 1000, + parent, + &after_roots, + index, + current_roots[index], + ) + .expect("restore CWIA root"); + provider + .client() + .request::<_, ()>("anvil_setCode", (game, Bytes::from(restored))) + .await + .expect("set restored game code"); + let restored = provider.get_code_at(game).await.expect("read restored game code"); + assert_eq!( + read_cwia_roots(restored.as_ref(), 1000, parent, roots.len()), + current_roots + ); } } From ba3193504c87e1030164e2bb7a375fbc1fa4cd84 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 25 Sep 2026 15:44:33 +0000 Subject: [PATCH 122/299] feat(eip8130): move EIP-8130 activation from Zenith to Everest (#5250) * feat(eip8130): move EIP-8130 activation from Zenith to Everest EIP-8130 transactions, the system-account stub transition, batch validity, txpool and RPC gates, and their tests now key on Everest instead of the genesis-only Zenith gate. Adds the Everest devnet mode and system-test activation knob (L2_BASE_EVEREST_BLOCK). * fix(eip8130): write the Everest activation into devnet genesis The pinned op-deployer ignored L2_BASE_EVEREST_BLOCK, so everest and zenith modes never scheduled EIP-8130. Also correct "a Everest" to "an Everest". * fix(eip8130): point sequencer-proxy tests at the pre-Everest provider Main added these tests against the old pre-Zenith helper, which this branch renamed. --- .../tests/{zenith => everest}/derivation.rs | 26 +++--- .../harness/tests/{zenith => everest}/env.rs | 26 +++--- actions/harness/tests/everest/main.rs | 4 + actions/harness/tests/everest/transaction.rs | 30 +++++++ actions/harness/tests/zenith/main.rs | 4 - actions/harness/tests/zenith/transaction.rs | 30 ------- .../consensus/src/transaction/tx_type.rs | 8 +- .../common/evm/src/{zenith.rs => everest.rs} | 26 +++--- .../common/evm/src/executor/block_executor.rs | 4 +- crates/common/evm/src/lib.rs | 4 +- .../common/evm2/src/{zenith.rs => everest.rs} | 14 ++-- crates/common/evm2/src/executor.rs | 8 +- crates/common/evm2/src/lib.rs | 4 +- crates/common/evm2/src/nonce_manager.rs | 4 +- crates/common/evm2/src/transaction.rs | 2 +- crates/common/evm2/src/transition.rs | 2 +- crates/common/evm2/tests/transition_hooks.rs | 24 +++--- crates/common/evm2/tests/transition_parity.rs | 12 +-- .../common/precompile-storage/src/journal.rs | 2 +- crates/common/rpc-types/src/eip8130.rs | 4 +- crates/common/rpc-types/src/lib.rs | 2 +- crates/consensus/protocol/src/batch/single.rs | 21 +++-- crates/consensus/protocol/src/batch/span.rs | 18 ++-- .../consensus/protocol/src/batch/validity.rs | 6 +- .../eip8130-rpc-node/tests/receipt.rs | 10 +-- .../execution/eip8130-rpc-node/tests/rpc.rs | 26 +++--- crates/execution/eip8130-rpc/README.md | 4 +- crates/execution/eip8130-rpc/src/estimate.rs | 4 +- crates/execution/eip8130-rpc/src/eth.rs | 10 +-- .../src/{zenith_gate.rs => everest_gate.rs} | 22 ++--- crates/execution/eip8130-rpc/src/lib.rs | 4 +- .../execution/eip8130-rpc/src/nonce_reader.rs | 4 +- crates/execution/flashblocks/src/rpc/eth.rs | 8 +- .../flashblocks/src/state_builder.rs | 2 +- crates/execution/rpc/src/error.rs | 4 +- crates/execution/rpc/src/eth/transaction.rs | 6 +- crates/execution/txpool-rpc/src/lib.rs | 2 +- crates/execution/txpool-rpc/src/rpc.rs | 82 +++++++++---------- crates/execution/txpool/src/pool.rs | 4 +- crates/execution/txpool/src/validator.rs | 36 ++++---- crates/utilities/test-utils/src/genesis.rs | 7 +- crates/utilities/test-utils/src/lib.rs | 2 +- etc/docker/Dockerfile.devnet | 2 +- etc/docker/Justfile | 12 ++- etc/docker/README.md | 11 ++- etc/docker/devnet-env | 4 + etc/docker/docker-compose.yml | 1 + etc/systems/src/setup/container.rs | 12 +++ etc/systems/src/smoke.rs | 12 +++ .../tests/common/{zenith.rs => everest.rs} | 18 ++-- etc/systems/tests/eip8130.rs | 10 +-- etc/systems/tests/tx_forwarding.rs | 8 +- 52 files changed, 326 insertions(+), 286 deletions(-) rename actions/harness/tests/{zenith => everest}/derivation.rs (86%) rename actions/harness/tests/{zenith => everest}/env.rs (88%) create mode 100644 actions/harness/tests/everest/main.rs create mode 100644 actions/harness/tests/everest/transaction.rs delete mode 100644 actions/harness/tests/zenith/main.rs delete mode 100644 actions/harness/tests/zenith/transaction.rs rename crates/common/evm/src/{zenith.rs => everest.rs} (85%) rename crates/common/evm2/src/{zenith.rs => everest.rs} (89%) rename crates/execution/eip8130-rpc/src/{zenith_gate.rs => everest_gate.rs} (79%) rename etc/systems/tests/common/{zenith.rs => everest.rs} (60%) diff --git a/actions/harness/tests/zenith/derivation.rs b/actions/harness/tests/everest/derivation.rs similarity index 86% rename from actions/harness/tests/zenith/derivation.rs rename to actions/harness/tests/everest/derivation.rs index 78c62e0fd5..d3ea63aa3e 100644 --- a/actions/harness/tests/zenith/derivation.rs +++ b/actions/harness/tests/everest/derivation.rs @@ -1,4 +1,4 @@ -//! Derivation-layer rejection of pre-Zenith EIP-8130 transactions. +//! Derivation-layer rejection of pre-Everest EIP-8130 transactions. use alloy_primitives::B256; use base_action_harness::{ @@ -7,24 +7,24 @@ use base_action_harness::{ }; use base_batcher_encoder::{DaType, EncoderConfig}; -use crate::env::ZenithTestEnv; +use crate::env::EverestTestEnv; -/// An EIP-8130 transaction batched into a pre-Zenith L2 block is dropped by the -/// verifier's derivation pipeline (`BatchValidity::Drop::Eip8130PreZenith`), and +/// An EIP-8130 transaction batched into a pre-Everest L2 block is dropped by the +/// verifier's derivation pipeline (`BatchValidity::Drop::Eip8130PreEverest`), and /// the pipeline force-includes a deposit-only block in its place once the /// sequencing window closes. /// -/// Setup (`block_time` = 2, Zenith at ts = 8 → L2 block 4): +/// Setup (`block_time` = 2, Everest at ts = 8 → L2 block 4): /// ```text /// seq_window_size = 4 (epoch-0 window closes at L1 block 4) /// L1 block 1: batch for L2 block 1 (user tx) → derived normally /// L1 block 2: batch for L2 block 2 (user tx) → derived normally -/// L1 block 3: batch for L2 block 3 (EIP-8130, ts = 6) → DROPPED (pre-Zenith) +/// L1 block 3: batch for L2 block 3 (EIP-8130, ts = 6) → DROPPED (pre-Everest) /// L1 block 4: empty (closes epoch-0 window) → block 3 force-included /// as deposit-only /// ``` #[tokio::test] -async fn eip8130_batch_is_dropped_before_zenith() { +async fn eip8130_batch_is_dropped_before_everest() { let batcher_cfg = BatcherConfig { encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, ..BatcherConfig::default() @@ -36,7 +36,7 @@ async fn eip8130_batch_is_dropped_before_zenith() { .with_azul_at(0) .with_beryl_at(0) .with_cobalt_at(8) - .with_zenith_at(8) + .with_everest_at(8) .with_seq_window_size(4) .build(); let chain_id = rollup_cfg.l2_chain_id.id(); @@ -45,14 +45,14 @@ async fn eip8130_batch_is_dropped_before_zenith() { let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); let mut builder = h.create_l2_sequencer(l1_chain); - // Build three pre-Zenith L2 blocks. Blocks 1-2 carry a normal user tx; block + // Build three pre-Everest L2 blocks. Blocks 1-2 carry a normal user tx; block // 3 (ts = 6) carries an EIP-8130 transaction whose batch the verifier must - // drop because Zenith is not active until ts = 8. + // drop because Everest is not active until ts = 8. let mut eip8130_block_hash = B256::ZERO; let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg); for i in 1u64..=3 { if i == 3 { - let tx = ZenithTestEnv::eip8130_user_tx(chain_id, 0); + let tx = EverestTestEnv::eip8130_user_tx(chain_id, 0); let block = builder.build_next_block_with_transactions(vec![tx]).await; eip8130_block_hash = block.header.hash_slow(); batcher.push_block(block); @@ -79,7 +79,7 @@ async fn eip8130_batch_is_dropped_before_zenith() { assert_eq!( node.l2_safe_number(), 2, - "blocks 1-2 derive; block 3's EIP-8130 batch is dropped pre-Zenith" + "blocks 1-2 derive; block 3's EIP-8130 batch is dropped pre-Everest" ); // Close the epoch-0 sequencing window (0 + seq_window_size 4 = 4) so the @@ -105,6 +105,6 @@ async fn eip8130_batch_is_dropped_before_zenith() { assert_eq!( user_txs(3), Some(0), - "block 3: EIP-8130 batch dropped pre-Zenith -> deposit-only, 0 user txs" + "block 3: EIP-8130 batch dropped pre-Everest -> deposit-only, 0 user txs" ); } diff --git a/actions/harness/tests/zenith/env.rs b/actions/harness/tests/everest/env.rs similarity index 88% rename from actions/harness/tests/zenith/env.rs rename to actions/harness/tests/everest/env.rs index eaeb58871e..d1e71838f4 100644 --- a/actions/harness/tests/zenith/env.rs +++ b/actions/harness/tests/everest/env.rs @@ -1,4 +1,4 @@ -//! Shared test environment for Base Zenith action tests. +//! Shared test environment for Base Everest action tests. use alloy_primitives::{Bytes, U256}; use alloy_signer::SignerSync; @@ -12,12 +12,12 @@ use base_common_consensus::{ }; use base_test_utils::Account; -/// L2 timestamp where the Zenith fork activates in these tests. -pub(crate) const ZENITH_ACTIVATION_TIMESTAMP: u64 = 4; +/// L2 timestamp where the Everest fork activates in these tests. +pub(crate) const EVEREST_ACTIVATION_TIMESTAMP: u64 = 4; -/// Test environment preconfigured to cross the Base Zenith activation at L2 block 2. -pub(crate) struct ZenithTestEnv { - /// Sequencer used to build Zenith test blocks. +/// Test environment preconfigured to cross the Base Everest activation at L2 block 2. +pub(crate) struct EverestTestEnv { + /// Sequencer used to build Everest test blocks. pub(crate) sequencer: L2Sequencer, harness: ActionTestHarness, batcher_cfg: BatcherConfig, @@ -26,9 +26,9 @@ pub(crate) struct ZenithTestEnv { chain_id: u64, } -impl ZenithTestEnv { +impl EverestTestEnv { /// Creates an environment with all forks through Jovian active at genesis - /// and Base Zenith active at timestamp 4. + /// and Base Everest active at timestamp 4. pub(crate) fn new() -> Self { let batcher_cfg = BatcherConfig { encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, @@ -40,8 +40,8 @@ impl ZenithTestEnv { .with_jovian_at(0) .with_azul_at(0) .with_beryl_at(0) - .with_cobalt_at(ZENITH_ACTIVATION_TIMESTAMP) - .with_zenith_at(ZENITH_ACTIVATION_TIMESTAMP) + .with_cobalt_at(EVEREST_ACTIVATION_TIMESTAMP) + .with_everest_at(EVEREST_ACTIVATION_TIMESTAMP) .build(); let chain_id = rollup_cfg.l2_chain_id.id(); let harness = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); @@ -87,7 +87,7 @@ impl ZenithTestEnv { assert_eq!( self.node.l2_safe_number(), expected_safe_head, - "all {expected_safe_head} L2 blocks must derive through the Zenith boundary" + "all {expected_safe_head} L2 blocks must derive through the Everest boundary" ); } @@ -115,7 +115,7 @@ impl ZenithTestEnv { /// /// Exposed as an associated function (no `self`) so derivation tests that /// build their own harness can reuse it without constructing a full - /// [`ZenithTestEnv`]. + /// [`EverestTestEnv`]. pub(crate) fn eip8130_user_tx(chain_id: u64, nonce_sequence: u64) -> BaseTxEnvelope { let alice = Account::Alice; @@ -146,7 +146,7 @@ impl ZenithTestEnv { } } -impl Default for ZenithTestEnv { +impl Default for EverestTestEnv { fn default() -> Self { Self::new() } diff --git a/actions/harness/tests/everest/main.rs b/actions/harness/tests/everest/main.rs new file mode 100644 index 0000000000..4f57f416de --- /dev/null +++ b/actions/harness/tests/everest/main.rs @@ -0,0 +1,4 @@ +//! Action tests for the Base Everest upgrade. +mod derivation; +mod env; +mod transaction; diff --git a/actions/harness/tests/everest/transaction.rs b/actions/harness/tests/everest/transaction.rs new file mode 100644 index 0000000000..8878e0c85f --- /dev/null +++ b/actions/harness/tests/everest/transaction.rs @@ -0,0 +1,30 @@ +//! EIP-8130 transaction tests across the Base Everest boundary. +use alloy_consensus::TxReceipt; +use base_common_consensus::BaseReceipt; + +use crate::env::EverestTestEnv; + +#[tokio::test] +async fn eip8130_transaction_executes_and_derives_at_everest() { + let mut env = EverestTestEnv::new(); + + // Block 1 has timestamp 2, before Everest. + let pre_everest = env.sequencer.build_empty_block().await; + + // Block 2 has timestamp 4, exactly when Everest activates. + let tx = env.create_eip8130_tx(0); + let everest_block = env.sequencer.build_next_block_with_transactions(vec![tx]).await; + + // The EIP-8130 transaction must produce a successful account-abstraction + // receipt with a single committed call phase. + let receipt = env.user_tx_receipt(&everest_block, 0); + let BaseReceipt::Eip8130(receipt) = receipt else { + panic!("expected an EIP-8130 receipt"); + }; + assert!(receipt.inner.status()); + assert_eq!(receipt.phase_statuses, vec![0x01]); + + // Send both blocks through the batcher and verifier, asserting the verifier + // re-derives them with matching state. + env.derive_blocks([(pre_everest, 1), (everest_block, 2)], 2).await; +} diff --git a/actions/harness/tests/zenith/main.rs b/actions/harness/tests/zenith/main.rs deleted file mode 100644 index 34d55f7809..0000000000 --- a/actions/harness/tests/zenith/main.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Action tests for the Base Zenith upgrade. -mod derivation; -mod env; -mod transaction; diff --git a/actions/harness/tests/zenith/transaction.rs b/actions/harness/tests/zenith/transaction.rs deleted file mode 100644 index 86747b2a5f..0000000000 --- a/actions/harness/tests/zenith/transaction.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! EIP-8130 transaction tests across the Base Zenith boundary. -use alloy_consensus::TxReceipt; -use base_common_consensus::BaseReceipt; - -use crate::env::ZenithTestEnv; - -#[tokio::test] -async fn eip8130_transaction_executes_and_derives_at_zenith() { - let mut env = ZenithTestEnv::new(); - - // Block 1 has timestamp 2, before Zenith. - let pre_zenith = env.sequencer.build_empty_block().await; - - // Block 2 has timestamp 4, exactly when Zenith activates. - let tx = env.create_eip8130_tx(0); - let zenith_block = env.sequencer.build_next_block_with_transactions(vec![tx]).await; - - // The EIP-8130 transaction must produce a successful account-abstraction - // receipt with a single committed call phase. - let receipt = env.user_tx_receipt(&zenith_block, 0); - let BaseReceipt::Eip8130(receipt) = receipt else { - panic!("expected an EIP-8130 receipt"); - }; - assert!(receipt.inner.status()); - assert_eq!(receipt.phase_statuses, vec![0x01]); - - // Send both blocks through the batcher and verifier, asserting the verifier - // re-derives them with matching state. - env.derive_blocks([(pre_zenith, 1), (zenith_block, 2)], 2).await; -} diff --git a/crates/common/consensus/src/transaction/tx_type.rs b/crates/common/consensus/src/transaction/tx_type.rs index 2fcee200e9..1150e24497 100644 --- a/crates/common/consensus/src/transaction/tx_type.rs +++ b/crates/common/consensus/src/transaction/tx_type.rs @@ -14,12 +14,12 @@ pub const DEPOSIT_TX_TYPE_ID: u8 = 126; // 0x7E /// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 pub const EIP8130_TX_TYPE_ID: u8 = 121; // 0x79 -/// Canonical user-facing rejection message for EIP-8130 transactions submitted before Zenith. +/// Canonical user-facing rejection message for EIP-8130 transactions submitted before Everest. /// /// Used by `base-execution-rpc` when an EIP-8130 transaction is submitted before -/// the Zenith fork is active at the latest block timestamp. -pub const EIP8130_REJECTION_MSG: &str = "EIP-8130 (account abstraction) transactions are gated behind Zenith; \ - eth_sendRawTransaction does not accept transaction type 0x79 before Zenith"; +/// the Everest fork is active at the latest block timestamp. +pub const EIP8130_REJECTION_MSG: &str = "EIP-8130 (account abstraction) transactions are gated behind Everest; \ + eth_sendRawTransaction does not accept transaction type 0x79 before Everest"; #[allow(clippy::derivable_impls)] impl Default for OpTxType { diff --git a/crates/common/evm/src/zenith.rs b/crates/common/evm/src/everest.rs similarity index 85% rename from crates/common/evm/src/zenith.rs rename to crates/common/evm/src/everest.rs index 6ca3973a21..94fbb2f7d7 100644 --- a/crates/common/evm/src/zenith.rs +++ b/crates/common/evm/src/everest.rs @@ -25,19 +25,19 @@ const SYSTEM_ACCOUNT_STUB: [u8; 1] = [0xEF]; /// every chain where EIP-8130 is enabled. const CODELESS_SYSTEM_ACCOUNTS: [Address; 1] = [NonceManagerStorage::ADDRESS]; -/// The Zenith upgrade enables EIP-8130. The enshrined execution path writes +/// The Everest upgrade enables EIP-8130. The enshrined execution path writes /// persistent state (e.g. 2D nonce channels) to system accounts that hold /// storage but carry no code, leaving them EIP-161-"empty" and liable to be /// reaped — discarding that storage — by end-of-block state clearing. /// -/// This issues an irregular state transition at the Zenith activation that +/// This issues an irregular state transition at the Everest activation that /// force-deploys a one-byte code stub onto those accounts, mirroring the Canyon /// create2-deployer transition in [`ensure_create2_deployer`]. Once an account /// has code it is no longer EIP-161-empty and survives clearing. /// /// The stub is only planted on an account that has no code yet, so it never /// overwrites a real deployment, and it is idempotent: it fires on the first -/// Zenith block and is a no-op thereafter. +/// Everest block and is a no-op thereafter. /// /// [`ensure_create2_deployer`]: crate::ensure_create2_deployer pub fn ensure_eip8130_system_accounts( @@ -48,7 +48,7 @@ pub fn ensure_eip8130_system_accounts( where DB: Database + DatabaseCommit, { - if !chain_spec.is_zenith_active_at_timestamp(timestamp) { + if !chain_spec.is_everest_active_at_timestamp(timestamp) { return Ok(()); } @@ -89,13 +89,13 @@ mod tests { const ADDR: Address = NonceManagerStorage::ADDRESS; - /// Zenith active: the code-less nonce manager is given the `0xEF` stub so it + /// Everest active: the code-less nonce manager is given the `0xEF` stub so it /// is no longer EIP-161-empty. #[test] - fn zenith_active_plants_stub_on_codeless_system_account() { + fn everest_active_plants_stub_on_codeless_system_account() { let mut db = InMemoryDB::default(); - ensure_eip8130_system_accounts(zenith(Some(0)), 100, &mut db).unwrap(); + ensure_eip8130_system_accounts(everest(Some(0)), 100, &mut db).unwrap(); let acc = db.basic(ADDR).unwrap().expect("system account must exist"); assert!(!acc.is_empty_code_hash(), "the stub must give the account a non-empty code hash"); @@ -105,12 +105,12 @@ mod tests { ); } - /// Zenith inactive: nothing is planted. + /// Everest inactive: nothing is planted. #[test] - fn zenith_inactive_is_a_noop() { + fn everest_inactive_is_a_noop() { let mut db = InMemoryDB::default(); - ensure_eip8130_system_accounts(zenith(None), 100, &mut db).unwrap(); + ensure_eip8130_system_accounts(everest(None), 100, &mut db).unwrap(); assert!(db.basic(ADDR).unwrap().is_none(), "no system account should be materialized"); } @@ -129,16 +129,16 @@ mod tests { }, ); - ensure_eip8130_system_accounts(zenith(Some(0)), 100, &mut db).unwrap(); + ensure_eip8130_system_accounts(everest(Some(0)), 100, &mut db).unwrap(); let acc = db.basic(ADDR).unwrap().unwrap(); assert_eq!(acc.code_hash, real.hash_slow(), "a real deployment must not be overwritten"); } - fn zenith(timestamp: Option) -> RollupConfig { + fn everest(timestamp: Option) -> RollupConfig { let mut config = RollupConfig::default(); if let Some(timestamp) = timestamp { - config.set_upgrade_activation_timestamp(BaseUpgrade::Zenith, timestamp); + config.set_upgrade_activation_timestamp(BaseUpgrade::Everest, timestamp); } config } diff --git a/crates/common/evm/src/executor/block_executor.rs b/crates/common/evm/src/executor/block_executor.rs index b37044b861..8602723582 100644 --- a/crates/common/evm/src/executor/block_executor.rs +++ b/crates/common/evm/src/executor/block_executor.rs @@ -192,11 +192,11 @@ where ) .map_err(BlockExecutionError::other)?; - // At the Zenith (EIP-8130) transition, plant a code stub on the code-less + // At the Everest (EIP-8130) transition, plant a code stub on the code-less // enshrined system accounts (the 2D nonce manager) so the persistent state // the enshrined path writes to them is not reaped by EIP-161 end-of-block // state clearing. - crate::zenith::ensure_eip8130_system_accounts( + crate::everest::ensure_eip8130_system_accounts( &self.spec, self.evm.block().timestamp().saturating_to(), self.evm.db_mut(), diff --git a/crates/common/evm/src/lib.rs b/crates/common/evm/src/lib.rs index abbc00cb40..055ab7a736 100644 --- a/crates/common/evm/src/lib.rs +++ b/crates/common/evm/src/lib.rs @@ -64,8 +64,8 @@ pub use receipt_builder::{AlloyReceiptBuilder, BaseReceiptBuilder}; mod canyon; pub use canyon::ensure_create2_deployer; -mod zenith; -pub use zenith::ensure_eip8130_system_accounts; +mod everest; +pub use everest::ensure_eip8130_system_accounts; mod executor; pub use executor::{ diff --git a/crates/common/evm2/src/zenith.rs b/crates/common/evm2/src/everest.rs similarity index 89% rename from crates/common/evm2/src/zenith.rs rename to crates/common/evm2/src/everest.rs index 2291c3d448..c9cd175817 100644 --- a/crates/common/evm2/src/zenith.rs +++ b/crates/common/evm2/src/everest.rs @@ -1,4 +1,4 @@ -//! Zenith EIP-8130 system-account stub transition. +//! Everest EIP-8130 system-account stub transition. use alloy_primitives::{Address, Bytes, KECCAK256_EMPTY}; use base_common_eip8130::NonceManagerSlots; @@ -30,25 +30,25 @@ const SYSTEM_ACCOUNT_STUB: [u8; 1] = [0xEF]; /// `base_common_precompiles` `NonceManagerStorage::ADDRESS`. const CODELESS_SYSTEM_ACCOUNTS: [Address; 1] = [NonceManagerSlots::ADDRESS]; -/// The Zenith EIP-8130 system-account transition. +/// The Everest EIP-8130 system-account transition. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Zenith; +pub struct Everest; -impl Zenith { - /// The Zenith upgrade enables EIP-8130, whose enshrined execution path writes persistent state +impl Everest { + /// The Everest upgrade enables EIP-8130, whose enshrined execution path writes persistent state /// to system accounts that hold storage but carry no code, leaving them EIP-161-"empty" and /// liable to be reaped by end-of-block state clearing. /// /// This force-deploys a one-byte code stub onto those accounts so they are no longer empty. It /// is planted only on an account that has no code yet, so it never overwrites a real - /// deployment, and it is idempotent: it fires on the first Zenith block and is a no-op after. + /// deployment, and it is idempotent: it fires on the first Everest block and is a no-op after. pub fn ensure_eip8130_system_accounts( chain_spec: &impl BaseForkActivations, timestamp: u64, evm: &mut Evm<'_, BaseEvmTypes>, block_state: &mut BlockStateAccumulator, ) -> HandlerResult<()> { - if !chain_spec.is_active_at_timestamp(BaseUpgrade::Zenith, timestamp) { + if !chain_spec.is_active_at_timestamp(BaseUpgrade::Everest, timestamp) { return Ok(()); } diff --git a/crates/common/evm2/src/executor.rs b/crates/common/evm2/src/executor.rs index 95203cf7fa..9210ed80c0 100644 --- a/crates/common/evm2/src/executor.rs +++ b/crates/common/evm2/src/executor.rs @@ -12,7 +12,7 @@ use evm2::{ }; use crate::{ - BaseEvmTypes, BaseForkActivations, BaseTime, Canyon, IntrinsicGas, Zenith, + BaseEvmTypes, BaseForkActivations, BaseTime, Canyon, Everest, IntrinsicGas, transaction::BaseTxEnvelope, }; @@ -139,7 +139,7 @@ pub struct BlockExecutionResult { /// the block's state delta. /// /// The flow is [`apply_pre_execution`](Self::apply_pre_execution) (block-boundary system calls), -/// then [`apply_transition_hooks`](Self::apply_transition_hooks) (the Canyon/Denim/Zenith +/// then [`apply_transition_hooks`](Self::apply_transition_hooks) (the Canyon/Denim/Everest /// irregular state transitions), then [`execute_transaction`](Self::execute_transaction) per /// transaction (running it through the registry, building its receipt with running cumulative gas, /// and committing its state into a [`BlockStateAccumulator`]), then [`finish`](Self::finish). The @@ -213,7 +213,7 @@ impl<'a> BaseBlockExecutor<'a> { /// Applies the Base transition-block irregular state changes, in the reference order: the /// Canyon create2-deployer force-deploy, the Denim `BaseTime` predeploy install, then the - /// Zenith EIP-8130 system-account stub. Each is fork-gated on `chain_spec` at this block's + /// Everest EIP-8130 system-account stub. Each is fork-gated on `chain_spec` at this block's /// timestamp and commits its state into the block state. Must run after /// [`apply_pre_execution`](Self::apply_pre_execution) and before any transactions. pub fn apply_transition_hooks( @@ -228,7 +228,7 @@ impl<'a> BaseBlockExecutor<'a> { &mut self.block_state, )?; BaseTime::ensure_predeploy(chain_spec, timestamp, &mut self.evm, &mut self.block_state)?; - Zenith::ensure_eip8130_system_accounts( + Everest::ensure_eip8130_system_accounts( chain_spec, timestamp, &mut self.evm, diff --git a/crates/common/evm2/src/lib.rs b/crates/common/evm2/src/lib.rs index 30caf25cc7..4e2a2aba79 100644 --- a/crates/common/evm2/src/lib.rs +++ b/crates/common/evm2/src/lib.rs @@ -6,8 +6,8 @@ pub use spec::BaseSpecId; mod canyon; pub use canyon::Canyon; -mod zenith; -pub use zenith::Zenith; +mod everest; +pub use everest::Everest; mod base_time; pub use base_time::{BaseTime, BaseTimeTransitionError}; diff --git a/crates/common/evm2/src/nonce_manager.rs b/crates/common/evm2/src/nonce_manager.rs index d5b45437e7..016fe32b65 100644 --- a/crates/common/evm2/src/nonce_manager.rs +++ b/crates/common/evm2/src/nonce_manager.rs @@ -1,7 +1,7 @@ //! EIP-8130 2D nonce-manager storage access for the EVM2 engine. //! //! The nonce manager is a code-less EIP-8130 system account (given the `0xEF` reap-protection stub -//! by the [`Zenith`](crate::Zenith) transition) that persists per-`(account, nonce_key)` 2D +//! by the [`Everest`](crate::Everest) transition) that persists per-`(account, nonce_key)` 2D //! sequence nonces in the state trie. The engine-neutral slot layout — the system-account address, //! the ERC-7201 base slots, and the mapping-slot derivation — lives in //! [`base_common_eip8130::NonceManagerSlots`], shared with the revm path so the two engines cannot @@ -105,7 +105,7 @@ mod tests { const KEY: U256 = U256::from_limbs([7, 0, 0, 0]); fn evm(db: InMemoryDB) -> Evm<'static, BaseEvmTypes> { - let spec = BaseSpecId::new(BaseUpgrade::Zenith); + let spec = BaseSpecId::new(BaseUpgrade::Everest); Evm::new( spec, BlockEnv::::default(), diff --git a/crates/common/evm2/src/transaction.rs b/crates/common/evm2/src/transaction.rs index 82a78242bc..bbe93828a1 100644 --- a/crates/common/evm2/src/transaction.rs +++ b/crates/common/evm2/src/transaction.rs @@ -21,7 +21,7 @@ pub enum BaseTxEnvelope { /// which `TxEnvelope` alone cannot reproduce. enveloped: Bytes, }, - /// An enshrined EIP-8130 account-abstraction transaction (type `0x79`, Zenith onwards). Its + /// An enshrined EIP-8130 account-abstraction transaction (type `0x79`, Everest onwards). Its /// enshrined execution path is layered on with the EIP-8130 track; this variant carries the /// signed transaction so the envelope and receipts can already represent it. /// diff --git a/crates/common/evm2/src/transition.rs b/crates/common/evm2/src/transition.rs index 5151c63301..09bfc55d70 100644 --- a/crates/common/evm2/src/transition.rs +++ b/crates/common/evm2/src/transition.rs @@ -1,6 +1,6 @@ //! Irregular (non-transaction) state transitions for Base block boundaries. //! -//! The Canyon, Denim, and Zenith block-boundary hooks apply *irregular* state changes — direct +//! The Canyon, Denim, and Everest block-boundary hooks apply *irregular* state changes — direct //! edits to account code and storage that are not the result of executing a transaction. This //! module provides the two pieces they share: [`BaseForkActivations`], the revm-free fork-schedule //! input used to detect activation blocks, and [`IrregularStateChange`], which persists an edit diff --git a/crates/common/evm2/tests/transition_hooks.rs b/crates/common/evm2/tests/transition_hooks.rs index 6313c849ee..08b424b9aa 100644 --- a/crates/common/evm2/tests/transition_hooks.rs +++ b/crates/common/evm2/tests/transition_hooks.rs @@ -1,9 +1,9 @@ //! Transition-block hook tests for `base-common-evm2`. //! -//! Validates the Canyon create2-deployer, Denim `BaseTime`, and Zenith EIP-8130 system-account +//! Validates the Canyon create2-deployer, Denim `BaseTime`, and Everest EIP-8130 system-account //! irregular state transitions applied by [`BaseBlockExecutor::apply_transition_hooks`]: their //! fork gating, the state they install, and their idempotency. Each hook's parity with the revm -//! reference (`base-common-evm`'s `canyon`/`zenith`/`base_time` modules) is in the shape of the +//! reference (`base-common-evm`'s `canyon`/`everest`/`base_time` modules) is in the shape of the //! installed code/storage, asserted here against the same addresses and constants. use alloy_primitives::{Address, B256, Bytes, KECCAK256_EMPTY, U256, address, b256, uint}; @@ -89,9 +89,9 @@ fn canyon_is_skipped_after_the_activation_block() { } #[test] -fn zenith_plants_stub_on_codeless_nonce_manager() { - let mut executor = executor(BaseUpgrade::Zenith, 1000, db_with_valid_proxy()); - executor.apply_transition_hooks(&schedule(BaseUpgrade::Zenith, 1000)).expect("hooks apply"); +fn everest_plants_stub_on_codeless_nonce_manager() { + let mut executor = executor(BaseUpgrade::Everest, 1000, db_with_valid_proxy()); + executor.apply_transition_hooks(&schedule(BaseUpgrade::Everest, 1000)).expect("hooks apply"); let (mut evm, _, _) = executor.finish(); let stub_hash = Bytecode::new_legacy(Bytes::from_static(&[0xEF])).hash_slow(); @@ -100,26 +100,26 @@ fn zenith_plants_stub_on_codeless_nonce_manager() { } #[test] -fn zenith_does_not_overwrite_existing_code() { +fn everest_does_not_overwrite_existing_code() { let real = Bytecode::new_raw(Bytes::from_static(&[0x60, 0x00])); let real_hash = real.hash_slow(); let mut db = db_with_valid_proxy(); db.insert_account_info(&NONCE_MANAGER, evm2::AccountInfo::new(U256::ZERO, 0, real_hash, real)); - let mut executor = executor(BaseUpgrade::Zenith, 1000, db); - executor.apply_transition_hooks(&schedule(BaseUpgrade::Zenith, 1000)).expect("hooks apply"); + let mut executor = executor(BaseUpgrade::Everest, 1000, db); + executor.apply_transition_hooks(&schedule(BaseUpgrade::Everest, 1000)).expect("hooks apply"); let (mut evm, _, _) = executor.finish(); assert_eq!(code_hash(&mut evm, NONCE_MANAGER), real_hash, "existing deployment preserved"); } #[test] -fn zenith_is_skipped_before_activation() { +fn everest_is_skipped_before_activation() { let mut executor = executor(BaseUpgrade::Isthmus, 1000, db_with_valid_proxy()); - // Zenith is scheduled far in the future; at ts=1000 it is inactive. - executor.apply_transition_hooks(&schedule(BaseUpgrade::Zenith, 10_000)).expect("hooks apply"); + // Everest is scheduled far in the future; at ts=1000 it is inactive. + executor.apply_transition_hooks(&schedule(BaseUpgrade::Everest, 10_000)).expect("hooks apply"); let (mut evm, _, _) = executor.finish(); - assert_eq!(code_hash(&mut evm, NONCE_MANAGER), KECCAK256_EMPTY, "no stub before Zenith"); + assert_eq!(code_hash(&mut evm, NONCE_MANAGER), KECCAK256_EMPTY, "no stub before Everest"); } /// Seeds a valid `BaseTime` proxy (with code and the canonical admin) so the transition can link diff --git a/crates/common/evm2/tests/transition_parity.rs b/crates/common/evm2/tests/transition_parity.rs index 14a4c6b0eb..b9ebffef9b 100644 --- a/crates/common/evm2/tests/transition_parity.rs +++ b/crates/common/evm2/tests/transition_parity.rs @@ -1,6 +1,6 @@ //! Differential parity harness for the transition-block hooks. //! -//! Runs each Base transition hook (Canyon create2-deployer, Zenith EIP-8130 system-account stub, +//! Runs each Base transition hook (Canyon create2-deployer, Everest EIP-8130 system-account stub, //! Denim `BaseTime` predeploy) through the `base-common-evm2` executor and, on an equivalent //! database, through the revm-based `base-common-evm` reference function, asserting the two engines //! install byte-identical state (the affected account code hashes and, for `BaseTime`, the linked @@ -104,18 +104,18 @@ fn canyon_create2_deployer_matches_revm() { } #[test] -fn zenith_system_account_stub_matches_revm() { - // evm2 (Canyon active-at-0 so it does not re-fire; only Zenith does). - let mut schedule = evm2_schedule(BaseUpgrade::Zenith); +fn everest_system_account_stub_matches_revm() { + // evm2 (Canyon active-at-0 so it does not re-fire; only Everest does). + let mut schedule = evm2_schedule(BaseUpgrade::Everest); schedule.set_activation_timestamp(BaseUpgrade::Canyon, 0); - let mut executor = evm2_executor(BaseUpgrade::Zenith, evm2_db_with_valid_base_time_proxy()); + let mut executor = evm2_executor(BaseUpgrade::Everest, evm2_db_with_valid_base_time_proxy()); executor.apply_transition_hooks(&schedule).expect("hooks apply"); let (mut evm, _, _) = executor.finish(); let evm2_hash = evm2_code_hash(&mut evm, NONCE_MANAGER); // revm reference. let mut db = RevmDb::default(); - ensure_eip8130_system_accounts(revm_schedule(BaseUpgrade::Zenith), ACTIVATION_TS, &mut db) + ensure_eip8130_system_accounts(revm_schedule(BaseUpgrade::Everest), ACTIVATION_TS, &mut db) .expect("reference applies"); let revm_hash = revm_code_hash(&mut db, NONCE_MANAGER); diff --git a/crates/common/precompile-storage/src/journal.rs b/crates/common/precompile-storage/src/journal.rs index 324147df1f..44b86ccbf6 100644 --- a/crates/common/precompile-storage/src/journal.rs +++ b/crates/common/precompile-storage/src/journal.rs @@ -151,7 +151,7 @@ impl PrecompileStorageProvider for JournalStorageProvider<'_> { // nonce/balance/code. Code-less enshrined system accounts that hold // persistent storage (e.g. the 2D `NonceManager`) are instead made // EIP-161-non-empty out of band by a one-byte code stub planted at the - // Zenith transition (see `ensure_eip8130_system_accounts`), so their + // Everest transition (see `ensure_eip8130_system_accounts`), so their // storage survives end-of-block state clearing without a per-write guard. self.internals .sstore(address, key, value) diff --git a/crates/common/rpc-types/src/eip8130.rs b/crates/common/rpc-types/src/eip8130.rs index 27d16abc52..7e6ab64a63 100644 --- a/crates/common/rpc-types/src/eip8130.rs +++ b/crates/common/rpc-types/src/eip8130.rs @@ -22,8 +22,8 @@ const AUTHENTICATOR_SELECTOR_LEN: usize = 20; /// Maximum caller-supplied authentication payload length. pub(crate) const MAX_AUTH_SIZE: u32 = 8_192; -/// Canonical invalid-params message for EIP-8130 RPC reads before Zenith. -pub const EIP8130_PRE_ZENITH_RPC_ERROR: &str = "EIP-8130 RPC features are not active before the Zenith hard fork; the `nonce_key` parameter is not supported at this block"; +/// Canonical invalid-params message for EIP-8130 RPC reads before Everest. +pub const EIP8130_PRE_EVEREST_RPC_ERROR: &str = "EIP-8130 RPC features are not active before the Everest hard fork; the `nonce_key` parameter is not supported at this block"; /// Reth-free EIP-8130 channel-nonce helpers. #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/common/rpc-types/src/lib.rs b/crates/common/rpc-types/src/lib.rs index 3d023e0b06..eba4faaac6 100644 --- a/crates/common/rpc-types/src/lib.rs +++ b/crates/common/rpc-types/src/lib.rs @@ -27,7 +27,7 @@ pub use receipt::{ #[cfg(feature = "eip8130")] mod eip8130; #[cfg(feature = "eip8130")] -pub use eip8130::{EIP8130_PRE_ZENITH_RPC_ERROR, Eip8130Nonce}; +pub use eip8130::{EIP8130_PRE_EVEREST_RPC_ERROR, Eip8130Nonce}; mod transaction; pub use transaction::{ diff --git a/crates/consensus/protocol/src/batch/single.rs b/crates/consensus/protocol/src/batch/single.rs index b58843bfef..b191aeb668 100644 --- a/crates/consensus/protocol/src/batch/single.rs +++ b/crates/consensus/protocol/src/batch/single.rs @@ -190,11 +190,11 @@ impl SingleBatch { { return BatchValidity::Drop(BatchDropReason::Eip7702PreIsthmus); } - // If Zenith is not active yet and the transaction is an 8130, drop the batch. - if !cfg.is_zenith_active(self.timestamp) + // If Everest is not active yet and the transaction is an 8130, drop the batch. + if !cfg.is_everest_active(self.timestamp) && tx.as_ref().first() == Some(&(OpTxType::Eip8130 as u8)) { - return BatchValidity::Drop(BatchDropReason::Eip8130PreZenith); + return BatchValidity::Drop(BatchDropReason::Eip8130PreEverest); } } @@ -606,14 +606,14 @@ mod tests { } /// Minimal batch tx bytes whose leading 2718 type byte marks an EIP-8130 - /// transaction. Batch validation keys the Zenith gate on this type byte, so + /// transaction. Batch validation keys the Everest gate on this type byte, so /// a fully formed envelope is unnecessary here. fn eip_8130_tx_bytes() -> Bytes { Bytes::from(vec![OpTxType::Eip8130 as u8, 0x00]) } #[test] - fn test_check_batch_drop_8130_pre_zenith() { + fn test_check_batch_drop_8130_pre_everest() { let mut transactions = example_transactions(); transactions.push(eip_8130_tx_bytes()); @@ -625,7 +625,7 @@ mod tests { transactions, }; - // Cobalt is active, but Zenith is not active yet. + // Cobalt is active, but Everest is not active yet. let cfg = RollupConfig { max_sequencer_drift: 1, block_time: 1, @@ -643,12 +643,12 @@ mod tests { let inclusion_block = BlockInfo::default(); assert_eq!( single_batch.check_batch(&cfg, &l1_blocks, l2_safe_head, &inclusion_block), - BatchValidity::Drop(BatchDropReason::Eip8130PreZenith) + BatchValidity::Drop(BatchDropReason::Eip8130PreEverest) ); } #[test] - fn test_check_batch_accept_8130_post_zenith() { + fn test_check_batch_accept_8130_post_everest() { let mut transactions = example_transactions(); transactions.push(eip_8130_tx_bytes()); @@ -660,7 +660,7 @@ mod tests { transactions, }; - // Notice: Zenith is active. + // Notice: Everest is active. let cfg = RollupConfig { max_sequencer_drift: 1, block_time: 1, @@ -668,8 +668,7 @@ mod tests { base: BaseUpgradeConfig { cobalt: Some(0), denim: Some(0), - everest: None, - zenith: Some(0), + everest: Some(0), ..Default::default() }, ..Default::default() diff --git a/crates/consensus/protocol/src/batch/span.rs b/crates/consensus/protocol/src/batch/span.rs index 7e85a9df4f..327c5dc946 100644 --- a/crates/consensus/protocol/src/batch/span.rs +++ b/crates/consensus/protocol/src/batch/span.rs @@ -498,12 +498,12 @@ impl SpanBatch { return BatchValidity::Drop(BatchDropReason::Eip7702PreIsthmus); } - // If Zenith is not active yet and the transaction is an 8130, drop the batch. - if !cfg.is_zenith_active(batch.timestamp) + // If Everest is not active yet and the transaction is an 8130, drop the batch. + if !cfg.is_everest_active(batch.timestamp) && tx.as_ref().first() == Some(&(OpTxType::Eip8130 as u8)) { - warn!(target: "batch_span", tx_index = i, "EIP-8130 transactions are not supported pre-Zenith"); - return BatchValidity::Drop(BatchDropReason::Eip8130PreZenith); + warn!(target: "batch_span", tx_index = i, "EIP-8130 transactions are not supported pre-Everest"); + return BatchValidity::Drop(BatchDropReason::Eip8130PreEverest); } } } @@ -2228,27 +2228,27 @@ mod tests { }; assert_eq!( batch.check_batch(&cfg, &l1_blocks, l2_safe_head, &inclusion_block, &mut fetcher).await, - BatchValidity::Drop(BatchDropReason::Eip8130PreZenith) + BatchValidity::Drop(BatchDropReason::Eip8130PreEverest) ); let logs = trace_store.get_by_level(Level::WARN); assert_eq!(logs.len(), 1); assert!( - logs[0].contains("EIP-8130 transactions are not supported pre-Zenith") + logs[0].contains("EIP-8130 transactions are not supported pre-Everest") && logs[0].contains("tx_index") && logs[0].contains('0') ); } #[tokio::test] - async fn test_check_batch_rejects_eip8130_span_post_zenith() { - // Zenith enables EIP-8130 after Denim has disabled Span batches, so + async fn test_check_batch_rejects_eip8130_span_post_everest() { + // Everest enables EIP-8130 after Denim has disabled Span batches, so // EIP-8130 transactions must use singular batches. let cfg = RollupConfig { seq_window_size: 100, max_sequencer_drift: 100, upgrades: UpgradeConfig { delta_time: Some(0), - base: BaseUpgradeConfig { denim: Some(0), zenith: Some(0), ..Default::default() }, + base: BaseUpgradeConfig { denim: Some(0), everest: Some(0), ..Default::default() }, ..Default::default() }, block_time: 10, diff --git a/crates/consensus/protocol/src/batch/validity.rs b/crates/consensus/protocol/src/batch/validity.rs index f776d39ef5..6c8e6eb9fd 100644 --- a/crates/consensus/protocol/src/batch/validity.rs +++ b/crates/consensus/protocol/src/batch/validity.rs @@ -42,8 +42,8 @@ pub enum BatchDropReason { DepositTransaction, /// EIP-7702 transaction included before Isthmus activation. Eip7702PreIsthmus, - /// EIP-8130 transaction included before Zenith activation. - Eip8130PreZenith, + /// EIP-8130 transaction included before Everest activation. + Eip8130PreEverest, /// Non-empty batch in Jovian transition block. NonEmptyTransitionBlock, @@ -97,7 +97,7 @@ impl core::fmt::Display for BatchDropReason { Self::EmptyTransaction => write!(f, "batch contains empty transaction"), Self::DepositTransaction => write!(f, "batch contains deposit transaction"), Self::Eip7702PreIsthmus => write!(f, "EIP-7702 transaction before Isthmus activation"), - Self::Eip8130PreZenith => write!(f, "EIP-8130 transaction before Zenith activation"), + Self::Eip8130PreEverest => write!(f, "EIP-8130 transaction before Everest activation"), Self::NonEmptyTransitionBlock => write!(f, "non-empty batch in transition block"), Self::SpanBatchPreDelta => write!(f, "span batch received before Delta upgrade"), Self::SpanBatchPostDenim => write!(f, "span batch received after Denim activation"), diff --git a/crates/execution/eip8130-rpc-node/tests/receipt.rs b/crates/execution/eip8130-rpc-node/tests/receipt.rs index 6b815945bf..783eeec5fe 100644 --- a/crates/execution/eip8130-rpc-node/tests/receipt.rs +++ b/crates/execution/eip8130-rpc-node/tests/receipt.rs @@ -15,7 +15,7 @@ use base_execution_chainspec::BaseChainSpec; use base_execution_eip8130_rpc_node::{Eip8130RpcExtension, Eip8130RpcMode}; use base_node_runner::test_utils::{L1_BLOCK_INFO_DEPOSIT_TX, TestHarness}; use base_protocol::BaseTimeUpdateTx; -use base_test_utils::{Account, DEVNET_CHAIN_ID, build_test_genesis_zenith}; +use base_test_utils::{Account, DEVNET_CHAIN_ID, build_test_genesis_everest}; /// EIP-8130 transaction type byte. const EIP8130_TX_TYPE: u8 = 0x79; @@ -32,7 +32,7 @@ fn base_time_deposit() -> Bytes { /// successful type `0x79` receipt. #[tokio::test] async fn eip8130_transaction_is_mined_and_has_a_receipt() -> eyre::Result<()> { - let chain_spec = Arc::new(BaseChainSpec::from_genesis(build_test_genesis_zenith())); + let chain_spec = Arc::new(BaseChainSpec::from_genesis(build_test_genesis_everest())); let harness = TestHarness::builder() .with_chain_spec(chain_spec) .with_ext::(Eip8130RpcMode::Register) @@ -108,7 +108,7 @@ async fn eip8130_transaction_is_mined_and_has_a_receipt() -> eyre::Result<()> { /// its receipt reports `phaseStatuses == [0x01]`. #[tokio::test] async fn eip8130_receipt_reports_phase_statuses() -> eyre::Result<()> { - let chain_spec = Arc::new(BaseChainSpec::from_genesis(build_test_genesis_zenith())); + let chain_spec = Arc::new(BaseChainSpec::from_genesis(build_test_genesis_everest())); let harness = TestHarness::builder() .with_chain_spec(chain_spec) .with_ext::(Eip8130RpcMode::Register) @@ -173,7 +173,7 @@ async fn eip8130_receipt_reports_phase_statuses() -> eyre::Result<()> { /// locking the `tx.payer.unwrap_or(sender)` precedence at RPC. #[tokio::test] async fn eip8130_sponsored_receipt_reports_declared_payer() -> eyre::Result<()> { - let chain_spec = Arc::new(BaseChainSpec::from_genesis(build_test_genesis_zenith())); + let chain_spec = Arc::new(BaseChainSpec::from_genesis(build_test_genesis_everest())); let harness = TestHarness::builder() .with_chain_spec(chain_spec) .with_ext::(Eip8130RpcMode::Register) @@ -247,7 +247,7 @@ async fn two_eip8130_transactions_in_one_block_attribute_phase_statuses() -> eyr // `PUSH1 0x00, PUSH1 0x00, REVERT` — a contract that always reverts with // empty data, seeded into genesis so a phase can be made to revert. let revert_addr = address!("0x00000000000000000000000000000000000000fd"); - let mut genesis = build_test_genesis_zenith(); + let mut genesis = build_test_genesis_everest(); genesis.alloc.insert( revert_addr, GenesisAccount { code: Some(bytes!("60006000fd")), ..Default::default() }, diff --git a/crates/execution/eip8130-rpc-node/tests/rpc.rs b/crates/execution/eip8130-rpc-node/tests/rpc.rs index 413f3b98b4..58f9c3bb43 100644 --- a/crates/execution/eip8130-rpc-node/tests/rpc.rs +++ b/crates/execution/eip8130-rpc-node/tests/rpc.rs @@ -5,7 +5,7 @@ //! (protocol-nonce delegation for `nonce_key == 0`, `INVALID_PARAMS` for the //! `NONCE_KEY_MAX` sentinel, and a real 2D-channel read) and the EIP-8130 //! `eth_estimateGas` path, by exercising the full RPC stack against a test -//! harness. Both the channel read and the estimate are gated on the Zenith fork. +//! harness. Both the channel read and the estimate are gated on the Everest fork. use std::{collections::BTreeMap, sync::Arc}; @@ -17,7 +17,7 @@ use base_common_precompiles::NonceManagerStorage; use base_execution_chainspec::BaseChainSpec; use base_execution_eip8130_rpc_node::{Eip8130RpcExtension, Eip8130RpcMode}; use base_node_runner::test_utils::TestHarness; -use base_test_utils::{Account, build_test_genesis_cobalt, build_test_genesis_zenith}; +use base_test_utils::{Account, build_test_genesis_cobalt, build_test_genesis_everest}; use serde_json::json; /// Launches a harness with the standalone EIP-8130 override registered over the @@ -33,9 +33,9 @@ async fn setup_with(genesis: Genesis) -> eyre::Result<(TestHarness, RpcClient)> Ok((harness, client)) } -/// Zenith-activated harness (the common case for EIP-8130 RPC reads). +/// Everest-activated harness (the common case for EIP-8130 RPC reads). async fn setup() -> eyre::Result<(TestHarness, RpcClient)> { - setup_with(build_test_genesis_zenith()).await + setup_with(build_test_genesis_everest()).await } /// A hex (`0x`) authentication blob for an `eth_estimateGas` request: a 20-byte @@ -92,7 +92,7 @@ async fn nonce_key_reads_seeded_channel_value() -> eyre::Result<()> { // Seed `nonces[alice][7] = 42` into the Nonce Manager precompile's storage. let slot = NonceManagerStorage::nonce_slot(alice, nonce_key).expect("non-protocol nonce key"); - let mut genesis = build_test_genesis_zenith(); + let mut genesis = build_test_genesis_everest(); genesis.alloc.insert( NonceManagerStorage::ADDRESS, GenesisAccount { @@ -114,18 +114,18 @@ async fn nonce_key_reads_seeded_channel_value() -> eyre::Result<()> { Ok(()) } -/// A non-zero `nonce_key` read before the Zenith fork must be rejected: EIP-8130 -/// RPC features are gated on Zenith, mirroring the txpool's pre-activation +/// A non-zero `nonce_key` read before the Everest fork must be rejected: EIP-8130 +/// RPC features are gated on Everest, mirroring the txpool's pre-activation /// rejection of EIP-8130 transactions. #[tokio::test] -async fn nonce_key_pre_zenith_is_rejected() -> eyre::Result<()> { +async fn nonce_key_pre_everest_is_rejected() -> eyre::Result<()> { let (_harness, client) = setup_with(build_test_genesis_cobalt()).await?; let alice: Address = Account::Alice.address(); let result: Result = client.request("eth_getTransactionCount", (alice, "latest", U256::from(7u64))).await; - let err = result.expect_err("pre-Zenith nonce_key read must error"); + let err = result.expect_err("pre-Everest nonce_key read must error"); let err_str = err.to_string(); assert!(err_str.contains("-32602"), "expected INVALID_PARAMS (-32602), got: {err_str}"); Ok(()) @@ -256,7 +256,7 @@ async fn estimate_gas_for_eip8130_request_with_reverting_call_fails() -> eyre::R let alice: Address = Account::Alice.address(); // `PUSH1 0x00, PUSH1 0x00, REVERT` — always reverts with empty data. let revert_addr = address!("0x00000000000000000000000000000000000000fd"); - let mut genesis = build_test_genesis_zenith(); + let mut genesis = build_test_genesis_everest(); genesis.alloc.insert( revert_addr, GenesisAccount { code: Some(bytes!("60006000fd")), ..Default::default() }, @@ -305,17 +305,17 @@ async fn estimate_gas_for_plain_request_delegates() -> eyre::Result<()> { Ok(()) } -/// An EIP-8130 `eth_estimateGas` request before the Zenith fork must be +/// An EIP-8130 `eth_estimateGas` request before the Everest fork must be /// rejected, matching the `nonce_key` read gate. #[tokio::test] -async fn estimate_gas_for_eip8130_request_pre_zenith_is_rejected() -> eyre::Result<()> { +async fn estimate_gas_for_eip8130_request_pre_everest_is_rejected() -> eyre::Result<()> { let (_harness, client) = setup_with(build_test_genesis_cobalt()).await?; let alice: Address = Account::Alice.address(); let request = json!({ "from": alice, "calls": [] }); let result: Result = client.request("eth_estimateGas", (request, "latest")).await; - let err = result.expect_err("pre-Zenith EIP-8130 estimate must error"); + let err = result.expect_err("pre-Everest EIP-8130 estimate must error"); let err_str = err.to_string(); assert!(err_str.contains("-32602"), "expected INVALID_PARAMS (-32602), got: {err_str}"); Ok(()) diff --git a/crates/execution/eip8130-rpc/README.md b/crates/execution/eip8130-rpc/README.md index c7b0369ef5..950c440e7e 100644 --- a/crates/execution/eip8130-rpc/README.md +++ b/crates/execution/eip8130-rpc/README.md @@ -48,5 +48,5 @@ authentication), one simulation yields the exact estimate; no gas-limit binary search is needed. Plain (non-8130) requests fall through to the standard reth estimator unchanged. -Both helpers are fork-agnostic: callers gate on the Zenith hard fork via -[`Eip8130ZenithGate`] before invoking them. +Both helpers are fork-agnostic: callers gate on the Everest hard fork via +[`Eip8130EverestGate`] before invoking them. diff --git a/crates/execution/eip8130-rpc/src/estimate.rs b/crates/execution/eip8130-rpc/src/estimate.rs index 4990c2cb0b..010a6572c5 100644 --- a/crates/execution/eip8130-rpc/src/estimate.rs +++ b/crates/execution/eip8130-rpc/src/estimate.rs @@ -33,8 +33,8 @@ use revm::context::{Block, BlockEnv, TxEnv, result::ExecutionResult}; /// the whole gas limit from scratch). The simulation is built from an unsigned /// request with a stub authentication blob and never commits state. /// -/// **Fork-agnostic on purpose.** This does not check Zenith activation; callers -/// must gate via [`crate::Eip8130ZenithGate`] before invoking it. +/// **Fork-agnostic on purpose.** This does not check Everest activation; callers +/// must gate via [`crate::Eip8130EverestGate`] before invoking it. /// /// **Revert semantics match standard `eth_estimateGas`.** If a phased call /// reverts (or the simulation halts), this returns an execution error carrying diff --git a/crates/execution/eip8130-rpc/src/eth.rs b/crates/execution/eip8130-rpc/src/eth.rs index fc70eeba52..cb225d1756 100644 --- a/crates/execution/eip8130-rpc/src/eth.rs +++ b/crates/execution/eip8130-rpc/src/eth.rs @@ -22,7 +22,7 @@ use reth_storage_api::BlockReaderIdExt; use revm::context::{BlockEnv, TxEnv}; use tracing::debug; -use crate::{ChannelNonceReader, Eip8130GasEstimator, Eip8130ZenithGate}; +use crate::{ChannelNonceReader, Eip8130EverestGate, Eip8130GasEstimator}; /// Eth API override trait that adds EIP-8130 `nonce_key` support to /// `eth_getTransactionCount`. @@ -56,7 +56,7 @@ pub trait Eip8130EthApiOverride { /// A request carrying EIP-8130 fields (account changes, calls, `nonce_key`, /// `valid_after`/`valid_before`, or metadata) is estimated via a single /// read-only EIP-8130 - /// simulation against the block state (gated on the Zenith fork). The + /// simulation against the block state (gated on the Everest fork). The /// EIP-8130 pipeline charges deterministic, signature-independent gas, so no /// gas-limit binary search is needed. A plain request falls through to the /// standard reth estimator unchanged. @@ -110,14 +110,14 @@ where // EIP-8130 channel read. Only `nonce_key != 0` uses the precompile // path; `Some(0)` is the protocol nonce by EIP-8130's reservation - // and falls through to the standard resolution. The Zenith gate + // and falls through to the standard resolution. The Everest gate // lives here (not above) so the default hot path — absent // `nonce_key` and `Some(0)` — is not slowed down by a sync header // resolution. if let Some(key) = nonce_key && key != U256::ZERO { - Eip8130ZenithGate::check(&self.eth_api, block_id)?; + Eip8130EverestGate::check(&self.eth_api, block_id)?; return ChannelNonceReader::read(&self.eth_api, address, key, block_id, None).await; } @@ -152,7 +152,7 @@ where debug!(message = "rpc::eip8130::estimate_gas", block_id = ?block_id); - Eip8130ZenithGate::check(&self.eth_api, block_id)?; + Eip8130EverestGate::check(&self.eth_api, block_id)?; // This standalone override only receives state overrides (the // `eth_estimateGas` RPC signature carries no block overrides); the // estimator still accepts the full `EvmOverrides` so the flashblocks diff --git a/crates/execution/eip8130-rpc/src/zenith_gate.rs b/crates/execution/eip8130-rpc/src/everest_gate.rs similarity index 79% rename from crates/execution/eip8130-rpc/src/zenith_gate.rs rename to crates/execution/eip8130-rpc/src/everest_gate.rs index 6798caab91..d9c83d3c2d 100644 --- a/crates/execution/eip8130-rpc/src/zenith_gate.rs +++ b/crates/execution/eip8130-rpc/src/everest_gate.rs @@ -1,9 +1,9 @@ -//! Zenith fork-activation gate for EIP-8130 RPC reads. +//! Everest fork-activation gate for EIP-8130 RPC reads. use alloy_consensus::BlockHeader; use alloy_eips::{BlockId, BlockNumberOrTag}; use base_common_chains::Upgrades; -use base_common_rpc_types::{BaseRpcTypes, EIP8130_PRE_ZENITH_RPC_ERROR}; +use base_common_rpc_types::{BaseRpcTypes, EIP8130_PRE_EVEREST_RPC_ERROR}; use jsonrpsee_types::{ ErrorObjectOwned, error::{INTERNAL_ERROR_CODE, INVALID_PARAMS_CODE}, @@ -13,10 +13,10 @@ use reth_rpc_eth_api::{RpcNodeCore, helpers::FullEthApi}; use reth_storage_api::BlockReaderIdExt; use tracing::warn; -/// Rejects EIP-8130 RPC reads issued before the Zenith hard fork has +/// Rejects EIP-8130 RPC reads issued before the Everest hard fork has /// activated at the requested block. /// -/// Mirrors the txpool's Zenith gate (which rejects EIP-8130 transactions +/// Mirrors the txpool's Everest gate (which rejects EIP-8130 transactions /// with `TxTypeNotSupported` pre-activation) on the read side. Callers /// invoke this only on paths that actually consult the precompile (i.e. /// `nonce_key != 0`); requests with no `nonce_key` or `Some(0)` are @@ -24,12 +24,12 @@ use tracing::warn; /// the gate to keep the hot path free of a sync header resolution. /// /// The block-of-query timestamp drives the gate, so historical queries -/// against pre-Zenith blocks are gated even when Zenith is currently active. +/// against pre-Everest blocks are gated even when Everest is currently active. #[derive(Debug)] -pub struct Eip8130ZenithGate; +pub struct Eip8130EverestGate; -impl Eip8130ZenithGate { - /// Errors with `INVALID_PARAMS` if Zenith is not active at `block_id`'s +impl Eip8130EverestGate { + /// Errors with `INVALID_PARAMS` if Everest is not active at `block_id`'s /// timestamp. Resolves `Pending` and `Latest` block ids to the head /// block's timestamp. pub fn check(eth_api: &Eth, block_id: BlockId) -> Result<(), ErrorObjectOwned> @@ -40,10 +40,10 @@ impl Eip8130ZenithGate { { let provider = eth_api.provider(); let timestamp = Self::resolve_timestamp(provider, block_id)?; - if !provider.chain_spec().is_zenith_active_at_timestamp(timestamp) { + if !provider.chain_spec().is_everest_active_at_timestamp(timestamp) { return Err(ErrorObjectOwned::owned( INVALID_PARAMS_CODE, - EIP8130_PRE_ZENITH_RPC_ERROR, + EIP8130_PRE_EVEREST_RPC_ERROR, None::<()>, )); } @@ -68,7 +68,7 @@ impl Eip8130ZenithGate { warn!( error = %err, block_id = ?block_id, - "Zenith gate: failed to resolve block header" + "Everest gate: failed to resolve block header" ); ErrorObjectOwned::owned(INTERNAL_ERROR_CODE, "failed to resolve block", None::<()>) })? diff --git a/crates/execution/eip8130-rpc/src/lib.rs b/crates/execution/eip8130-rpc/src/lib.rs index 88c4ce94d9..cfa2da3a16 100644 --- a/crates/execution/eip8130-rpc/src/lib.rs +++ b/crates/execution/eip8130-rpc/src/lib.rs @@ -3,8 +3,8 @@ mod nonce_reader; pub use nonce_reader::ChannelNonceReader; -mod zenith_gate; -pub use zenith_gate::Eip8130ZenithGate; +mod everest_gate; +pub use everest_gate::Eip8130EverestGate; mod estimate; pub use estimate::Eip8130GasEstimator; diff --git a/crates/execution/eip8130-rpc/src/nonce_reader.rs b/crates/execution/eip8130-rpc/src/nonce_reader.rs index 97d47c7f31..df1877ab22 100644 --- a/crates/execution/eip8130-rpc/src/nonce_reader.rs +++ b/crates/execution/eip8130-rpc/src/nonce_reader.rs @@ -18,8 +18,8 @@ use reth_rpc_eth_types::EthApiError; /// (protocol nonce, expiring-nonce sentinel, real 2D channel). /// /// **Fork-agnostic on purpose.** This helper does not check that the -/// Zenith fork has activated. Callers must enforce that themselves -/// (typically via [`crate::Eip8130ZenithGate`]) before invoking +/// Everest fork has activated. Callers must enforce that themselves +/// (typically via [`crate::Eip8130EverestGate`]) before invoking /// [`Self::read`]. #[derive(Debug)] pub struct ChannelNonceReader; diff --git a/crates/execution/flashblocks/src/rpc/eth.rs b/crates/execution/flashblocks/src/rpc/eth.rs index 60da9d9866..caa8acb90e 100644 --- a/crates/execution/flashblocks/src/rpc/eth.rs +++ b/crates/execution/flashblocks/src/rpc/eth.rs @@ -63,7 +63,7 @@ use alloy_rpc_types_eth::Filter; use base_common_evm::BaseTransaction as BaseRevm; use base_common_network::Base; use base_common_rpc_types::{BaseLogResponse, BaseRpcTypes, BaseTransactionRequest}; -use base_execution_eip8130_rpc::{ChannelNonceReader, Eip8130GasEstimator, Eip8130ZenithGate}; +use base_execution_eip8130_rpc::{ChannelNonceReader, Eip8130EverestGate, Eip8130GasEstimator}; use jsonrpsee::{ core::{RpcResult, async_trait}, proc_macros::rpc, @@ -296,7 +296,7 @@ where if let Some(key) = nonce_key && key != U256::ZERO { - Eip8130ZenithGate::check(&self.eth_api, block_id)?; + Eip8130EverestGate::check(&self.eth_api, block_id)?; Metrics::rpc_get_transaction_count().increment(1); let (resolved_block, overrides) = if block_id.is_pending() { let pending_blocks = self.flashblocks_state.get_pending_blocks(); @@ -494,11 +494,11 @@ where let final_overrides = state_overrides_builder.build(); // EIP-8130 request: estimate via a single read-only simulation against - // the (pending-merged) block state, gated on the Zenith fork. The + // the (pending-merged) block state, gated on the Everest fork. The // deterministic, signature-independent EIP-8130 gas charge means no // gas-limit binary search is needed. if transaction.as_eip8130().is_some() { - Eip8130ZenithGate::check(&self.eth_api, block_id)?; + Eip8130EverestGate::check(&self.eth_api, block_id)?; return Eip8130GasEstimator::estimate( &self.eth_api, transaction, diff --git a/crates/execution/flashblocks/src/state_builder.rs b/crates/execution/flashblocks/src/state_builder.rs index 15fefc04d9..d3fd913f87 100644 --- a/crates/execution/flashblocks/src/state_builder.rs +++ b/crates/execution/flashblocks/src/state_builder.rs @@ -194,7 +194,7 @@ where ensure_create2_deployer(spec, self.pending_block.timestamp, self.evm.db_mut()) .map_err(|e| ExecutionError::EvmEnv(e.to_string()))?; - // At the Zenith (EIP-8130) transition, plant a code stub on the code-less + // At the Everest (EIP-8130) transition, plant a code stub on the code-less // enshrined system accounts (the 2D nonce manager) so the persistent state // the enshrined path writes to them is not reaped by EIP-161 end-of-block // state clearing. diff --git a/crates/execution/rpc/src/error.rs b/crates/execution/rpc/src/error.rs index d482989c2e..4dd357a435 100644 --- a/crates/execution/rpc/src/error.rs +++ b/crates/execution/rpc/src/error.rs @@ -76,11 +76,11 @@ pub enum BaseInvalidTransactionError { #[error("missing enveloped transaction bytes")] MissingEnvelopedTx, /// An EIP-8130 (account-abstraction) transaction was submitted via - /// `eth_sendRawTransaction` before the Zenith fork was active. + /// `eth_sendRawTransaction` before the Everest fork was active. /// /// The transaction type byte (`0x79`) is recognised by the consensus layer for /// decoding/serialization purposes, but RPC admission is rejected until the - /// Zenith fork is active. The txpool validator enforces the same fork gate for + /// Everest fork is active. The txpool validator enforces the same fork gate for /// transactions arriving over devp2p. #[error("{}", base_common_consensus::EIP8130_REJECTION_MSG)] Eip8130NotAccepted, diff --git a/crates/execution/rpc/src/eth/transaction.rs b/crates/execution/rpc/src/eth/transaction.rs index c9ad9464e6..ad1948b8e4 100644 --- a/crates/execution/rpc/src/eth/transaction.rs +++ b/crates/execution/rpc/src/eth/transaction.rs @@ -65,7 +65,7 @@ where let (tx, pool_transaction) = tx.split(); if pool_transaction.consensus_ref().ty() == EIP8130_TX_TYPE_ID - && !self.is_zenith_active_at_latest()? + && !self.is_everest_active_at_latest()? { return Err(BaseInvalidTransactionError::Eip8130NotAccepted.into()); } @@ -242,11 +242,11 @@ where N::Provider: BlockReaderIdExt + ChainSpecProvider, Rpc: RpcConvert, { - fn is_zenith_active_at_latest(&self) -> Result { + fn is_everest_active_at_latest(&self) -> Result { let Some(header) = self.provider().latest_header()? else { return Ok(false); }; - Ok(self.provider().chain_spec().is_zenith_active_at_timestamp(header.timestamp())) + Ok(self.provider().chain_spec().is_everest_active_at_timestamp(header.timestamp())) } } diff --git a/crates/execution/txpool-rpc/src/lib.rs b/crates/execution/txpool-rpc/src/lib.rs index fb14ff0112..bfe109bf33 100644 --- a/crates/execution/txpool-rpc/src/lib.rs +++ b/crates/execution/txpool-rpc/src/lib.rs @@ -12,7 +12,7 @@ pub use rpc::{ AdminTxPoolApiImpl, AdminTxPoolApiServer, SendRawTransactionValidityApiImpl, SendRawTransactionValidityApiServer, SendRawTransactionValidityOptions, Status, TransactionStatusApiImpl, TransactionStatusApiServer, TransactionStatusResponse, - VALIDITY_TX_PRE_COBALT_RPC_ERROR, VALIDITY_TX_PRE_ZENITH_RPC_ERROR, + VALIDITY_TX_PRE_COBALT_RPC_ERROR, VALIDITY_TX_PRE_EVEREST_RPC_ERROR, }; mod extension; diff --git a/crates/execution/txpool-rpc/src/rpc.rs b/crates/execution/txpool-rpc/src/rpc.rs index 9c8d0cfde2..8997a758d7 100644 --- a/crates/execution/txpool-rpc/src/rpc.rs +++ b/crates/execution/txpool-rpc/src/rpc.rs @@ -32,12 +32,12 @@ pub const VALIDITY_TX_PRE_COBALT_RPC_ERROR: &str = "validity transactions are gated behind the Cobalt hard fork"; /// Rejection message returned when an EIP-8130 (account abstraction) validity transaction is -/// submitted before the Zenith hard fork is active at the latest block. +/// submitted before the Everest hard fork is active at the latest block. /// -/// EIP-8130 validity transactions are fork-gated on Zenith; other transaction types (e.g. EIP-1559) +/// EIP-8130 validity transactions are fork-gated on Everest; other transaction types (e.g. EIP-1559) /// carry validity predicates under the experimental flag alone. -pub const VALIDITY_TX_PRE_ZENITH_RPC_ERROR: &str = "EIP-8130 validity transactions are gated behind \ - the Zenith hard fork; they are not accepted before Zenith is active"; +pub const VALIDITY_TX_PRE_EVEREST_RPC_ERROR: &str = "EIP-8130 validity transactions are gated behind \ + the Everest hard fork; they are not accepted before Everest is active"; /// Legacy full-block cadence used before Denim activates. const LEGACY_BLOCK_INTERVAL_MILLIS: u64 = 2_000; @@ -348,16 +348,16 @@ where ) })?; - // EIP-8130 (account abstraction) validity transactions are fork-gated on Zenith. Other + // EIP-8130 (account abstraction) validity transactions are fork-gated on Everest. Other // transaction types (e.g. EIP-1559) carry validity predicates under the experimental flag - // alone and are accepted before Zenith activates. - let zenith_active = latest.is_some_and(|(_, timestamp)| { - self.provider.chain_spec().is_zenith_active_at_timestamp(timestamp) + // alone and are accepted before Everest activates. + let everest_active = latest.is_some_and(|(_, timestamp)| { + self.provider.chain_spec().is_everest_active_at_timestamp(timestamp) }); - if transaction.ty() == EIP8130_TX_TYPE_ID && !zenith_active { + if transaction.ty() == EIP8130_TX_TYPE_ID && !everest_active { return Err(ErrorObjectOwned::owned( ErrorCode::InvalidParams.code(), - VALIDITY_TX_PRE_ZENITH_RPC_ERROR, + VALIDITY_TX_PRE_EVEREST_RPC_ERROR, None::<()>, )); } @@ -439,7 +439,7 @@ mod tests { TransactionEventBuilder, TransactionEventCapture, TransactionEventProducer, TransactionEventType, }; - use base_test_utils::{build_test_genesis, build_test_genesis_zenith}; + use base_test_utils::{build_test_genesis, build_test_genesis_everest}; use httpmock::prelude::*; use reth_chainspec::ForkCondition; use reth_provider::test_utils::MockEthProvider; @@ -452,17 +452,17 @@ mod tests { use super::*; - /// Provider whose latest header sits after Zenith activation, so the fork gate is open. - fn zenith_provider() -> MockEthProvider> { - let mut genesis = build_test_genesis_zenith(); + /// Provider whose latest header sits after Everest activation, so the fork gate is open. + fn everest_provider() -> MockEthProvider> { + let mut genesis = build_test_genesis_everest(); genesis.config.chain_id = ChainConfig::mainnet().chain_id; MockEthProvider::::new() .with_chain_spec(Arc::new(BaseChainSpec::from_genesis(genesis))) .with_genesis_block() } - /// Provider whose latest header predates Zenith activation, so the fork gate is closed. - fn pre_zenith_provider() -> MockEthProvider> { + /// Provider whose latest header predates Everest activation, so the fork gate is closed. + fn pre_everest_provider() -> MockEthProvider> { MockEthProvider::::new() .with_chain_spec(Arc::new( BaseChainSpecBuilder::base_mainnet().cobalt_activated().build(), @@ -538,7 +538,7 @@ mod tests { #[test] fn max_validity_expiry_blocks_uses_the_active_full_block_cadence() { let legacy = SendRawTransactionValidityApiImpl::new( - pre_zenith_provider(), + pre_everest_provider(), test_transaction_sender(), ); assert_eq!(legacy.max_validity_expiry_blocks(0), 30); @@ -720,7 +720,7 @@ mod tests { let capture = TransactionEventCapture::install(); let signer = PrivateKeySigner::random(); let raw = signed_eip1559(&signer, 0, 1); - let rpc = validity_rpc(zenith_provider()); + let rpc = validity_rpc(everest_provider()); let options = SendRawTransactionValidityOptions { validity: all_predicate_variants() }; let tx_hash = rpc.send_raw_transaction_validity(raw, options).await.unwrap_or_else(|_| { @@ -839,7 +839,7 @@ mod tests { ) .unwrap(); let rpc = SendRawTransactionValidityApiImpl::new( - pre_zenith_provider(), + pre_everest_provider(), test_transaction_sender(), ) .with_sequencer_client(client); @@ -866,7 +866,7 @@ mod tests { }); let client = SequencerClient::new_http_with_headers(sequencer.base_url(), vec![]).unwrap(); let rpc = SendRawTransactionValidityApiImpl::new( - pre_zenith_provider(), + pre_everest_provider(), test_transaction_sender(), ) .with_sequencer_client(client); @@ -879,18 +879,18 @@ mod tests { #[test] fn send_raw_transaction_validity_method_is_registered() { let rpc = - SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); + SendRawTransactionValidityApiImpl::new(everest_provider(), test_transaction_sender()); let module = SendRawTransactionValidityApiServer::into_rpc(rpc); assert!(module.method_names().any(|name| name == "base_sendRawTransactionValidity")); } #[tokio::test] - async fn send_raw_transaction_validity_rejects_eip8130_before_zenith() { + async fn send_raw_transaction_validity_rejects_eip8130_before_everest() { let signer = PrivateKeySigner::random(); let raw = signed_eip8130(&signer); let rpc = SendRawTransactionValidityApiImpl::new( - pre_zenith_provider(), + pre_everest_provider(), test_transaction_sender(), ); let options = SendRawTransactionValidityOptions { validity: all_predicate_variants() }; @@ -898,21 +898,21 @@ mod tests { let error = rpc .send_raw_transaction_validity(raw, options) .await - .expect_err("EIP-8130 validity transactions should be rejected before Zenith"); + .expect_err("EIP-8130 validity transactions should be rejected before Everest"); assert_eq!(error.code(), ErrorCode::InvalidParams.code()); - assert_eq!(error.message(), VALIDITY_TX_PRE_ZENITH_RPC_ERROR); + assert_eq!(error.message(), VALIDITY_TX_PRE_EVEREST_RPC_ERROR); } #[tokio::test] - async fn send_raw_transaction_validity_accepts_eip1559_before_zenith() { - // EIP-1559 validity transactions are gated by the experimental flag alone, not by Zenith, - // so they clear the fork gate before Zenith activates. The admission event fires only once + async fn send_raw_transaction_validity_accepts_eip1559_before_everest() { + // EIP-1559 validity transactions are gated by the experimental flag alone, not by Everest, + // so they clear the fork gate before Everest activates. The admission event fires only once // the gate is cleared; the noop pool then rejects insertion. let capture = TransactionEventCapture::install(); let signer = PrivateKeySigner::random(); let raw = signed_eip1559(&signer, 0, 1); - let rpc = validity_rpc(pre_zenith_provider()); + let rpc = validity_rpc(pre_everest_provider()); let options = SendRawTransactionValidityOptions { validity: all_predicate_variants() }; let error = rpc @@ -920,21 +920,21 @@ mod tests { .await .expect_err("the noop pool rejects insertion after the fork gate is cleared"); - assert_ne!(error.message(), VALIDITY_TX_PRE_ZENITH_RPC_ERROR); + assert_ne!(error.message(), VALIDITY_TX_PRE_EVEREST_RPC_ERROR); assert!( capture .events() .iter() .any(|event| event.event_type == TransactionEventType::TxpoolSendRawTransactionValidity), - "admission event should fire once the Zenith gate is cleared for EIP-1559" + "admission event should fire once the Everest gate is cleared for EIP-1559" ); } #[tokio::test] async fn send_raw_transaction_validity_rejects_malformed_transaction() { let rpc = - SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); + SendRawTransactionValidityApiImpl::new(everest_provider(), test_transaction_sender()); let (raw, options) = validity_request(Bytes::from_static(&[0xff])); let error = rpc @@ -949,7 +949,7 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_enforces_configured_predicate_limit() { let rpc = SendRawTransactionValidityApiImpl::with_max_validity_predicates( - zenith_provider(), + everest_provider(), 2, test_transaction_sender(), ); @@ -969,7 +969,7 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_rejects_empty_predicates() { let rpc = - SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); + SendRawTransactionValidityApiImpl::new(everest_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity.clear(); @@ -985,7 +985,7 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_requires_block_number_expiry() { let rpc = - SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); + SendRawTransactionValidityApiImpl::new(everest_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity = vec![ValidityPredicate::BlockNumber { op: base_execution_txpool::ValidityOperator::GreaterThanOrEqual, @@ -1005,7 +1005,7 @@ mod tests { async fn send_raw_transaction_validity_rejects_block_number_bound_outside_window() { // Legacy blocks are 2 seconds, so the default 60-second window permits 30 blocks. // With the head at 100, the build target is 101 and the maximum bound is 131. - let provider = pre_zenith_provider(); + let provider = pre_everest_provider(); provider.add_block( B256::repeat_byte(8), BaseBlock { @@ -1032,7 +1032,7 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_rejects_storage_value_outside_mask() { let rpc = - SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); + SendRawTransactionValidityApiImpl::new(everest_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity = vec![ValidityPredicate::Storage { address: Address::repeat_byte(0xab), @@ -1054,7 +1054,7 @@ mod tests { #[tokio::test] async fn send_raw_transaction_validity_rejects_unsatisfiable_flashblock_index() { let rpc = - SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); + SendRawTransactionValidityApiImpl::new(everest_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); // A flashblock-index predicate that only holds at index 0, which pooled // transactions never reach, would park forever if admitted. @@ -1136,7 +1136,7 @@ mod tests { // The genesis block is the latest committed block, so the block being built is 1. // A predicate capping inclusion at block 0 can never be satisfied. let rpc = - SendRawTransactionValidityApiImpl::new(zenith_provider(), test_transaction_sender()); + SendRawTransactionValidityApiImpl::new(everest_provider(), test_transaction_sender()); let (raw, mut options) = validity_request(Bytes::from_static(&[0x02])); options.validity = vec![ValidityPredicate::BlockNumber { op: base_execution_txpool::ValidityOperator::LessThanOrEqual, @@ -1156,7 +1156,7 @@ mod tests { async fn send_raw_transaction_validity_rejects_bound_behind_a_later_head() { // With the head at block 100, the block being built is 101; `block_number < 101` // caps inclusion at block 100, which has already been sealed. - let provider = zenith_provider(); + let provider = everest_provider(); provider.add_block( B256::repeat_byte(7), BaseBlock { @@ -1188,7 +1188,7 @@ mod tests { let capture = TransactionEventCapture::install(); let signer = PrivateKeySigner::random(); let raw = signed_eip1559(&signer, 0, 1); - let provider = zenith_provider(); + let provider = everest_provider(); provider.add_block( B256::repeat_byte(7), BaseBlock { diff --git a/crates/execution/txpool/src/pool.rs b/crates/execution/txpool/src/pool.rs index cdd87919c6..2abfe45d5b 100644 --- a/crates/execution/txpool/src/pool.rs +++ b/crates/execution/txpool/src/pool.rs @@ -1808,7 +1808,7 @@ mod tests { }; use base_execution_chainspec::BaseChainSpec; use base_execution_evm::BaseEvmConfig; - use base_test_utils::build_test_genesis_zenith; + use base_test_utils::build_test_genesis_everest; use futures::{StreamExt, future::join_all}; use reth_primitives_traits::SealedBlock; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; @@ -2035,7 +2035,7 @@ mod tests { fn build_integration_pool() -> (IntegrationPool, MockEthProvider>) { - let mut genesis = build_test_genesis_zenith(); + let mut genesis = build_test_genesis_everest(); genesis.config.chain_id = test_chain_id(); let chain_spec = Arc::new(BaseChainSpec::from_genesis(genesis)); let client = MockEthProvider::::new() diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index a36698dff2..b8fe084491 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -884,7 +884,7 @@ where /// This behaves the same as [`EthTransactionValidator::validate_one_with_state`], but in /// addition applies Base-specific validity checks: /// - ensures tx is not eip4844 - /// - for eip8130 (account abstraction): rejects submissions before the Zenith upgrade is + /// - for eip8130 (account abstraction): rejects submissions before the Everest upgrade is /// active, runs structural checks, then runs EIP-8130-specific stateful validation for /// actor authorization, nonce/replay state, intrinsic gas, create/delegation safety, and /// payer funding instead of using the inner Eth validator @@ -1674,7 +1674,7 @@ where /// Runs the mempool admission checks that apply to EIP-8130 (account /// abstraction) transactions without requiring authenticator dispatch or account - /// state lookups. Enforces the Zenith fork gate and the structural + /// state lookups. Enforces the Everest fork gate and the structural /// invariants listed in EIP-8130 § Validation and § Nonce-Free Mode. fn validate_eip8130_structural( &self, @@ -1694,8 +1694,8 @@ where // the atomic concurrently. let now = self.block_timestamp(); // Fork gate: EIP-8130 (account abstraction) transactions are only - // admissible to the pool once the Zenith upgrade is active. - if !self.chain_spec().is_zenith_active_at_timestamp(now) { + // admissible to the pool once the Everest upgrade is active. + if !self.chain_spec().is_everest_active_at_timestamp(now) { return Err(InvalidTransactionError::TxTypeNotSupported.into()); } let local_chain_id = self.inner.chain_spec().chain().id(); @@ -2170,7 +2170,7 @@ mod tests { use base_execution_chainspec::{BaseChainSpec, BaseChainSpecBuilder}; use base_execution_eip8130::{AccountChangeApplier, ConfigChangeAuthorizer}; use base_execution_evm::BaseEvmConfig; - use base_test_utils::{Account, build_test_genesis_zenith}; + use base_test_utils::{Account, build_test_genesis_everest}; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; use reth_transaction_pool::{ TransactionOrigin, TransactionValidationOutcome, blobstore::InMemoryBlobStore, @@ -2186,8 +2186,8 @@ mod tests { BaseEvmConfig, >; - fn zenith_chain_spec() -> Arc { - let mut genesis = build_test_genesis_zenith(); + fn everest_chain_spec() -> Arc { + let mut genesis = build_test_genesis_everest(); genesis.config.chain_id = test_chain_id(); Arc::new(BaseChainSpec::from_genesis(genesis)) } @@ -2206,16 +2206,16 @@ mod tests { BaseTransactionValidator::with_block_info(inner, BaseL1BlockInfo::default()) } - /// Builds a [`BaseTransactionValidator`] against a Zenith-activated test chain spec with - /// no accounts seeded. EIP-8130 admission is fork-gated on Zenith, so the structural-gate - /// tests run with Zenith active (at genesis) to exercise the checks past the fork gate. + /// Builds a [`BaseTransactionValidator`] against an Everest-activated test chain spec with + /// no accounts seeded. EIP-8130 admission is fork-gated on Everest, so the structural-gate + /// tests run with Everest active (at genesis) to exercise the checks past the fork gate. fn build_test_validator() -> TestValidator { - build_test_validator_with_spec(zenith_chain_spec()) + build_test_validator_with_spec(everest_chain_spec()) } - /// Builds a Zenith-activated validator with a custom encoded transaction-size limit. + /// Builds an Everest-activated validator with a custom encoded transaction-size limit. fn build_test_validator_with_max_tx_input_bytes(max_tx_input_bytes: usize) -> TestValidator { - let chain_spec = zenith_chain_spec(); + let chain_spec = everest_chain_spec(); let client = MockEthProvider::::new() .with_chain_spec(Arc::clone(&chain_spec)) .with_genesis_block(); @@ -2228,12 +2228,12 @@ mod tests { BaseTransactionValidator::with_block_info(inner, BaseL1BlockInfo::default()) } - /// Builds a Zenith-activated validator with one canonical account seeded. + /// Builds an Everest-activated validator with one canonical account seeded. fn build_test_validator_with_account( address: Address, account: ExtendedAccount, ) -> TestValidator { - let chain_spec = zenith_chain_spec(); + let chain_spec = everest_chain_spec(); let client = MockEthProvider::::new() .with_chain_spec(Arc::clone(&chain_spec)) .with_genesis_block(); @@ -2588,7 +2588,7 @@ mod tests { } #[test] - fn rejects_eip8130_before_zenith_activation() { + fn rejects_eip8130_before_everest_activation() { // Cobalt alone does not open the EIP-8130 gate. let chain_spec = BaseChainSpecBuilder::base_mainnet().cobalt_activated().build(); let validator = build_test_validator_with_spec(Arc::new(chain_spec)); @@ -3522,7 +3522,7 @@ mod tests { #[test] fn eip8130_payer_max_cost_includes_l1_and_operator_fees() { let chain_config = ChainConfig::mainnet(); - let chain_spec = zenith_chain_spec(); + let chain_spec = everest_chain_spec(); let signer = PrivateKeySigner::random(); let sender = signer.address(); // Headroom above the worst-case intrinsic: admission pins the sender policy @@ -3582,7 +3582,7 @@ mod tests { #[test] fn nonce_free_manifest_uses_transaction_validity_window() { - let chain_spec = zenith_chain_spec(); + let chain_spec = everest_chain_spec(); let signer = PrivateKeySigner::random(); let now = 100; // `valid_before` is in milliseconds; at the admission-window edge it is diff --git a/crates/utilities/test-utils/src/genesis.rs b/crates/utilities/test-utils/src/genesis.rs index 671c74fe83..435c31d251 100644 --- a/crates/utilities/test-utils/src/genesis.rs +++ b/crates/utilities/test-utils/src/genesis.rs @@ -153,8 +153,8 @@ pub fn build_test_genesis_cobalt() -> Genesis { genesis } -/// Builds a test genesis with Denim, Everest, and Zenith enabled at timestamp 0. -pub fn build_test_genesis_zenith() -> Genesis { +/// Builds a test genesis with Denim and Everest enabled at timestamp 0. +pub fn build_test_genesis_everest() -> Genesis { let mut genesis = build_test_genesis_cobalt(); genesis.config.extra_fields.insert( "base".to_string(), @@ -163,8 +163,7 @@ pub fn build_test_genesis_zenith() -> Genesis { "beryl": 0, "cobalt": 0, "denim": 0, - "everest": 0, - "zenith": 0 + "everest": 0 }), ); genesis diff --git a/crates/utilities/test-utils/src/lib.rs b/crates/utilities/test-utils/src/lib.rs index 1515530869..5ebc6a2d97 100644 --- a/crates/utilities/test-utils/src/lib.rs +++ b/crates/utilities/test-utils/src/lib.rs @@ -13,7 +13,7 @@ pub use accounts::Account; mod genesis; pub use genesis::{ DEVNET_CHAIN_ID, GENESIS_GAS_LIMIT, build_test_genesis, build_test_genesis_azul, - build_test_genesis_cobalt, build_test_genesis_zenith, + build_test_genesis_cobalt, build_test_genesis_everest, }; #[cfg(feature = "contracts")] diff --git a/etc/docker/Dockerfile.devnet b/etc/docker/Dockerfile.devnet index c005f3933c..d70c43d3a0 100644 --- a/etc/docker/Dockerfile.devnet +++ b/etc/docker/Dockerfile.devnet @@ -25,7 +25,7 @@ RUN go build -o /go/bin/eth2-val-tools . # Pin the source revision so setup image builds are reproducible. FROM public.ecr.aws/docker/library/golang:1.26-alpine AS base-devnet-builder RUN apk add --no-cache git ca-certificates -ADD https://github.com/base/optimism.git#0066b17c3fe0cbb5ea935de6d5b18d4fc86dc439 /op-deployer +ADD https://github.com/base/optimism.git#896142b334ea20220aa552e9478e5ec406716911 /op-deployer WORKDIR /op-deployer RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/etc/docker/Justfile b/etc/docker/Justfile index 941d4d58aa..cd163f053d 100644 --- a/etc/docker/Justfile +++ b/etc/docker/Justfile @@ -307,16 +307,22 @@ _up-devnet compose_profile mode: compose_profile={{ quote(compose_profile) }} devnet_mode={{ quote(mode) }} - # Denim is scheduled through devnet-env. Zenith mode additionally schedules the - # genesis-only Zenith gate used for future hardfork feature testing. + # Denim is scheduled through devnet-env. Everest mode additionally schedules Everest + # (EIP-8130). Zenith mode schedules Everest plus the genesis-only Zenith gate used for + # future hardfork feature testing. case "$devnet_mode" in default) ;; + everest) + export L2_BASE_DENIM_BLOCK="${L2_BASE_DENIM_BLOCK-25}" + export L2_BASE_EVEREST_BLOCK="${L2_BASE_EVEREST_BLOCK:-100}" + ;; zenith) export L2_BASE_DENIM_BLOCK="${L2_BASE_DENIM_BLOCK-25}" + export L2_BASE_EVEREST_BLOCK="${L2_BASE_EVEREST_BLOCK:-100}" export L2_BASE_ZENITH_BLOCK="${L2_BASE_ZENITH_BLOCK:-100}" ;; *) - echo "ERROR: mode must be 'default' or 'zenith' (got '${devnet_mode}')" >&2 + echo "ERROR: mode must be 'default', 'everest', or 'zenith' (got '${devnet_mode}')" >&2 exit 1 ;; esac diff --git a/etc/docker/README.md b/etc/docker/README.md index 5c49da0e7d..c2e94897dc 100644 --- a/etc/docker/README.md +++ b/etc/docker/README.md @@ -34,7 +34,7 @@ image cannot bypass the integrated offline generator. Bump the tag in the setup container, smoke test, Justfile, and Compose files when changing this interface. Compose devnets and the system-test stack use `op-deployer`, built from -[`base/optimism` at `0066b17c`](https://github.com/base/optimism/tree/0066b17c3fe0cbb5ea935de6d5b18d4fc86dc439). +[`base/optimism` at `896142b3`](https://github.com/base/optimism/tree/896142b334ea20220aa552e9478e5ec406716911). The binary generates both chains offline in one process, applies Base activation settings, merges L1 contract allocations with the prefunded genesis template, and writes the final execution and rollup configs. Contract artifacts are unpacked @@ -190,8 +190,15 @@ Denim. Native payload building supports balance, storage, and block-number predicates; `flashblock_index` predicates remain specific to the Flashblocks builder and are rejected after the Denim cutover. +Everest enables EIP-8130 account-abstraction transactions. Everest mode additionally +activates Everest at block 100: + +```bash +just devnet up everest +``` + Zenith is the permanently unscheduled, genesis-only gate for future hardfork feature testing. -Zenith mode additionally activates Zenith at block 100: +Zenith mode additionally activates Everest and Zenith at block 100: ```bash just devnet up zenith diff --git a/etc/docker/devnet-env b/etc/docker/devnet-env index 4601e3e4b9..187700cba1 100644 --- a/etc/docker/devnet-env +++ b/etc/docker/devnet-env @@ -234,6 +234,10 @@ L2_BASE_BERYL_BLOCK=21 L2_BASE_COBALT_BLOCK=22 # Base Denim activates at block 25 by default. Override with another non-negative block number. L2_BASE_DENIM_BLOCK=25 +# Optional: set to a non-negative block number to schedule Base Everest (EIP-8130) in devnet. +# `just devnet up everest` and `just devnet up zenith` default this to 100. +# Leave unset to avoid setting base.everest during genesis generation. +# L2_BASE_EVEREST_BLOCK=100 # Optional: set to a non-negative block number to schedule the genesis-only Base Zenith gate # used for future hardfork feature testing. `just devnet up zenith` defaults this to 100. # Leave unset to avoid setting base.zenith during genesis generation. diff --git a/etc/docker/docker-compose.yml b/etc/docker/docker-compose.yml index e0c19e2954..dfccb1e03f 100644 --- a/etc/docker/docker-compose.yml +++ b/etc/docker/docker-compose.yml @@ -54,6 +54,7 @@ services: - L2_BASE_BERYL_BLOCK=${L2_BASE_BERYL_BLOCK-} - L2_BASE_COBALT_BLOCK=${L2_BASE_COBALT_BLOCK-} - L2_BASE_DENIM_BLOCK=${L2_BASE_DENIM_BLOCK-} + - L2_BASE_EVEREST_BLOCK=${L2_BASE_EVEREST_BLOCK-} - L2_BASE_ZENITH_BLOCK=${L2_BASE_ZENITH_BLOCK-} - CHAIN_ID=${L1_CHAIN_ID} - SLOT_DURATION=${L1_SLOT_DURATION_OVERRIDE:-12} diff --git a/etc/systems/src/setup/container.rs b/etc/systems/src/setup/container.rs index 1a599a415f..dc87bb0368 100644 --- a/etc/systems/src/setup/container.rs +++ b/etc/systems/src/setup/container.rs @@ -244,6 +244,7 @@ pub struct SetupContainer { base_beryl_activation_block: Option, base_cobalt_activation_block: Option, base_denim_activation_block: Option, + base_everest_activation_block: Option, base_zenith_activation_block: Option, } @@ -260,6 +261,7 @@ impl SetupContainer { base_beryl_activation_block: None, base_cobalt_activation_block: None, base_denim_activation_block: None, + base_everest_activation_block: None, base_zenith_activation_block: None, } } @@ -312,6 +314,12 @@ impl SetupContainer { self } + /// Sets the L2 block number at which Base Everest activates. + pub const fn with_base_everest_activation_block(mut self, block: u64) -> Self { + self.base_everest_activation_block = Some(block); + self + } + /// Sets the L2 block number at which the genesis-only Base Zenith testing gate activates. pub const fn with_base_zenith_activation_block(mut self, block: u64) -> Self { self.base_zenith_activation_block = Some(block); @@ -370,6 +378,10 @@ impl SetupContainer { container = container.with_env_var("L2_BASE_DENIM_BLOCK", block.to_string()); } + if let Some(block) = self.base_everest_activation_block { + container = container.with_env_var("L2_BASE_EVEREST_BLOCK", block.to_string()); + } + if let Some(block) = self.base_zenith_activation_block { container = container.with_env_var("L2_BASE_ZENITH_BLOCK", block.to_string()); } diff --git a/etc/systems/src/smoke.rs b/etc/systems/src/smoke.rs index fd6380401c..831ff216cf 100644 --- a/etc/systems/src/smoke.rs +++ b/etc/systems/src/smoke.rs @@ -390,6 +390,7 @@ pub struct SystemTestStackBuilder { base_beryl_activation_block: Option, base_cobalt_activation_block: Option, base_denim_activation_block: Option, + base_everest_activation_block: Option, base_zenith_activation_block: Option, output_dir: Option, tx_forwarding_config: Option, @@ -434,6 +435,7 @@ impl SystemTestStackBuilder { || self.base_beryl_activation_block.is_some() || self.base_cobalt_activation_block.is_some() || self.base_denim_activation_block.is_some() + || self.base_everest_activation_block.is_some() || self.base_zenith_activation_block.is_some() } @@ -496,6 +498,12 @@ impl SystemTestStackBuilder { self } + /// Sets the L2 block number at which Base Everest activates. + pub const fn with_base_everest_activation_block(mut self, block: u64) -> Self { + self.base_everest_activation_block = Some(block); + self + } + /// Sets the L2 block number at which the genesis-only Base Zenith testing gate activates. pub const fn with_base_zenith_activation_block(mut self, block: u64) -> Self { self.base_zenith_activation_block = Some(block); @@ -757,6 +765,10 @@ impl SystemTestStackBuilder { setup = setup.with_base_denim_activation_block(block); } + if let Some(block) = self.base_everest_activation_block { + setup = setup.with_base_everest_activation_block(block); + } + if let Some(block) = self.base_zenith_activation_block { setup = setup.with_base_zenith_activation_block(block); } diff --git a/etc/systems/tests/common/zenith.rs b/etc/systems/tests/common/everest.rs similarity index 60% rename from etc/systems/tests/common/zenith.rs rename to etc/systems/tests/common/everest.rs index 7f34079a76..a0129da3ca 100644 --- a/etc/systems/tests/common/zenith.rs +++ b/etc/systems/tests/common/everest.rs @@ -1,4 +1,4 @@ -//! Zenith stack helper. Path-included only by tests that activate Zenith. +//! Everest stack helper. Path-included only by tests that activate Everest. use alloy_provider::RootProvider; use base_common_network::Base; @@ -11,18 +11,18 @@ use super::common::{ }; pub(crate) const BASE_COBALT_ACTIVATION_BLOCK: u64 = 5; -pub(crate) const BASE_ZENITH_ACTIVATION_BLOCK: u64 = 7; +pub(crate) const BASE_EVEREST_ACTIVATION_BLOCK: u64 = 7; -/// Starts a system test stack with Zenith active at block 7 and waits for block 8. +/// Starts a system test stack with Everest active at block 7 and waits for block 8. /// /// The returned [`SystemTestStack`] must be kept alive for the duration of the test; /// dropping it shuts down the underlying containers. -pub(crate) async fn start_zenith_system() -> Result<(SystemTestStack, RootProvider)> { - start_zenith_stack(SystemTestStackBuilder::new()).await +pub(crate) async fn start_everest_system() -> Result<(SystemTestStack, RootProvider)> { + start_everest_stack(SystemTestStackBuilder::new()).await } -/// Same as [`start_zenith_system`], with extra [`SystemTestStackBuilder`] options applied first. -pub(crate) async fn start_zenith_stack( +/// Same as [`start_everest_system`], with extra [`SystemTestStackBuilder`] options applied first. +pub(crate) async fn start_everest_stack( builder: SystemTestStackBuilder, ) -> Result<(SystemTestStack, RootProvider)> { let system = builder @@ -31,10 +31,10 @@ pub(crate) async fn start_zenith_stack( .with_base_azul_activation_block(BASE_AZUL_ACTIVATION_BLOCK) .with_base_beryl_activation_block(BASE_BERYL_ACTIVATION_BLOCK) .with_base_cobalt_activation_block(BASE_COBALT_ACTIVATION_BLOCK) - .with_base_zenith_activation_block(BASE_ZENITH_ACTIVATION_BLOCK) + .with_base_everest_activation_block(BASE_EVEREST_ACTIVATION_BLOCK) .build() .await?; let provider = system.l2_builder_provider()?; - wait_for_block(&provider, BASE_ZENITH_ACTIVATION_BLOCK + 1).await?; + wait_for_block(&provider, BASE_EVEREST_ACTIVATION_BLOCK + 1).await?; Ok((system, provider)) } diff --git a/etc/systems/tests/eip8130.rs b/etc/systems/tests/eip8130.rs index 15a767c4e3..dca6c064d4 100644 --- a/etc/systems/tests/eip8130.rs +++ b/etc/systems/tests/eip8130.rs @@ -3,8 +3,8 @@ #[path = "common/balance.rs"] mod balance; mod common; -#[path = "common/zenith.rs"] -mod zenith; +#[path = "common/everest.rs"] +mod everest; #[path = "common/zk_dry_run.rs"] mod zk_dry_run; @@ -28,10 +28,10 @@ use eyre::{Result, WrapErr, ensure}; /// EIP-8130 transaction type byte. const EIP8130_TX_TYPE: u8 = 0x79; -/// Mines a minimal EOA-path EIP-8130 transaction on the Zenith system-test stack. +/// Mines a minimal EOA-path EIP-8130 transaction on the Everest system-test stack. #[tokio::test] async fn eip8130_transaction_is_mined() -> Result<()> { - let (_system, provider) = zenith::start_zenith_system().await?; + let (_system, provider) = everest::start_everest_system().await?; let (_tx_hash, receipt) = send_minimal_eip8130(&provider).await?; assert!(receipt.status(), "EIP-8130 transaction receipt must report success"); @@ -63,7 +63,7 @@ async fn eip8130_block_dry_run_proves() -> Result<()> { ); let (system, provider) = - zenith::start_zenith_stack(SystemTestStackBuilder::new().with_force_batch_submission()) + everest::start_everest_stack(SystemTestStackBuilder::new().with_force_batch_submission()) .await?; let (_tx_hash, receipt) = send_minimal_eip8130(&provider).await?; let block_number = diff --git a/etc/systems/tests/tx_forwarding.rs b/etc/systems/tests/tx_forwarding.rs index e28cccf45a..14aad77d4a 100644 --- a/etc/systems/tests/tx_forwarding.rs +++ b/etc/systems/tests/tx_forwarding.rs @@ -33,7 +33,7 @@ use tokio::time::{sleep, timeout}; const L1_CHAIN_ID: u64 = 1337; const L2_CHAIN_ID: u64 = 84538453; const DENIM_ACTIVATION_BLOCK: u64 = 0; -const ZENITH_ACTIVATION_BLOCK: u64 = 0; +const EVEREST_ACTIVATION_BLOCK: u64 = 0; const TX_RECEIPT_TIMEOUT: Duration = Duration::from_secs(60); const PENDING_TX_TIMEOUT: Duration = Duration::from_secs(15); @@ -63,14 +63,14 @@ fn block_expiry_bound(current_block: u64) -> ValidityPredicate { } /// Starts a separate mempool and builder pair using the native Denim payload builder with validity -/// transport enabled on both nodes and Zenith active for EIP-8130 transactions. +/// transport enabled on both nodes and Everest active for EIP-8130 transactions. async fn start_validity_system() -> Result { let system = SystemTestStackBuilder::new() .with_l1_chain_id(L1_CHAIN_ID) .with_l2_chain_id(L2_CHAIN_ID) .with_base_cobalt_activation_block(0) .with_base_denim_activation_block(DENIM_ACTIVATION_BLOCK) - .with_base_zenith_activation_block(ZENITH_ACTIVATION_BLOCK) + .with_base_everest_activation_block(EVEREST_ACTIVATION_BLOCK) .with_tx_forwarding( TxForwardingConfig::new(vec![]).with_resend_after_ms(2000).with_max_batch_size(100), ) @@ -455,7 +455,7 @@ async fn test_validity_transaction_submitted_directly_to_builder_is_included() - Ok(()) } -/// Verifies a Zenith EIP-8130 transaction can carry validity predicates through forwarding and be +/// Verifies an Everest EIP-8130 transaction can carry validity predicates through forwarding and be /// included by the native Denim payload builder. #[tokio::test] async fn test_eip8130_validity_transaction_is_included_by_native_builder() -> Result<()> { From 2dc9d23ba23614b3990c197888c43159e47ad995 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 25 Sep 2026 15:59:33 +0000 Subject: [PATCH 123/299] ci: remove base-std access token (#5318) --- .depot/workflows/base-anvil-package.yml | 27 +++++++----------------- .depot/workflows/base-std-fork-tests.yml | 8 ++----- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/.depot/workflows/base-anvil-package.yml b/.depot/workflows/base-anvil-package.yml index 415b5970f9..0b75b9cbeb 100644 --- a/.depot/workflows/base-anvil-package.yml +++ b/.depot/workflows/base-anvil-package.yml @@ -110,25 +110,15 @@ jobs: - name: Resolve base-std ref id: base-std shell: bash - env: - GH_TOKEN: ${{ secrets.BASE_STD_TOKEN }} run: | set -euo pipefail - if base_std_sha="$(gh api repos/base/base-std/git/ref/heads/"$BASE_STD_REF" --jq '.object.sha' 2>/dev/null)" \ - && [[ -n "$base_std_sha" ]]; then - : - elif base_std_sha="$(gh api repos/base/base-std/git/ref/tags/"$BASE_STD_REF" --jq '.object.sha' 2>/dev/null)" \ - && [[ -n "$base_std_sha" ]]; then - : - else - base_std_sha="$(gh api "repos/base/base-std/git/ref/$BASE_STD_REF" --jq '.object.sha')" - fi - - if [[ -z "$base_std_sha" ]]; then - echo "::error::Unable to resolve base-std ref $BASE_STD_REF" - exit 1 - fi + resolve_dir="$RUNNER_TEMP/base-std-resolve" + rm -rf "$resolve_dir" + git init "$resolve_dir" + git -C "$resolve_dir" remote add origin https://github.com/base/base-std.git + git -C "$resolve_dir" fetch --depth 1 origin "$BASE_STD_REF" + base_std_sha="$(git -C "$resolve_dir" rev-parse FETCH_HEAD)" echo "sha=$base_std_sha" >> "$GITHUB_OUTPUT" - name: Build patched base-anvil binaries @@ -153,7 +143,6 @@ jobs: if: matrix.settings.smoke_test shell: bash env: - GH_TOKEN: ${{ secrets.BASE_STD_TOKEN }} BASE_STD_SHA: ${{ steps.base-std.outputs.sha }} run: | set -euo pipefail @@ -162,8 +151,8 @@ jobs: base_std_dir="$workdir/base-std" mkdir -p "$workdir" - gh auth setup-git - gh repo clone base/base-std "$base_std_dir" -- --filter=blob:none --no-checkout + git clone --filter=blob:none --no-checkout \ + https://github.com/base/base-std.git "$base_std_dir" git -C "$base_std_dir" fetch --depth 1 origin "$BASE_STD_SHA" git -C "$base_std_dir" checkout --detach FETCH_HEAD git -C "$base_std_dir" submodule update --init --recursive --depth 1 diff --git a/.depot/workflows/base-std-fork-tests.yml b/.depot/workflows/base-std-fork-tests.yml index c229f901eb..825a6510fc 100644 --- a/.depot/workflows/base-std-fork-tests.yml +++ b/.depot/workflows/base-std-fork-tests.yml @@ -68,10 +68,6 @@ jobs: just: "false" - name: Checkout pinned fork-test pair shell: bash - env: - # BASE_STD_TOKEN must be a PAT with Contents:Read on base/base-std. - # GITHUB_TOKEN is repo-scoped and cannot access other repos, even internal ones. - GH_TOKEN: ${{ secrets.BASE_STD_TOKEN }} run: | set -euo pipefail @@ -81,8 +77,8 @@ jobs: # FETCH_HEAD supports commit SHAs and tags (including annotated tags), # keeping refs independently configurable for each repository. - gh repo clone base/base-std "$workdir/base-std" -- \ - --filter=blob:none --no-checkout + git clone --filter=blob:none --no-checkout \ + https://github.com/base/base-std.git "$workdir/base-std" git -C "$workdir/base-std" fetch --depth 1 origin "$BASE_STD_REF" git -C "$workdir/base-std" checkout --detach FETCH_HEAD git -C "$workdir/base-std" submodule update --init --recursive --depth 1 From 3426d8bbbf3d768ffaea16d1a0515d716f397f03 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Fri, 25 Sep 2026 17:06:07 +0000 Subject: [PATCH 124/299] refactor(harness): wait for the batch driver with a marker in its L1 head source (#5284) * refactor(harness): wait for the batch driver with a marker in its L1 head source * docs(harness): align the batcher actor docs with the idle marker * docs(harness): say why the idle marker waits and document the tx manager fields * docs(harness): name the idle timeout and fix stale references in the batcher docs * fix(harness): answer an idle marker on the poll after the one that takes it --- actions/README.md | 6 +- actions/harness/src/batcher/actor.rs | 244 +++++++++--------- actions/harness/src/batcher/mod.rs | 2 +- actions/harness/src/batcher/source.rs | 96 ++++--- actions/harness/src/batcher/tx_manager.rs | 116 ++------- actions/harness/src/lib.rs | 4 +- actions/harness/tests/batcher/submission.rs | 10 +- .../tests/batcher/submission_failure.rs | 14 +- .../harness/tests/batcher/txpool_blocked.rs | 4 +- 9 files changed, 226 insertions(+), 270 deletions(-) diff --git a/actions/README.md b/actions/README.md index 09b2f80595..274910409b 100644 --- a/actions/README.md +++ b/actions/README.md @@ -172,8 +172,10 @@ failed, or reorged. For the common happy path, call `batcher.advance(&mut h.l1).await`: it drains the L2 source, flushes the encoder, mines one L1 block, and confirms the resulting receipts. For more exact scenarios, use `encode_only`, -`stage_n_frames`, `confirm_staged`, `fail_next_n_submissions`, `reorg`, and -`wait_until_requeued`. +`stage_n_frames`, `confirm_staged`, `mine_pending`, `fail_next_n_submissions` +and `reorg`. +Every `async` method of `Batcher` returns once the driver is idle again, so the +test can read the tx manager's queues right after. ## Writing a test diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 2eeddb6bb7..3e4d4d72c4 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -7,7 +7,7 @@ use base_batcher_core::{ DaThrottle, NoopThrottleClient, ThrottleController, }; use base_batcher_encoder::{BatchEncoder, EncoderConfig}; -use base_batcher_source::{L2BlockEvent, test_utils::ChannelL1HeadSource}; +use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; use base_common_consensus::BaseBlock; use base_common_genesis::RollupConfig; use base_protocol::BlockInfo; @@ -17,7 +17,7 @@ use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; use crate::{ - ActionL2Source, BlockSourceItem, HarnessBlockSource, L1Block, L1Miner, L1MinerTxManager, + ActionL2Source, HarnessL1HeadSource, L1Block, L1HeadItem, L1Miner, L1MinerTxManager, L2BlockProvider, }; @@ -77,44 +77,49 @@ pub enum BatcherError { /// The end-of-cycle flush failed, for example because the driver is stopped. #[error("flush failed: {0}")] Flush(#[from] AdminError), - /// The driver exited, or did not catch up with the harness within the timeout. + /// The driver exited, or did not catch up with the harness within + /// [`Batcher::IDLE_TIMEOUT`]. #[error("the batch driver exited or stalled")] DriverUnavailable, } /// Batcher actor that drives a persistent [`BatchDriver`] through [`L1Miner`]. /// -/// On construction, `Batcher` spawns a [`BatchDriver`] as a background tokio -/// task backed by a [`HarnessBlockSource`] for L2 block delivery, a -/// [`ChannelL1HeadSource`] fed by [`L1MinerTxManager`], and an admin channel. -/// This mirrors the production batcher architecture: the driver owns its -/// encoding pipeline and transaction manager and runs its own async loop. +/// On construction, `Batcher` spawns a [`BatchDriver`] as a background tokio task backed by +/// a [`ChannelBlockSource`] for L2 block delivery, a [`HarnessL1HeadSource`] for L1 heads, +/// and an admin channel. This mirrors the production batcher architecture: the driver owns +/// its encoding pipeline and transaction manager and runs its own async loop. +/// +/// Every `async` method returns once the driver is idle again, that is once it has taken +/// what it was given, encoded it, handed the resulting submissions to the tx manager and +/// applied every receipt. The harness waits for that with a marker queued in the L1 head +/// source; see [`L1HeadItem::Marker`]. /// /// Each call to [`advance`] drives one complete batch cycle: /// 1. Drain the L2 source and forward each block to the driver via the block source. -/// 2. Wait for the driver to take and encode every block (a marker in the block source). +/// 2. Wait for the driver to encode every block. /// 3. Flush through the admin channel, exactly as an operator would, to close and /// release the current channel. -/// 4. Wait for the driver's next submit pass (a second marker), after which every -/// resulting submission has been handed to the tx manager, not just the first. -/// 5. Mine one L1 block via the shared [`L1MinerTxManager`], firing all -/// receipt oneshots and delivering the new L1 head to the driver. -/// 6. Yield to let the driver confirm receipts and advance its L1 head. +/// 4. Wait for the driver to hand every resulting submission to the tx manager. +/// 5. Stage every pending submission, mine one L1 block, fire the receipts of the +/// submissions it includes and deliver the new L1 head to the driver. +/// 6. Wait for the driver to confirm the receipts and advance its L1 head. /// /// The driver's [`BatchEncoder`] state is persistent across `advance()` calls. /// The driver task continues running between cycles, waiting for new events. /// /// [`advance`]: Batcher::advance /// [`BatchDriver`]: base_batcher_core::BatchDriver -/// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource pub struct Batcher { /// The L2 block source to drain on each [`advance`](Batcher::advance) cycle. l2_source: S, - /// Feeds the driver's block source with block and reorg events, and with markers. - source_tx: mpsc::UnboundedSender, + /// Feeds the driver's block source with block and reorg events. + source_tx: mpsc::UnboundedSender, + /// Feeds the driver's L1 head source with mined heads, and with markers. + l1_head_tx: mpsc::UnboundedSender, /// Admin channel to the driver, used to flush at the end of a cycle. admin: AdminHandle, - /// Shared tx manager — used to mine blocks and fire receipt/L1 head events. + /// Shared tx manager — used to stage submissions and fire their receipts. tx_manager: L1MinerTxManager, /// Background driver task handle. driver_task: tokio::task::JoinHandle>, @@ -132,32 +137,28 @@ impl std::fmt::Debug for Batcher { } impl Batcher { + /// How long a method waits for the driver to go idle before giving up. + pub const IDLE_TIMEOUT: Duration = Duration::from_secs(10); + /// Create a new [`Batcher`] backed by a persistent [`BatchDriver`] task. /// - /// Spawns the driver immediately. The driver will not process any events - /// until the first [`advance`] call. + /// Spawns the driver immediately. /// /// # Panics /// /// Panics if `config.encoder` is invalid, or if `config.batcher_address` is not the /// address of `config.l1_signer`. - /// - /// [`advance`]: Batcher::advance pub fn new(l2_source: S, rollup_config: &RollupConfig, config: BatcherConfig) -> Self { let l1_chain_id = rollup_config.l1_chain_id; let pipeline = BatchEncoder::new(Arc::new(rollup_config.clone()), config.encoder.clone()) .expect("valid encoder config"); - let (source, source_tx) = HarnessBlockSource::new(); + let (source, source_tx) = ChannelBlockSource::new(); + let (l1_head_source, l1_head_tx) = HarnessL1HeadSource::new(); let (admin, admin_rx) = AdminHandle::channel(); - // L1 head source: mine_block() sends the mined block number; the driver - // calls advance_l1_head() when the channel delivers it. - let (l1_source, l1_head_tx) = ChannelL1HeadSource::new(); - let tx_manager = - L1MinerTxManager::new(config.l1_signer.clone(), config.inbox_address, l1_chain_id) - .with_l1_head_tx(l1_head_tx); + L1MinerTxManager::new(config.l1_signer.clone(), config.inbox_address, l1_chain_id); assert_eq!( config.batcher_address, tx_manager.sender_address(), @@ -175,8 +176,9 @@ impl Batcher { tx_manager.clone(), BatchDriverConfig { inbox: config.inbox_address, - // `encode_only` returns after one submit pass, so a cycle must not produce - // more submissions than this. No action test comes close. + // Past this many transactions in flight the driver goes idle with submissions + // still in the pipeline, so `encode_only` would return before handing them all + // to the tx manager. No action test comes close. max_pending_transactions: 16, drain_timeout: Duration::from_secs(10), force_blobs_when_throttling: true, @@ -185,7 +187,7 @@ impl Batcher { DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), BatchDriverInputs { source, - l1_head_source: l1_source, + l1_head_source, // The driver learns the L1 head from the blocks the tests mine. initial_l1_head: 0, initial_safe_head: BlockInfo::from_l2_genesis(&rollup_config.genesis), @@ -201,15 +203,13 @@ impl Batcher { driver.run().await }); - Self { l2_source, source_tx, admin, tx_manager, driver_task, cancel } + Self { l2_source, source_tx, l1_head_tx, admin, tx_manager, driver_task, cancel } } /// Drain the L2 source and forward all blocks to the driver, then flush. /// - /// Performs steps 1–4 of [`advance`] without mining. After this returns, every frame - /// resulting from the flush (not just the first) has been handed to the tx manager, so - /// [`pending_count`] reflects the *complete* set of frame transactions waiting, even - /// when a cycle produces more than one frame. + /// Performs steps 1–4 of [`advance`] without mining. Once it returns, every frame the + /// flush released has been handed to the tx manager, so [`pending_count`] counts them all. /// /// # Panics /// @@ -233,7 +233,7 @@ impl Batcher { let mut block_count = 0u64; while let Some(block) = self.l2_source.next_block() { - self.send(BlockSourceItem::Event(L2BlockEvent::Block(Box::new(block))))?; + self.send_event(L2BlockEvent::Block(Box::new(block)))?; block_count += 1; } if block_count == 0 { @@ -242,30 +242,58 @@ impl Batcher { // Admin commands outrank the block source in the driver's select, so wait until every // block above is taken and encoded before asking for the flush. - self.wait_for_driver().await?; + self.wait_until_idle().await?; self.admin.flush().await?; // The flush is answered before the driver's next encode-and-submit pass. Wait for that // pass so every frame the flush released has been handed to the tx manager. - self.wait_for_driver().await + self.wait_until_idle().await } - /// Queue an item for the driver's block source. Fails if the driver task has exited. - fn send(&self, item: BlockSourceItem) -> Result<(), BatcherError> { - self.source_tx.send(item).map_err(|_| BatcherError::DriverUnavailable) + /// Queue an event for the driver's block source. Fails if the driver task has exited. + fn send_event(&self, event: L2BlockEvent) -> Result<(), BatcherError> { + self.source_tx.send(event).map_err(|_| BatcherError::DriverUnavailable) } - /// Wait until the driver has taken everything sent to its block source so far and run - /// the encode-and-submit pass that follows. - async fn wait_for_driver(&self) -> Result<(), BatcherError> { + /// Queue an item for the driver's L1 head source. Fails if the driver task has exited. + fn send_l1_head_item(&self, item: L1HeadItem) -> Result<(), BatcherError> { + self.l1_head_tx.send(item).map_err(|_| BatcherError::DriverUnavailable) + } + + /// Wait until the driver has nothing left to do: everything sent so far is taken, + /// encoded and submitted, and every receipt is applied. + async fn wait_until_idle(&self) -> Result<(), BatcherError> { let (reached_tx, reached_rx) = oneshot::channel(); - self.send(BlockSourceItem::Marker(reached_tx))?; - match tokio::time::timeout(Duration::from_secs(10), reached_rx).await { + self.send_l1_head_item(L1HeadItem::Marker(reached_tx))?; + match tokio::time::timeout(Self::IDLE_TIMEOUT, reached_rx).await { Ok(Ok(())) => Ok(()), _ => Err(BatcherError::DriverUnavailable), } } + /// Deliver `head` to the driver as the new L1 head and wait until it is applied. + async fn deliver_l1_head(&self, head: u64) -> Result<(), BatcherError> { + self.send_l1_head_item(L1HeadItem::Head(head))?; + self.wait_until_idle().await + } + + /// Fire receipts for the staged items included in `block`, then deliver its number as + /// the new L1 head. + async fn try_confirm_staged(&self, block: &L1Block) -> Result<(), BatcherError> { + // Receipts first: the driver serves them before L1 heads, so a failed submission is + // requeued before the head advances. + self.tx_manager.confirm_block(block); + self.deliver_l1_head(block.number()).await + } + + /// Stage every pending submission, mine one L1 block and confirm it. + async fn try_mine_pending(&self, l1: &mut L1Miner) -> Result { + self.tx_manager.stage_n_to_l1(l1, usize::MAX); + let block = l1.mine_block().clone(); + self.try_confirm_staged(&block).await?; + Ok(block.number()) + } + /// Returns the number of encoded-but-not-yet-staged pending frame submissions. pub fn pending_count(&self) -> usize { self.tx_manager.pending_count() @@ -288,15 +316,12 @@ impl Batcher { /// [`TxManager::send_async`] will resolve with /// [`TxManagerError::Rpc`] instead of queuing to the L1 miner. The driver /// requeues the frame and retries, so calling this before [`encode_only`] - /// simulates transient L1 submission failures without losing data. - /// - /// Use [`wait_until_requeued`] after [`encode_only`] to wait for the driver - /// to process the failures and return frames to the pending queue. + /// simulates transient L1 submission failures without losing data. Once + /// [`encode_only`] returns, the retried frames are back in the pending queue. /// /// [`TxManager::send_async`]: base_tx_manager::TxManager::send_async /// [`TxManagerError::Rpc`]: base_tx_manager::TxManagerError::Rpc /// [`encode_only`]: Batcher::encode_only - /// [`wait_until_requeued`]: Batcher::wait_until_requeued pub fn fail_next_n_submissions(&self, n: usize) { self.tx_manager.fail_next_n(n); } @@ -308,17 +333,17 @@ impl Batcher { /// [`TxManager::send_async`] resolves with [`TxManagerError::AlreadyReserved`]. /// The driver classifies this as [`TxOutcome::TxpoolBlocked`]: it requeues /// the frames, stops submitting, and calls [`TxManager::cancel_tx`] on its - /// next loop iteration to clear the slot before resubmitting. Use - /// [`cancellation_count`] to assert the recovery path ran, and - /// [`wait_until_requeued`] to wait for the frames to return to pending. + /// next loop iteration to clear the slot before resubmitting. Once + /// [`encode_only`] returns, the recovery has run: use [`cancellation_count`] + /// to assert it did. /// /// [`BatchDriver`]: base_batcher_core::BatchDriver /// [`TxManager::send_async`]: base_tx_manager::TxManager::send_async /// [`TxManager::cancel_tx`]: base_tx_manager::TxManager::cancel_tx /// [`TxManagerError::AlreadyReserved`]: base_tx_manager::TxManagerError::AlreadyReserved /// [`TxOutcome::TxpoolBlocked`]: base_batcher_core::TxOutcome::TxpoolBlocked + /// [`encode_only`]: Batcher::encode_only /// [`cancellation_count`]: Batcher::cancellation_count - /// [`wait_until_requeued`]: Batcher::wait_until_requeued pub fn block_next_n_submissions(&self, n: usize) { self.tx_manager.block_next_n(n); } @@ -333,87 +358,64 @@ impl Batcher { /// Mine all pending frame submissions in one L1 block. /// - /// Stages every pending frame, mines one L1 block, fires all receipt - /// oneshots, and yields once so the driver processes confirmations. - /// Returns the mined block number. + /// Stages every pending frame, mines one L1 block, fires all receipts and waits until + /// the driver has confirmed them. Returns the mined block number. /// - /// Use this after [`wait_until_requeued`] to confirm a requeued batch - /// without needing to encode new L2 blocks. + /// Use this to confirm a requeued batch without encoding new L2 blocks. + /// + /// # Panics /// - /// [`wait_until_requeued`]: Batcher::wait_until_requeued + /// Panics if the driver task has exited, or did not catch up within + /// [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). pub async fn mine_pending(&self, l1: &mut L1Miner) -> u64 { - let block_number = self.tx_manager.mine_block(l1); - tokio::task::yield_now().await; - block_number + self.try_mine_pending(l1) + .await + .unwrap_or_else(|e| panic!("Batcher::mine_pending failed: {e}")) } /// Drop the first `n` pending frame submissions without staging them to L1. /// /// Returns the actual number dropped. Use this to skip specific frame - /// positions when testing non-sequential frame submission scenarios. + /// positions when testing non-sequential frame submission scenarios. The driver sees + /// each dropped submission fail and resubmits it on its next `async` call. pub fn drop_n_frames(&self, n: usize) -> usize { self.tx_manager.drop_n(n) } - /// Fire receipts for all staged items from `block` and yield to let - /// the driver process confirmations and the new L1 head. + /// Fire receipts for all staged items included in `block`, deliver its number to the + /// driver as the new L1 head, and wait until the driver has applied both. + /// + /// # Panics + /// + /// Panics if the driver task has exited, or did not catch up within + /// [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). pub async fn confirm_staged(&self, block: &L1Block) { - self.tx_manager.confirm_block(block); - tokio::task::yield_now().await; + self.try_confirm_staged(block) + .await + .unwrap_or_else(|e| panic!("Batcher::confirm_staged failed: {e}")); } /// Simulate an L1 reorg back to `block_number`. /// - /// Truncates the L1 chain via [`L1Miner::reorg_to`], fires failure - /// receipts for every item in `pending` and `staged`, and publishes the new - /// L1 head to the driver. - /// - /// Items already confirmed via [`confirm_staged`] (and thus living in - /// the driver's own `in_flight` set) are **not** covered — see - /// [`L1MinerTxManager::reorg_to`] for details. + /// Truncates the L1 chain via [`L1Miner::reorg_to`], fires failure receipts for every + /// item in `pending` and `staged`, delivers the new L1 head to the driver, and waits + /// until the driver has requeued and resubmitted the failed frames. /// - /// This method is synchronous. After returning, call - /// [`wait_until_requeued`] to wait for the driver to process the failure - /// receipts and return frames to the pending queue. + /// Submissions already confirmed through [`confirm_staged`] are not revisited. /// /// # Panics /// /// Panics if `block_number` exceeds the current L1 chain tip - /// (`ReorgError::BeyondTip`). + /// (`ReorgError::BeyondTip`), or if the driver task has exited or did not catch up + /// within [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). /// /// [`confirm_staged`]: Batcher::confirm_staged - /// [`wait_until_requeued`]: Batcher::wait_until_requeued - pub fn reorg(&self, block_number: u64, l1: &mut L1Miner) { + pub async fn reorg(&self, block_number: u64, l1: &mut L1Miner) { + // Failure receipts first, for the same reason as in `try_confirm_staged`. self.tx_manager.reorg_to(block_number, l1); - } - - /// Wait until at least `expected` frames are back in the pending queue - /// after an L1 reorg. - /// - /// A reorg requires two driver loop iterations to complete: the first - /// handles each failed receipt and requeues the frames in the encoder - /// pipeline; the second calls `submit_pending()` → [`send_async`] to - /// return them to the pending queue. This method polls - /// [`pending_count`] until the condition is satisfied. - /// - /// # Panics - /// - /// Panics if `pending_count()` does not reach `expected` within the - /// polling iteration limit (20 yields). - /// - /// [`send_async`]: crate::L1MinerTxManager::send_async - /// [`pending_count`]: Batcher::pending_count - pub async fn wait_until_requeued(&self, expected: usize) { - for _ in 0..20 { - if self.pending_count() >= expected { - return; - } - tokio::task::yield_now().await; - } - panic!( - "timed out waiting for {expected} requeued frames after reorg; got {}", - self.pending_count() - ); + self.deliver_l1_head(block_number) + .await + .unwrap_or_else(|e| panic!("Batcher::reorg failed: {e}")); } /// Signal that the batcher has been repointed to a different L2 node. @@ -423,12 +425,13 @@ impl Batcher { /// /// # Panics /// - /// Panics if the driver task has exited, or did not apply the reorg within the timeout. + /// Panics if the driver task has exited, or did not apply the reorg within + /// [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). /// /// [`BatchDriver`]: base_batcher_core::BatchDriver pub async fn signal_reorg(&self) { - self.send(BlockSourceItem::Event(L2BlockEvent::Reorg)).expect("driver task alive"); - self.wait_for_driver().await.expect("driver applies the reorg"); + self.send_event(L2BlockEvent::Reorg).expect("driver task alive"); + self.wait_until_idle().await.expect("driver applies the reorg"); } /// Run one full batch cycle through the production [`BatchDriver`] path. @@ -452,14 +455,7 @@ impl Batcher { /// [`advance`]: Batcher::advance pub async fn try_advance(&mut self, l1: &mut L1Miner) -> Result<(), BatcherError> { self.try_encode_only().await?; - - // Mine one L1 block: submits all pending txs/blobs, fires receipt - // oneshots, and sends the new L1 head to the driver. - self.tx_manager.mine_block(l1); - - // Yield to let the driver process the receipts and the new L1 head. - tokio::task::yield_now().await; - + self.try_mine_pending(l1).await?; Ok(()) } } diff --git a/actions/harness/src/batcher/mod.rs b/actions/harness/src/batcher/mod.rs index d9eecf604f..2b56048254 100644 --- a/actions/harness/src/batcher/mod.rs +++ b/actions/harness/src/batcher/mod.rs @@ -4,7 +4,7 @@ mod actor; pub use actor::{Batcher, BatcherConfig, BatcherError}; mod source; -pub use source::{BlockSourceItem, HarnessBlockSource}; +pub use source::{HarnessL1HeadSource, L1HeadItem}; mod tx_manager; pub use tx_manager::{Inner, L1MinerTxManager, L1SignedSubmission, Pending}; diff --git a/actions/harness/src/batcher/source.rs b/actions/harness/src/batcher/source.rs index cc4088d8c5..565a8897af 100644 --- a/actions/harness/src/batcher/source.rs +++ b/actions/harness/src/batcher/source.rs @@ -1,48 +1,64 @@ -//! Block source that lets the harness wait for the driver. +//! L1 head source that lets the harness wait for the driver. //! //! Hand-rolled rather than mocked: the driver polls it from its `select!` loop for the whole -//! test, and the harness must interleave sync markers with the events it feeds in FIFO order. +//! test, and the harness must interleave sync markers with the heads it feeds in FIFO order. + +use std::task::Poll; use async_trait::async_trait; -use base_batcher_source::{L2BlockEvent, UnsafeBlockSource}; +use base_batcher_source::L1HeadSource; use tokio::sync::{mpsc, oneshot}; -/// One item queued for the driver's block source. +/// One item queued for the driver's L1 head source. #[derive(Debug)] -pub enum BlockSourceItem { +pub enum L1HeadItem { /// Delivered to the driver as is. - Event(L2BlockEvent), - /// Answered when the driver waits on the source again, that is once it has taken every - /// item queued before this one. - /// - /// [`BatchDriver::run`] does all the work it can before waiting for the next event, so by - /// then the last of those items has been encoded and any frames it released submitted. + Head(u64), + /// Answered when the driver is idle: the L1 head arm is the lowest-priority input of + /// the driver's biased `select!` and is disabled while encoding is in progress, so it is + /// only polled once the buffered work is encoded and submitted and every other input + /// is waiting. /// - /// [`BatchDriver::run`]: base_batcher_core::BatchDriver::run + /// The source answers a marker on the poll after the one that takes it, so the driver + /// has checked every other input again since the marker was queued: what the harness + /// sent just before the marker is then taken first, even on a multi-thread runtime. Marker(oneshot::Sender<()>), } -/// [`UnsafeBlockSource`] fed by the harness through a channel. +/// [`L1HeadSource`] fed by the harness through a channel. #[derive(Debug)] -pub struct HarnessBlockSource { - rx: mpsc::UnboundedReceiver, +pub struct HarnessL1HeadSource { + /// Heads and markers queued by the harness, in order. + rx: mpsc::UnboundedReceiver, } -impl HarnessBlockSource { +impl HarnessL1HeadSource { /// Create a source and the sender that feeds it. - pub fn new() -> (Self, mpsc::UnboundedSender) { + pub fn new() -> (Self, mpsc::UnboundedSender) { let (tx, rx) = mpsc::unbounded_channel(); (Self { rx }, tx) } } #[async_trait] -impl UnsafeBlockSource for HarnessBlockSource { - async fn next(&mut self) -> L2BlockEvent { +impl L1HeadSource for HarnessL1HeadSource { + async fn next(&mut self) -> u64 { loop { match self.rx.recv().await { - Some(BlockSourceItem::Event(event)) => return event, - Some(BlockSourceItem::Marker(reached)) => { + Some(L1HeadItem::Head(head)) => return head, + Some(L1HeadItem::Marker(reached)) => { + // Return pending once, with the task woken so the driver polls again at + // once, and answer on that next poll. + let mut polled = false; + std::future::poll_fn(|cx| { + if polled { + return Poll::Ready(()); + } + polled = true; + cx.waker().wake_by_ref(); + Poll::Pending + }) + .await; let _ = reached.send(()); } None => std::future::pending().await, @@ -53,40 +69,50 @@ impl UnsafeBlockSource for HarnessBlockSource { #[cfg(test)] mod tests { - use std::time::Duration; + use std::{ + pin::pin, + task::{Context, Waker}, + time::Duration, + }; use super::*; #[tokio::test] - async fn marker_is_answered_once_the_events_before_it_are_taken() { - let (mut source, tx) = HarnessBlockSource::new(); + async fn marker_is_answered_by_the_poll_after_the_one_that_takes_it() { + let (mut source, tx) = HarnessL1HeadSource::new(); let (reached_tx, mut reached_rx) = oneshot::channel(); - tx.send(BlockSourceItem::Event(L2BlockEvent::Reorg)).unwrap(); - tx.send(BlockSourceItem::Marker(reached_tx)).unwrap(); - tx.send(BlockSourceItem::Event(L2BlockEvent::Reorg)).unwrap(); + tx.send(L1HeadItem::Head(1)).unwrap(); + tx.send(L1HeadItem::Marker(reached_tx)).unwrap(); - assert!(matches!(source.next().await, L2BlockEvent::Reorg)); + assert_eq!(source.next().await, 1); assert!(reached_rx.try_recv().is_err(), "the marker must wait for the next poll"); - assert!(matches!(source.next().await, L2BlockEvent::Reorg)); - assert!(reached_rx.try_recv().is_ok(), "the next poll must answer the marker"); + let mut next = pin!(source.next()); + assert!(next.as_mut().poll(&mut Context::from_waker(Waker::noop())).is_pending()); + assert!( + reached_rx.try_recv().is_err(), + "the poll that takes the marker must not answer it" + ); + + assert!(next.as_mut().poll(&mut Context::from_waker(Waker::noop())).is_pending()); + assert!(reached_rx.try_recv().is_ok(), "the poll after must answer the marker"); } #[tokio::test] async fn marker_is_answered_even_when_nothing_follows() { - let (mut source, tx) = HarnessBlockSource::new(); + let (mut source, tx) = HarnessL1HeadSource::new(); let (reached_tx, reached_rx) = oneshot::channel(); - tx.send(BlockSourceItem::Marker(reached_tx)).unwrap(); + tx.send(L1HeadItem::Marker(reached_tx)).unwrap(); - // The source keeps waiting for an event, but the marker is already answered. + // The source keeps waiting for a head, but the marker is already answered. let next = tokio::time::timeout(Duration::from_millis(10), source.next()); - assert!(next.await.is_err(), "no event must be returned"); + assert!(next.await.is_err(), "no head must be returned"); assert!(reached_rx.await.is_ok()); } #[tokio::test] async fn parks_once_the_harness_is_gone() { - let (mut source, tx) = HarnessBlockSource::new(); + let (mut source, tx) = HarnessL1HeadSource::new(); drop(tx); let next = tokio::time::timeout(Duration::from_millis(10), source.next()); diff --git a/actions/harness/src/batcher/tx_manager.rs b/actions/harness/src/batcher/tx_manager.rs index 31ad7e9b0c..8926f8d294 100644 --- a/actions/harness/src/batcher/tx_manager.rs +++ b/actions/harness/src/batcher/tx_manager.rs @@ -13,12 +13,13 @@ use base_tx_manager::{ BlobTxBuilder, SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError, TxManagerResult, }; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::oneshot; use tracing::info; use crate::{L1Block, L1Miner}; -/// A pending submission waiting for [`L1MinerTxManager::mine_block`] to fire its receipt. +/// A submission waiting to be staged, then for [`L1MinerTxManager::confirm_block`] to fire +/// its receipt. pub struct Pending { /// Signed L1 transaction submitted to the miner. envelope: TxEnvelope, @@ -49,7 +50,9 @@ impl std::fmt::Debug for Pending { /// Internal mutable state for [`L1MinerTxManager`]: pending and staged submissions. #[derive(Debug, Default)] pub struct Inner { + /// Signed submissions not yet handed to the miner. pending: Vec, + /// Submissions in the miner's queue, waiting for a receipt. staged: Vec, /// Next nonce to use for signed production-mode transactions. next_nonce: u64, @@ -75,62 +78,34 @@ pub struct Inner { /// Adapts [`L1Miner`] to the [`TxManager`] trait for action tests. /// -/// [`send_async`] enqueues a [`TxCandidate`] and returns a [`SendHandle`] that -/// resolves when [`mine_block`] is called. The spawned [`BatchDriver`] task -/// suspends on these handles; [`Batcher::encode_only`] returns once every -/// submission of a cycle has been enqueued, so [`mine_block`] can follow it directly. +/// [`send_async`] signs a [`TxCandidate`], queues it as pending and returns a [`SendHandle`] +/// that resolves once the transaction is staged to the miner and [`confirm_block`] sees it +/// mined. The spawned [`BatchDriver`] task suspends on these handles; [`Batcher`] stages, +/// mines and confirms from the test side. /// /// [`L1MinerTxManager`] is cheaply cloneable (Arc bump). Pass one clone to -/// [`BatchDriver`] and retain the other for [`mine_block`] calls from the test. -/// -/// When constructed with [`with_l1_head_tx`], [`mine_block`] automatically -/// sends the new L1 head to a paired [`ChannelL1HeadSource`] so that the -/// [`BatchDriver`] observes live L1 head updates. +/// [`BatchDriver`] and retain the other for the test. /// /// [`send_async`]: L1MinerTxManager::send_async -/// [`mine_block`]: L1MinerTxManager::mine_block -/// [`with_l1_head_tx`]: L1MinerTxManager::with_l1_head_tx +/// [`confirm_block`]: L1MinerTxManager::confirm_block /// [`BatchDriver`]: base_batcher_core::BatchDriver -/// [`Batcher::encode_only`]: crate::Batcher::encode_only -/// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource +/// [`Batcher`]: crate::Batcher #[derive(Debug, Clone)] pub struct L1MinerTxManager { + /// Pending and staged submissions, shared with the driver's clone. inner: Arc>, + /// Default `to` for candidates that do not name one. inbox_address: Address, + /// Signs every submission. Its address is the batcher's sender. signer: PrivateKeySigner, + /// L1 chain id stamped on every signed transaction. chain_id: u64, - /// Optional L1 head channel sender. When set, [`mine_block`] publishes the mined - /// block number so a paired [`ChannelL1HeadSource`] can advance the driver's L1 head. - /// - /// [`mine_block`]: L1MinerTxManager::mine_block - /// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource - l1_head_tx: Option>, } impl L1MinerTxManager { /// Create a new manager. pub fn new(signer: PrivateKeySigner, inbox_address: Address, chain_id: u64) -> Self { - Self { - inner: Arc::new(Mutex::new(Inner::default())), - inbox_address, - signer, - chain_id, - l1_head_tx: None, - } - } - - /// Attach an L1 head channel sender. - /// - /// After each [`mine_block`] call, the mined block number is sent to this channel. - /// A [`BatchDriver`] constructed with the paired [`ChannelL1HeadSource`] will observe - /// the update and advance its pipeline's L1 head accordingly. - /// - /// [`mine_block`]: L1MinerTxManager::mine_block - /// [`BatchDriver`]: base_batcher_core::BatchDriver - /// [`ChannelL1HeadSource`]: base_batcher_source::test_utils::ChannelL1HeadSource - pub fn with_l1_head_tx(mut self, tx: mpsc::UnboundedSender) -> Self { - self.l1_head_tx = Some(tx); - self + Self { inner: Arc::new(Mutex::new(Inner::default())), inbox_address, signer, chain_id } } /// Returns the number of pending (not yet staged) submissions. @@ -212,8 +187,7 @@ impl L1MinerTxManager { count } - /// Fire receipt oneshots for staged items included in `block` and (if configured) - /// publish the new L1 head. + /// Fire receipt oneshots for staged items included in `block`. /// /// Staged items without receipts in `block` remain staged. This models the /// production transaction manager's receipt polling: RPC submission can succeed @@ -243,39 +217,20 @@ impl L1MinerTxManager { for (responder, response) in responses { let _ = responder.send(response); } - if let Some(tx) = &self.l1_head_tx { - let _ = tx.send(block.number()); - } } /// Simulate an L1 reorg back to `block_number`. /// - /// Calls [`L1Miner::reorg_to`] to truncate the canonical chain, fires a - /// failure receipt for every pending and staged submission (since their - /// inclusion block has been discarded or they are no longer valid), and - /// publishes the new L1 head so the [`BatchDriver`] observes the reorg. + /// Calls [`L1Miner::reorg_to`] to truncate the canonical chain and fires a + /// failure receipt for every pending and staged submission, since their + /// inclusion block has been discarded or they are no longer valid. /// /// Both `pending` (not yet staged) and `staged` (submitted to L1 but not - /// yet confirmed) items are drained. This ensures no [`SendHandle`] is - /// left dangling, which would block the driver's `in_flight.next()`. - /// - /// # Ordering - /// - /// Failure receipts are fired *before* the new L1 head is sent. - /// This is intentional: the driver's `select!` loop prioritises receipt - /// processing over head events, so firing receipts first ensures the - /// driver requeues any failed frames before it advances its L1 head. + /// yet confirmed) items are drained, so every [`SendHandle`] resolves and no + /// submission holds an in-flight slot forever. /// - /// # In-flight items + /// Submissions already confirmed through [`confirm_block`] are not revisited. /// - /// This method only covers items still in the `pending` or `staged` - /// queues. Items that have already been confirmed via [`confirm_block`] - /// and are living in the driver's own `in_flight` set are not touched. - /// Call this method *before* `confirm_staged` (or immediately after a - /// yield has let the driver drain `in_flight`) to avoid leaving the - /// driver in an inconsistent state. - /// - /// [`BatchDriver`]: base_batcher_core::BatchDriver /// [`SendHandle`]: base_tx_manager::SendHandle /// [`confirm_block`]: L1MinerTxManager::confirm_block pub fn reorg_to(&self, block_number: u64, l1: &mut L1Miner) { @@ -290,32 +245,9 @@ impl L1MinerTxManager { for p in pending.into_iter().chain(staged) { let _ = p.responder.send(Err(TxManagerError::Rpc("reorg".to_string()))); } - if let Some(tx) = &self.l1_head_tx { - let _ = tx.send(block_number); - } info!(block_number = %block_number, drained = %drained, "simulated L1 reorg"); } - /// Submit all pending transactions/blobs to `l1`, mine one block, resolve - /// all waiting [`SendHandle`]s with the real mined block number, and - /// (if configured) publish the block number to the L1 head channel. - /// - /// # Timing - /// - /// Call this once the spawned [`BatchDriver`] task has called [`send_async`] for - /// every submission of the cycle, which [`Batcher::encode_only`] guarantees on return. - /// - /// [`send_async`]: L1MinerTxManager::send_async - /// [`BatchDriver`]: base_batcher_core::BatchDriver - /// [`Batcher::encode_only`]: crate::Batcher::encode_only - pub fn mine_block(&self, l1: &mut L1Miner) -> u64 { - self.stage_n_to_l1(l1, usize::MAX); - let block = l1.mine_block().clone(); - let block_number = block.number(); - self.confirm_block(&block); - block_number - } - /// Build a signed transaction envelope and matching blob sidecar index for /// production-mode DA. pub fn sign_candidate( diff --git a/actions/harness/src/lib.rs b/actions/harness/src/lib.rs index 27e464d6c4..2db756b426 100644 --- a/actions/harness/src/lib.rs +++ b/actions/harness/src/lib.rs @@ -36,8 +36,8 @@ mod derivation_fixture; mod batcher; pub use batcher::{ - Batcher, BatcherConfig, BatcherError, BlockSourceItem, HarnessBlockSource, Inner, - L1MinerTxManager, L1SignedSubmission, Pending, + Batcher, BatcherConfig, BatcherError, HarnessL1HeadSource, Inner, L1HeadItem, L1MinerTxManager, + L1SignedSubmission, Pending, }; mod matrix; diff --git a/actions/harness/tests/batcher/submission.rs b/actions/harness/tests/batcher/submission.rs index 23efd33ed1..131ecb2a1e 100644 --- a/actions/harness/tests/batcher/submission.rs +++ b/actions/harness/tests/batcher/submission.rs @@ -135,11 +135,11 @@ async fn batcher_reorg_during_submission() { chain.push(h.l1.tip().clone()); // --- L1 reorg back to genesis (frames still in staged) --- - // reorg_to fires Err(TxManagerError::Rpc("reorg")) for every staged item and - // sends L1 head 0. The driver handles each failed receipt with - // pipeline.requeue(id), rewinding the channel cursor without re-encoding. - batcher.reorg(0, &mut h.l1); - batcher.wait_until_requeued(1).await; + // Every staged item gets a failure receipt and the driver learns of L1 head 0. It + // handles each failed receipt with pipeline.requeue(id), rewinding the channel cursor + // without re-encoding, then resubmits the frame. + batcher.reorg(0, &mut h.l1).await; + assert_eq!(batcher.pending_count(), 1, "the reorged frame must be resubmitted"); // Mine an empty replacement block on the new fork, then resubmit the // requeued frames using the same Batcher (no drop/recreate required). diff --git a/actions/harness/tests/batcher/submission_failure.rs b/actions/harness/tests/batcher/submission_failure.rs index dd12af0846..6969018f2a 100644 --- a/actions/harness/tests/batcher/submission_failure.rs +++ b/actions/harness/tests/batcher/submission_failure.rs @@ -4,7 +4,7 @@ //! sequencer → batcher (submission fails) → batcher requeues → derivation //! //! The [`BatchDriver`] reacts to a failed [`SendHandle`] by calling -//! `pipeline.requeue_frame()` and retrying on the next loop iteration. +//! `pipeline.requeue(id)` and retrying on the next loop iteration. //! The [`L1MinerTxManager`]'s `fail_next_n` mechanism fires an immediate //! [`TxManagerError::Rpc`] receipt so this path is exercised without any //! real L1 interaction. @@ -47,9 +47,9 @@ async fn submission_failure_requeues_and_derivation_recovers() { batcher.fail_next_n_submissions(1); batcher.encode_only().await; - // Wait for the driver to process the failure receipt and return the frame - // to the pending queue via requeue + re-submit. - batcher.wait_until_requeued(1).await; + // The driver has processed the failure receipt and returned the frame to the + // pending queue via requeue + re-submit. + assert_eq!(batcher.pending_count(), 1, "the failed frame must be resubmitted"); // Mine the successfully requeued frame into an L1 block. let block_num = batcher.mine_pending(&mut h.l1).await; @@ -90,9 +90,9 @@ async fn consecutive_failures_then_success_derives_correctly() { batcher.fail_next_n_submissions(3); batcher.encode_only().await; - // Poll until the frame has made it through all three failures and back to - // pending via the successful fourth submission. - batcher.wait_until_requeued(1).await; + // The frame has made it through all three failures and back to pending via the + // successful fourth submission. + assert_eq!(batcher.pending_count(), 1, "the failed frame must be resubmitted"); let block_num = batcher.mine_pending(&mut h.l1).await; chain.push(h.l1.tip().clone()); diff --git a/actions/harness/tests/batcher/txpool_blocked.rs b/actions/harness/tests/batcher/txpool_blocked.rs index 6bd168996f..5e782c568a 100644 --- a/actions/harness/tests/batcher/txpool_blocked.rs +++ b/actions/harness/tests/batcher/txpool_blocked.rs @@ -63,7 +63,7 @@ async fn txpool_blocked_recovers_via_cancel_tx_and_derives() { // Driver path: TxpoolBlocked receipt → requeue → recover_txpool → cancel_tx // → resubmit. The frame returns to pending only after the blockage clears. - batcher.wait_until_requeued(1).await; + assert_eq!(batcher.pending_count(), 1, "the blocked frame must be resubmitted"); assert_eq!( batcher.cancellation_count(), @@ -112,7 +112,7 @@ async fn consecutive_txpool_blocks_recover_and_derive() { batcher.block_next_n_submissions(2); batcher.encode_only().await; - batcher.wait_until_requeued(1).await; + assert_eq!(batcher.pending_count(), 1, "the blocked frame must be resubmitted"); assert_eq!( batcher.cancellation_count(), From b7f1a1343366d0f4505e63f7ab9de3c423df30b2 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Fri, 25 Sep 2026 17:54:47 +0000 Subject: [PATCH 125/299] refactor(harness): make the batcher actor a plain struct over ActionL2Source (#5285) * refactor(harness): make the batcher actor a plain struct over ActionL2Source * docs(harness): state what the batcher needs from an ActionL2Source block * docs(harness): head actor.rs with a module doc and keep push_block next to new * refactor(harness): pass the batcher driver its runtime inline --- actions/README.md | 15 +++---- actions/harness/src/action.rs | 15 ------- actions/harness/src/batcher/actor.rs | 60 ++++++++++--------------- actions/harness/src/common/l2_source.rs | 18 ++++---- actions/harness/src/lib.rs | 2 +- 5 files changed, 39 insertions(+), 71 deletions(-) diff --git a/actions/README.md b/actions/README.md index 274910409b..537aea3908 100644 --- a/actions/README.md +++ b/actions/README.md @@ -41,7 +41,7 @@ actions/ └── harness/ base-action-harness crate src/ ├── lib.rs public API (re-exports) - ├── action.rs Action trait, L2BlockProvider trait + ├── action.rs Action trait ├── harness.rs ActionTestHarness ├── matrix.rs ForkMatrix (upgrade combinations) ├── test_rollup_config.rs TestRollupConfigBuilder @@ -146,12 +146,11 @@ in-memory. ## ActionL2Source and BaseBlock The batcher actor needs to read L2 blocks in order to know what to batch. -`ActionL2Source` is a `VecDeque` that implements -`L2BlockProvider`. Tests usually fill it with blocks produced by -`L2Sequencer`, which uses the production L1 origin selector, attributes -builder, and in-process engine client. Each block therefore contains a real -L1-info deposit transaction and signed user transactions, rather than a -batcher-only mock shape. +`ActionL2Source` is a `VecDeque`. Tests usually fill it with blocks +produced by `L2Sequencer`, which uses the production L1 origin selector, +attributes builder, and in-process engine client. Each block therefore +contains a real L1-info deposit transaction and signed user transactions, +rather than a batcher-only mock shape. `ActionTestHarness::create_l2_source(n)` is the shortcut for building a source with `n` sequenced blocks. Tests that need precise block contents can create @@ -161,7 +160,7 @@ manually. ## Batcher actor -`Batcher` drains `BaseBlock`s from an `L2BlockProvider` and forwards them to a +`Batcher` drains `BaseBlock`s from an `ActionL2Source` and forwards them to a production `BatchDriver` running in a background tokio task. The driver owns a `BatchEncoder`, channel manager behavior, calldata/blob frame construction, and submission flow. The harness-owned boundary is `L1MinerTxManager`, which diff --git a/actions/harness/src/action.rs b/actions/harness/src/action.rs index b8c5c01e52..7d7c6868c0 100644 --- a/actions/harness/src/action.rs +++ b/actions/harness/src/action.rs @@ -1,5 +1,3 @@ -use base_common_consensus::BaseBlock; - /// A discrete step that a test actor can perform. /// /// Every actor in the action test framework implements this trait. A test @@ -19,16 +17,3 @@ pub trait Action { /// Perform one action step and return the result. fn act(&mut self) -> Result; } - -/// A source of L2 blocks for the batcher to consume. -/// -/// Implementations yield fully-formed [`BaseBlock`]s in order. The batcher -/// extracts the L1 epoch from the first (deposit) transaction in each block, -/// filters out all deposit transactions, and encodes the remaining user -/// transactions into a [`SingleBatch`] for submission. -/// -/// [`SingleBatch`]: base_protocol::SingleBatch -pub trait L2BlockProvider { - /// Return the next L2 block, or `None` if the source is exhausted. - fn next_block(&mut self) -> Option; -} diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 3e4d4d72c4..4bc509b81c 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -1,3 +1,5 @@ +//! [`Batcher`] actor driving a production [`BatchDriver`] through [`L1Miner`]. + use std::{sync::Arc, time::Duration}; use alloy_primitives::B256; @@ -14,12 +16,8 @@ use base_protocol::BlockInfo; use base_runtime::TokioRuntime; use base_tx_manager::TxManager; use tokio::sync::{mpsc, oneshot}; -use tokio_util::sync::CancellationToken; -use crate::{ - ActionL2Source, HarnessL1HeadSource, L1Block, L1HeadItem, L1Miner, L1MinerTxManager, - L2BlockProvider, -}; +use crate::{ActionL2Source, HarnessL1HeadSource, L1Block, L1HeadItem, L1Miner, L1MinerTxManager}; /// Configuration for the [`Batcher`] actor. #[derive(Debug, Clone)] @@ -110,9 +108,10 @@ pub enum BatcherError { /// /// [`advance`]: Batcher::advance /// [`BatchDriver`]: base_batcher_core::BatchDriver -pub struct Batcher { +#[derive(Debug)] +pub struct Batcher { /// The L2 block source to drain on each [`advance`](Batcher::advance) cycle. - l2_source: S, + l2_source: ActionL2Source, /// Feeds the driver's block source with block and reorg events. source_tx: mpsc::UnboundedSender, /// Feeds the driver's L1 head source with mined heads, and with markers. @@ -121,22 +120,11 @@ pub struct Batcher { admin: AdminHandle, /// Shared tx manager — used to stage submissions and fire their receipts. tx_manager: L1MinerTxManager, - /// Background driver task handle. + /// Background driver task, aborted on drop. driver_task: tokio::task::JoinHandle>, - /// Token used to cancel the background driver on drop. - cancel: CancellationToken, -} - -impl std::fmt::Debug for Batcher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Batcher") - .field("l2_source", &self.l2_source) - .field("tx_manager", &self.tx_manager) - .finish_non_exhaustive() - } } -impl Batcher { +impl Batcher { /// How long a method waits for the driver to go idle before giving up. pub const IDLE_TIMEOUT: Duration = Duration::from_secs(10); @@ -148,7 +136,11 @@ impl Batcher { /// /// Panics if `config.encoder` is invalid, or if `config.batcher_address` is not the /// address of `config.l1_signer`. - pub fn new(l2_source: S, rollup_config: &RollupConfig, config: BatcherConfig) -> Self { + pub fn new( + l2_source: ActionL2Source, + rollup_config: &RollupConfig, + config: BatcherConfig, + ) -> Self { let l1_chain_id = rollup_config.l1_chain_id; let pipeline = BatchEncoder::new(Arc::new(rollup_config.clone()), config.encoder.clone()) .expect("valid encoder config"); @@ -165,13 +157,10 @@ impl Batcher { "BatcherConfig::batcher_address must match BatcherConfig::l1_signer" ); - let cancel = CancellationToken::new(); - let runtime = TokioRuntime::with_token(cancel.clone()); - let (derivation_status_tx, derivation_status_rx) = mpsc::channel(1); let driver = BatchDriver::new( - runtime, + TokioRuntime::new(), pipeline, tx_manager.clone(), BatchDriverConfig { @@ -203,7 +192,14 @@ impl Batcher { driver.run().await }); - Self { l2_source, source_tx, l1_head_tx, admin, tx_manager, driver_task, cancel } + Self { l2_source, source_tx, l1_head_tx, admin, tx_manager, driver_task } + } + + /// Push a block into the L2 source for the next [`advance`] call. + /// + /// [`advance`]: Batcher::advance + pub fn push_block(&mut self, block: BaseBlock) { + self.l2_source.push(block); } /// Drain the L2 source and forward all blocks to the driver, then flush. @@ -460,18 +456,8 @@ impl Batcher { } } -impl Batcher { - /// Push a block into the L2 source for the next [`advance`] call. - /// - /// [`advance`]: Batcher::advance - pub fn push_block(&mut self, block: BaseBlock) { - self.l2_source.push(block); - } -} - -impl Drop for Batcher { +impl Drop for Batcher { fn drop(&mut self) { - self.cancel.cancel(); self.driver_task.abort(); } } diff --git a/actions/harness/src/common/l2_source.rs b/actions/harness/src/common/l2_source.rs index 6b7f024b26..7453445e6e 100644 --- a/actions/harness/src/common/l2_source.rs +++ b/actions/harness/src/common/l2_source.rs @@ -2,12 +2,11 @@ use std::collections::VecDeque; use base_common_consensus::BaseBlock; -use crate::L2BlockProvider; - /// A pre-built queue of [`BaseBlock`]s for the batcher to drain. /// -/// Tests push fully-formed blocks into the source, which the batcher -/// consumes one at a time via [`L2BlockProvider::next_block`]. +/// Tests push fully-formed blocks into the source, which the batcher consumes one at a +/// time via [`next_block`](Self::next_block). Each block must start with its L1-info +/// deposit: the batcher reads the block's L1 epoch from it. #[derive(Debug, Default)] pub struct ActionL2Source { blocks: VecDeque, @@ -31,6 +30,11 @@ impl ActionL2Source { self.blocks.push_back(block); } + /// Return the next L2 block, or `None` if the source is exhausted. + pub fn next_block(&mut self) -> Option { + self.blocks.pop_front() + } + /// Return the number of blocks remaining. pub fn remaining(&self) -> usize { self.blocks.len() @@ -53,9 +57,3 @@ impl FromIterator for ActionL2Source { Self::from_blocks(iter) } } - -impl L2BlockProvider for ActionL2Source { - fn next_block(&mut self) -> Option { - self.blocks.pop_front() - } -} diff --git a/actions/harness/src/lib.rs b/actions/harness/src/lib.rs index 2db756b426..3e2714da95 100644 --- a/actions/harness/src/lib.rs +++ b/actions/harness/src/lib.rs @@ -4,7 +4,7 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] mod action; -pub use action::{Action, L2BlockProvider}; +pub use action::Action; mod conductor; pub use conductor::{ConductorState, TestConductor, TestConductorHandle}; From 74a922c3210fad1bbe9512bb87f85e196b1a8c31 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Fri, 25 Sep 2026 18:52:18 +0000 Subject: [PATCH 126/299] fix(rpc): preserve pending queries without flashblocks snapshots (#5287) * fix(builder): bound pending multiplex shadow builds Amp-Thread-ID: https://ampcode.com/threads/T-01a0d0cd-011e-758c-9601-876ea18301ae Co-authored-by: Amp * fix(rpc): preserve executed pending blocks without flashblocks Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0d989-355a-72ce-8fbf-c0d11ecbbe5d * Remove unrelated shadow-builder changes from the PR Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0d989-355a-72ce-8fbf-c0d11ecbbe5d --------- Co-authored-by: Amp --- .../flashblocks-node/tests/flashblocks_rpc.rs | 84 +++++++++++++++++++ crates/execution/flashblocks/src/rpc/eth.rs | 65 +++++++------- crates/execution/rpc/src/eth/base_time.rs | 31 +++++-- crates/execution/rpc/src/eth/transaction.rs | 5 +- .../runner/tests/rpc_timestamp_ms.rs | 46 +++++++++- 5 files changed, 191 insertions(+), 40 deletions(-) diff --git a/crates/execution/flashblocks-node/tests/flashblocks_rpc.rs b/crates/execution/flashblocks-node/tests/flashblocks_rpc.rs index 9924f33e10..40096e0693 100644 --- a/crates/execution/flashblocks-node/tests/flashblocks_rpc.rs +++ b/crates/execution/flashblocks-node/tests/flashblocks_rpc.rs @@ -396,6 +396,90 @@ const TEST_LOG_TOPIC_1: B256 = const TEST_PARENT_BEACON_BLOCK_ROOT: B256 = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); +#[tokio::test] +async fn test_pending_without_flashblocks_snapshot() -> Result<()> { + let harness = FlashblocksHarness::new().await?; + let client = harness.rpc_client()?; + // Emit LOG0 during creation, then return runtime code that returns 42. + // Both the creation log and the contract exist only in the executed pending block. + let (deployment, contract, _) = Account::Deployer.create_deployment_tx( + bytes!("60006000a0600a6011600039600a6000f3602a60005260206000f3"), + 0, + )?; + let prepared = harness.prepare_unsafe_block(vec![L1_BLOCK_INFO_DEPOSIT_TX, deployment]).await?; + assert!(harness.flashblocks_state().get_pending_blocks().as_ref().is_none()); + + let call = json!({ "to": contract, "gas": "0x186a0" }); + let latest_call: Bytes = client.request("eth_call", json!([call, "latest"])).await?; + assert!(latest_call.is_empty()); + let latest_gas: U256 = client.request("eth_estimateGas", json!([call, "latest"])).await?; + let latest_nonce: U256 = client + .request("eth_getTransactionCount", json!([Account::Deployer.address(), "latest"])) + .await?; + assert_eq!(latest_nonce, U256::ZERO); + + let block: serde_json::Value = + client.request("eth_getBlockByNumber", json!(["pending", false])).await?; + let count: U256 = + client.request("eth_getBlockTransactionCountByNumber", json!(["pending"])).await?; + let nonce: U256 = client + .request("eth_getTransactionCount", json!([Account::Deployer.address(), "pending"])) + .await?; + let output: Bytes = client.request("eth_call", json!([call, "pending"])).await?; + let gas: U256 = client.request("eth_estimateGas", json!([call, "pending"])).await?; + let simulated: serde_json::Value = client + .request("eth_simulateV1", json!([{ "blockStateCalls": [{ "calls": [call] }] }, "pending"])) + .await?; + // Base's local_pending_block returns latest, so regular log filtering falls through + // to range validation and rejects an end above the canonical head, even for pending-only. + let mut log_errors = Vec::new(); + for from in ["pending", "earliest"] { + let result: Result = client + .request("eth_getLogs", json!([{ "fromBlock": from, "toBlock": "pending" }])) + .await; + log_errors + .push(result.err().and_then(|error| error.as_error_resp().map(|error| error.code))); + } + let expected_output = format!("0x{:064x}", 42); + assert_eq!( + json!({ + "blockHash": block["hash"], + "transactionCount": count, + "nonce": nonce, + "call": output, + "executesContractForGas": gas > latest_gas, + "simulatedCall": simulated[0]["calls"][0]["returnData"], + "logErrors": log_errors, + }), + json!({ + "blockHash": prepared.new_block_hash, + "transactionCount": U256::from(2), + "nonce": U256::from(1), + "call": expected_output, + "executesContractForGas": true, + "simulatedCall": expected_output, + "logErrors": [-32602, -32602], + }), + ); + + let latest: serde_json::Value = + client.request("eth_getBlockByNumber", json!(["latest", false])).await?; + assert_eq!(latest["hash"], json!(prepared.parent_hash)); + + // Once FCU consumes the executed pending block, the regular fallback is latest again. + harness.engine().update_forkchoice(prepared.parent_hash, prepared.new_block_hash, None).await?; + harness.wait_for_header(prepared.new_block_hash, prepared.new_block_number).await?; + let pending: serde_json::Value = + client.request("eth_getBlockByNumber", json!(["pending", false])).await?; + assert_eq!(pending["hash"], json!(prepared.new_block_hash)); + let logs: Vec = client + .request("eth_getLogs", json!([{ "fromBlock": "pending", "toBlock": "pending" }])) + .await?; + assert_eq!(logs.len(), 1); + assert_eq!(logs[0]["address"], json!(contract)); + Ok(()) +} + #[tokio::test] async fn test_get_pending_block() -> Result<()> { let setup = TestSetup::new().await?; diff --git a/crates/execution/flashblocks/src/rpc/eth.rs b/crates/execution/flashblocks/src/rpc/eth.rs index caa8acb90e..c06c59249c 100644 --- a/crates/execution/flashblocks/src/rpc/eth.rs +++ b/crates/execution/flashblocks/src/rpc/eth.rs @@ -220,13 +220,9 @@ where if pending_blocks.as_ref().is_some() { return Ok(pending_blocks.get_block(full)); } - // No pending state available — treat `pending` as `latest` - EthBlocks::rpc_block(&self.eth_api, BlockNumberOrTag::Latest.into(), full) - .await - .map_err(Into::into) - } else { - EthBlocks::rpc_block(&self.eth_api, number.into(), full).await.map_err(Into::into) } + + EthBlocks::rpc_block(&self.eth_api, number.into(), full).await.map_err(Into::into) } async fn get_transaction_receipt( @@ -300,10 +296,14 @@ where Metrics::rpc_get_transaction_count().increment(1); let (resolved_block, overrides) = if block_id.is_pending() { let pending_blocks = self.flashblocks_state.get_pending_blocks(); - ( - pending_blocks.get_canonical_block_number().into(), - pending_blocks.get_state_overrides(), - ) + if pending_blocks.is_some() { + ( + pending_blocks.get_canonical_block_number().into(), + pending_blocks.get_state_overrides(), + ) + } else { + (block_id, None) + } } else { (block_id, None) }; @@ -322,15 +322,17 @@ where if block_id.is_pending() { Metrics::rpc_get_transaction_count().increment(1); let pending_blocks = self.flashblocks_state.get_pending_blocks(); - let canon_block = pending_blocks.get_canonical_block_number(); - let fb_count = pending_blocks.get_transaction_count(address); + if pending_blocks.is_some() { + let canon_block = pending_blocks.get_canonical_block_number(); + let fb_count = pending_blocks.get_transaction_count(address); - let canon_count = - EthState::transaction_count(&self.eth_api, address, Some(canon_block.into())) - .await - .map_err(Into::into)?; + let canon_count = + EthState::transaction_count(&self.eth_api, address, Some(canon_block.into())) + .await + .map_err(Into::into)?; - return Ok(canon_count + fb_count); + return Ok(canon_count + fb_count); + } } EthState::transaction_count(&self.eth_api, address, block_number).await.map_err(Into::into) @@ -443,8 +445,10 @@ where if block_id.is_pending() { Metrics::rpc_call().increment(1); let pending_blocks = self.flashblocks_state.get_pending_blocks(); - block_id = pending_blocks.get_canonical_block_number().into(); - pending_overrides.state = pending_blocks.get_state_overrides(); + if pending_blocks.is_some() { + block_id = pending_blocks.get_canonical_block_number().into(); + pending_overrides.state = pending_blocks.get_state_overrides(); + } } // Apply user's overrides on top @@ -484,8 +488,10 @@ where if block_id.is_pending() { Metrics::rpc_estimate_gas().increment(1); let pending_blocks = self.flashblocks_state.get_pending_blocks(); - block_id = pending_blocks.get_canonical_block_number().into(); - pending_overrides.state = pending_blocks.get_state_overrides(); + if pending_blocks.is_some() { + block_id = pending_blocks.get_canonical_block_number().into(); + pending_overrides.state = pending_blocks.get_state_overrides(); + } } let mut state_overrides_builder = @@ -535,8 +541,10 @@ where if block_id.is_pending() { Metrics::rpc_simulate_v1().increment(1); let pending_blocks = self.flashblocks_state.get_pending_blocks(); - block_id = pending_blocks.get_canonical_block_number().into(); - pending_overrides.state = pending_blocks.get_state_overrides(); + if pending_blocks.is_some() { + block_id = pending_blocks.get_canonical_block_number().into(); + pending_overrides.state = pending_blocks.get_state_overrides(); + } } // Prepend flashblocks pending overrides to the block state calls @@ -584,6 +592,9 @@ where let mut all_logs = Vec::new(); let pending_blocks = self.flashblocks_state.get_pending_blocks(); + if pending_blocks.is_none() { + return self.eth_filter.logs(filter).await; + } let mut fetched_logs = HashSet::new(); // Get historical logs if fromBlock is not pending @@ -634,14 +645,6 @@ where let count = block.transactions.len(); return Ok(Some(U256::from(count))); } - // No pending state available — treat `pending` as `latest` - return EthBlocks::block_transaction_count( - &self.eth_api, - BlockNumberOrTag::Latest.into(), - ) - .await - .map(|opt| opt.map(U256::from)) - .map_err(Into::into); } EthBlocks::block_transaction_count(&self.eth_api, number.into()) diff --git a/crates/execution/rpc/src/eth/base_time.rs b/crates/execution/rpc/src/eth/base_time.rs index 629be105cf..3b82f0c8a2 100644 --- a/crates/execution/rpc/src/eth/base_time.rs +++ b/crates/execution/rpc/src/eth/base_time.rs @@ -9,7 +9,8 @@ use alloy_primitives::BlockHash; use base_common_consensus::BaseTransaction; use base_protocol::BaseTimeUpdateTx; use lru::LruCache; -use reth_storage_api::{TransactionsProvider, errors::ProviderError}; +use reth_primitives_traits::BlockBody as _; +use reth_storage_api::{BlockReader, errors::ProviderError}; /// Cache of validated `BaseTime` timestamps keyed by block hash. #[derive(Clone, Debug)] @@ -39,7 +40,7 @@ impl BaseTimeCache { ) -> Result, ProviderError> where T: BaseTransaction, - Provider: TransactionsProvider, + Provider: BlockReader, { if let Some(timestamp_ms) = self.timestamps.lock().unwrap_or_else(PoisonError::into_inner).get(&block_hash).copied() @@ -47,13 +48,31 @@ impl BaseTimeCache { return Ok(timestamp_ms); } - let transactions = match provider.transactions_by_block(block_hash.into()) { - Ok(Some(transactions)) => transactions, - Ok(None) | Err(ProviderError::BlockExpired { .. }) => return Ok(None), + match provider.transactions_by_block(block_hash.into()) { + Ok(Some(transactions)) => { + return Ok(self.insert_from_transactions( + block_hash, + block_number, + block_timestamp, + &transactions, + )); + } + Ok(None) | Err(ProviderError::BlockExpired { .. }) => {} Err(error) => return Err(error), + } + + // Transaction lookups exclude executed payloads until forkchoice makes them canonical. + let Some(block) = provider.pending_block()?.filter(|block| block.hash() == block_hash) + else { + return Ok(None); }; - Ok(self.insert_from_transactions(block_hash, block_number, block_timestamp, &transactions)) + Ok(self.insert_from_transactions( + block_hash, + block_number, + block_timestamp, + block.body().transactions(), + )) } /// Validates, caches, and returns a block's millisecond timestamp from its transactions. diff --git a/crates/execution/rpc/src/eth/transaction.rs b/crates/execution/rpc/src/eth/transaction.rs index ad1948b8e4..c85a943592 100644 --- a/crates/execution/rpc/src/eth/transaction.rs +++ b/crates/execution/rpc/src/eth/transaction.rs @@ -27,7 +27,8 @@ use reth_rpc_eth_api::{ }; use reth_rpc_eth_types::{EthApiError, TransactionSource, block::convert_transaction_receipt}; use reth_storage_api::{ - BlockReaderIdExt, ProviderTx, ReceiptProvider, TransactionsProvider, errors::ProviderError, + BlockReader, BlockReaderIdExt, ProviderTx, ReceiptProvider, TransactionsProvider, + errors::ProviderError, }; use reth_transaction_pool::{ AddedTransactionOutcome, PoolTransaction, TransactionOrigin, TransactionPool, @@ -281,7 +282,7 @@ impl BaseTxInfoMapper { impl TxInfoMapper for BaseTxInfoMapper where T: BaseTransaction + SignedTransaction, - Provider: TransactionsProvider + ReceiptProvider, + Provider: BlockReader + ReceiptProvider, { type Out = BaseTransactionInfo; type Err = ProviderError; diff --git a/crates/execution/runner/tests/rpc_timestamp_ms.rs b/crates/execution/runner/tests/rpc_timestamp_ms.rs index 6ee2949005..30037f1938 100644 --- a/crates/execution/runner/tests/rpc_timestamp_ms.rs +++ b/crates/execution/runner/tests/rpc_timestamp_ms.rs @@ -1,4 +1,4 @@ -//! End-to-end wire checks for canonical Denim RPC timestamps. +//! End-to-end wire checks for canonical and pending Denim RPC timestamps. use std::{collections::HashMap, sync::Arc, time::Duration}; @@ -42,6 +42,50 @@ fn receipt_logs(receipts: &Value, transaction_hash: &str) -> Value { .clone() } +#[tokio::test] +async fn pending_denim_timestamp_is_independent_of_request_order() -> eyre::Result<()> { + let mut genesis = build_test_genesis(); + genesis.config.extra_fields.insert("base".into(), json!({ "denim": 3 })); + let harness = TestHarness::builder() + .with_chain_spec(Arc::new(BaseChainSpec::from_genesis(genesis))) + .build() + .await?; + let client = harness.rpc_client()?; + let base_time = BaseTimeUpdateTx::new(TIMESTAMP_MILLIS_PART)?.into_deposit_tx(BLOCK_NUMBER); + let prepared = harness + .prepare_unsafe_block(vec![L1_BLOCK_INFO_DEPOSIT_TX, base_time.encoded_2718().into()]) + .await?; + + let cold = + request(&client, "eth_getTransactionByBlockNumberAndIndex", json!(["pending", "0x1"])) + .await?; + assert_eq!(cold["blockHash"], json!(prepared.new_block_hash)); + assert_eq!(cold["hash"], json!(base_time.hash())); + + // Even a hashes-only block request warms the timestamp cache. + let block = request(&client, "eth_getBlockByNumber", json!(["pending", false])).await?; + assert_eq!(block["hash"], json!(prepared.new_block_hash)); + assert_quantity(&block, "timestampMs"); + let warm = + request(&client, "eth_getTransactionByBlockNumberAndIndex", json!(["pending", "0x1"])) + .await?; + assert_quantity(&warm, "blockTimestampMs"); + assert_eq!(cold, warm, "a block request must not change pending transaction metadata"); + assert_quantity(&cold, "blockTimestampMs"); + + // Neither RPC request is allowed to advance forkchoice. + let latest = request(&client, "eth_getBlockByNumber", json!(["latest", false])).await?; + assert_eq!(latest["hash"], json!(prepared.parent_hash)); + harness.engine().update_forkchoice(prepared.parent_hash, prepared.new_block_hash, None).await?; + harness.wait_for_header(prepared.new_block_hash, prepared.new_block_number).await?; + let canonical = + request(&client, "eth_getTransactionByBlockNumberAndIndex", json!(["latest", "0x1"])) + .await?; + assert_eq!(canonical["hash"], cold["hash"]); + assert_quantity(&canonical, "blockTimestampMs"); + Ok(()) +} + #[tokio::test] async fn canonical_denim_rpc_responses_include_millisecond_timestamps() -> eyre::Result<()> { let mut genesis = build_test_genesis(); From f0a94cbf66a55c26b64dd5fd14f122083e945fb7 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 25 Sep 2026 19:26:03 +0000 Subject: [PATCH 127/299] bench(trie): add tombstone skip reads benchmark (#5276) Adds a focused Criterion benchmark for cursor iteration across deeply versioned tombstoned keys. The RocksDB cursor fix and its regression coverage are now supplied by main. --- crates/execution/trie/Cargo.toml | 4 + .../trie/benches/tombstone_skip_reads.rs | 159 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 crates/execution/trie/benches/tombstone_skip_reads.rs diff --git a/crates/execution/trie/Cargo.toml b/crates/execution/trie/Cargo.toml index f7c01396f2..c829b5e564 100644 --- a/crates/execution/trie/Cargo.toml +++ b/crates/execution/trie/Cargo.toml @@ -97,6 +97,10 @@ harness = false name = "cursor_iteration" harness = false +[[bench]] +name = "tombstone_skip_reads" +harness = false + [features] serde-bincode-compat = [ "alloy-consensus/serde-bincode-compat", diff --git a/crates/execution/trie/benches/tombstone_skip_reads.rs b/crates/execution/trie/benches/tombstone_skip_reads.rs new file mode 100644 index 0000000000..8d8f08f496 --- /dev/null +++ b/crates/execution/trie/benches/tombstone_skip_reads.rs @@ -0,0 +1,159 @@ +//! Benchmarks a cursor `next()` walk (`RocksdbVersionedCursor::next_live_candidate`) +//! across a keyspace dominated by TOMBSTONED keys that each carry a deep +//! version chain, sorting before a small number of genuinely live keys. +//! +//! Every graveyard key is written across `VERSIONS_PER_DEAD_KEY` sequential +//! blocks and then tombstoned in one final block. A forward cursor walk must +//! resolve each graveyard key as dead and skip past its entire version chain +//! before reaching the live keys. This isolates the fix under discussion: a +//! resolved-dead key's remaining raw rows should be skipped with a single +//! seek instead of being scanned one row at a time. + +use std::{hint::black_box, sync::Arc}; + +use alloy_eips::{BlockNumHash, eip1898::BlockWithParent}; +use alloy_primitives::{B256, U256}; +use base_execution_trie::{BaseProofsInitialStateStore, BaseProofsStore, RocksdbProofsStorage}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use reth_primitives_traits::Account; +use reth_trie::hashed_cursor::HashedCursor; +use reth_trie_common::{HashedPostState, updates::TrieUpdates}; +use tempfile::TempDir; + +const GRAVEYARD_KEYS: usize = 200; +const VERSIONS_PER_DEAD_KEY: u64 = 200; +const LIVE_KEYS: usize = 8; + +struct TombstoneFixture { + _dir: TempDir, + storage: Arc, + max_block_number: u64, +} + +fn key_for(index: usize) -> B256 { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&(index as u64).to_be_bytes()); + B256::from(bytes) +} + +fn graveyard_key(index: usize) -> B256 { + key_for(index) +} + +fn live_key(index: usize) -> B256 { + key_for(GRAVEYARD_KEYS + 1_000_000 + index) +} + +fn account_at(nonce: u64) -> Account { + Account { nonce, balance: U256::from(nonce), bytecode_hash: None } +} + +const fn block_for(number: u64, parent: B256) -> BlockWithParent { + BlockWithParent { + parent, + block: BlockNumHash { + number, + hash: if number == 0 { B256::ZERO } else { B256::repeat_byte(number as u8) }, + }, + } +} + +fn store_accounts( + rocksdb: &Arc, + block_ref: BlockWithParent, + entries: impl IntoIterator)>, +) { + let mut post_state = HashedPostState::default(); + for (key, value) in entries { + post_state.accounts.insert(key, value); + } + rocksdb + .store_trie_updates( + block_ref, + base_execution_trie::BlockStateDiff { + sorted_trie_updates: TrieUpdates::default().into_sorted(), + sorted_post_state: post_state.into_sorted(), + }, + ) + .expect("store block state diff"); +} + +fn create_fixture() -> TombstoneFixture { + let dir = TempDir::new().expect("create temp dir"); + let rocksdb = Arc::new(RocksdbProofsStorage::new(dir.path()).expect("create RocksDB storage")); + + let genesis = (0..GRAVEYARD_KEYS) + .map(|index| (graveyard_key(index), Some(account_at(0)))) + .chain((0..LIVE_KEYS).map(|index| (live_key(index), Some(account_at(0))))); + rocksdb.store_hashed_accounts(genesis.collect()).expect("store genesis hashed accounts"); + rocksdb.set_initial_state_anchor(BlockNumHash::new(0, B256::ZERO)).expect("set anchor"); + rocksdb.commit_initial_state().expect("commit initial state"); + + let mut parent_hash = B256::ZERO; + for version in 1..=VERSIONS_PER_DEAD_KEY { + let block_ref = block_for(version, parent_hash); + store_accounts( + &rocksdb, + block_ref, + (0..GRAVEYARD_KEYS).map(|index| (graveyard_key(index), Some(account_at(version)))), + ); + parent_hash = block_ref.block.hash; + } + + let tombstone_block = VERSIONS_PER_DEAD_KEY + 1; + let block_ref = block_for(tombstone_block, parent_hash); + store_accounts( + &rocksdb, + block_ref, + (0..GRAVEYARD_KEYS).map(|index| (graveyard_key(index), None)), + ); + + let fixture = + TombstoneFixture { _dir: dir, storage: rocksdb, max_block_number: tombstone_block }; + validate_fixture(&fixture); + fixture +} + +fn validate_fixture(fixture: &TombstoneFixture) { + let mut cursor = + fixture.storage.account_hashed_cursor(fixture.max_block_number).expect("open cursor"); + let mut live_count = 0; + while let Some((key, _account)) = cursor.next().expect("cursor next") { + assert!(key >= live_key(0), "graveyard key surfaced as live: {key}"); + live_count += 1; + } + assert_eq!(live_count, LIVE_KEYS, "expected only the live keys to survive the walk"); +} + +fn walk_full_keyspace(fixture: &TombstoneFixture) -> usize { + let mut cursor = + fixture.storage.account_hashed_cursor(fixture.max_block_number).expect("open cursor"); + let mut count = 0; + while let Some(entry) = cursor.next().expect("cursor next") { + black_box(entry); + count += 1; + } + count +} + +fn tombstone_skip_benches(c: &mut Criterion) { + let fixture = create_fixture(); + + let mut group = c.benchmark_group("tombstone_skip_reads"); + group.sample_size(10); + + group.bench_function( + BenchmarkId::new( + "full_keyspace_walk", + format!("{GRAVEYARD_KEYS}_dead_x_{VERSIONS_PER_DEAD_KEY}_versions"), + ), + |b| { + b.iter(|| black_box(walk_full_keyspace(&fixture))); + }, + ); + + group.finish(); +} + +criterion_group!(benches, tombstone_skip_benches); +criterion_main!(benches); From 0c50432b3d0320578eb659f92bfa8efe6e8df6aa Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Fri, 25 Sep 2026 19:54:27 +0000 Subject: [PATCH 128/299] perf(trie): use exact point lookups for proofs state reads (#5275) * perf(trie): use exact point lookups for proofs state reads BaseProofsStateProviderRef served basic_account and storage reads with a range-cursor seek plus an equality check. On a miss, tombstone, zero slot, or key with only newer versions, seek walks forward across dead keys and all of their version rows, which made debug_executePayload witness runs take minutes on RocksDB proofs nodes. Add hashed_account_with_tx and hashed_storage_with_tx point lookups to BaseProofsStore, backed by the existing exact latest-version lookup in the RocksDB and MDBX versioned cursors, and use them from the provider. Semantics are unchanged: newest version at or below the bound, None for absent, tombstoned, or zero-valued entries. The metrics wrapper records them as hashed_cursor_seek_exact. * bench(trie): cover dead-key scans in proofs point reads Add a dead_key_reads criterion bench that places dead neighbors right after a target in hash order (a tombstoned account, keys with only versions newer than the read block, and a run of zero-valued storage slots) and measures the previous hashed-cursor seek plus equality check against the provider point-lookup path. --- crates/execution/exex/README.md | 2 +- crates/execution/trie/Cargo.toml | 4 + .../execution/trie/benches/dead_key_reads.rs | 324 ++++++++++++++++++ crates/execution/trie/src/api.rs | 29 ++ crates/execution/trie/src/db/cursor.rs | 20 ++ crates/execution/trie/src/db/rocksdb.rs | 47 +++ crates/execution/trie/src/db/store.rs | 27 ++ crates/execution/trie/src/in_memory.rs | 34 ++ crates/execution/trie/src/metrics.rs | 34 ++ crates/execution/trie/src/provider.rs | 35 +- .../trie/tests/hashed_cursors/mod.rs | 116 +++++++ crates/execution/trie/tests/lib.rs | 25 ++ 12 files changed, 677 insertions(+), 20 deletions(-) create mode 100644 crates/execution/trie/benches/dead_key_reads.rs diff --git a/crates/execution/exex/README.md b/crates/execution/exex/README.md index 701b4c8f56..2117b9091c 100644 --- a/crates/execution/exex/README.md +++ b/crates/execution/exex/README.md @@ -136,7 +136,7 @@ When the `metrics` feature is enabled, the proofs-history system exposes Prometh Per-operation `duration_seconds` histograms are recorded for: `store_account_branch`, `store_storage_branch`, `store_hashed_account`, `store_hashed_storage`, `trie_cursor_seek_exact`, `trie_cursor_seek`, `trie_cursor_next`, `trie_cursor_current`, -`hashed_cursor_seek`, `hashed_cursor_next`. +`hashed_cursor_seek`, `hashed_cursor_seek_exact`, `hashed_cursor_next`. ## Performance diff --git a/crates/execution/trie/Cargo.toml b/crates/execution/trie/Cargo.toml index c829b5e564..ef2637b962 100644 --- a/crates/execution/trie/Cargo.toml +++ b/crates/execution/trie/Cargo.toml @@ -101,6 +101,10 @@ harness = false name = "tombstone_skip_reads" harness = false +[[bench]] +name = "dead_key_reads" +harness = false + [features] serde-bincode-compat = [ "alloy-consensus/serde-bincode-compat", diff --git a/crates/execution/trie/benches/dead_key_reads.rs b/crates/execution/trie/benches/dead_key_reads.rs new file mode 100644 index 0000000000..dbf9992873 --- /dev/null +++ b/crates/execution/trie/benches/dead_key_reads.rs @@ -0,0 +1,324 @@ +//! Benchmarks proofs-history point reads whose hash-order neighbors are dead keys. +//! +//! `BaseProofsStateProviderRef::basic_account` and `storage` used to answer +//! point reads with a range-cursor `seek()` plus an equality check. On a miss, +//! the `RocksDB` cursor walks forward to the next live key, iterating every +//! version row of each dead neighbor on the way (a key whose latest version is +//! a tombstone, or whose versions are all newer than the read snapshot), and +//! the storage cursor additionally steps over consecutive zero-valued slots. +//! The provider now uses exact point lookups instead. +//! +//! Each scenario places the dead neighbors immediately after the target in +//! hash order (target hash + 1, + 2, ...) and a live terminator key after +//! them, then measures: +//! +//! - `old`: the previous logic, `seek(target)` on a hashed cursor plus an equality check. +//! - `new`: the provider read path (`basic_account` / `storage`). +//! +//! Scenarios, parameterized over `N` dead versions or zero slots: +//! +//! - `account_tombstone_neighbor`: account miss whose next key is a deleted account with `N` +//! versions ending in a tombstone. +//! - `account_newer_neighbor`: account miss at block 0 whose next key only has `N` versions newer +//! than block 0. +//! - `storage_newer_neighbor`: storage miss at block 0 whose next slot only has `N` versions newer +//! than block 0. +//! - `storage_zero_run`: storage read of a zero-valued slot followed by `N` more zero-valued slots, +//! then a live slot. + +use std::{hint::black_box, sync::Arc}; + +use alloy_eips::{BlockNumHash, eip1898::BlockWithParent}; +use alloy_primitives::{Address, B256, U256, keccak256}; +use base_execution_trie::{ + BaseProofsInitialStateStore, BaseProofsStorage, BaseProofsStore, BlockStateDiff, + RocksdbProofsStorage, provider::BaseProofsStateProviderRef, +}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use reth_primitives_traits::Account; +use reth_provider::{AccountReader, StateProvider, noop::NoopProvider}; +use reth_trie::hashed_cursor::HashedCursor; +use reth_trie_common::{HashedPostState, HashedStorage, updates::TrieUpdates}; +use tempfile::TempDir; + +const DEAD_COUNTS: [u64; 3] = [10, 1_000, 10_000]; +const LIVE_VALUE: U256 = U256::from_limbs([1, 0, 0, 0]); + +type Storage = BaseProofsStorage>; + +/// A target key plus the hashed keys that follow it in hash order. +struct Target { + /// Plain key handed to the provider, which hashes it: a storage slot, or a left-padded + /// address. + key: B256, + /// Hashed key the old cursor path seeks: `keccak256(slot)` or `keccak256(address)`. + hashed: B256, +} + +impl Target { + fn account(seed: &str) -> Self { + let address = Address::from_word(keccak256(seed.as_bytes())); + Self { key: address.into_word(), hashed: keccak256(address) } + } + + fn slot(seed: &str) -> Self { + let key = keccak256(seed.as_bytes()); + Self { key, hashed: keccak256(key) } + } + + fn address(&self) -> Address { + Address::from_word(self.key) + } + + /// Hashed key `offset` positions after the target in hash order. + fn next(&self, offset: u64) -> B256 { + B256::from(U256::from_be_bytes(self.hashed.0) + U256::from(offset)) + } +} + +struct Fixture { + _dir: TempDir, + storage: Storage, + head: u64, + dead: u64, + /// Account miss whose neighbor is tombstoned at head. + tombstone: Target, + /// Account miss whose neighbor only has versions above block 0. + newer_account: Target, + /// Account holding `newer_slot` and its neighbor. + newer_storage_account: Target, + /// Storage miss whose neighbor slot only has versions above block 0. + newer_slot: Target, + /// Account holding the zero-valued slot run. + zero_account: Target, + /// Zero-valued slot followed by `dead` more zero-valued slots. + zero_slot: Target, +} + +const fn account(nonce: u64) -> Account { + Account { nonce, balance: U256::from_limbs([nonce + 1, 0, 0, 0]), bytecode_hash: None } +} + +fn block_hash(number: u64) -> B256 { + if number == 0 { B256::ZERO } else { B256::from(U256::from(number)) } +} + +fn block_for(number: u64) -> BlockWithParent { + BlockWithParent { + parent: block_hash(number - 1), + block: BlockNumHash { number, hash: block_hash(number) }, + } +} + +fn create_fixture(dead: u64) -> Fixture { + let dir = TempDir::new().expect("create temp dir"); + let rocksdb = Arc::new(RocksdbProofsStorage::new(dir.path()).expect("create RocksDB storage")); + + let tombstone = Target::account("tombstone"); + let newer_account = Target::account("newer-account"); + let newer_storage_account = Target::account("newer-storage-account"); + let newer_slot = Target::slot("newer-slot"); + let zero_account = Target::account("zero-account"); + let zero_slot = Target::slot("zero-slot"); + + // Block 0: live terminators after each dead run, plus the storage owners. + rocksdb + .store_hashed_accounts(vec![ + (tombstone.next(2), Some(account(0))), + (newer_account.next(2), Some(account(0))), + (newer_storage_account.hashed, Some(account(0))), + (zero_account.hashed, Some(account(0))), + ]) + .expect("store hashed accounts"); + let zero_run = (0..=dead).map(|offset| (zero_slot.next(offset), U256::ZERO)); + rocksdb + .store_hashed_storages_bulk(vec![ + (newer_storage_account.hashed, vec![(newer_slot.next(2), LIVE_VALUE)]), + ( + zero_account.hashed, + zero_run.chain([(zero_slot.next(dead + 1), LIVE_VALUE)]).collect(), + ), + ]) + .expect("store hashed storages"); + rocksdb + .set_initial_state_anchor(BlockNumHash::new(0, B256::ZERO)) + .expect("set initial state anchor"); + rocksdb.commit_initial_state().expect("commit initial state"); + + // Blocks 1..=dead: version rows for each dead neighbor. + for number in 1..=dead { + let mut post_state = HashedPostState::default(); + let tombstone_value = (number < dead).then(|| account(number)); + post_state.accounts.insert(tombstone.next(1), tombstone_value); + post_state.accounts.insert(newer_account.next(1), Some(account(number))); + post_state.storages.insert( + newer_storage_account.hashed, + HashedStorage::from_iter([(newer_slot.next(1), U256::from(number))]), + ); + let diff = BlockStateDiff { + sorted_trie_updates: TrieUpdates::default().into_sorted(), + sorted_post_state: post_state.into_sorted(), + }; + rocksdb.store_trie_updates(block_for(number), diff).expect("store block"); + } + rocksdb.flush_and_compact().expect("flush and compact RocksDB fixture"); + + let fixture = Fixture { + _dir: dir, + storage: BaseProofsStorage::from(rocksdb), + head: dead, + dead, + tombstone, + newer_account, + newer_storage_account, + newer_slot, + zero_account, + zero_slot, + }; + validate_fixture(&fixture); + fixture +} + +/// Previous `basic_account` logic: hashed cursor `seek()` plus an equality check. +fn old_account(storage: &Storage, hashed: B256, max_block: u64) -> Option { + old_account_seek(storage, hashed, max_block) + .and_then(|(key, value)| (key == hashed).then_some(value)) +} + +fn old_account_seek(storage: &Storage, hashed: B256, max_block: u64) -> Option<(B256, Account)> { + let tx = storage.ro_tx().expect("ro tx"); + let mut cursor = storage.account_hashed_cursor_with_tx(&tx, max_block).expect("cursor"); + cursor.seek(hashed).expect("seek") +} + +/// Previous `storage` logic: storage cursor `seek()` plus an equality check. +fn old_storage(storage: &Storage, account: B256, slot: B256, max_block: u64) -> Option { + old_storage_seek(storage, account, slot, max_block) + .and_then(|(key, value)| (key == slot).then_some(value)) +} + +fn old_storage_seek( + storage: &Storage, + account: B256, + slot: B256, + max_block: u64, +) -> Option<(B256, U256)> { + let tx = storage.ro_tx().expect("ro tx"); + let mut cursor = + storage.storage_hashed_cursor_with_tx(&tx, account, max_block).expect("cursor"); + cursor.seek(slot).expect("seek") +} + +fn new_account(storage: &Storage, address: Address, max_block: u64) -> Option { + BaseProofsStateProviderRef::new(Box::::default(), storage, max_block) + .basic_account(&address) + .expect("read account") +} + +fn new_storage(storage: &Storage, address: Address, slot: B256, max_block: u64) -> Option { + BaseProofsStateProviderRef::new(Box::::default(), storage, max_block) + .storage(address, slot) + .expect("read storage") +} + +fn validate_fixture(f: &Fixture) { + let storage = &f.storage; + + // Every target reads as absent on both paths. + assert_eq!(old_account(storage, f.tombstone.hashed, f.head), None); + assert_eq!(new_account(storage, f.tombstone.address(), f.head), None); + assert_eq!(old_account(storage, f.newer_account.hashed, 0), None); + assert_eq!(new_account(storage, f.newer_account.address(), 0), None); + assert_eq!(old_storage(storage, f.newer_storage_account.hashed, f.newer_slot.hashed, 0), None); + assert_eq!(new_storage(storage, f.newer_storage_account.address(), f.newer_slot.key, 0), None); + assert_eq!(old_storage(storage, f.zero_account.hashed, f.zero_slot.hashed, f.head), None); + assert_eq!(new_storage(storage, f.zero_account.address(), f.zero_slot.key, f.head), None); + + // The old seek walks past every dead neighbor and lands on the live terminator. + assert_eq!( + old_account_seek(storage, f.tombstone.hashed, f.head), + Some((f.tombstone.next(2), account(0))) + ); + assert_eq!( + old_account_seek(storage, f.newer_account.hashed, 0), + Some((f.newer_account.next(2), account(0))) + ); + assert_eq!( + old_storage_seek(storage, f.newer_storage_account.hashed, f.newer_slot.hashed, 0), + Some((f.newer_slot.next(2), LIVE_VALUE)) + ); + assert_eq!( + old_storage_seek(storage, f.zero_account.hashed, f.zero_slot.hashed, f.head), + Some((f.zero_slot.next(f.dead + 1), LIVE_VALUE)) + ); + + // The dead neighbors are what the fixture claims they are. + assert_eq!(old_account(storage, f.tombstone.next(1), f.head - 1), Some(account(f.head - 1))); + assert_eq!(old_account(storage, f.tombstone.next(1), f.head), None); + assert_eq!(old_account(storage, f.newer_account.next(1), 1), Some(account(1))); + assert_eq!( + old_storage(storage, f.newer_storage_account.hashed, f.newer_slot.next(1), 1), + Some(U256::from(1)) + ); +} + +fn dead_key_benches(c: &mut Criterion) { + let mut group = c.benchmark_group("dead_key_reads"); + group.sample_size(10); + + for dead in DEAD_COUNTS { + let f = create_fixture(dead); + let storage = &f.storage; + + group.bench_function(BenchmarkId::new("account_tombstone_neighbor/old", dead), |b| { + b.iter(|| black_box(old_account(storage, f.tombstone.hashed, f.head))); + }); + group.bench_function(BenchmarkId::new("account_tombstone_neighbor/new", dead), |b| { + b.iter(|| black_box(new_account(storage, f.tombstone.address(), f.head))); + }); + + group.bench_function(BenchmarkId::new("account_newer_neighbor/old", dead), |b| { + b.iter(|| black_box(old_account(storage, f.newer_account.hashed, 0))); + }); + group.bench_function(BenchmarkId::new("account_newer_neighbor/new", dead), |b| { + b.iter(|| black_box(new_account(storage, f.newer_account.address(), 0))); + }); + + group.bench_function(BenchmarkId::new("storage_newer_neighbor/old", dead), |b| { + b.iter(|| { + black_box(old_storage( + storage, + f.newer_storage_account.hashed, + f.newer_slot.hashed, + 0, + )) + }); + }); + group.bench_function(BenchmarkId::new("storage_newer_neighbor/new", dead), |b| { + b.iter(|| { + black_box(new_storage( + storage, + f.newer_storage_account.address(), + f.newer_slot.key, + 0, + )) + }); + }); + + group.bench_function(BenchmarkId::new("storage_zero_run/old", dead), |b| { + b.iter(|| { + black_box(old_storage(storage, f.zero_account.hashed, f.zero_slot.hashed, f.head)) + }); + }); + group.bench_function(BenchmarkId::new("storage_zero_run/new", dead), |b| { + b.iter(|| { + black_box(new_storage(storage, f.zero_account.address(), f.zero_slot.key, f.head)) + }); + }); + } + + group.finish(); +} + +criterion_group!(benches, dead_key_benches); +criterion_main!(benches); diff --git a/crates/execution/trie/src/api.rs b/crates/execution/trie/src/api.rs index dbecd451c6..3b68892172 100644 --- a/crates/execution/trie/src/api.rs +++ b/crates/execution/trie/src/api.rs @@ -177,6 +177,35 @@ pub trait BaseProofsStore: Send + Sync + Debug { Self: 'db, 'db: 'tx; + /// Point lookup of the account at exactly `hashed_address`, reusing `tx`. + /// + /// Returns the newest version with block number <= `max_block_number`, or `None` if the key + /// is absent or that version is a tombstone. Unlike seeking an + /// [`AccountHashedCursor`](Self::AccountHashedCursor), this never scans neighboring keys. + fn hashed_account_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db; + + /// Point lookup of storage slot `hashed_slot` of `hashed_address`, reusing `tx`. + /// + /// Returns the newest version with block number <= `max_block_number`, or `None` if the slot + /// is absent, tombstoned, or zero. Unlike seeking a + /// [`StorageCursor`](Self::StorageCursor), this never scans neighboring slots. + fn hashed_storage_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + hashed_slot: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db; + /// Store a batch of trie updates. /// /// If wiped is true, the entire storage trie is wiped, but this is unsupported going forward, diff --git a/crates/execution/trie/src/db/cursor.rs b/crates/execution/trie/src/db/cursor.rs index 5a40fc649b..3ef02735d7 100644 --- a/crates/execution/trie/src/db/cursor.rs +++ b/crates/execution/trie/src/db/cursor.rs @@ -293,6 +293,18 @@ where pub const fn new(cursor: Cursor, block_number: u64, hashed_address: B256) -> Self { Self { inner: BlockNumberVersionedCursor::new(cursor, block_number), hashed_address } } + + /// Returns the live, non-zero value of exactly slot `key` at or below the cursor's max block. + /// + /// Unlike [`HashedCursor::seek`], this never scans past `key`, so misses, tombstones, and + /// zero-valued slots cost a single point lookup. + pub fn seek_exact(&mut self, key: B256) -> Result, DatabaseError> { + Ok(self + .inner + .seek_exact(HashedStorageKey::new(self.hashed_address, key))? + .map(|(_, value)| value.0) + .filter(|value| !value.is_zero())) + } } impl HashedCursor for MdbxStorageCursor @@ -384,6 +396,14 @@ where pub const fn new(cursor: Cursor, block_number: u64) -> Self { Self { inner: BlockNumberVersionedCursor::new(cursor, block_number) } } + + /// Returns the live account at exactly `key` at or below the cursor's max block. + /// + /// Unlike [`HashedCursor::seek`], this never scans past `key`, so misses and tombstones cost a + /// single point lookup. + pub fn seek_exact(&mut self, key: B256) -> Result, DatabaseError> { + Ok(self.inner.seek_exact(key)?.map(|(_, account)| account)) + } } impl HashedCursor for MdbxAccountCursor diff --git a/crates/execution/trie/src/db/rocksdb.rs b/crates/execution/trie/src/db/rocksdb.rs index d1aabd6eac..a198693701 100644 --- a/crates/execution/trie/src/db/rocksdb.rs +++ b/crates/execution/trie/src/db/rocksdb.rs @@ -1833,6 +1833,33 @@ impl BaseProofsStore for RocksdbProofsStorage { Ok(RocksdbAccountCursor::new_with_snapshot(Arc::clone(tx), max_block_number)) } + fn hashed_account_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + Ok(self.account_hashed_cursor_with_tx(tx, max_block_number)?.seek_exact(hashed_address)?) + } + + fn hashed_storage_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + hashed_slot: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + Ok(self + .storage_hashed_cursor_with_tx(tx, hashed_address, max_block_number)? + .seek_exact(hashed_slot)?) + } + fn store_trie_updates( &self, block_ref: BlockWithParent, @@ -2857,6 +2884,18 @@ impl<'db> RocksdbStorageCursor<'db> { hashed_address, } } + + /// Returns the live, non-zero value of exactly slot `key` at or below the cursor's max block. + /// + /// Unlike [`HashedCursor::seek`], this never scans past `key`, so misses, tombstones, and + /// zero-valued slots cost a single point lookup. + pub fn seek_exact(&mut self, key: B256) -> Result, DatabaseError> { + Ok(self + .inner + .seek_exact(HashedStorageKey::new(self.hashed_address, key))? + .map(|(_, value)| value.0) + .filter(|value| !value.is_zero())) + } } impl HashedCursor for RocksdbStorageCursor<'_> { @@ -2934,6 +2973,14 @@ impl<'db> RocksdbAccountCursor<'db> { ) -> Self { Self { inner: RocksdbVersionedCursor::new_with_snapshot(snapshot, max_block_number) } } + + /// Returns the live account at exactly `key` at or below the cursor's max block. + /// + /// Unlike [`HashedCursor::seek`], this never scans past `key`, so misses and tombstones cost a + /// single point lookup. + pub fn seek_exact(&mut self, key: B256) -> Result, DatabaseError> { + Ok(self.inner.seek_exact(key)?.map(|(_, account)| account)) + } } impl HashedCursor for RocksdbAccountCursor<'_> { diff --git a/crates/execution/trie/src/db/store.rs b/crates/execution/trie/src/db/store.rs index 13b1163e05..1fb7b66066 100644 --- a/crates/execution/trie/src/db/store.rs +++ b/crates/execution/trie/src/db/store.rs @@ -812,6 +812,33 @@ impl BaseProofsStore for MdbxProofsStorage { Ok(MdbxAccountCursor::new(cursor, max_block_number)) } + fn hashed_account_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + Ok(self.account_hashed_cursor_with_tx(tx, max_block_number)?.seek_exact(hashed_address)?) + } + + fn hashed_storage_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + hashed_slot: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + Ok(self + .storage_hashed_cursor_with_tx(tx, hashed_address, max_block_number)? + .seek_exact(hashed_slot)?) + } + fn store_trie_updates( &self, block_ref: BlockWithParent, diff --git a/crates/execution/trie/src/in_memory.rs b/crates/execution/trie/src/in_memory.rs index 3a73a3993f..02e5016622 100644 --- a/crates/execution/trie/src/in_memory.rs +++ b/crates/execution/trie/src/in_memory.rs @@ -644,6 +644,40 @@ impl BaseProofsStore for InMemoryProofsStorage { self.account_hashed_cursor(max_block_number) } + fn hashed_account_with_tx<'db>( + &self, + _tx: &Self::Tx<'db>, + hashed_address: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + // In-memory cursors materialize only live entries, so an equality-checked seek is exact. + Ok(self + .account_hashed_cursor(max_block_number)? + .seek(hashed_address)? + .and_then(|(key, account)| (key == hashed_address).then_some(account))) + } + + fn hashed_storage_with_tx<'db>( + &self, + _tx: &Self::Tx<'db>, + hashed_address: B256, + hashed_slot: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + // In-memory cursors materialize only live, non-zero entries, so an equality-checked seek + // is exact. + Ok(self + .storage_hashed_cursor(hashed_address, max_block_number)? + .seek(hashed_slot)? + .and_then(|(key, value)| (key == hashed_slot).then_some(value))) + } + fn store_trie_updates( &self, block_ref: BlockWithParent, diff --git a/crates/execution/trie/src/metrics.rs b/crates/execution/trie/src/metrics.rs index 39360ea663..8c67c9e99f 100644 --- a/crates/execution/trie/src/metrics.rs +++ b/crates/execution/trie/src/metrics.rs @@ -67,6 +67,8 @@ pub enum StorageOperation { TrieCursorCurrent, /// Hashed cursor seek HashedCursorSeek, + /// Hashed account or storage point lookup + HashedCursorSeekExact, /// Hashed cursor next HashedCursorNext, } @@ -84,6 +86,7 @@ impl StorageOperation { Self::TrieCursorNext => "trie_cursor_next", Self::TrieCursorCurrent => "trie_cursor_current", Self::HashedCursorSeek => "hashed_cursor_seek", + Self::HashedCursorSeekExact => "hashed_cursor_seek_exact", Self::HashedCursorNext => "hashed_cursor_next", } } @@ -498,6 +501,37 @@ where Ok(BaseProofsHashedCursorWithMetrics::new(cursor, Arc::clone(&self.metrics))) } + #[inline] + fn hashed_account_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + self.metrics.record_operation(StorageOperation::HashedCursorSeekExact, || { + self.storage.hashed_account_with_tx(tx, hashed_address, max_block_number) + }) + } + + #[inline] + fn hashed_storage_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + hashed_slot: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + self.metrics.record_operation(StorageOperation::HashedCursorSeekExact, || { + self.storage.hashed_storage_with_tx(tx, hashed_address, hashed_slot, max_block_number) + }) + } + #[inline] fn store_trie_updates( &self, diff --git a/crates/execution/trie/src/provider.rs b/crates/execution/trie/src/provider.rs index c4ed979c4c..1b56fb66d6 100644 --- a/crates/execution/trie/src/provider.rs +++ b/crates/execution/trie/src/provider.rs @@ -15,7 +15,6 @@ use reth_revm::{ }; use reth_trie::{ StateRoot, StorageRoot, - hashed_cursor::HashedCursor, proof::{self, Proof}, witness::TrieWitness, }; @@ -93,16 +92,19 @@ impl<'a, Storage: BaseProofsStore + Clone> BaseProofsStateProviderRef<'a, Storag hashed_key: B256, ) -> ProviderResult> { let tx = self.ensure_tx()?; - let mut cursor = self - .storage - .storage_hashed_cursor_with_tx(&tx, keccak256(address.0), self.block_number) - .map_err(Into::::into)?; - let found = StateMetrics::record_seek( + let hashed_address = keccak256(address.0); + Ok(StateMetrics::record_seek( StateSeekKind::Storage, - || cursor.seek(hashed_key).map_err(Into::::into), - |found| found.as_ref().is_some_and(|(key, _)| *key == hashed_key), - )?; - Ok(found.and_then(|(key, value)| (key == hashed_key).then_some(value))) + || { + self.storage.hashed_storage_with_tx( + &tx, + hashed_address, + hashed_key, + self.block_number, + ) + }, + Option::is_some, + )?) } } @@ -239,16 +241,11 @@ impl<'a, Storage: BaseProofsStore> AccountReader for BaseProofsStateProviderRef< fn basic_account(&self, address: &Address) -> ProviderResult> { let hashed_key = keccak256(address.0); let tx = self.ensure_tx()?; - let mut cursor = self - .storage - .account_hashed_cursor_with_tx(&tx, self.block_number) - .map_err(Into::::into)?; - let found = StateMetrics::record_seek( + Ok(StateMetrics::record_seek( StateSeekKind::Account, - || cursor.seek(hashed_key).map_err(Into::::into), - |found| found.as_ref().is_some_and(|(key, _)| *key == hashed_key), - )?; - Ok(found.and_then(|(key, account)| (key == hashed_key).then_some(account))) + || self.storage.hashed_account_with_tx(&tx, hashed_key, self.block_number), + Option::is_some, + )?) } } diff --git a/crates/execution/trie/tests/hashed_cursors/mod.rs b/crates/execution/trie/tests/hashed_cursors/mod.rs index 301b44f7b2..0f5e10ece1 100644 --- a/crates/execution/trie/tests/hashed_cursors/mod.rs +++ b/crates/execution/trie/tests/hashed_cursors/mod.rs @@ -697,3 +697,119 @@ fn test_rocksdb_exact_lookup_returns_none_when_versions_are_above_bound() Ok(()) } + +/// Point lookups must return exactly the newest live version at or below the bound, and must not +/// fall through to a neighbor when the target is missing, tombstoned, zero, or only newer. The +/// layout places misses next to a key whose many versions end in a tombstone, which is the shape +/// that made range-cursor seeks walk dead history. +#[test_case(InMemoryProofsStorage::new(); "InMemory")] +#[test_case(create_mdbx_proofs_storage(); "Mdbx")] +#[test_case(create_rocksdb_proofs_storage(); "Rocksdb")] +#[serial] +fn test_point_lookups_match_exact_seek_semantics< + S: BaseProofsStore + BaseProofsInitialStateStore, +>( + storage: S, +) -> Result<(), BaseProofsStorageError> { + const DEAD_KEY_LIVE_BLOCKS: u64 = 50; + const DEAD_KEY_TOMBSTONE_BLOCK: u64 = DEAD_KEY_LIVE_BLOCKS + 1; + const UPDATE_BLOCK: u64 = 55; + const LAST_BLOCK: u64 = 60; + + let left = B256::repeat_byte(0x10); + let miss_before_dead = B256::repeat_byte(0x18); + let dead = B256::repeat_byte(0x20); + let miss_after_dead = B256::repeat_byte(0x28); + let right = B256::repeat_byte(0x30); + let updated = B256::repeat_byte(0x50); + let newer_only = B256::repeat_byte(0x60); + let hashed_address = B256::repeat_byte(0xA0); + + let account_v1 = create_test_account_with_values(1, 100, 0xAA); + let account_v2 = create_test_account_with_values(2, 200, 0xBB); + + let mut parent = B256::ZERO; + for block in 1..=LAST_BLOCK { + let mut post_state = HashedPostState::default(); + let mut hashed_storage = HashedStorage::default(); + if block == 1 { + post_state.accounts.insert(left, Some(account_v1)); + post_state.accounts.insert(right, Some(account_v1)); + post_state.accounts.insert(updated, Some(account_v1)); + hashed_storage.storage.insert(left, U256::from(100)); + hashed_storage.storage.insert(right, U256::from(300)); + hashed_storage.storage.insert(updated, U256::from(7)); + } + if block <= DEAD_KEY_LIVE_BLOCKS { + post_state + .accounts + .insert(dead, Some(create_test_account_with_values(block, block, 0xDD))); + hashed_storage.storage.insert(dead, U256::from(block)); + } + if block == DEAD_KEY_TOMBSTONE_BLOCK { + post_state.accounts.insert(dead, None); + hashed_storage.storage.insert(dead, U256::ZERO); + } + if block == UPDATE_BLOCK { + post_state.accounts.insert(updated, Some(account_v2)); + post_state.accounts.insert(newer_only, Some(account_v2)); + hashed_storage.storage.insert(updated, U256::ZERO); + hashed_storage.storage.insert(newer_only, U256::from(600)); + } + post_state.storages.insert(hashed_address, hashed_storage); + + let block_ref = + BlockWithParent::new(parent, NumHash::new(block, B256::with_last_byte(block as u8))); + storage.store_trie_updates( + block_ref, + BlockStateDiff { + sorted_trie_updates: TrieUpdatesSorted::default(), + sorted_post_state: post_state.into_sorted(), + }, + )?; + parent = block_ref.block.hash; + } + + let tx = storage.ro_tx()?; + let account = |key, max_block| storage.hashed_account_with_tx(&tx, key, max_block); + let slot = |key, max_block| storage.hashed_storage_with_tx(&tx, hashed_address, key, max_block); + + let before_update = UPDATE_BLOCK - 1; + assert_eq!(account(left, before_update)?, Some(account_v1)); + assert_eq!(account(right, before_update)?, Some(account_v1)); + assert_eq!(account(updated, before_update)?, Some(account_v1)); + assert_eq!(account(updated, LAST_BLOCK)?, Some(account_v2)); + assert_eq!(account(newer_only, before_update)?, None); + assert_eq!(account(newer_only, LAST_BLOCK)?, Some(account_v2)); + assert_eq!( + account(dead, DEAD_KEY_LIVE_BLOCKS)?, + Some(create_test_account_with_values(DEAD_KEY_LIVE_BLOCKS, DEAD_KEY_LIVE_BLOCKS, 0xDD)) + ); + assert_eq!(account(dead, LAST_BLOCK)?, None); + assert_eq!(account(miss_before_dead, LAST_BLOCK)?, None); + assert_eq!(account(miss_after_dead, LAST_BLOCK)?, None); + + assert_eq!(slot(left, before_update)?, Some(U256::from(100))); + assert_eq!(slot(right, before_update)?, Some(U256::from(300))); + assert_eq!(slot(updated, before_update)?, Some(U256::from(7))); + assert_eq!(slot(updated, LAST_BLOCK)?, None); + assert_eq!(slot(newer_only, before_update)?, None); + assert_eq!(slot(newer_only, LAST_BLOCK)?, Some(U256::from(600))); + assert_eq!(slot(dead, DEAD_KEY_LIVE_BLOCKS)?, Some(U256::from(DEAD_KEY_LIVE_BLOCKS))); + assert_eq!(slot(dead, LAST_BLOCK)?, None); + assert_eq!(slot(miss_before_dead, LAST_BLOCK)?, None); + assert_eq!(slot(miss_after_dead, LAST_BLOCK)?, None); + + // Point lookups agree with the equality-checked range seek they replace. + for key in [left, miss_before_dead, dead, miss_after_dead, right, updated, newer_only] { + for max_block in [0, 1, DEAD_KEY_LIVE_BLOCKS, before_update, UPDATE_BLOCK, LAST_BLOCK] { + assert_eq!(account(key, max_block)?, account_exact(&storage, key, max_block)?); + assert_eq!( + slot(key, max_block)?, + storage_exact(&storage, hashed_address, key, max_block)? + ); + } + } + + Ok(()) +} diff --git a/crates/execution/trie/tests/lib.rs b/crates/execution/trie/tests/lib.rs index 6f004e9a5b..5a86711709 100644 --- a/crates/execution/trie/tests/lib.rs +++ b/crates/execution/trie/tests/lib.rs @@ -210,6 +210,31 @@ impl BaseProofsStore for TestRocksdbProofsStorage { self.storage.account_hashed_cursor_with_tx(tx, max_block_number) } + fn hashed_account_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + self.storage.hashed_account_with_tx(tx, hashed_address, max_block_number) + } + + fn hashed_storage_with_tx<'db>( + &self, + tx: &Self::Tx<'db>, + hashed_address: B256, + hashed_slot: B256, + max_block_number: u64, + ) -> BaseProofsStorageResult> + where + Self: 'db, + { + self.storage.hashed_storage_with_tx(tx, hashed_address, hashed_slot, max_block_number) + } + fn store_trie_updates( &self, block_ref: BlockWithParent, From d28fc74ba1537e1bb5b23c451948847cf31ef2f3 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Fri, 25 Sep 2026 22:45:05 +0000 Subject: [PATCH 129/299] fix(rpc): select simulation rules from the simulated timestamp (#5320) Amp-Thread-ID: https://ampcode.com/threads/T-01a0d9c9-970a-721d-9ce8-67d46f51b7e6 Co-authored-by: Amp --- crates/execution/evm/src/config.rs | 59 ++++++++++- crates/execution/runner/tests/rpc_simulate.rs | 100 ++++++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 crates/execution/runner/tests/rpc_simulate.rs diff --git a/crates/execution/evm/src/config.rs b/crates/execution/evm/src/config.rs index 4974d25c7a..a1a8a69b9d 100644 --- a/crates/execution/evm/src/config.rs +++ b/crates/execution/evm/src/config.rs @@ -58,10 +58,12 @@ impl reth_rpc_eth_api::helpers::pending_block:: { fn build_pending_env( parent: &SealedHeader, - _block_overrides: Option<&alloy_rpc_types_eth::BlockOverrides>, + block_overrides: Option<&alloy_rpc_types_eth::BlockOverrides>, ) -> Self { Self { - timestamp: parent.timestamp().saturating_add(12), + timestamp: block_overrides + .and_then(|overrides| overrides.time) + .unwrap_or_else(|| parent.timestamp().saturating_add(12)), suggested_fee_recipient: parent.beneficiary(), prev_randao: B256::random(), gas_limit: parent.gas_limit(), @@ -297,6 +299,59 @@ mod tests { BaseEvmConfig::base(Arc::new(BaseChainSpec::mainnet())) } + #[cfg(feature = "rpc")] + #[test] + fn pending_timestamp_override_selects_matching_fork_and_base_fee() { + use alloy_rpc_types_eth::BlockOverrides; + use reth_chainspec::{ + BaseFeeParams, BaseFeeParamsKind, EthereumHardfork, ForkCondition, Hardfork, + }; + use reth_primitives_traits::SealedHeader; + use reth_rpc_eth_api::helpers::pending_block::BuildPendingEnv; + + use super::BaseNextBlockEnvAttributes; + + let mut chain_spec = BaseChainSpecBuilder::default() + .chain(0.into()) + .genesis(Genesis::default()) + .canyon_activated() + .with_fork(BaseUpgrade::Azul, ForkCondition::Timestamp(10)) + .with_fork(EthereumHardfork::Osaka, ForkCondition::Timestamp(10)) + .build(); + chain_spec.inner.base_fee_params = BaseFeeParamsKind::Variable( + vec![ + (EthereumHardfork::London.boxed(), BaseFeeParams::new(10, 2)), + (BaseUpgrade::Azul.boxed(), BaseFeeParams::new(50, 2)), + ] + .into(), + ); + let config = BaseEvmConfig::base(Arc::new(chain_spec)); + let parent = SealedHeader::seal_slow(Header { + timestamp: 1, + gas_limit: 20_000_000, + base_fee_per_gas: Some(1_000), + ..Default::default() + }); + + // An empty parent reduces the fee by 1/10 before Azul and 1/50 afterwards. + for (time, timestamp, upgrade, base_fee) in [ + (Some(0), 0_u64, BaseUpgrade::Canyon, 900), + (Some(1), 1, BaseUpgrade::Canyon, 900), + (Some(9), 9, BaseUpgrade::Canyon, 900), + (Some(10), 10, BaseUpgrade::Azul, 980), + (Some(40), 40, BaseUpgrade::Azul, 980), + (None, 13, BaseUpgrade::Azul, 980), + ] { + let overrides = BlockOverrides { time, ..Default::default() }; + let attributes = + BaseNextBlockEnvAttributes::build_pending_env(&parent, Some(&overrides)); + let env = config.next_evm_env(&parent, &attributes).unwrap(); + assert_eq!(env.block_env.timestamp, U256::from(timestamp), "override {time:?}"); + assert_eq!(env.cfg_env.spec, BaseSpecId::new(upgrade), "override {time:?}"); + assert_eq!(env.block_env.basefee, base_fee, "override {time:?}"); + } + } + #[test] fn test_evm_env_uses_azul_for_genesis_chain_spec() { let chain_spec = Arc::new( diff --git a/crates/execution/runner/tests/rpc_simulate.rs b/crates/execution/runner/tests/rpc_simulate.rs new file mode 100644 index 0000000000..1b24bc1a2f --- /dev/null +++ b/crates/execution/runner/tests/rpc_simulate.rs @@ -0,0 +1,100 @@ +//! Wire checks for timestamp-dependent simulation environments. + +use std::sync::Arc; + +use base_execution_chainspec::BaseChainSpec; +use base_node_runner::test_utils::TestHarness; +use base_test_utils::{Account, build_test_genesis}; +use serde_json::{Value, json}; + +#[tokio::test] +async fn simulation_selects_forks_at_the_simulated_timestamp() -> eyre::Result<()> { + // Azul activates before or after the old genesis + 12 fallback, respectively. + for activation in [10, 20] { + let mut genesis = build_test_genesis(); + genesis.config.osaka_time = Some(activation); + genesis.config.extra_fields.insert("base".into(), json!({ "azul": activation })); + let harness = TestHarness::builder() + .with_chain_spec(Arc::new(BaseChainSpec::from_genesis(genesis))) + .build() + .await?; + let client = harness.rpc_client()?; + let probe = Account::Bob.address(); + // CLZ(1) returns 255 under Osaka and halts before Osaka. Also return TIMESTAMP. + let code = "0x60011e6000524260205260406000f3"; + let call = json!({ "from": Account::Alice.address(), "to": probe, "gas": "0x186a0" }); + let timestamps = if activation == 10 { [3, 9, 10, 12] } else { [20, 21, 22, 24] }; + let first_override = if activation == 10 { Value::Null } else { json!({ "time": "0x14" }) }; + + for trace_transfers in [false, true] { + let response: Value = client.request("eth_simulateV1", json!([{ + "traceTransfers": trace_transfers, + "blockStateCalls": [ + { "blockOverrides": first_override, "stateOverrides": { (probe.to_string()): { "code": code } }, "calls": [call] }, + { "blockOverrides": { "time": format!("0x{:x}", timestamps[1]) }, "calls": [call] }, + { "blockOverrides": { "time": format!("0x{:x}", timestamps[2]) }, "calls": [call] }, + { "calls": [call] } + ] + }, "latest"])).await?; + + let blocks = response.as_array().unwrap(); + assert_eq!(blocks.len(), 4); + for (block, timestamp) in blocks.iter().zip(timestamps) { + assert_eq!(block["timestamp"], json!(format!("0x{timestamp:x}"))); + let result = &block["calls"][0]; + if timestamp < activation { + assert_eq!(result["status"], "0x0", "CLZ must be disabled at {timestamp}"); + assert!(!result["error"].is_null()); + } else { + assert_eq!(result["status"], "0x1", "CLZ must be enabled at {timestamp}"); + assert_eq!( + result["returnData"], + json!(format!("0x{:064x}{timestamp:064x}", 255)) + ); + } + } + } + } + Ok(()) +} + +#[tokio::test] +async fn simulation_preserves_same_second_denim_timestamps_and_overrides() -> eyre::Result<()> { + let mut genesis = build_test_genesis(); + genesis.config.extra_fields.insert("base".into(), json!({ "denim": 3 })); + let harness = TestHarness::builder() + .with_chain_spec(Arc::new(BaseChainSpec::from_genesis(genesis))) + .build() + .await?; + let client = harness.rpc_client()?; + let probe = Account::Bob.address(); + // Return TIMESTAMP and BASEFEE. Explicit nonzero gas price keeps BASEFEE observable. + let code = "0x426000524860205260406000f3"; + let call = json!({ "from": Account::Alice.address(), "to": probe, "gas": "0x186a0", "gasPrice": "0x3b9aca00" }); + let response: Value = client + .request( + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + { "stateOverrides": { (probe.to_string()): { "code": code } }, "calls": [call] }, + { "blockOverrides": { "baseFeePerGas": "0x2a" }, "calls": [call] }, + { "blockOverrides": { "time": "0x4" }, "calls": [call] }, + { "calls": [call] } + ] + }, "latest"]), + ) + .await?; + let blocks = response.as_array().unwrap(); + assert_eq!(blocks.len(), 4); + for (index, (timestamp, base_fee)) in [(3, 0), (3, 42), (4, 0), (4, 0)].into_iter().enumerate() + { + assert_eq!(blocks[index]["number"], json!(format!("0x{:x}", index + 1))); + assert_eq!(blocks[index]["timestamp"], json!(format!("0x{timestamp:x}"))); + assert_eq!(blocks[index]["calls"][0]["status"], "0x1"); + assert_eq!( + blocks[index]["calls"][0]["returnData"], + json!(format!("0x{timestamp:064x}{base_fee:064x}")) + ); + } + Ok(()) +} From 41c674e36ed5511626b1ef0167d9d638149a1c26 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Sat, 26 Sep 2026 02:26:57 +0000 Subject: [PATCH 130/299] fix(proof-host): join in-flight payload witness prefetch instead of duplicating RPC (#5322) * fix(proof-host): join in-flight payload witness prefetch instead of duplicating RPC * fix(proof-host): claim payload witness atomically so foreground and prefetch never both fetch --- crates/proof/host/src/handler.rs | 204 ++++++++++++++++++++++++++++--- 1 file changed, 184 insertions(+), 20 deletions(-) diff --git a/crates/proof/host/src/handler.rs b/crates/proof/host/src/handler.rs index 20c383f061..34d30c6ac4 100644 --- a/crates/proof/host/src/handler.rs +++ b/crates/proof/host/src/handler.rs @@ -26,7 +26,7 @@ use base_proof::{Hint, HintType, ROOTS_OF_UNITY}; use base_proof_preimage::{PreimageKey, PreimageKeyType}; use base_protocol::{BlockInfo, OutputRoot}; use futures::FutureExt; -use tokio::sync::Semaphore; +use tokio::sync::{Semaphore, watch}; use tracing::{debug, error, warn}; use crate::{ @@ -59,6 +59,10 @@ struct PayloadWitnessPrefetchState { scheduled_block_order: VecDeque, scheduled_lookaheads: HashSet, scheduled_lookahead_order: VecDeque, + // Claims on a payload's RPC, keyed by parent hash, held by a prefetch (bounded by its semaphore) + // or by the foreground hint handler (one at a time). Waiters are woken when the sender in + // `InFlightGuard` is dropped. + in_flight: HashMap)>, } #[derive(Debug)] @@ -91,12 +95,21 @@ impl PayloadWitnessPrefetcher { } } + #[cfg(test)] fn take_ready(&self, parent_block_hash: B256, payload_attributes_digest: B256) -> bool { - let mut state = self.lock_state(); - let Some(ready_payload_attributes_digest) = state.ready.get(&parent_block_hash) else { - return false; - }; - if *ready_payload_attributes_digest != payload_attributes_digest { + Self::take_ready_locked( + &mut self.lock_state(), + parent_block_hash, + payload_attributes_digest, + ) + } + + fn take_ready_locked( + state: &mut PayloadWitnessPrefetchState, + parent_block_hash: B256, + payload_attributes_digest: B256, + ) -> bool { + if state.ready.get(&parent_block_hash) != Some(&payload_attributes_digest) { return false; } state.ready.remove(&parent_block_hash); @@ -104,6 +117,69 @@ impl PayloadWitnessPrefetcher { true } + /// Serves the hint from a finished prefetch, waiting for one of the same payload that is still + /// in flight. Otherwise returns `Err` so the caller fetches the witness itself, holding a claim + /// (when none conflicts) that makes a prefetch starting later skip this payload. + /// + /// The ready check, in-flight check and claim happen under one lock, so a foreground request + /// and a prefetch can never both issue `debug_executePayload` for the same payload. + async fn take_ready_or_claim( + &self, + parent_block_hash: B256, + payload_attributes_digest: B256, + ) -> std::result::Result<(), Option> { + loop { + let mut rx = { + let mut state = self.lock_state(); + if Self::take_ready_locked(&mut state, parent_block_hash, payload_attributes_digest) + { + return Ok(()); + } + match state.in_flight.get(&parent_block_hash) { + Some((digest, rx)) if *digest == payload_attributes_digest => rx.clone(), + // A prefetch for different attributes can't serve this hint. + Some(_) => return Err(None), + None => { + return Err(Some(self.claim_locked( + &mut state, + parent_block_hash, + payload_attributes_digest, + ))); + } + } + }; + // The sender never sends; this resolves once the prefetch drops its claim, after + // marking the witness ready on success. On failure the next pass claims it instead. + let _ = rx.changed().await; + } + } + + /// Claims the payload for a prefetch unless it is already ready or claimed. + fn mark_in_flight( + &self, + parent_block_hash: B256, + payload_attributes_digest: B256, + ) -> Option { + let mut state = self.lock_state(); + if state.ready.contains_key(&parent_block_hash) + || state.in_flight.contains_key(&parent_block_hash) + { + return None; + } + Some(self.claim_locked(&mut state, parent_block_hash, payload_attributes_digest)) + } + + fn claim_locked( + &self, + state: &mut PayloadWitnessPrefetchState, + parent_block_hash: B256, + payload_attributes_digest: B256, + ) -> InFlightGuard { + let (tx, rx) = watch::channel(()); + state.in_flight.insert(parent_block_hash, (payload_attributes_digest, rx)); + InFlightGuard { prefetcher: self.clone(), parent_block_hash, _tx: tx } + } + pub(crate) async fn schedule_lookahead( &self, kv: SharedKeyValueStore, @@ -367,6 +443,14 @@ impl PayloadWitnessPrefetcher { } }; + // Lets a foreground request for the same payload wait for this prefetch instead of + // issuing a duplicate RPC. Dropped after `mark_ready`, or on any failure path. + let Some(_in_flight_guard) = + self.mark_in_flight(parent_block_hash, payload_attributes_digest) + else { + return false; + }; + let execute_payload_response = match base_metrics::time!(Metrics::l2_proof_node_rpc_latency_seconds(), { fetch_payload_witness( @@ -409,6 +493,20 @@ impl PayloadWitnessPrefetcher { } } +#[derive(Debug)] +struct InFlightGuard { + prefetcher: PayloadWitnessPrefetcher, + parent_block_hash: B256, + _tx: watch::Sender<()>, +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + // `_tx` is dropped after this runs, waking waiters once the entry is gone. + self.prefetcher.lock_state().in_flight.remove(&self.parent_block_hash); + } +} + #[derive(Debug)] struct ScheduledLookaheadGuard { prefetcher: PayloadWitnessPrefetcher, @@ -1281,20 +1379,30 @@ async fn handle_hint_inner( let parent_block_hash = B256::from_slice(&hint.data.as_ref()[..32]); let encoded_payload_attributes = &hint.data[32..]; - if let Some(prefetcher) = payload_witness_prefetcher.as_ref() - && prefetcher.take_ready(parent_block_hash, keccak256(encoded_payload_attributes)) - { - // Prefetched preimages are written into the same proof-session KV store, which is - // append-only for the lifetime of a proof request. The guest emits this hint with - // serde_json::to_vec(BasePayloadAttributes), matching the digest stored by - // prefetch, so the ready cache does not retain or compare full transaction bytes. - debug!( - target: HOST_SERVER_TARGET, - ?parent_block_hash, - "payload witness served from prefetch cache" - ); - prefetcher.schedule_lookahead(Arc::clone(&kv), parent_block_hash).await; - return Ok(()); + let mut _claim = None; + if let Some(prefetcher) = payload_witness_prefetcher.as_ref() { + match prefetcher + .take_ready_or_claim(parent_block_hash, keccak256(encoded_payload_attributes)) + .await + { + Ok(()) => { + // Prefetched preimages are written into the same proof-session KV store, + // which is append-only for the lifetime of a proof request. The guest emits + // this hint with serde_json::to_vec(BasePayloadAttributes), matching the + // digest stored by prefetch, so the ready cache does not retain or compare + // full transaction bytes. + debug!( + target: HOST_SERVER_TARGET, + ?parent_block_hash, + "payload witness served from prefetch cache" + ); + prefetcher.schedule_lookahead(Arc::clone(&kv), parent_block_hash).await; + return Ok(()); + } + // Held until this handler's own fetch finishes, so a prefetch of the same + // payload skips instead of issuing a duplicate RPC. + Err(claim) => _claim = claim, + } } let payload_attributes: BasePayloadAttributes = @@ -1682,6 +1790,62 @@ mod tests { assert!(matches!(error, HostError::Transport(_))); } + #[tokio::test] + async fn test_payload_witness_foreground_waits_for_in_flight_prefetch() { + // No responses are queued, so any foreground RPC would fail the hint. + let l2 = provider_builder::().connect_mocked_client(Asserter::new()); + let providers = test_providers(l2); + let prefetcher = test_prefetcher(); + let kv: SharedKeyValueStore = Arc::new(RwLock::new(MemoryKeyValueStore::new())); + let mut cfg = test_cfg(); + cfg.prover.enable_experimental_witness_endpoint = true; + let encoded_attributes = serde_json::to_vec(&BasePayloadAttributes::default()).unwrap(); + let digest = keccak256(&encoded_attributes); + let hint = HintType::L2PayloadWitness + .with_data(&[TEST_HASH.as_slice(), encoded_attributes.as_slice()]); + + let in_flight_guard = prefetcher.mark_in_flight(TEST_HASH, digest).unwrap(); + let foreground = tokio::spawn({ + let prefetcher = prefetcher.clone(); + async move { + handle_hint_with_prefetchers(hint, &cfg, &providers, kv, Some(prefetcher), None) + .await + } + }); + for _ in 0..10 { + tokio::task::yield_now().await; + } + assert!(!foreground.is_finished()); + + prefetcher.mark_ready(TEST_HASH, digest); + drop(in_flight_guard); + + foreground.await.unwrap().unwrap(); + assert!(prefetcher.lock_state().in_flight.is_empty()); + } + + #[tokio::test] + async fn test_payload_witness_foreground_claim_blocks_later_prefetch() { + let prefetcher = test_prefetcher(); + let digest = keccak256(b"attributes"); + + // Nothing ready or in flight: the foreground claims the payload, so a prefetch that + // reaches its claim afterwards skips instead of issuing a second RPC. + let claim = prefetcher.take_ready_or_claim(TEST_HASH, digest).await.unwrap_err(); + assert!(claim.is_some()); + assert!(prefetcher.mark_in_flight(TEST_HASH, digest).is_none()); + + drop(claim); + assert!(prefetcher.lock_state().in_flight.is_empty()); + let prefetch_claim = prefetcher.mark_in_flight(TEST_HASH, digest); + assert!(prefetch_claim.is_some()); + + // A finished prefetch also blocks a new prefetch claim. + drop(prefetch_claim); + prefetcher.mark_ready(TEST_HASH, digest); + assert!(prefetcher.mark_in_flight(TEST_HASH, digest).is_none()); + } + #[tokio::test] async fn test_payload_witness_request_times_out() { let request = From 5d6d351b0a86bd52f2d7dc3e35d94943b4f53ea3 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Sat, 26 Sep 2026 04:54:42 +0000 Subject: [PATCH 131/299] feat(audit): partition transaction events by retention class and day (#5252) * feat(audit): partition transaction events by retention class and day Row-by-row retention could not keep up on mainnet. The unpartitioned table grew to ~1.4 TiB, its random-key indexes stopped fitting in memory, and every insert paid several random reads, so the database sat at its instance IOPS baseline and ingest failed whenever vacuum or an index build added I/O. Migration 005 drops the table and recreates it partitioned by retention_class, then by UTC day of event_time. Existing rows are discarded; Postgres is the operational window, not the archive. The payload_id, producer/event_type, and retention indexes are not recreated because no read path uses them. The retention worker now creates day partitions from the start of each class's window through three days ahead and drops days that are past their window plus a one-hour grace period. The runtime role does not own the table, so partition DDL goes through SECURITY DEFINER functions granted only to audit_archiver. Ingest rejects events whose event_time is outside the stored partitions instead of failing the whole batch. * feat(audit): retire pre-partition migrations 001-004 Migration 005 recreates the whole schema, so the earlier migrations only add risk: on a database where 004 never recorded, the migrator would run a concurrent index build over the old table right before 005 drops it. Move 001-004 to test fixtures and let the migrator ignore their recorded rows. Any other applied migration this binary does not embed still fails the migration. Fresh databases now apply only 005; existing ones keep their 001-004 history. * docs(audit): update insert bind-parameter count for retention_class Co-authored-by: Toshi * fix(audit): address partitioning review findings - Key rows on (event_id, retention_class, event_date) and partition by event_date, so same-day re-emissions of an event_id with a new event_time still dedupe as they did before partitioning. - Seed each default retention window in migration 005, so pods that go ready before the first maintenance pass can store every admitted day. - Refresh the partition horizon gauge from the catalog on every replica each pass, net of the admitted future skew, so it keeps falling when maintenance stalls or fails. - Cap the maintenance interval at one day, under the three-day look-ahead. - Hold the sqlx migration lock across the retired-migration check and the run. - Lower the insert chunk to 4,000 rows for the extra bind parameter. * refactor(audit): restart transaction event migrations at a partitioned baseline Replace migration 005 with a baseline 001 and reset legacy history instead of ignoring it. The migrator recognizes recorded 001-004 rows by version and checksum, then drops the old table, deletes those rows, and applies the baseline in one transaction. Other databases get sqlx's standard checks. Readiness now compares the baseline checksum, since legacy 001 also recorded version 1. Add a committed pg_dump schema snapshot that fresh and upgraded databases must match. * fix(audit): keep the migration error when releasing its lock fails Co-authored-by: Toshi --------- Co-authored-by: Toshi --- bin/audit-archiver/README.md | 117 +- bin/audit-archiver/src/main.rs | 99 +- crates/infra/audit/README.md | 18 + .../001_transaction_events.sql | 0 .../002_transaction_event_retention_index.sql | 0 ...ansaction_event_expired_rejected_index.sql | 0 ...ansaction_event_expired_rejected_index.sql | 0 .../001_transaction_events_partitioned.sql | 228 +++ crates/infra/audit/schema.sql | 299 ++++ crates/infra/audit/src/lib.rs | 12 +- crates/infra/audit/src/metrics.rs | 26 +- crates/infra/audit/src/transaction_events.rs | 1286 +++++++++++------ .../tests/postgres_transaction_events.rs | 984 ++++++++----- docs/transaction-events/README.md | 12 +- 14 files changed, 2079 insertions(+), 1002 deletions(-) rename crates/infra/audit/{migrations => legacy_migrations}/001_transaction_events.sql (100%) rename crates/infra/audit/{migrations => legacy_migrations}/002_transaction_event_retention_index.sql (100%) rename crates/infra/audit/{migrations => legacy_migrations}/003_transaction_event_expired_rejected_index.sql (100%) rename crates/infra/audit/{migrations => legacy_migrations}/004_transaction_event_expired_rejected_index.sql (100%) create mode 100644 crates/infra/audit/migrations/001_transaction_events_partitioned.sql create mode 100644 crates/infra/audit/schema.sql diff --git a/bin/audit-archiver/README.md b/bin/audit-archiver/README.md index 1f4d3f7d86..4158a7067a 100644 --- a/bin/audit-archiver/README.md +++ b/bin/audit-archiver/README.md @@ -32,73 +32,62 @@ just devnet tx-observability-smoke ## Transaction event retention -When `TIPS_AUDIT_POSTGRES_URL` is set, a background worker deletes expired -`transaction_events` rows. Expire is I/O-heavy and shares the instance with -HTTP ingest, so it is not a continuous `DELETE` loop. - -The worker uses a dedicated one-connection Postgres pool and -`pg_try_advisory_lock`. Replicas that lose the lock return immediately and do -not occupy ingest connections. The first tick runs at startup. Later ticks wait -the retention interval. Missed ticks are skipped so a slow pass does not stack -catch-up work. - -Rows expire by `ingested_at` in three classes, oldest first within a pass: hot -(high-volume proxy and builder-decision events), then warm (ingress, simulation -success, txpool-forward), then cold (failures, drops, inclusion, flashblocks). -The pass budget (`max_batches`) is shared across classes, so a large hot -backlog can defer warm and cold until a later pass. - -### Two clocks - -`TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_STATEMENT_TIMEOUT_MS` (default `30000`) -is the Postgres `statement_timeout` on **each** expire `DELETE`. It is a stall -fuse, not a target runtime. A canceled statement rolls back so the connection -can be reused; any `ctid`s already found are discarded. - -`TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS` (default `3600`) is both -the worker tick spacing and the maximum wall-clock age of one in-pass scan -cycle. A cycle can run many 30s-capped statements. A single delete cannot run -for an hour. The hourly default matches the original pass budget (1000 batches -× 10000 rows) and keeps expire from deleting continuously. - -### One delete batch - -Each batch is one transaction: `SET LOCAL statement_timeout`, a keyset -`SELECT … FOR UPDATE SKIP LOCKED LIMIT n` on `(event_type, ingested_at, -event_id)`, then `DELETE` by `ctid`. `SKIP LOCKED` skips rows held by ingest; -it does not skip dead tuples or bound I/O. The resume cursor is the smallest -selected key so the next `WHERE key < cursor` walk does not rescan rows just -deleted. - -The configured `LIMIT` (default 10000) is the size to use when the index walk -is dense. On `57014` (statement timeout), expire tries `LIMIT 1`. If that -succeeds, it bisects between the last full success and the timed-out size. If -`LIMIT 1` times out, that class stops for the rest of the pass; warm and cold -still run. Progress (`LIMIT` bounds and cursor) is in memory on the lock -holder for this pass. - -When a cycle is older than the retention interval and at least one batch has -returned, expire restarts the cycle immediately on the same lock: new cutoff, -configured `LIMIT`, cleared cursor. The first batch of a cycle always runs, -even when the interval is 1s. A queued post-timeout `LIMIT 1` also runs before -restart. Cycle restart is not a replica handoff and does not wait for the -worker ticker. - -Watch `transaction_events_expired`, -`transaction_events_expire_statement_timeouts`, -`transaction_event_retention_effective_batch_limit`, and -`transaction_event_retention_cycles_ended`. If a class times out the -configured `LIMIT` on every cycle, lower -`TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_BATCH_SIZE` (or the statement timeout) -in that environment rather than shrinking the defaults. +`transaction_events` is partitioned by retention class (`hot`, `warm`, +`cold`), then by UTC day of `event_time`. Retention drops whole day partitions +instead of deleting rows, so expiry creates no dead tuples, index bloat, or +vacuum work, and each day's indexes stay small enough to cache. + +Classes: hot (high-volume proxy and builder-decision events), warm (ingress, +simulation success, txpool-forward), and cold (failures, drops, inclusion, +flashblocks). An event's class comes from its `event_type` at ingest and is +stored in `retention_class`. + +### Partition maintenance + +When `TIPS_AUDIT_POSTGRES_URL` is set, a background worker maintains +partitions. It uses a dedicated one-connection Postgres pool and +`pg_try_advisory_lock`, so only one replica changes partitions at a time and +lock losers do not occupy ingest connections. The first pass runs at startup; +later passes wait the retention interval, skipping missed ticks. + +Each pass, for each class: + +- creates missing day partitions from the start of the retention window + through three days after today, so ingest keeps working for three days if + maintenance stops +- drops day partitions that are entirely older than the retention window plus + a one-hour grace period + +The runtime role does not own the table, so partition DDL goes through +`SECURITY DEFINER` functions created by the baseline migration +(`001_transaction_events_partitioned.sql`) and executable only by +`audit_archiver`. Create uses `CREATE TABLE` + `ATTACH PARTITION`, which only +takes a `SHARE UPDATE EXCLUSIVE` lock on the class partition. Drop detaches +first (a brief `ACCESS EXCLUSIVE` lock on the class partition, which queues +inserts for that class) and then drops the detached table in a separate +transaction. Each statement runs under +`TIPS_AUDIT_TRANSACTION_EVENT_PARTITION_LOCK_TIMEOUT_MS`; a statement that +times out is skipped and retried on the next pass. + +### Ingest admission + +Ingest rejects events whose `event_time` is older than their class's retention +window or more than one hour in the future. Those events would have no +partition, and one bad timestamp would otherwise fail the whole insert batch. +Rejected events count toward `transaction_events_outside_retention_window`. + +Watch `transaction_event_partition_horizon_seconds`, which every replica +refreshes from the catalog each pass, net of the one-hour future skew (alert +well before it reaches zero), `transaction_event_partitions_created`, +`transaction_event_partitions_dropped`, +`transaction_event_partition_lock_timeouts`, and +`transaction_event_retention_failures`. ### Environment -- `TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS` (default `3600`): seconds between retention passes, and the maximum age of one expire scan cycle +- `TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS` (default `3600`, at most `86400`): seconds between partition maintenance passes - `TIPS_AUDIT_TRANSACTION_EVENT_HOT_RETENTION_DAYS` (default `3`) - `TIPS_AUDIT_TRANSACTION_EVENT_WARM_RETENTION_DAYS` (default `7`) -- `TIPS_AUDIT_TRANSACTION_EVENT_COLD_RETENTION_DAYS` (default `30`) -- `TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_BATCH_SIZE` (default `10000`): rows deleted per statement when the scan is not bisected -- `TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_MAX_BATCHES` (default `1000`): delete statements per locked pass -- `TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_STATEMENT_TIMEOUT_MS` (default `30000`): Postgres `statement_timeout` per expire `DELETE` +- `TIPS_AUDIT_TRANSACTION_EVENT_COLD_RETENTION_DAYS` (default `30`, at most `90`) +- `TIPS_AUDIT_TRANSACTION_EVENT_PARTITION_LOCK_TIMEOUT_MS` (default `5000`): Postgres `lock_timeout` per partition create, detach, or drop diff --git a/bin/audit-archiver/src/main.rs b/bin/audit-archiver/src/main.rs index 89ffa074a8..e1fe574111 100644 --- a/bin/audit-archiver/src/main.rs +++ b/bin/audit-archiver/src/main.rs @@ -8,10 +8,8 @@ use audit_archiver_lib::{ DEFAULT_TRANSACTION_EVENT_COLD_RETENTION_DAYS, DEFAULT_TRANSACTION_EVENT_HOT_RETENTION_DAYS, DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, DEFAULT_TRANSACTION_EVENT_MAX_DATA_BYTES, DEFAULT_TRANSACTION_EVENT_MAX_EVENT_BYTES, DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES, - DEFAULT_TRANSACTION_EVENT_RETENTION_BATCH_SIZE, + DEFAULT_TRANSACTION_EVENT_PARTITION_LOCK_TIMEOUT_MS, DEFAULT_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS, - DEFAULT_TRANSACTION_EVENT_RETENTION_MAX_BATCHES, - DEFAULT_TRANSACTION_EVENT_RETENTION_STATEMENT_TIMEOUT_MS, DEFAULT_TRANSACTION_EVENT_WARM_RETENTION_DAYS, Metrics, PgTransactionEventSink, RpcEventReader, S3EventReaderWriter, TransactionEventIngestConfig, TransactionEventRetentionConfig, }; @@ -130,11 +128,10 @@ struct Args { #[arg(long, env = "TIPS_AUDIT_POSTGRES_MAX_CONNECTIONS", default_value = "10")] postgres_max_connections: u32, - /// Seconds between transaction-event retention delete passes. The first - /// pass runs immediately at startup; later passes wait this interval. - /// An expire scan cycle older than this interval is restarted at the - /// configured batch size after at least one returned attempt, except - /// while a post-timeout LIMIT 1 is still queued. + /// Seconds between transaction-event partition maintenance passes. The + /// first pass runs immediately at startup; later passes wait this + /// interval. Each pass creates upcoming day partitions and drops expired + /// ones. #[arg( long, env = "TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS", @@ -142,7 +139,8 @@ struct Args { )] transaction_event_retention_interval_secs: u64, - /// Days to keep high-volume proxy and builder-decision events. + /// Days to keep high-volume proxy and builder-decision events, by + /// `event_time`. #[arg( long, env = "TIPS_AUDIT_TRANSACTION_EVENT_HOT_RETENTION_DAYS", @@ -150,7 +148,8 @@ struct Args { )] transaction_event_hot_retention_days: u32, - /// Days to keep ingress, simulation-success, and txpool-forward events. + /// Days to keep ingress, simulation-success, and txpool-forward events, by + /// `event_time`. #[arg( long, env = "TIPS_AUDIT_TRANSACTION_EVENT_WARM_RETENTION_DAYS", @@ -158,7 +157,8 @@ struct Args { )] transaction_event_warm_retention_days: u32, - /// Days to keep failures, drops, inclusion, and flashblock events. + /// Days to keep failures, drops, inclusion, and flashblock events, by + /// `event_time`. #[arg( long, env = "TIPS_AUDIT_TRANSACTION_EVENT_COLD_RETENTION_DAYS", @@ -166,35 +166,17 @@ struct Args { )] transaction_event_cold_retention_days: u32, - /// Maximum rows deleted in one retention statement. - #[arg( - long, - env = "TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_BATCH_SIZE", - default_value_t = DEFAULT_TRANSACTION_EVENT_RETENTION_BATCH_SIZE - )] - transaction_event_retention_batch_size: u32, - - /// Maximum delete statements in one locked retention pass. Shared across - /// hot, then warm, then cold; raise this if a hot backlog starves warmer - /// classes. - #[arg( - long, - env = "TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_MAX_BATCHES", - default_value_t = DEFAULT_TRANSACTION_EVENT_RETENTION_MAX_BATCHES - )] - transaction_event_retention_max_batches: u32, - - /// Postgres statement timeout for one retention delete, in milliseconds. + /// Postgres `lock_timeout` for one partition create, detach, or drop, in + /// milliseconds. /// - /// Bounds a sparse expire scan so it cannot hold the advisory lock for - /// hours. On timeout, expire tries `LIMIT 1` then bisects the batch size. - /// A `LIMIT 1` timeout advances to the next retention class. + /// Detach briefly queues inserts for its retention class, so a blocked + /// statement gives up after this timeout and retries on the next pass. #[arg( long, - env = "TIPS_AUDIT_TRANSACTION_EVENT_RETENTION_STATEMENT_TIMEOUT_MS", - default_value_t = DEFAULT_TRANSACTION_EVENT_RETENTION_STATEMENT_TIMEOUT_MS + env = "TIPS_AUDIT_TRANSACTION_EVENT_PARTITION_LOCK_TIMEOUT_MS", + default_value_t = DEFAULT_TRANSACTION_EVENT_PARTITION_LOCK_TIMEOUT_MS )] - transaction_event_retention_statement_timeout_ms: u64, + transaction_event_partition_lock_timeout_ms: u64, /// HTTP path for Vector transaction-event batch ingest. #[arg( @@ -285,12 +267,8 @@ async fn run_server(args: Args) -> Result<()> { hot_days: args.transaction_event_hot_retention_days, warm_days: args.transaction_event_warm_retention_days, cold_days: args.transaction_event_cold_retention_days, - delete_batch_size: args.transaction_event_retention_batch_size, - max_batches: args.transaction_event_retention_max_batches, - statement_timeout_ms: args.transaction_event_retention_statement_timeout_ms, + partition_lock_timeout_ms: args.transaction_event_partition_lock_timeout_ms, interval_secs: args.transaction_event_retention_interval_secs, - test_hot_sleep_ms: None, - test_hot_sleep_min_limit: None, } .validate()?; let retention_interval = Duration::from_secs(retention_config.interval_secs); @@ -305,9 +283,7 @@ async fn run_server(args: Args) -> Result<()> { transaction_event_hot_retention_days = retention_config.hot_days, transaction_event_warm_retention_days = retention_config.warm_days, transaction_event_cold_retention_days = retention_config.cold_days, - transaction_event_retention_batch_size = retention_config.delete_batch_size, - transaction_event_retention_max_batches = retention_config.max_batches, - transaction_event_retention_statement_timeout_ms = retention_config.statement_timeout_ms, + transaction_event_partition_lock_timeout_ms = retention_config.partition_lock_timeout_ms, transaction_event_retention_interval_secs = retention_interval.as_secs(), rpc_cache_capacity = args.rpc_cache_capacity, rpc_cache_ttl_secs = args.rpc_cache_ttl_secs, @@ -329,7 +305,11 @@ async fn run_server(args: Args) -> Result<()> { let rpc_addr = SocketAddr::from(([0, 0, 0, 0], args.rpc_port)); let transaction_event_sink = if let Some(postgres_url) = &args.postgres_url { - Some(PgTransactionEventSink::connect(postgres_url, args.postgres_max_connections).await?) + Some( + PgTransactionEventSink::connect(postgres_url, args.postgres_max_connections) + .await? + .with_retention_config(retention_config)?, + ) } else { None }; @@ -385,8 +365,7 @@ async fn run_server(args: Args) -> Result<()> { ); info!("Audit archiver initialized, starting main loop"); - let retention_worker = - run_retention_worker(retention_sink, retention_config, retention_interval); + let retention_worker = run_retention_worker(retention_sink, retention_interval); tokio::select! { result = archiver.run() => result, @@ -399,35 +378,37 @@ async fn run_server(args: Args) -> Result<()> { async fn run_retention_worker( transaction_event_sink: Option, - retention_config: TransactionEventRetentionConfig, retention_interval: Duration, ) -> Result<()> { let Some(sink) = transaction_event_sink else { return std::future::pending().await; }; - // First tick is immediate so a new replica starts expiry without waiting - // a full interval. Skip missed ticks so a slow pass does not catch up. + // First tick is immediate so a new replica creates today's and upcoming + // partitions without waiting a full interval. Skip missed ticks so a slow + // pass does not catch up. let mut ticker = interval(retention_interval); ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); loop { ticker.tick().await; - match sink.expire_old_events(retention_config).await { - Ok(outcome) if outcome.rows_deleted > 0 => { + match sink.maintain_partitions().await { + Ok(outcome) + if outcome.partitions_created > 0 + || outcome.partitions_dropped > 0 + || outcome.lock_timeouts > 0 => + { info!( - rows_deleted = outcome.rows_deleted, - hot_rows_deleted = outcome.hot_rows_deleted, - warm_rows_deleted = outcome.warm_rows_deleted, - cold_rows_deleted = outcome.cold_rows_deleted, - batches = outcome.batches, - "transaction event retention deleted expired rows" + partitions_created = outcome.partitions_created, + partitions_dropped = outcome.partitions_dropped, + lock_timeouts = outcome.lock_timeouts, + "transaction event partition maintenance changed partitions" ); } Ok(_) => {} Err(err) => { Metrics::transaction_event_retention_failures().increment(1); - error!(error = %err, "transaction event retention failed"); + error!(error = %err, "transaction event partition maintenance failed"); } } } diff --git a/crates/infra/audit/README.md b/crates/infra/audit/README.md index 7b76965339..6bd9d2a157 100644 --- a/crates/infra/audit/README.md +++ b/crates/infra/audit/README.md @@ -25,6 +25,24 @@ let publisher = RpcBundleEventPublisher::new(rpc_url, timeout)?; AuditConnector::connect_batched(event_rx, publisher, batch_size, batch_wait); ``` +## Postgres schema + +`migrations/` holds the transaction event schema. `schema.sql` is a committed +`pg_dump` of the schema those migrations produce, without dated day partitions. +The Postgres integration tests fail if a fresh or upgraded database differs +from it. After changing a migration, regenerate the snapshot and review the +diff: + +```bash +UPDATE_SCHEMA_SNAPSHOT=1 cargo test -p audit-archiver-lib \ + --test postgres_transaction_events postgres_schema_matches_committed_snapshot +``` + +`legacy_migrations/` holds the pre-partition migrations 001-004. They are never +applied. The migrator recognizes their recorded rows by version and checksum, +then drops the old table and resets that history in the same transaction that +applies the partitioned baseline. + ## License Licensed under the [MIT License](https://github.com/base/base/blob/main/LICENSE). diff --git a/crates/infra/audit/migrations/001_transaction_events.sql b/crates/infra/audit/legacy_migrations/001_transaction_events.sql similarity index 100% rename from crates/infra/audit/migrations/001_transaction_events.sql rename to crates/infra/audit/legacy_migrations/001_transaction_events.sql diff --git a/crates/infra/audit/migrations/002_transaction_event_retention_index.sql b/crates/infra/audit/legacy_migrations/002_transaction_event_retention_index.sql similarity index 100% rename from crates/infra/audit/migrations/002_transaction_event_retention_index.sql rename to crates/infra/audit/legacy_migrations/002_transaction_event_retention_index.sql diff --git a/crates/infra/audit/migrations/003_transaction_event_expired_rejected_index.sql b/crates/infra/audit/legacy_migrations/003_transaction_event_expired_rejected_index.sql similarity index 100% rename from crates/infra/audit/migrations/003_transaction_event_expired_rejected_index.sql rename to crates/infra/audit/legacy_migrations/003_transaction_event_expired_rejected_index.sql diff --git a/crates/infra/audit/migrations/004_transaction_event_expired_rejected_index.sql b/crates/infra/audit/legacy_migrations/004_transaction_event_expired_rejected_index.sql similarity index 100% rename from crates/infra/audit/migrations/004_transaction_event_expired_rejected_index.sql rename to crates/infra/audit/legacy_migrations/004_transaction_event_expired_rejected_index.sql diff --git a/crates/infra/audit/migrations/001_transaction_events_partitioned.sql b/crates/infra/audit/migrations/001_transaction_events_partitioned.sql new file mode 100644 index 0000000000..16dcac7222 --- /dev/null +++ b/crates/infra/audit/migrations/001_transaction_events_partitioned.sql @@ -0,0 +1,228 @@ +-- Baseline transaction_events schema, partitioned by retention class, then by +-- UTC day of event_time (stored as event_date). +-- +-- Retention drops whole day partitions instead of deleting rows, so expiry +-- no longer produces dead tuples, index bloat, or long vacuums. Each day's +-- random-key indexes (event_id, tx_hash, ...) stay small enough to cache. +-- +-- This replaces the pre-partition migrations 001-004 (legacy_migrations/). +-- Before applying it to a database that recorded them, audit-archiver drops +-- the old table and deletes their _sqlx_migrations rows in the same +-- transaction, so every database starts from this migration. That discards +-- every existing transaction_events row: Postgres is the operational query +-- window, not the archive. + +-- Postgres requires unique constraints to include the partition key, so the +-- primary key adds retention_class and event_date to event_id. retention_class +-- is fixed per event_type, and event_date is the UTC day of event_time. A +-- producer that re-emits the same event_id with a new event_time on the same +-- UTC day still dedupes, as it did when event_id alone was the key; only +-- re-emissions that straddle midnight UTC store a second row. +CREATE TABLE transaction_events ( + event_id TEXT NOT NULL, + schema_version TEXT NOT NULL, + event_time TIMESTAMPTZ NOT NULL, + event_date DATE NOT NULL, + ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + retention_class TEXT NOT NULL, + producer TEXT NOT NULL, + event_type TEXT NOT NULL, + network TEXT, + tx_hash TEXT, + block_hash TEXT, + block_number BIGINT, + payload_id TEXT, + request_id TEXT, + data JSONB NOT NULL, + PRIMARY KEY (event_id, retention_class, event_date) +) PARTITION BY LIST (retention_class); + +CREATE TABLE transaction_events_hot PARTITION OF transaction_events + FOR VALUES IN ('hot') PARTITION BY RANGE (event_date); +CREATE TABLE transaction_events_warm PARTITION OF transaction_events + FOR VALUES IN ('warm') PARTITION BY RANGE (event_date); +CREATE TABLE transaction_events_cold PARTITION OF transaction_events + FOR VALUES IN ('cold') PARTITION BY RANGE (event_date); + +-- Only indexes used by audit-archiver read APIs. The unused payload_id and +-- producer/event_type indexes from 001 and the retention index from 002 are +-- not recreated; each index is write amplification on every insert. +CREATE INDEX transaction_events_tx_hash_event_time_idx + ON transaction_events (tx_hash, event_time) + WHERE tx_hash IS NOT NULL; + +CREATE INDEX transaction_events_block_number_event_time_idx + ON transaction_events (block_number, event_time) + WHERE block_number IS NOT NULL; + +CREATE INDEX transaction_events_block_hash_event_time_idx + ON transaction_events (block_hash, event_time) + WHERE block_hash IS NOT NULL; + +CREATE INDEX transaction_events_rejected_event_time_idx + ON transaction_events (event_type, event_time DESC) + WHERE event_type IN ('SIMULATION_FAILED', 'BUILDER_REJECTED', 'BUILDER_EXPIRED'); + +CREATE INDEX transaction_events_bundle_hash_event_time_idx + ON transaction_events ((data->>'bundle_hash'), event_time) + WHERE data ? 'bundle_hash'; + +CREATE INDEX transaction_events_bundle_id_event_time_idx + ON transaction_events ((data->>'bundle_id'), event_time) + WHERE data ? 'bundle_id'; + +-- Partition DDL requires owning the partitioned table, which the runtime role +-- does not. These SECURITY DEFINER functions run as the migration role and +-- only touch day partitions whose names they derive from a validated +-- retention class and date. +-- +-- Create uses CREATE TABLE + ATTACH PARTITION so the parent only takes a +-- SHARE UPDATE EXCLUSIVE lock and concurrent inserts keep flowing. +CREATE FUNCTION transaction_events_create_partition(p_class TEXT, p_day DATE) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + parent_name TEXT; + partition_name TEXT; +BEGIN + IF p_class IS NULL OR p_class NOT IN ('hot', 'warm', 'cold') THEN + RAISE EXCEPTION 'unknown transaction event retention class: %', p_class; + END IF; + IF p_day IS NULL THEN + RAISE EXCEPTION 'transaction event partition day is required'; + END IF; + + parent_name := 'transaction_events_' || p_class; + partition_name := parent_name || '_' || to_char(p_day, 'YYYYMMDD'); + IF to_regclass(format('public.%I', partition_name)) IS NOT NULL THEN + RETURN FALSE; + END IF; + + EXECUTE format( + 'CREATE TABLE public.%I (LIKE public.%I INCLUDING DEFAULTS)', + partition_name, + parent_name + ); + EXECUTE format( + 'ALTER TABLE public.%I ATTACH PARTITION public.%I FOR VALUES FROM (%L) TO (%L)', + parent_name, + partition_name, + to_char(p_day, 'YYYY-MM-DD'), + to_char(p_day + 1, 'YYYY-MM-DD') + ); + RETURN TRUE; +END; +$$; + +-- Detach and drop are separate calls so the parent's ACCESS EXCLUSIVE lock +-- from DETACH is released before DROP unlinks the partition's files. +CREATE FUNCTION transaction_events_detach_partition(p_class TEXT, p_day DATE) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + parent_name TEXT; + partition_name TEXT; +BEGIN + IF p_class IS NULL OR p_class NOT IN ('hot', 'warm', 'cold') THEN + RAISE EXCEPTION 'unknown transaction event retention class: %', p_class; + END IF; + IF p_day IS NULL THEN + RAISE EXCEPTION 'transaction event partition day is required'; + END IF; + + parent_name := 'transaction_events_' || p_class; + partition_name := parent_name || '_' || to_char(p_day, 'YYYYMMDD'); + IF NOT EXISTS ( + SELECT 1 + FROM pg_inherits + WHERE inhrelid = to_regclass(format('public.%I', partition_name)) + AND inhparent = to_regclass(format('public.%I', parent_name)) + ) THEN + RETURN FALSE; + END IF; + + EXECUTE format( + 'ALTER TABLE public.%I DETACH PARTITION public.%I', + parent_name, + partition_name + ); + RETURN TRUE; +END; +$$; + +CREATE FUNCTION transaction_events_drop_detached_partition(p_class TEXT, p_day DATE) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + partition_name TEXT; + partition_oid REGCLASS; +BEGIN + IF p_class IS NULL OR p_class NOT IN ('hot', 'warm', 'cold') THEN + RAISE EXCEPTION 'unknown transaction event retention class: %', p_class; + END IF; + IF p_day IS NULL THEN + RAISE EXCEPTION 'transaction event partition day is required'; + END IF; + + partition_name := 'transaction_events_' || p_class || '_' || to_char(p_day, 'YYYYMMDD'); + partition_oid := to_regclass(format('public.%I', partition_name)); + IF partition_oid IS NULL THEN + RETURN FALSE; + END IF; + IF EXISTS (SELECT 1 FROM pg_class WHERE oid = partition_oid AND relispartition) THEN + RAISE EXCEPTION 'transaction event partition % is still attached', partition_name; + END IF; + + EXECUTE format('DROP TABLE public.%I', partition_name); + RETURN TRUE; +END; +$$; + +REVOKE ALL ON FUNCTION transaction_events_create_partition(TEXT, DATE) FROM PUBLIC; +REVOKE ALL ON FUNCTION transaction_events_detach_partition(TEXT, DATE) FROM PUBLIC; +REVOKE ALL ON FUNCTION transaction_events_drop_detached_partition(TEXT, DATE) FROM PUBLIC; + +-- Seed each class's default retention window (hot 3, warm 7, cold 30 days) +-- through three days ahead, so pods that go ready right after this migration +-- can store any event ingest admits under the default config. Runtime +-- maintenance keeps the windows current and fills any non-default window. +DO $$ +DECLARE + class_name TEXT; + window_days INTEGER; + today DATE := (now() AT TIME ZONE 'UTC')::date; + partition_day DATE; +BEGIN + FOR class_name, window_days IN + SELECT * FROM (VALUES ('hot', 3), ('warm', 7), ('cold', 30)) AS windows + LOOP + partition_day := ((now() - make_interval(days => window_days)) AT TIME ZONE 'UTC')::date; + WHILE partition_day <= today + 3 LOOP + PERFORM transaction_events_create_partition(class_name, partition_day); + partition_day := partition_day + 1; + END LOOP; + END LOOP; +END $$; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'audit_archiver') THEN + GRANT SELECT ON _sqlx_migrations TO audit_archiver; + GRANT SELECT, INSERT, UPDATE, DELETE ON transaction_events TO audit_archiver; + GRANT EXECUTE ON FUNCTION transaction_events_create_partition(TEXT, DATE) + TO audit_archiver; + GRANT EXECUTE ON FUNCTION transaction_events_detach_partition(TEXT, DATE) + TO audit_archiver; + GRANT EXECUTE ON FUNCTION transaction_events_drop_detached_partition(TEXT, DATE) + TO audit_archiver; + END IF; +END $$; diff --git a/crates/infra/audit/schema.sql b/crates/infra/audit/schema.sql new file mode 100644 index 0000000000..832ccd9be3 --- /dev/null +++ b/crates/infra/audit/schema.sql @@ -0,0 +1,299 @@ +-- Schema produced by crates/infra/audit/migrations, excluding dated day +-- partitions and _sqlx_migrations. Generated by the postgres_transaction_events +-- tests; regenerate with UPDATE_SCHEMA_SNAPSHOT=1 instead of editing by hand. + +CREATE FUNCTION public.transaction_events_create_partition(p_class text, p_day date) RETURNS boolean + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' + AS $$ +DECLARE + parent_name TEXT; + partition_name TEXT; +BEGIN + IF p_class IS NULL OR p_class NOT IN ('hot', 'warm', 'cold') THEN + RAISE EXCEPTION 'unknown transaction event retention class: %', p_class; + END IF; + IF p_day IS NULL THEN + RAISE EXCEPTION 'transaction event partition day is required'; + END IF; + + parent_name := 'transaction_events_' || p_class; + partition_name := parent_name || '_' || to_char(p_day, 'YYYYMMDD'); + IF to_regclass(format('public.%I', partition_name)) IS NOT NULL THEN + RETURN FALSE; + END IF; + + EXECUTE format( + 'CREATE TABLE public.%I (LIKE public.%I INCLUDING DEFAULTS)', + partition_name, + parent_name + ); + EXECUTE format( + 'ALTER TABLE public.%I ATTACH PARTITION public.%I FOR VALUES FROM (%L) TO (%L)', + parent_name, + partition_name, + to_char(p_day, 'YYYY-MM-DD'), + to_char(p_day + 1, 'YYYY-MM-DD') + ); + RETURN TRUE; +END; +$$; + +CREATE FUNCTION public.transaction_events_detach_partition(p_class text, p_day date) RETURNS boolean + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' + AS $$ +DECLARE + parent_name TEXT; + partition_name TEXT; +BEGIN + IF p_class IS NULL OR p_class NOT IN ('hot', 'warm', 'cold') THEN + RAISE EXCEPTION 'unknown transaction event retention class: %', p_class; + END IF; + IF p_day IS NULL THEN + RAISE EXCEPTION 'transaction event partition day is required'; + END IF; + + parent_name := 'transaction_events_' || p_class; + partition_name := parent_name || '_' || to_char(p_day, 'YYYYMMDD'); + IF NOT EXISTS ( + SELECT 1 + FROM pg_inherits + WHERE inhrelid = to_regclass(format('public.%I', partition_name)) + AND inhparent = to_regclass(format('public.%I', parent_name)) + ) THEN + RETURN FALSE; + END IF; + + EXECUTE format( + 'ALTER TABLE public.%I DETACH PARTITION public.%I', + parent_name, + partition_name + ); + RETURN TRUE; +END; +$$; + +CREATE FUNCTION public.transaction_events_drop_detached_partition(p_class text, p_day date) RETURNS boolean + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' + AS $$ +DECLARE + partition_name TEXT; + partition_oid REGCLASS; +BEGIN + IF p_class IS NULL OR p_class NOT IN ('hot', 'warm', 'cold') THEN + RAISE EXCEPTION 'unknown transaction event retention class: %', p_class; + END IF; + IF p_day IS NULL THEN + RAISE EXCEPTION 'transaction event partition day is required'; + END IF; + + partition_name := 'transaction_events_' || p_class || '_' || to_char(p_day, 'YYYYMMDD'); + partition_oid := to_regclass(format('public.%I', partition_name)); + IF partition_oid IS NULL THEN + RETURN FALSE; + END IF; + IF EXISTS (SELECT 1 FROM pg_class WHERE oid = partition_oid AND relispartition) THEN + RAISE EXCEPTION 'transaction event partition % is still attached', partition_name; + END IF; + + EXECUTE format('DROP TABLE public.%I', partition_name); + RETURN TRUE; +END; +$$; + +CREATE TABLE public.transaction_events ( + event_id text NOT NULL, + schema_version text NOT NULL, + event_time timestamp with time zone NOT NULL, + event_date date NOT NULL, + ingested_at timestamp with time zone DEFAULT now() NOT NULL, + retention_class text NOT NULL, + producer text NOT NULL, + event_type text NOT NULL, + network text, + tx_hash text, + block_hash text, + block_number bigint, + payload_id text, + request_id text, + data jsonb NOT NULL +) +PARTITION BY LIST (retention_class); + +CREATE TABLE public.transaction_events_cold ( + event_id text NOT NULL, + schema_version text NOT NULL, + event_time timestamp with time zone NOT NULL, + event_date date NOT NULL, + ingested_at timestamp with time zone DEFAULT now() NOT NULL, + retention_class text NOT NULL, + producer text NOT NULL, + event_type text NOT NULL, + network text, + tx_hash text, + block_hash text, + block_number bigint, + payload_id text, + request_id text, + data jsonb NOT NULL +) +PARTITION BY RANGE (event_date); + +CREATE TABLE public.transaction_events_hot ( + event_id text NOT NULL, + schema_version text NOT NULL, + event_time timestamp with time zone NOT NULL, + event_date date NOT NULL, + ingested_at timestamp with time zone DEFAULT now() NOT NULL, + retention_class text NOT NULL, + producer text NOT NULL, + event_type text NOT NULL, + network text, + tx_hash text, + block_hash text, + block_number bigint, + payload_id text, + request_id text, + data jsonb NOT NULL +) +PARTITION BY RANGE (event_date); + +CREATE TABLE public.transaction_events_warm ( + event_id text NOT NULL, + schema_version text NOT NULL, + event_time timestamp with time zone NOT NULL, + event_date date NOT NULL, + ingested_at timestamp with time zone DEFAULT now() NOT NULL, + retention_class text NOT NULL, + producer text NOT NULL, + event_type text NOT NULL, + network text, + tx_hash text, + block_hash text, + block_number bigint, + payload_id text, + request_id text, + data jsonb NOT NULL +) +PARTITION BY RANGE (event_date); + +ALTER TABLE ONLY public.transaction_events ATTACH PARTITION public.transaction_events_cold FOR VALUES IN ('cold'); + +ALTER TABLE ONLY public.transaction_events ATTACH PARTITION public.transaction_events_hot FOR VALUES IN ('hot'); + +ALTER TABLE ONLY public.transaction_events ATTACH PARTITION public.transaction_events_warm FOR VALUES IN ('warm'); + +ALTER TABLE ONLY public.transaction_events + ADD CONSTRAINT transaction_events_pkey PRIMARY KEY (event_id, retention_class, event_date); + +ALTER TABLE ONLY public.transaction_events_cold + ADD CONSTRAINT transaction_events_cold_pkey PRIMARY KEY (event_id, retention_class, event_date); + +ALTER TABLE ONLY public.transaction_events_hot + ADD CONSTRAINT transaction_events_hot_pkey PRIMARY KEY (event_id, retention_class, event_date); + +ALTER TABLE ONLY public.transaction_events_warm + ADD CONSTRAINT transaction_events_warm_pkey PRIMARY KEY (event_id, retention_class, event_date); + +CREATE INDEX transaction_events_block_hash_event_time_idx ON ONLY public.transaction_events USING btree (block_hash, event_time) WHERE (block_hash IS NOT NULL); + +CREATE INDEX transaction_events_block_number_event_time_idx ON ONLY public.transaction_events USING btree (block_number, event_time) WHERE (block_number IS NOT NULL); + +CREATE INDEX transaction_events_bundle_hash_event_time_idx ON ONLY public.transaction_events USING btree (((data ->> 'bundle_hash'::text)), event_time) WHERE (data ? 'bundle_hash'::text); + +CREATE INDEX transaction_events_bundle_id_event_time_idx ON ONLY public.transaction_events USING btree (((data ->> 'bundle_id'::text)), event_time) WHERE (data ? 'bundle_id'::text); + +CREATE INDEX transaction_events_cold_block_hash_event_time_idx ON ONLY public.transaction_events_cold USING btree (block_hash, event_time) WHERE (block_hash IS NOT NULL); + +CREATE INDEX transaction_events_cold_block_number_event_time_idx ON ONLY public.transaction_events_cold USING btree (block_number, event_time) WHERE (block_number IS NOT NULL); + +CREATE INDEX transaction_events_rejected_event_time_idx ON ONLY public.transaction_events USING btree (event_type, event_time DESC) WHERE (event_type = ANY (ARRAY['SIMULATION_FAILED'::text, 'BUILDER_REJECTED'::text, 'BUILDER_EXPIRED'::text])); + +CREATE INDEX transaction_events_cold_event_type_event_time_idx ON ONLY public.transaction_events_cold USING btree (event_type, event_time DESC) WHERE (event_type = ANY (ARRAY['SIMULATION_FAILED'::text, 'BUILDER_REJECTED'::text, 'BUILDER_EXPIRED'::text])); + +CREATE INDEX transaction_events_cold_expr_event_time_idx ON ONLY public.transaction_events_cold USING btree (((data ->> 'bundle_hash'::text)), event_time) WHERE (data ? 'bundle_hash'::text); + +CREATE INDEX transaction_events_cold_expr_event_time_idx1 ON ONLY public.transaction_events_cold USING btree (((data ->> 'bundle_id'::text)), event_time) WHERE (data ? 'bundle_id'::text); + +CREATE INDEX transaction_events_tx_hash_event_time_idx ON ONLY public.transaction_events USING btree (tx_hash, event_time) WHERE (tx_hash IS NOT NULL); + +CREATE INDEX transaction_events_cold_tx_hash_event_time_idx ON ONLY public.transaction_events_cold USING btree (tx_hash, event_time) WHERE (tx_hash IS NOT NULL); + +CREATE INDEX transaction_events_hot_block_hash_event_time_idx ON ONLY public.transaction_events_hot USING btree (block_hash, event_time) WHERE (block_hash IS NOT NULL); + +CREATE INDEX transaction_events_hot_block_number_event_time_idx ON ONLY public.transaction_events_hot USING btree (block_number, event_time) WHERE (block_number IS NOT NULL); + +CREATE INDEX transaction_events_hot_event_type_event_time_idx ON ONLY public.transaction_events_hot USING btree (event_type, event_time DESC) WHERE (event_type = ANY (ARRAY['SIMULATION_FAILED'::text, 'BUILDER_REJECTED'::text, 'BUILDER_EXPIRED'::text])); + +CREATE INDEX transaction_events_hot_expr_event_time_idx ON ONLY public.transaction_events_hot USING btree (((data ->> 'bundle_hash'::text)), event_time) WHERE (data ? 'bundle_hash'::text); + +CREATE INDEX transaction_events_hot_expr_event_time_idx1 ON ONLY public.transaction_events_hot USING btree (((data ->> 'bundle_id'::text)), event_time) WHERE (data ? 'bundle_id'::text); + +CREATE INDEX transaction_events_hot_tx_hash_event_time_idx ON ONLY public.transaction_events_hot USING btree (tx_hash, event_time) WHERE (tx_hash IS NOT NULL); + +CREATE INDEX transaction_events_warm_block_hash_event_time_idx ON ONLY public.transaction_events_warm USING btree (block_hash, event_time) WHERE (block_hash IS NOT NULL); + +CREATE INDEX transaction_events_warm_block_number_event_time_idx ON ONLY public.transaction_events_warm USING btree (block_number, event_time) WHERE (block_number IS NOT NULL); + +CREATE INDEX transaction_events_warm_event_type_event_time_idx ON ONLY public.transaction_events_warm USING btree (event_type, event_time DESC) WHERE (event_type = ANY (ARRAY['SIMULATION_FAILED'::text, 'BUILDER_REJECTED'::text, 'BUILDER_EXPIRED'::text])); + +CREATE INDEX transaction_events_warm_expr_event_time_idx ON ONLY public.transaction_events_warm USING btree (((data ->> 'bundle_hash'::text)), event_time) WHERE (data ? 'bundle_hash'::text); + +CREATE INDEX transaction_events_warm_expr_event_time_idx1 ON ONLY public.transaction_events_warm USING btree (((data ->> 'bundle_id'::text)), event_time) WHERE (data ? 'bundle_id'::text); + +CREATE INDEX transaction_events_warm_tx_hash_event_time_idx ON ONLY public.transaction_events_warm USING btree (tx_hash, event_time) WHERE (tx_hash IS NOT NULL); + +ALTER INDEX public.transaction_events_block_hash_event_time_idx ATTACH PARTITION public.transaction_events_cold_block_hash_event_time_idx; + +ALTER INDEX public.transaction_events_block_number_event_time_idx ATTACH PARTITION public.transaction_events_cold_block_number_event_time_idx; + +ALTER INDEX public.transaction_events_rejected_event_time_idx ATTACH PARTITION public.transaction_events_cold_event_type_event_time_idx; + +ALTER INDEX public.transaction_events_bundle_hash_event_time_idx ATTACH PARTITION public.transaction_events_cold_expr_event_time_idx; + +ALTER INDEX public.transaction_events_bundle_id_event_time_idx ATTACH PARTITION public.transaction_events_cold_expr_event_time_idx1; + +ALTER INDEX public.transaction_events_pkey ATTACH PARTITION public.transaction_events_cold_pkey; + +ALTER INDEX public.transaction_events_tx_hash_event_time_idx ATTACH PARTITION public.transaction_events_cold_tx_hash_event_time_idx; + +ALTER INDEX public.transaction_events_block_hash_event_time_idx ATTACH PARTITION public.transaction_events_hot_block_hash_event_time_idx; + +ALTER INDEX public.transaction_events_block_number_event_time_idx ATTACH PARTITION public.transaction_events_hot_block_number_event_time_idx; + +ALTER INDEX public.transaction_events_rejected_event_time_idx ATTACH PARTITION public.transaction_events_hot_event_type_event_time_idx; + +ALTER INDEX public.transaction_events_bundle_hash_event_time_idx ATTACH PARTITION public.transaction_events_hot_expr_event_time_idx; + +ALTER INDEX public.transaction_events_bundle_id_event_time_idx ATTACH PARTITION public.transaction_events_hot_expr_event_time_idx1; + +ALTER INDEX public.transaction_events_pkey ATTACH PARTITION public.transaction_events_hot_pkey; + +ALTER INDEX public.transaction_events_tx_hash_event_time_idx ATTACH PARTITION public.transaction_events_hot_tx_hash_event_time_idx; + +ALTER INDEX public.transaction_events_block_hash_event_time_idx ATTACH PARTITION public.transaction_events_warm_block_hash_event_time_idx; + +ALTER INDEX public.transaction_events_block_number_event_time_idx ATTACH PARTITION public.transaction_events_warm_block_number_event_time_idx; + +ALTER INDEX public.transaction_events_rejected_event_time_idx ATTACH PARTITION public.transaction_events_warm_event_type_event_time_idx; + +ALTER INDEX public.transaction_events_bundle_hash_event_time_idx ATTACH PARTITION public.transaction_events_warm_expr_event_time_idx; + +ALTER INDEX public.transaction_events_bundle_id_event_time_idx ATTACH PARTITION public.transaction_events_warm_expr_event_time_idx1; + +ALTER INDEX public.transaction_events_pkey ATTACH PARTITION public.transaction_events_warm_pkey; + +ALTER INDEX public.transaction_events_tx_hash_event_time_idx ATTACH PARTITION public.transaction_events_warm_tx_hash_event_time_idx; + +REVOKE ALL ON FUNCTION public.transaction_events_create_partition(p_class text, p_day date) FROM PUBLIC; +GRANT ALL ON FUNCTION public.transaction_events_create_partition(p_class text, p_day date) TO audit_archiver; + +REVOKE ALL ON FUNCTION public.transaction_events_detach_partition(p_class text, p_day date) FROM PUBLIC; +GRANT ALL ON FUNCTION public.transaction_events_detach_partition(p_class text, p_day date) TO audit_archiver; + +REVOKE ALL ON FUNCTION public.transaction_events_drop_detached_partition(p_class text, p_day date) FROM PUBLIC; +GRANT ALL ON FUNCTION public.transaction_events_drop_detached_partition(p_class text, p_day date) TO audit_archiver; + +GRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.transaction_events TO audit_archiver; diff --git a/crates/infra/audit/src/lib.rs b/crates/infra/audit/src/lib.rs index dbd4708c38..a1ad86f755 100644 --- a/crates/infra/audit/src/lib.rs +++ b/crates/infra/audit/src/lib.rs @@ -39,13 +39,13 @@ pub use transaction_events::{ DEFAULT_TRANSACTION_EVENT_BATCH_PATH, DEFAULT_TRANSACTION_EVENT_COLD_RETENTION_DAYS, DEFAULT_TRANSACTION_EVENT_HOT_RETENTION_DAYS, DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, DEFAULT_TRANSACTION_EVENT_MAX_DATA_BYTES, DEFAULT_TRANSACTION_EVENT_MAX_EVENT_BYTES, - DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES, DEFAULT_TRANSACTION_EVENT_QUERY_LIMIT, - DEFAULT_TRANSACTION_EVENT_RETENTION_BATCH_SIZE, + DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES, + DEFAULT_TRANSACTION_EVENT_PARTITION_LOCK_TIMEOUT_MS, DEFAULT_TRANSACTION_EVENT_QUERY_LIMIT, DEFAULT_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS, - DEFAULT_TRANSACTION_EVENT_RETENTION_MAX_BATCHES, - DEFAULT_TRANSACTION_EVENT_RETENTION_STATEMENT_TIMEOUT_MS, - DEFAULT_TRANSACTION_EVENT_WARM_RETENTION_DAYS, MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE, - MAX_TRANSACTION_EVENT_QUERY_LIMIT, PgTransactionEventSink, RejectedTransactionEventQuery, + DEFAULT_TRANSACTION_EVENT_WARM_RETENTION_DAYS, MAX_TRANSACTION_EVENT_FUTURE_SKEW_SECS, + MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE, MAX_TRANSACTION_EVENT_QUERY_LIMIT, + MAX_TRANSACTION_EVENT_RETENTION_DAYS, MAX_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS, + PgTransactionEventSink, RejectedTransactionEventQuery, TRANSACTION_EVENT_PARTITION_DAYS_AHEAD, TransactionEventBatchResponse, TransactionEventBatchStatus, TransactionEventIngestConfig, TransactionEventInsertOutcome, TransactionEventItemResult, TransactionEventItemStatus, TransactionEventRecord, TransactionEventRetentionClass, TransactionEventRetentionConfig, diff --git a/crates/infra/audit/src/metrics.rs b/crates/infra/audit/src/metrics.rs index 2da1e42a74..5f29aaf74f 100644 --- a/crates/infra/audit/src/metrics.rs +++ b/crates/infra/audit/src/metrics.rs @@ -50,26 +50,28 @@ base_metrics::define_metrics! { transaction_events_validation_failures: counter, #[describe("Transaction observability event database persistence failures")] transaction_events_database_failures: counter, - #[describe("Retryable Postgres lock errors on transaction observability INSERT or expire DELETE")] + #[describe("Retryable Postgres lock errors on transaction observability INSERT")] #[label(name = "reason", default = ["deadlock", "serialization", "lock_timeout"])] transaction_events_persist_retries: counter, #[describe("Transaction observability HTTP ingest batch size")] transaction_event_batch_size: histogram, #[describe("Duration of transaction observability Postgres batch writes")] transaction_event_batch_write_duration: histogram, - #[describe("Expired transaction observability rows deleted from Postgres")] + #[describe("Transaction observability events rejected because event_time is outside the stored partitions")] + #[label(name = "reason", default = ["expired", "future"])] + transaction_events_outside_retention_window: counter, + #[describe("Transaction observability day partitions created")] #[label(name = "retention_class", default = ["hot", "warm", "cold"])] - transaction_events_expired: counter, - #[describe("Transaction observability expire batches canceled by statement timeout")] + transaction_event_partitions_created: counter, + #[describe("Expired transaction observability day partitions dropped")] #[label(name = "retention_class", default = ["hot", "warm", "cold"])] - transaction_events_expire_statement_timeouts: counter, - #[describe("Effective transaction observability expire DELETE LIMIT")] + transaction_event_partitions_dropped: counter, + #[describe("Transaction observability partition DDL statements skipped after lock_timeout")] + #[label(name = "action", default = ["create", "detach", "drop"])] + transaction_event_partition_lock_timeouts: counter, + #[describe("Seconds until transaction observability ingest would reach a missing day partition")] #[label(name = "retention_class", default = ["hot", "warm", "cold"])] - transaction_event_retention_effective_batch_limit: gauge, - #[describe("Transaction observability expire scan cycles ended")] - #[label(name = "retention_class", default = ["hot", "warm", "cold"])] - #[label(name = "reason", default = ["interval", "exhausted"])] - transaction_event_retention_cycles_ended: counter, - #[describe("Transaction observability retention delete failures")] + transaction_event_partition_horizon_seconds: gauge, + #[describe("Transaction observability partition maintenance failures")] transaction_event_retention_failures: counter, } diff --git a/crates/infra/audit/src/transaction_events.rs b/crates/infra/audit/src/transaction_events.rs index 3988edf8ff..f5ad7fc9a2 100644 --- a/crates/infra/audit/src/transaction_events.rs +++ b/crates/infra/audit/src/transaction_events.rs @@ -1,7 +1,7 @@ //! HTTP ingest path for transaction observability events. use std::{ - collections::HashSet, + collections::{BTreeSet, HashSet}, sync::Arc, time::{Duration as StdDuration, Instant}, }; @@ -17,7 +17,7 @@ use axum::{ routing::post, }; use base_observability_events::{TransactionEvent, TransactionEventProducer, TransactionEventType}; -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Duration, NaiveDate, Utc}; use serde::{ Deserialize, Serialize, de::{ @@ -26,9 +26,13 @@ use serde::{ }, }; use serde_json::Value; -use sqlx::{Connection, PgPool, QueryBuilder, Row, migrate::Migrator, postgres::PgPoolOptions}; +use sqlx::{ + Connection, PgConnection, PgPool, QueryBuilder, Row, + migrate::{Migrate, Migration, Migrator}, + postgres::PgPoolOptions, +}; use tower_http::limit::RequestBodyLimitLayer; -use tracing::{error, warn}; +use tracing::{error, info, warn}; use crate::Metrics; @@ -49,14 +53,14 @@ pub const DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024; /// Maximum events inserted in one Postgres statement. /// -/// Each row uses 12 bind parameters, so this stays below Postgres' 65,535 bind -/// parameter limit with room for future columns. -pub const MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE: usize = 5_000; +/// Each row uses 14 bind parameters, so this stays below Postgres' 65,535 bind +/// parameter limit (4,000 x 14 = 56,000). +pub const MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE: usize = 4_000; /// Session `lock_timeout` applied to each persist INSERT. const TRANSACTION_EVENT_LOCK_TIMEOUT_SQL: &str = "SET LOCAL lock_timeout = '1s'"; -/// Attempts per INSERT chunk or expire DELETE batch, including the first try. +/// Attempts per INSERT chunk, including the first try. const TRANSACTION_EVENT_DB_MAX_ATTEMPTS: u32 = 3; /// Default days to keep high-volume proxy and builder-decision events. @@ -65,20 +69,39 @@ pub const DEFAULT_TRANSACTION_EVENT_HOT_RETENTION_DAYS: u32 = 3; pub const DEFAULT_TRANSACTION_EVENT_WARM_RETENTION_DAYS: u32 = 7; /// Default days to keep failures, drops, inclusion, and flashblock events. pub const DEFAULT_TRANSACTION_EVENT_COLD_RETENTION_DAYS: u32 = 30; -/// Default number of expired rows deleted in one statement. -pub const DEFAULT_TRANSACTION_EVENT_RETENTION_BATCH_SIZE: u32 = 10_000; -/// Default maximum delete statements per retention pass. +/// Maximum configurable retention days for any class. /// -/// Shared across hot, then warm, then cold. A large hot backlog can consume -/// the whole budget and defer warmer expiry until a later pass. -pub const DEFAULT_TRANSACTION_EVENT_RETENTION_MAX_BATCHES: u32 = 1_000; -/// Default seconds between retention passes. Also the maximum wall-clock age -/// of one expire scan cycle before it is restarted at the configured LIMIT. +/// Bounds the number of day partitions per class. +pub const MAX_TRANSACTION_EVENT_RETENTION_DAYS: u32 = 90; +/// Default seconds between partition maintenance passes. pub const DEFAULT_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS: u64 = 3_600; -/// Default Postgres statement timeout for one retention delete. +/// Maximum seconds between partition maintenance passes. +/// +/// Kept well under the [`TRANSACTION_EVENT_PARTITION_DAYS_AHEAD`] look-ahead so +/// a pass, and a retry after a failed pass, always lands before ingest runs +/// out of partitions. +pub const MAX_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS: u64 = 86_400; +/// Default Postgres `lock_timeout` for one partition create, detach, or drop. +/// +/// Detach takes an ACCESS EXCLUSIVE lock on the class partition, which queues +/// inserts for that class behind it. A short timeout keeps a blocked detach +/// from stalling ingest; the next pass retries it. +pub const DEFAULT_TRANSACTION_EVENT_PARTITION_LOCK_TIMEOUT_MS: u64 = 5_000; +/// Whole UTC days of partitions kept ahead of the current day. +/// +/// Ingest keeps working this long if partition maintenance stops. +pub const TRANSACTION_EVENT_PARTITION_DAYS_AHEAD: u32 = 3; +/// Maximum amount an event's `event_time` may be ahead of the server clock. +/// +/// Later events are rejected instead of failing the batch with a missing +/// partition. +pub const MAX_TRANSACTION_EVENT_FUTURE_SKEW_SECS: i64 = 3_600; +/// Extra age a day partition must reach past its retention window before it +/// is dropped. /// -/// Bounds a sparse `doomed` scan so it cannot hold the advisory lock for hours. -pub const DEFAULT_TRANSACTION_EVENT_RETENTION_STATEMENT_TIMEOUT_MS: u64 = 30_000; +/// Ingest admits events up to exactly the retention window, so the grace +/// period keeps an in-flight insert from racing its partition's drop. +const TRANSACTION_EVENT_PARTITION_DROP_GRACE_SECS: i64 = 3_600; const TRANSACTION_EVENT_RETENTION_ACQUIRE_TIMEOUT: StdDuration = StdDuration::from_secs(1); const TRANSACTION_EVENT_RETENTION_LOCK_ID: i64 = 744_697_762_131_337_711; @@ -143,8 +166,8 @@ impl Drop for RetentionAdvisoryLock { } } -/// Retention class used to pick a delete cutoff for an event type. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Retention class used to pick an event's partition and retention window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum TransactionEventRetentionClass { /// High-volume success-path and repeated builder-decision events. Hot, @@ -155,7 +178,11 @@ pub enum TransactionEventRetentionClass { } impl TransactionEventRetentionClass { - /// Stable lowercase value used as a bounded metric label. + /// Every retention class, in maintenance order. + pub const ALL: [Self; 3] = [Self::Hot, Self::Warm, Self::Cold]; + + /// Stable lowercase value used as a bounded metric label and as the + /// Postgres `retention_class` partition value. pub const fn as_str(self) -> &'static str { match self { Self::Hot => "hot", @@ -164,7 +191,12 @@ impl TransactionEventRetentionClass { } } - /// Classifies a transaction event at expiry. + /// Parses the Postgres `retention_class` value. + pub fn from_label(label: &str) -> Option { + Self::ALL.into_iter().find(|class| class.as_str() == label) + } + + /// Classifies a transaction event at ingest. /// /// The match is exhaustive so new `TransactionEventType` variants fail to /// compile until they are assigned a retention class deliberately. @@ -217,53 +249,27 @@ impl TransactionEventRetentionClass { } } - /// Event-type labels stored in Postgres for this class. - fn event_type_labels(self) -> Vec { - self.event_types().map(|event_type| event_type.to_string()).collect() - } - + #[cfg(test)] fn event_types(self) -> impl Iterator { TransactionEventType::all() .filter(move |event_type| Self::for_event_type(*event_type) == self) } } -/// Configuration for one transaction event retention pass. +/// Configuration for transaction event retention and partition maintenance. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TransactionEventRetentionConfig { - /// Days to keep hot event types after `ingested_at`. + /// Days to keep hot event types after `event_time`. pub hot_days: u32, - /// Days to keep warm event types after `ingested_at`. + /// Days to keep warm event types after `event_time`. pub warm_days: u32, - /// Days to keep cold event types after `ingested_at`. + /// Days to keep cold event types after `event_time`. pub cold_days: u32, - /// Maximum rows deleted in one statement. - pub delete_batch_size: u32, - /// Maximum delete statements in one locked pass. - /// - /// Shared across hot, then warm, then cold. A large hot backlog can - /// consume the whole budget and defer warmer expiry until a later pass. - /// Raise this if warm or cold expiry stalls behind hot deletes. - pub max_batches: u32, - /// Postgres `statement_timeout` applied to each expire DELETE, in + /// Postgres `lock_timeout` for one partition DDL statement, in /// milliseconds. - pub statement_timeout_ms: u64, - /// Seconds between retention passes. A scan cycle older than this is - /// restarted so a bisected LIMIT cannot crawl forever. The first batch of - /// a cycle always runs, even when this interval is shorter than one - /// statement. A queued `LIMIT 1` after statement timeout also runs before - /// the cycle restarts. + pub partition_lock_timeout_ms: u64, + /// Seconds between partition maintenance passes. pub interval_secs: u64, - /// Test-only sleep injected into hot expire statements so a short - /// `statement_timeout_ms` can cancel a statement without failing the pass. - /// - /// Leave this `None` in production. The serve binary never sets it. - #[doc(hidden)] - pub test_hot_sleep_ms: Option, - /// When set with `test_hot_sleep_ms`, sleep only if the batch LIMIT is at - /// least this value. `None` sleeps on every hot statement. - #[doc(hidden)] - pub test_hot_sleep_min_limit: Option, } impl Default for TransactionEventRetentionConfig { @@ -272,18 +278,14 @@ impl Default for TransactionEventRetentionConfig { hot_days: DEFAULT_TRANSACTION_EVENT_HOT_RETENTION_DAYS, warm_days: DEFAULT_TRANSACTION_EVENT_WARM_RETENTION_DAYS, cold_days: DEFAULT_TRANSACTION_EVENT_COLD_RETENTION_DAYS, - delete_batch_size: DEFAULT_TRANSACTION_EVENT_RETENTION_BATCH_SIZE, - max_batches: DEFAULT_TRANSACTION_EVENT_RETENTION_MAX_BATCHES, - statement_timeout_ms: DEFAULT_TRANSACTION_EVENT_RETENTION_STATEMENT_TIMEOUT_MS, + partition_lock_timeout_ms: DEFAULT_TRANSACTION_EVENT_PARTITION_LOCK_TIMEOUT_MS, interval_secs: DEFAULT_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS, - test_hot_sleep_ms: None, - test_hot_sleep_min_limit: None, } } } impl TransactionEventRetentionConfig { - /// Validates retention bounds before deleting expired rows. + /// Validates retention bounds before ingest or partition maintenance. pub fn validate(self) -> Result { anyhow::ensure!( self.hot_days > 0 && self.hot_days <= self.warm_days, @@ -294,26 +296,22 @@ impl TransactionEventRetentionConfig { "transaction event warm retention days must be at most cold days" ); anyhow::ensure!( - (1..=100_000).contains(&self.delete_batch_size), - "transaction event retention delete batch size must be between 1 and 100000" - ); - anyhow::ensure!( - (1..=1_000).contains(&self.max_batches), - "transaction event retention max batches must be between 1 and 1000" + self.cold_days <= MAX_TRANSACTION_EVENT_RETENTION_DAYS, + "transaction event cold retention days must be at most {MAX_TRANSACTION_EVENT_RETENTION_DAYS}" ); anyhow::ensure!( - (1..=300_000).contains(&self.statement_timeout_ms), - "transaction event retention statement timeout must be between 1ms and 300000ms" + (1..=60_000).contains(&self.partition_lock_timeout_ms), + "transaction event partition lock timeout must be between 1ms and 60000ms" ); anyhow::ensure!( - (1..=604_800).contains(&self.interval_secs), - "transaction event retention interval must be between 1 and 604800 seconds" + (1..=MAX_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS).contains(&self.interval_secs), + "transaction event retention interval must be between 1 and {MAX_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS} seconds" ); Ok(self) } - fn statement_timeout_sql(self) -> String { - format!("SET LOCAL statement_timeout = '{}ms'", self.statement_timeout_ms) + fn partition_lock_timeout_sql(self) -> String { + format!("SET LOCAL lock_timeout = '{}ms'", self.partition_lock_timeout_ms) } const fn class_days(self, class: TransactionEventRetentionClass) -> u32 { @@ -323,21 +321,73 @@ impl TransactionEventRetentionConfig { TransactionEventRetentionClass::Cold => self.cold_days, } } + + fn retention_window(self, class: TransactionEventRetentionClass) -> Duration { + Duration::days(i64::from(self.class_days(class))) + } + + /// Checks that ingest can store an event with this `event_time` now. + /// + /// Events older than their class's retention window would be dropped with + /// their partition anyway, and events too far in the future have no + /// partition yet. Rejecting both keeps one bad timestamp from failing a + /// whole insert batch. + fn admit_event_time( + self, + class: TransactionEventRetentionClass, + event_time: DateTime, + now: DateTime, + ) -> std::result::Result<(), EventTimeRejection> { + if event_time < now - self.retention_window(class) { + return Err(EventTimeRejection::Expired); + } + if event_time >= now + Duration::seconds(MAX_TRANSACTION_EVENT_FUTURE_SKEW_SECS) { + return Err(EventTimeRejection::Future); + } + Ok(()) + } } -/// Result of one locked retention pass. -#[derive(Debug, Clone, PartialEq, Eq)] +/// Why ingest refused an event's `event_time`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EventTimeRejection { + /// Older than the event's retention window. + Expired, + /// Further ahead of the server clock than the allowed skew. + Future, +} + +impl EventTimeRejection { + const fn as_str(self) -> &'static str { + match self { + Self::Expired => "expired", + Self::Future => "future", + } + } + + fn reason(self, class: TransactionEventRetentionClass) -> String { + match self { + Self::Expired => { + format!("event_time is older than the {} retention window", class.as_str()) + } + Self::Future => format!( + "event_time is more than {MAX_TRANSACTION_EVENT_FUTURE_SKEW_SECS} seconds in the future" + ), + } + } +} + +/// Result of one locked partition maintenance pass. +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TransactionEventRetentionOutcome { - /// Expired rows deleted in this pass. - pub rows_deleted: u64, - /// Delete statements executed in this pass. - pub batches: u64, - /// Expired hot rows deleted in this pass. - pub hot_rows_deleted: u64, - /// Expired warm rows deleted in this pass. - pub warm_rows_deleted: u64, - /// Expired cold rows deleted in this pass. - pub cold_rows_deleted: u64, + /// Whether this replica held the retention lock and ran the pass. + pub lock_acquired: bool, + /// Day partitions created in this pass. + pub partitions_created: u64, + /// Expired day partitions dropped in this pass. + pub partitions_dropped: u64, + /// Partition DDL statements skipped after hitting the lock timeout. + pub lock_timeouts: u64, } /// Configuration for transaction event HTTP ingest. @@ -419,21 +469,27 @@ pub struct TransactionEventInsertOutcome { pub const DEFAULT_TRANSACTION_EVENT_QUERY_LIMIT: i64 = 500; /// Hard maximum query result count for read APIs. pub const MAX_TRANSACTION_EVENT_QUERY_LIMIT: i64 = 2_000; -const REQUIRED_TRANSACTION_EVENT_MIGRATION_DESCRIPTION: &str = "transaction events"; +const REQUIRED_TRANSACTION_EVENT_MIGRATION_DESCRIPTION: &str = "transaction events partitioned"; static TRANSACTION_EVENT_MIGRATOR: Migrator = sqlx::migrate!("./migrations"); -/// Required sqlx migration version for transaction event storage. -fn required_transaction_event_migration_version() -> Result { +/// Pre-partition migrations 001-004, which the partitioned baseline replaced. +/// +/// They are embedded only so their recorded `_sqlx_migrations` rows can be +/// recognized by version and checksum and reset. They are never applied. +static LEGACY_TRANSACTION_EVENT_MIGRATOR: Migrator = sqlx::migrate!("./legacy_migrations"); + +/// Migration that creates the transaction event storage schema. +fn required_transaction_event_migration() -> Result<&'static Migration, &'static str> { let mut matching_migrations = TRANSACTION_EVENT_MIGRATOR.iter().filter(|migration| { migration.description.as_ref() == REQUIRED_TRANSACTION_EVENT_MIGRATION_DESCRIPTION }); let migration = matching_migrations.next().ok_or( - "transaction event migration 001_transaction_events.sql must be embedded in audit migrator", + "transaction event migration 001_transaction_events_partitioned.sql must be embedded in audit migrator", )?; if matching_migrations.next().is_some() { return Err("transaction event migration description must be unique"); } - Ok(migration.version) + Ok(migration) } /// Persisted transaction event row returned by audit read APIs. @@ -487,7 +543,7 @@ pub enum TransactionEventSchemaReadinessError { MigrationTableMissing, /// The transaction event migration has not completed successfully. #[error( - "transaction-event Postgres schema is not ready: required sqlx migration version {required_version} for 001_transaction_events.sql has not been applied successfully; run `audit-archiver migrate up` or the audit migration WorkflowTemplate before enabling TIPS_AUDIT_POSTGRES_URL" + "transaction-event Postgres schema is not ready: required sqlx migration version {required_version} for 001_transaction_events_partitioned.sql has not been applied successfully; run `audit-archiver migrate up` or the audit migration WorkflowTemplate before enabling TIPS_AUDIT_POSTGRES_URL" )] RequiredMigrationMissing { /// Required sqlx migration version. @@ -500,7 +556,7 @@ pub enum TransactionEventSchemaReadinessError { TransactionEventsRelationMissing, /// The expected table exists but cannot be queried by the runtime role. #[error( - "transaction-event Postgres schema is not ready: runtime role cannot query public.transaction_events; verify audit_archiver privileges from 001_transaction_events.sql" + "transaction-event Postgres schema is not ready: runtime role cannot query public.transaction_events; verify audit_archiver privileges from 001_transaction_events_partitioned.sql" )] TransactionEventsRelationUnavailable { /// Underlying database error. @@ -532,6 +588,19 @@ pub trait TransactionEventSink: Send + Sync { &self, events: &[TransactionEvent], ) -> std::result::Result; + + /// Checks whether the sink can store a validated event right now. + /// + /// Returns a rejection reason for events the sink would refuse, such as an + /// `event_time` outside the partitions it keeps. The default admits + /// everything. + fn admit_event( + &self, + _event: &TransactionEvent, + _now: DateTime, + ) -> std::result::Result<(), String> { + Ok(()) + } } /// Postgres-backed transaction event sink. @@ -539,19 +608,22 @@ pub trait TransactionEventSink: Send + Sync { pub struct PgTransactionEventSink { pool: PgPool, retention_pool: PgPool, + retention: TransactionEventRetentionConfig, } impl PgTransactionEventSink { /// Required sqlx migration version for transaction event storage. pub fn required_migration_version() -> Result { - required_transaction_event_migration_version() + required_transaction_event_migration().map(|migration| migration.version) } /// Connects to Postgres without running migrations. /// /// The ingest pool is used for persist, RPC reads, and `/readyz`. - /// Retention uses a dedicated one-connection pool with a short acquire - /// timeout so lock losers do not occupy ingest connections. + /// Partition maintenance uses a dedicated one-connection pool with a short + /// acquire timeout so lock losers do not occupy ingest connections. The + /// sink starts with the default retention config; see + /// [`Self::with_retention_config`]. pub async fn connect(database_url: &str, max_connections: u32) -> Result { let max_connections = max_connections.max(1); let pool = @@ -561,24 +633,83 @@ impl PgTransactionEventSink { .acquire_timeout(TRANSACTION_EVENT_RETENTION_ACQUIRE_TIMEOUT) .connect(database_url) .await?; - Ok(Self { pool, retention_pool }) + Ok(Self::new_with_retention_pool(pool, retention_pool)) } /// Runs pending Postgres migrations. + /// + /// A database that recorded the pre-partition migrations 001-004 is reset + /// first. Its old table and their `_sqlx_migrations` rows are dropped in the + /// transaction that applies the partitioned baseline, so the reset commits + /// only if the baseline does. Every other database gets sqlx's standard + /// checks. pub async fn migrate(database_url: &str) -> Result<()> { let pool = PgPoolOptions::new().max_connections(1).connect(database_url).await?; - TRANSACTION_EVENT_MIGRATOR.run(&pool).await?; - Ok(()) + let mut conn = pool.acquire().await?; + // Hold sqlx's migration lock across the legacy-history check and the + // run, so a concurrent migrator cannot change the history between + // them. The lock is session-level and reentrant; run_direct takes it + // again and releases its own hold. + conn.lock().await?; + let result = async { + let legacy_versions = legacy_transaction_event_migration_versions(&mut conn).await?; + if legacy_versions.is_empty() { + TRANSACTION_EVENT_MIGRATOR.run_direct(&mut *conn).await?; + return anyhow::Ok(()); + } + + let mut tx = conn.begin().await?; + // Bound the DROP's lock wait so a long-running vacuum or query on + // the old table fails the migration quickly instead of queueing + // ingest behind it. The migrator can simply be retried. + sqlx::query("SET LOCAL lock_timeout = '60s'").execute(&mut *tx).await?; + sqlx::query("DROP TABLE IF EXISTS transaction_events").execute(&mut *tx).await?; + sqlx::query("DELETE FROM _sqlx_migrations WHERE version = ANY($1)") + .bind(&legacy_versions) + .execute(&mut *tx) + .await?; + // sqlx applies each migration in a savepoint of this transaction. + TRANSACTION_EVENT_MIGRATOR.run_direct(&mut *tx).await?; + tx.commit().await?; + info!( + legacy_versions = ?legacy_versions, + "reset legacy transaction event migration history" + ); + anyhow::Ok(()) + } + .await; + // Keep the migration's own error. A failed unlock is harmless: this + // pool closes when migrate returns, which ends the session and releases + // the lock. + if let Err(err) = conn.unlock().await { + warn!(error = %err, "failed to release transaction event migration lock"); + } + result } /// Creates a sink from an existing ingest pool. Retention uses the same pool. pub fn new(pool: PgPool) -> Self { - Self { pool: pool.clone(), retention_pool: pool } + Self::new_with_retention_pool(pool.clone(), pool) } /// Creates a sink with a dedicated retention pool. - pub const fn new_with_retention_pool(pool: PgPool, retention_pool: PgPool) -> Self { - Self { pool, retention_pool } + pub fn new_with_retention_pool(pool: PgPool, retention_pool: PgPool) -> Self { + Self { pool, retention_pool, retention: TransactionEventRetentionConfig::default() } + } + + /// Sets the retention windows used for ingest admission and partition + /// maintenance. + pub fn with_retention_config( + mut self, + retention: TransactionEventRetentionConfig, + ) -> Result { + self.retention = retention.validate()?; + Ok(self) + } + + /// Retention windows used for ingest admission and partition maintenance. + pub const fn retention_config(&self) -> TransactionEventRetentionConfig { + self.retention } /// Checks whether transaction event Postgres storage is ready for runtime use. @@ -599,19 +730,23 @@ impl PgTransactionEventSink { return Err(TransactionEventSchemaReadinessError::MigrationTableMissing); } - let required_version = - required_transaction_event_migration_version().map_err(|source| { - TransactionEventSchemaReadinessError::MigrationMetadataInvalid { reason: source } - })?; - let migration_applied: Option = - sqlx::query_scalar("SELECT success FROM _sqlx_migrations WHERE version = $1") - .bind(required_version) + let required_migration = required_transaction_event_migration().map_err(|source| { + TransactionEventSchemaReadinessError::MigrationMetadataInvalid { reason: source } + })?; + let applied: Option<(bool, Vec)> = + sqlx::query_as("SELECT success, checksum FROM _sqlx_migrations WHERE version = $1") + .bind(required_migration.version) .fetch_optional(&self.pool) .await .map_err(|source| TransactionEventSchemaReadinessError::QueryFailed { source })?; - if !matches!(migration_applied, Some(true)) { + // The checksum distinguishes the partitioned baseline from the legacy + // migration that also recorded version 1. + let applied = applied.is_some_and(|(success, checksum)| { + success && checksum == required_migration.checksum.as_ref() + }); + if !applied { return Err(TransactionEventSchemaReadinessError::RequiredMigrationMissing { - required_version, + required_version: required_migration.version, }); } @@ -628,142 +763,140 @@ impl PgTransactionEventSink { Ok(()) } - /// Deletes expired `transaction_events` rows in bounded batches. + /// Creates upcoming day partitions and drops expired ones. + /// + /// Uses a session advisory lock so only one replica changes partitions at + /// a time. Returns an outcome with `lock_acquired = false` when another + /// replica holds the lock or the retention pool times out waiting for a + /// connection. The lock is released after the pass, including when a + /// statement fails. If unlock itself fails, the connection is detached so + /// the session lock cannot leak onto a reused pool connection. + /// + /// Each class keeps day partitions from the start of its retention window + /// through [`TRANSACTION_EVENT_PARTITION_DAYS_AHEAD`] days after today. A + /// day is dropped once all of it is older than the retention window plus + /// a one-hour grace period. Each DDL statement runs in its own transaction + /// under the configured `lock_timeout`; a statement that times out is + /// skipped and retried on the next pass. + pub async fn maintain_partitions(&self) -> Result { + self.maintain_partitions_at(Utc::now()).await + } + + /// Runs [`Self::maintain_partitions`] as if the current time were `now`. /// - /// Uses a session advisory lock so only one replica expires rows at a time. - /// Returns a zeroed outcome when another replica already holds the lock, or - /// when the retention pool times out waiting for a connection. - /// The lock is released after the pass, including when a batch fails. If - /// unlock itself fails, the connection is detached so the session lock - /// cannot leak onto a reused pool connection. - /// Each class resumes a descending keyset on this lock holder. A statement - /// timeout commits a `LIMIT 1` progress step when possible, then bisects - /// the batch LIMIT. A cycle older than `interval_secs` restarts at the - /// configured LIMIT after at least one returned attempt, but not while a - /// `LIMIT 1` fallback is queued. A `LIMIT 1` timeout stops that class and - /// continues the pass with the next class. - /// Scan progress is in memory for this pass; the next pass starts new - /// cycles. - /// Hot rows are expired first so high-churn types free space before colder - /// types consume the shared batch budget. That can starve warm and cold - /// deletes in one pass; raise `max_batches` if they stall. - pub async fn expire_old_events( + /// Tests use this to move the retention window without waiting for days. + #[doc(hidden)] + pub async fn maintain_partitions_at( &self, - config: TransactionEventRetentionConfig, + now: DateTime, ) -> Result { - // Public API: validate here so tests and other callers cannot skip it. - let config = config.validate()?; - let classes = [ - TransactionEventRetentionClass::Hot, - TransactionEventRetentionClass::Warm, - TransactionEventRetentionClass::Cold, - ]; + let outcome = self.run_locked_partition_maintenance(now).await; + // Every replica refreshes the horizon from the catalog, so the gauge + // keeps falling if the lock holder stalls or its passes fail. + self.refresh_partition_horizon(now).await; + outcome + } + + async fn refresh_partition_horizon(&self, now: DateTime) { + let existing = match self.retention_pool.acquire().await { + Ok(mut conn) => list_day_partitions(&mut conn).await, + Err(err) => Err(err.into()), + }; + match existing { + Ok(existing) => { + let attached: BTreeSet = existing + .iter() + .filter(|partition| partition.attached) + .map(|partition| partition.partition) + .collect(); + for class in TransactionEventRetentionClass::ALL { + Metrics::transaction_event_partition_horizon_seconds(class.as_str()) + .set(partition_horizon_secs(now, class, &attached)); + } + } + Err(err) => { + warn!(error = %err, "failed to refresh transaction event partition horizon"); + } + } + } + async fn run_locked_partition_maintenance( + &self, + now: DateTime, + ) -> Result { + let config = self.retention; let Some(mut lock) = RetentionAdvisoryLock::try_acquire(&self.retention_pool).await? else { - return Ok(TransactionEventRetentionOutcome { - rows_deleted: 0, - batches: 0, - hot_rows_deleted: 0, - warm_rows_deleted: 0, - cold_rows_deleted: 0, - }); + return Ok(TransactionEventRetentionOutcome::default()); }; let outcome = async { - let mut rows_deleted = 0u64; - let mut batches = 0u64; - let mut hot_rows_deleted = 0u64; - let mut warm_rows_deleted = 0u64; - let mut cold_rows_deleted = 0u64; - for class in classes { - let event_types = class.event_type_labels(); - let mut progress = RetentionClassProgress::new_cycle(Utc::now(), &config, class); - while batches < u64::from(config.max_batches) { - let now = Utc::now(); - if progress.should_end_cycle(now, config.interval_secs) { - warn!( - retention_class = class.as_str(), - cycle_age_secs = progress.cycle_age_secs(now), - batches_this_cycle = progress.batches_this_cycle, - "ending transaction event expire cycle after retention interval" - ); - Metrics::transaction_event_retention_cycles_ended( - class.as_str(), - "interval", - ) + let existing = list_day_partitions(lock.conn()).await?; + let plan = plan_partition_maintenance(now, config, &existing); + let lock_timeout_sql = config.partition_lock_timeout_sql(); + let mut outcome = + TransactionEventRetentionOutcome { lock_acquired: true, ..Default::default() }; + // Leftovers from a pass that detached but failed to drop. Drop them + // first so a same-named create cannot collide with them. + for partition in plan.drop_detached { + if run_partition_ddl( + lock.conn(), + &lock_timeout_sql, + PartitionDdl::DropDetached, + partition, + &mut outcome, + ) + .await? + { + outcome.partitions_dropped += 1; + Metrics::transaction_event_partitions_dropped(partition.class.as_str()) .increment(1); - progress = RetentionClassProgress::new_cycle(now, &config, class); - } - let batch_limit = expire_batch_limit( - config.delete_batch_size, - progress.limit_lo, - progress.limit_hi, - progress.needs_limit_one, - ); - Metrics::transaction_event_retention_effective_batch_limit(class.as_str()) - .set(f64::from(batch_limit)); - let outcome = delete_expired_event_batch( - lock.conn(), - &event_types, - &progress, - batch_limit, - class, - &config, - ) - .await?; - progress.batches_this_cycle = progress.batches_this_cycle.saturating_add(1); - batches += 1; - match outcome { - ExpireBatchOutcome::StatementTimeout => { - if batch_limit <= 1 { - progress.needs_limit_one = true; - break; - } - progress.needs_limit_one = true; - progress.limit_hi = Some(batch_limit); - } - ExpireBatchOutcome::Deleted { selected, cursor } => { - if selected == 0 { - Metrics::transaction_event_retention_cycles_ended( - class.as_str(), - "exhausted", - ) - .increment(1); - break; - } - rows_deleted += selected; - match class { - TransactionEventRetentionClass::Hot => hot_rows_deleted += selected, - TransactionEventRetentionClass::Warm => { - warm_rows_deleted += selected - } - TransactionEventRetentionClass::Cold => { - cold_rows_deleted += selected - } - } - Metrics::transaction_events_expired(class.as_str()).increment(selected); - if selected < u64::from(batch_limit) { - Metrics::transaction_event_retention_cycles_ended( - class.as_str(), - "exhausted", - ) - .increment(1); - break; - } - progress.needs_limit_one = false; - progress.limit_lo = batch_limit; - progress.cursor = cursor; - } - } } } - Ok(TransactionEventRetentionOutcome { - rows_deleted, - batches, - hot_rows_deleted, - warm_rows_deleted, - cold_rows_deleted, - }) + + for partition in plan.create { + if run_partition_ddl( + lock.conn(), + &lock_timeout_sql, + PartitionDdl::Create, + partition, + &mut outcome, + ) + .await? + { + outcome.partitions_created += 1; + Metrics::transaction_event_partitions_created(partition.class.as_str()) + .increment(1); + } + } + + for partition in plan.detach { + let detached = run_partition_ddl( + lock.conn(), + &lock_timeout_sql, + PartitionDdl::Detach, + partition, + &mut outcome, + ) + .await?; + if !detached { + continue; + } + if run_partition_ddl( + lock.conn(), + &lock_timeout_sql, + PartitionDdl::DropDetached, + partition, + &mut outcome, + ) + .await? + { + outcome.partitions_dropped += 1; + Metrics::transaction_event_partitions_dropped(partition.class.as_str()) + .increment(1); + } + } + + Ok(outcome) } .await; @@ -801,8 +934,9 @@ impl PgTransactionEventSink { loop { let mut query_builder = QueryBuilder::new( "INSERT INTO transaction_events \ - (event_id, schema_version, event_time, producer, event_type, network, tx_hash, \ - block_hash, block_number, payload_id, request_id, data) ", + (event_id, schema_version, event_time, event_date, retention_class, producer, \ + event_type, network, tx_hash, block_hash, block_number, payload_id, request_id, \ + data) ", ); query_builder.push_values( ordered.iter().copied().zip(block_numbers.iter().copied()), @@ -811,11 +945,15 @@ impl PgTransactionEventSink { let block_hash = event.block_hash.map(|hash| format!("{hash:#x}")); let producer = event.producer.to_string(); let event_type = event.event_type.to_string(); + let retention_class = + TransactionEventRetentionClass::for_event_type(event.event_type).as_str(); let data = Value::Object(event.data.clone()); row.push_bind(&event.event_id) .push_bind(&event.schema_version) .push_bind(event.event_time) + .push_bind(event.event_time.date_naive()) + .push_bind(retention_class) .push_bind(producer) .push_bind(event_type) .push_bind(&event.network) @@ -827,7 +965,11 @@ impl PgTransactionEventSink { .push_bind(data); }, ); - query_builder.push(" ON CONFLICT (event_id) DO NOTHING RETURNING event_id"); + // The partitioned primary key adds retention_class and event_date: + // retries and same-day re-emissions of an event_id still conflict. + query_builder.push( + " ON CONFLICT (event_id, retention_class, event_date) DO NOTHING RETURNING event_id", + ); let result = async { let mut tx = self.pool.begin().await?; @@ -1115,220 +1257,249 @@ fn persist_retry_sqlstate(code: &str) -> Option<&'static str> { } } -fn is_statement_timeout(err: &sqlx::Error) -> bool { - matches!(err, sqlx::Error::Database(database) if database.code().as_deref() == Some("57014")) -} +/// Versions of the recorded pre-partition migrations 001-004. +/// +/// A row counts only if both its version and checksum match a legacy +/// migration. A database that records legacy migrations alongside anything +/// else fails, so the reset never deletes history it does not recognize. +async fn legacy_transaction_event_migration_versions(conn: &mut PgConnection) -> Result> { + let migrations_table_exists: bool = + sqlx::query_scalar("SELECT to_regclass('_sqlx_migrations') IS NOT NULL") + .fetch_one(&mut *conn) + .await?; + if !migrations_table_exists { + return Ok(Vec::new()); + } -fn record_expire_statement_timeout( - err: &sqlx::Error, - class: TransactionEventRetentionClass, - attempted_batch_limit: u32, -) { - warn!( - error = %err, - retention_class = class.as_str(), - attempted_batch_limit, - "transaction event expire statement timed out" + let applied: Vec<(i64, Vec)> = + sqlx::query_as("SELECT version, checksum FROM _sqlx_migrations ORDER BY version") + .fetch_all(&mut *conn) + .await?; + let (legacy, other): (Vec<_>, Vec<_>) = applied.into_iter().partition(|(version, checksum)| { + LEGACY_TRANSACTION_EVENT_MIGRATOR.iter().any(|migration| { + migration.version == *version && migration.checksum.as_ref() == checksum.as_slice() + }) + }); + let legacy: Vec = legacy.into_iter().map(|(version, _)| version).collect(); + let other: Vec = other.into_iter().map(|(version, _)| version).collect(); + anyhow::ensure!( + legacy.is_empty() || other.is_empty(), + "database records legacy transaction event migrations {legacy:?} alongside unrecognized migrations {other:?}; refusing to reset migration history" ); - Metrics::transaction_events_expire_statement_timeouts(class.as_str()).increment(1); + Ok(legacy) } -/// Next DELETE LIMIT after timeouts (`hi`) and full successes (`lo`). -fn expire_batch_limit(configured: u32, lo: u32, hi: Option, needs_limit_one: bool) -> u32 { - if needs_limit_one { - return 1; - } - let configured = configured.max(1); - let Some(hi) = hi else { - return configured; - }; - let lo = lo.max(1).min(configured); - let hi = hi.max(1); - if lo.saturating_mul(2) >= hi { - return lo; - } - let mid = lo.saturating_add(hi) / 2; - mid.clamp(lo, configured).min(hi.saturating_sub(1)).max(1) +fn is_lock_timeout(err: &sqlx::Error) -> bool { + matches!(err, sqlx::Error::Database(database) if database.code().as_deref() == Some("55P03")) } -#[derive(Debug, Clone, PartialEq, Eq)] -struct RetentionScanCursor { - event_type: String, - ingested_at: DateTime, - event_id: String, +/// Midnight UTC at the start of `day`. +const fn utc_midnight(day: NaiveDate) -> DateTime { + day.and_time(chrono::NaiveTime::MIN).and_utc() } -#[derive(Debug, Clone, PartialEq, Eq)] -struct RetentionClassProgress { - scan_cutoff: DateTime, - cursor: Option, - limit_lo: u32, - limit_hi: Option, - needs_limit_one: bool, - cycle_started_at: DateTime, - batches_this_cycle: u32, +/// One UTC day partition of one retention class. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct DayPartition { + class: TransactionEventRetentionClass, + day: NaiveDate, } -impl RetentionClassProgress { - fn new_cycle( - now: DateTime, - config: &TransactionEventRetentionConfig, - class: TransactionEventRetentionClass, - ) -> Self { - Self { - scan_cutoff: now - Duration::days(i64::from(config.class_days(class))), - cursor: None, - limit_lo: 0, - limit_hi: None, - needs_limit_one: false, - cycle_started_at: now, - batches_this_cycle: 0, +impl DayPartition { + /// Table name used by the partition functions in the baseline migration. + fn table_name(self) -> String { + format!("transaction_events_{}_{}", self.class.as_str(), self.day.format("%Y%m%d")) + } + + fn from_table_name(name: &str) -> Option { + let (class, day) = name.strip_prefix("transaction_events_")?.split_once('_')?; + if day.len() != 8 { + return None; } + Some(Self { + class: TransactionEventRetentionClass::from_label(class)?, + day: NaiveDate::parse_from_str(day, "%Y%m%d").ok()?, + }) } - fn cycle_age_secs(&self, now: DateTime) -> i64 { - (now - self.cycle_started_at).num_seconds() + /// Exclusive upper bound of the partition's `event_time` range. + fn end(self) -> DateTime { + utc_midnight(self.day + Duration::days(1)) } +} - fn should_end_cycle(&self, now: DateTime, interval_secs: u64) -> bool { - if self.needs_limit_one || self.batches_this_cycle == 0 { - return false; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExistingPartition { + partition: DayPartition, + /// Whether the table is still attached to its class partition. + attached: bool, +} + +/// Partition DDL for one maintenance pass, in execution order. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct PartitionPlan { + /// Tables left detached by an earlier pass that failed before dropping. + drop_detached: Vec, + /// Missing days in each class's retention window and look-ahead. + create: Vec, + /// Attached days entirely older than the retention window plus grace. + detach: Vec, +} + +/// Plans which day partitions to create and drop at `now`. +fn plan_partition_maintenance( + now: DateTime, + config: TransactionEventRetentionConfig, + existing: &[ExistingPartition], +) -> PartitionPlan { + let attached: BTreeSet = existing + .iter() + .filter(|partition| partition.attached) + .map(|partition| partition.partition) + .collect(); + let mut plan = PartitionPlan { + drop_detached: existing + .iter() + .filter(|partition| !partition.attached) + .map(|partition| partition.partition) + .collect(), + ..Default::default() + }; + + let last_day = (now + Duration::seconds(MAX_TRANSACTION_EVENT_FUTURE_SKEW_SECS)).date_naive() + + Duration::days(i64::from(TRANSACTION_EVENT_PARTITION_DAYS_AHEAD)); + for class in TransactionEventRetentionClass::ALL { + let window = config.retention_window(class); + let mut day = (now - window).date_naive(); + while day <= last_day { + let partition = DayPartition { class, day }; + if !attached.contains(&partition) { + plan.create.push(partition); + } + day += Duration::days(1); } - let Ok(interval_secs) = i64::try_from(interval_secs) else { - return false; - }; - (now - self.cycle_started_at) >= Duration::seconds(interval_secs) + + let drop_before = + now - window - Duration::seconds(TRANSACTION_EVENT_PARTITION_DROP_GRACE_SECS); + plan.detach.extend( + attached + .iter() + .filter(|partition| partition.class == class && partition.end() <= drop_before) + .copied(), + ); } + plan } -enum ExpireBatchOutcome { - Deleted { selected: u64, cursor: Option }, - StatementTimeout, +/// Seconds until ingest for `class` could admit an event with no partition. +/// +/// Counts contiguous attached days starting with today, less the future +/// skew ingest admits. Zero when that coverage has already run out. +fn partition_horizon_secs( + now: DateTime, + class: TransactionEventRetentionClass, + attached: &BTreeSet, +) -> f64 { + let mut day = now.date_naive(); + if !attached.contains(&DayPartition { class, day }) { + return 0.0; + } + while attached.contains(&DayPartition { class, day: day + Duration::days(1) }) { + day += Duration::days(1); + } + let coverage_end = utc_midnight(day + Duration::days(1)); + let secs = (coverage_end - now).num_seconds() - MAX_TRANSACTION_EVENT_FUTURE_SKEW_SECS; + f64::from(i32::try_from(secs.max(0)).unwrap_or(i32::MAX)) } -async fn delete_expired_event_batch( - conn: &mut sqlx::PgConnection, - event_types: &[String], - progress: &RetentionClassProgress, - batch_limit: u32, - class: TransactionEventRetentionClass, - config: &TransactionEventRetentionConfig, -) -> Result { - let cutoff = progress.scan_cutoff; - let timeout_sql = config.statement_timeout_sql(); - let mut attempt = 1u32; - loop { - let mut tx = conn.begin().await?; - sqlx::query(&timeout_sql).execute(&mut *tx).await?; - let should_sleep = class == TransactionEventRetentionClass::Hot - && config.test_hot_sleep_ms.is_some() - && config.test_hot_sleep_min_limit.is_none_or(|min_limit| batch_limit >= min_limit); - if should_sleep && let Some(sleep_ms) = config.test_hot_sleep_ms { - let sleep_secs = sleep_ms as f64 / 1_000.0; - if let Err(err) = - sqlx::query("SELECT pg_sleep($1)").bind(sleep_secs).execute(&mut *tx).await - { - let _ = tx.rollback().await; - if is_statement_timeout(&err) { - record_expire_statement_timeout(&err, class, batch_limit); - return Ok(ExpireBatchOutcome::StatementTimeout); - } - return Err(err.into()); +async fn list_day_partitions(conn: &mut sqlx::PgConnection) -> Result> { + let rows: Vec<(String, bool)> = sqlx::query_as( + "SELECT c.relname::text, c.relispartition \ + FROM pg_class c \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = 'public' \ + AND c.relkind = 'r' \ + AND c.relname ~ '^transaction_events_(hot|warm|cold)_[0-9]{8}$'", + ) + .fetch_all(conn) + .await?; + Ok(rows + .into_iter() + .filter_map(|(name, attached)| { + DayPartition::from_table_name(&name) + .map(|partition| ExistingPartition { partition, attached }) + }) + .collect()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PartitionDdl { + Create, + Detach, + DropDetached, +} + +impl PartitionDdl { + const fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Detach => "detach", + Self::DropDetached => "drop", + } + } + + const fn sql(self) -> &'static str { + match self { + Self::Create => "SELECT public.transaction_events_create_partition($1, $2)", + Self::Detach => "SELECT public.transaction_events_detach_partition($1, $2)", + Self::DropDetached => { + "SELECT public.transaction_events_drop_detached_partition($1, $2)" } } - // Delete by ctid via Tid Scan so the batch is not a second primary-key - // lookup. ANY(ARRAY(ctid)) keeps that plan as catch-up shrinks the heap; - // USING ctid can seq-scan. FOR UPDATE SKIP LOCKED skips rows held by a - // concurrent writer instead of waiting. MATERIALIZED so the keyset - // cursor is read from the selected set, not a rewritten join. The - // resume key is the smallest selected tuple (last in DESC order) so - // the next `WHERE key < cursor` scan does not walk just-deleted dead - // index entries. - let result = sqlx::query( - "WITH doomed AS MATERIALIZED ( \ - SELECT ctid, event_type, ingested_at, event_id \ - FROM transaction_events \ - WHERE event_type = ANY($1) \ - AND ingested_at < $2 \ - AND ( \ - $3::text IS NULL \ - OR (event_type, ingested_at, event_id) < ($3, $4, $5) \ - ) \ - ORDER BY event_type DESC, ingested_at DESC, event_id DESC \ - FOR UPDATE SKIP LOCKED \ - LIMIT $6 \ - ), \ - deleted AS ( \ - DELETE FROM transaction_events \ - WHERE ctid = ANY (ARRAY(SELECT ctid FROM doomed)::tid[]) \ - RETURNING ctid \ - ) \ - SELECT \ - (SELECT COUNT(*) FROM doomed)::bigint, \ - (SELECT COUNT(*) FROM deleted)::bigint, \ - (SELECT event_type FROM doomed \ - ORDER BY event_type ASC, ingested_at ASC, event_id ASC LIMIT 1), \ - (SELECT ingested_at FROM doomed \ - ORDER BY event_type ASC, ingested_at ASC, event_id ASC LIMIT 1), \ - (SELECT event_id FROM doomed \ - ORDER BY event_type ASC, ingested_at ASC, event_id ASC LIMIT 1)", - ) - .bind(event_types) - .bind(cutoff) - .bind(progress.cursor.as_ref().map(|cursor| cursor.event_type.as_str())) - .bind(progress.cursor.as_ref().map(|cursor| cursor.ingested_at)) - .bind(progress.cursor.as_ref().map(|cursor| cursor.event_id.as_str())) - .bind(i64::from(batch_limit)) + } +} + +/// Runs one partition function in its own transaction. +/// +/// Returns whether the function changed anything. A lock timeout is counted +/// and reported as unchanged so the pass can continue; the next pass retries. +async fn run_partition_ddl( + conn: &mut sqlx::PgConnection, + lock_timeout_sql: &str, + ddl: PartitionDdl, + partition: DayPartition, + outcome: &mut TransactionEventRetentionOutcome, +) -> Result { + let mut tx = conn.begin().await?; + sqlx::query(lock_timeout_sql).execute(&mut *tx).await?; + let result: std::result::Result = sqlx::query_scalar(ddl.sql()) + .bind(partition.class.as_str()) + .bind(partition.day) .fetch_one(&mut *tx) .await; - match result { - Ok(row) => { - let selected: i64 = row.try_get(0)?; - let deleted: i64 = row.try_get(1)?; - if selected != deleted { - let _ = tx.rollback().await; - anyhow::bail!( - "transaction event expire selected {selected} rows but deleted {deleted}" - ); - } - let cursor = match ( - row.try_get::, _>(2)?, - row.try_get::>, _>(3)?, - row.try_get::, _>(4)?, - ) { - (Some(event_type), Some(ingested_at), Some(event_id)) => { - Some(RetentionScanCursor { event_type, ingested_at, event_id }) - } - _ => None, - }; - tx.commit().await?; - return Ok(ExpireBatchOutcome::Deleted { - selected: u64::try_from(selected).unwrap_or(0), - cursor, - }); - } - Err(err) => { - let _ = tx.rollback().await; - if is_statement_timeout(&err) { - record_expire_statement_timeout(&err, class, batch_limit); - return Ok(ExpireBatchOutcome::StatementTimeout); - } - let Some(reason) = persist_retry_reason(&err) else { - return Err(err.into()); - }; - if attempt >= TRANSACTION_EVENT_DB_MAX_ATTEMPTS { - return Err(err.into()); - } + match result { + Ok(changed) => { + tx.commit().await?; + Ok(changed) + } + Err(err) => { + let _ = tx.rollback().await; + if is_lock_timeout(&err) { warn!( - attempt, - reason, error = %err, - "retrying transaction event expire" + action = ddl.as_str(), + partition = %partition.table_name(), + "transaction event partition DDL hit lock timeout; retrying next pass" ); - Metrics::transaction_events_persist_retries(reason).increment(1); - tokio::time::sleep(StdDuration::from_millis(25 * u64::from(attempt))).await; - attempt += 1; + outcome.lock_timeouts += 1; + Metrics::transaction_event_partition_lock_timeouts(ddl.as_str()).increment(1); + return Ok(false); } + Err(anyhow::Error::new(err).context(format!( + "failed to {} transaction event partition {}", + ddl.as_str(), + partition.table_name() + ))) } } } @@ -1350,6 +1521,18 @@ impl TransactionEventSink for PgTransactionEventSink { Ok(TransactionEventInsertOutcome { inserted_event_ids }) } + + fn admit_event( + &self, + event: &TransactionEvent, + now: DateTime, + ) -> std::result::Result<(), String> { + let class = TransactionEventRetentionClass::for_event_type(event.event_type); + self.retention.admit_event_time(class, event.event_time, now).map_err(|rejection| { + Metrics::transaction_events_outside_retention_window(rejection.as_str()).increment(1); + rejection.reason(class) + }) + } } #[derive(Clone)] @@ -1434,8 +1617,15 @@ async fn ingest_transaction_event_batch( let mut seen = HashSet::new(); let mut valid_events = Vec::new(); + let now = Utc::now(); for raw_event in events { - match validate_transaction_event(raw_event, &state.config) { + let admitted = validate_transaction_event(raw_event, &state.config).and_then(|event| { + match state.sink.admit_event(&event, now) { + Ok(()) => Ok(event), + Err(reason) => Err(ValidationRejection { event_id: Some(event.event_id), reason }), + } + }); + match admitted { Ok(event) => { if seen.insert(event.event_id.clone()) { results.push(TransactionEventItemResult { @@ -1701,6 +1891,47 @@ mod tests { } } + /// Fake sink that applies the real retention-window admission check. + #[derive(Debug, Default)] + struct RetentionWindowSink { + inner: FakeSink, + retention: TransactionEventRetentionConfig, + } + + #[async_trait] + impl TransactionEventSink for RetentionWindowSink { + async fn insert_events( + &self, + events: &[TransactionEvent], + ) -> std::result::Result + { + self.inner.insert_events(events).await + } + + fn admit_event( + &self, + event: &TransactionEvent, + now: DateTime, + ) -> std::result::Result<(), String> { + let class = TransactionEventRetentionClass::for_event_type(event.event_type); + self.retention + .admit_event_time(class, event.event_time, now) + .map_err(|rejection| rejection.reason(class)) + } + } + + fn at(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value).unwrap().with_timezone(&Utc) + } + + fn day(value: &str) -> NaiveDate { + NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap() + } + + fn partition(class: TransactionEventRetentionClass, value: &str) -> DayPartition { + DayPartition { class, day: day(value) } + } + fn config() -> TransactionEventIngestConfig { TransactionEventIngestConfig { path: DEFAULT_TRANSACTION_EVENT_BATCH_PATH.to_string(), @@ -1825,64 +2056,168 @@ mod tests { .is_err() ); assert!( - TransactionEventRetentionConfig { delete_batch_size: 0, ..Default::default() } - .validate() - .is_err() + TransactionEventRetentionConfig { + cold_days: MAX_TRANSACTION_EVENT_RETENTION_DAYS + 1, + ..Default::default() + } + .validate() + .is_err() ); assert!( - TransactionEventRetentionConfig { max_batches: 0, ..Default::default() } + TransactionEventRetentionConfig { partition_lock_timeout_ms: 0, ..Default::default() } .validate() .is_err() ); assert!( - TransactionEventRetentionConfig { statement_timeout_ms: 0, ..Default::default() } - .validate() - .is_err() + TransactionEventRetentionConfig { + partition_lock_timeout_ms: 60_001, + ..Default::default() + } + .validate() + .is_err() ); assert!( - TransactionEventRetentionConfig { statement_timeout_ms: 300_001, ..Default::default() } + TransactionEventRetentionConfig { interval_secs: 0, ..Default::default() } .validate() .is_err() ); assert!( - TransactionEventRetentionConfig { interval_secs: 0, ..Default::default() } - .validate() - .is_err() + TransactionEventRetentionConfig { + interval_secs: MAX_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS + 1, + ..Default::default() + } + .validate() + .is_err(), + "an interval longer than a day could outrun the partition look-ahead" + ); + } + + #[test] + fn admits_event_times_inside_the_retention_window() { + let config = TransactionEventRetentionConfig::default(); + let now = at("2026-09-23T12:00:00Z"); + let hot = TransactionEventRetentionClass::Hot; + + assert_eq!(config.admit_event_time(hot, now, now), Ok(())); + assert_eq!(config.admit_event_time(hot, now - Duration::days(3), now), Ok(())); + assert_eq!( + config.admit_event_time(hot, now - Duration::days(3) - Duration::seconds(1), now), + Err(EventTimeRejection::Expired) + ); + assert_eq!( + config.admit_event_time( + TransactionEventRetentionClass::Cold, + now - Duration::days(29), + now + ), + Ok(()), + "cold events keep the longer window" + ); + assert_eq!(config.admit_event_time(hot, now + Duration::minutes(59), now), Ok(())); + assert_eq!( + config.admit_event_time(hot, now + Duration::hours(1), now), + Err(EventTimeRejection::Future) ); } #[test] - fn bisects_expire_batch_limit_below_the_timed_out_size() { - assert_eq!(expire_batch_limit(10_000, 0, None, false), 10_000); - assert_eq!(expire_batch_limit(10_000, 1, Some(10_000), true), 1); - assert_eq!(expire_batch_limit(10_000, 1, Some(10_000), false), 5_000); - assert_eq!(expire_batch_limit(10_000, 5_000, Some(10_000), false), 5_000); - assert_eq!(expire_batch_limit(10_000, 1, Some(5_000), false), 2_500); - assert_eq!(expire_batch_limit(10_000, 2_500, Some(5_000), false), 2_500); - assert_eq!(expire_batch_limit(10_000, 1, Some(2), false), 1); + fn round_trips_day_partition_table_names() { + let hot = partition(TransactionEventRetentionClass::Hot, "2026-09-23"); + assert_eq!(hot.table_name(), "transaction_events_hot_20260923"); + assert_eq!(DayPartition::from_table_name(&hot.table_name()), Some(hot)); + assert_eq!(hot.end(), at("2026-09-24T00:00:00Z")); + + for name in [ + "transaction_events", + "transaction_events_hot", + "transaction_events_tepid_20260923", + "transaction_events_hot_2026092", + "transaction_events_hot_20261341", + ] { + assert_eq!(DayPartition::from_table_name(name), None, "{name}"); + } } #[test] - fn time_boxes_expire_cycle_after_the_first_returned_batch() { - let now = Utc::now(); - let mut progress = RetentionClassProgress::new_cycle( - now - Duration::seconds(10), - &TransactionEventRetentionConfig::default(), - TransactionEventRetentionClass::Hot, + fn plans_each_class_window_plus_days_ahead() { + let config = TransactionEventRetentionConfig::default(); + let now = at("2026-09-23T12:00:00Z"); + + let plan = plan_partition_maintenance(now, config, &[]); + + let first_and_last = |class| { + let days: Vec<_> = + plan.create.iter().filter(|p| p.class == class).map(|p| p.day).collect(); + (days.len(), days.first().copied(), days.last().copied()) + }; + assert_eq!( + first_and_last(TransactionEventRetentionClass::Hot), + (7, Some(day("2026-09-20")), Some(day("2026-09-26"))) ); - assert!( - !progress.should_end_cycle(now, 1), - "a cycle with no returned batch must still run once" + assert_eq!( + first_and_last(TransactionEventRetentionClass::Warm), + (11, Some(day("2026-09-16")), Some(day("2026-09-26"))) + ); + assert_eq!( + first_and_last(TransactionEventRetentionClass::Cold), + (34, Some(day("2026-08-24")), Some(day("2026-09-26"))) + ); + assert!(plan.detach.is_empty()); + assert!(plan.drop_detached.is_empty()); + } + + #[test] + fn plans_drops_after_the_window_and_grace_period() { + let config = TransactionEventRetentionConfig::default(); + let hot = TransactionEventRetentionClass::Hot; + let existing = [ + // Ends at the window start on Sep 20 00:00; dropped once the grace + // period has also passed. + ExistingPartition { partition: partition(hot, "2026-09-19"), attached: true }, + ExistingPartition { partition: partition(hot, "2026-09-20"), attached: true }, + ExistingPartition { + partition: partition(TransactionEventRetentionClass::Cold, "2026-07-01"), + attached: false, + }, + ]; + + let within_grace = + plan_partition_maintenance(at("2026-09-23T00:30:00Z"), config, &existing); + assert!(within_grace.detach.is_empty(), "grace period keeps the partition"); + + let plan = plan_partition_maintenance(at("2026-09-23T12:00:00Z"), config, &existing); + assert_eq!(plan.detach, vec![partition(hot, "2026-09-19")]); + assert_eq!( + plan.drop_detached, + vec![partition(TransactionEventRetentionClass::Cold, "2026-07-01")] ); - progress.batches_this_cycle = 1; - progress.needs_limit_one = true; assert!( - !progress.should_end_cycle(now, 1), - "a queued LIMIT 1 after timeout must run before the cycle restarts" + !plan.create.contains(&partition(hot, "2026-09-20")), + "attached partitions are not recreated" + ); + } + + #[test] + fn measures_contiguous_partition_horizon_from_today() { + let hot = TransactionEventRetentionClass::Hot; + let now = at("2026-09-23T12:00:00Z"); + let attached: BTreeSet<_> = ["2026-09-23", "2026-09-24", "2026-09-25", "2026-09-27"] + .into_iter() + .map(|value| partition(hot, value)) + .collect(); + + // Sep 26 is missing, so coverage ends at Sep 26 00:00, and ingest + // admits events up to an hour ahead. + assert_eq!(partition_horizon_secs(now, hot, &attached), 2.5 * 86_400.0 - 3_600.0); + assert_eq!( + partition_horizon_secs(at("2026-09-25T23:30:00Z"), hot, &attached), + 0.0, + "coverage inside the future skew counts as exhausted" + ); + assert_eq!( + partition_horizon_secs(now, TransactionEventRetentionClass::Warm, &attached), + 0.0 ); - progress.needs_limit_one = false; - assert!(progress.should_end_cycle(now, 1)); - assert!(!progress.should_end_cycle(now, 3_600)); } #[test] @@ -1988,6 +2323,35 @@ mod tests { assert_eq!(response.rejected, 1); } + #[tokio::test] + async fn rejects_events_outside_the_retention_window() { + let state = state(Arc::new(RetentionWindowSink::default())); + let mut expired = event("expired-event"); + expired["event_time"] = json!(Utc::now() - Duration::days(4)); + let mut future = event("future-event"); + future["event_time"] = json!(Utc::now() + Duration::days(1)); + + let (status, Json(response)) = ingest_transaction_event_batch( + &state, + ndjson(vec![event("fresh-event"), expired, future]), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(response.status, TransactionEventBatchStatus::Partial); + assert_eq!(response.accepted, 1); + assert_eq!(response.rejected, 2); + let reasons: Vec<_> = + response.results.iter().filter_map(|result| result.reason.as_deref()).collect(); + assert_eq!( + reasons, + vec![ + "event_time is older than the hot retention window", + "event_time is more than 3600 seconds in the future" + ] + ); + } + #[tokio::test] async fn rejects_json_batch_wrapper() { let state = state(Arc::new(FakeSink::default())); diff --git a/crates/infra/audit/tests/postgres_transaction_events.rs b/crates/infra/audit/tests/postgres_transaction_events.rs index a40f0af255..d54d4e4496 100644 --- a/crates/infra/audit/tests/postgres_transaction_events.rs +++ b/crates/infra/audit/tests/postgres_transaction_events.rs @@ -7,7 +7,10 @@ //! cargo test -p audit-archiver-lib --test postgres_transaction_events -- --ignored //! ``` -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::{ + path::PathBuf, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; use audit_archiver_lib::{ MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE, PgTransactionEventSink, RejectedTransactionEventQuery, @@ -16,17 +19,34 @@ use audit_archiver_lib::{ use base_observability_events::TransactionEvent; use chrono::Utc; use serde_json::json; -use sqlx::{Executor, PgPool, postgres::PgPoolOptions}; -use testcontainers::{ImageExt, runners::AsyncRunner}; +use sqlx::{Executor, PgPool, migrate::Migrator, postgres::PgPoolOptions}; +use testcontainers::{ImageExt, core::ExecCommand, runners::AsyncRunner}; use testcontainers_modules::postgres::Postgres; -/// `testcontainers-modules` still defaults to Postgres 11, which rejects CTE -/// `AS MATERIALIZED` (Postgres 12+). Production RDS is Postgres 17. -const POSTGRES_TAG: &str = "16-alpine"; +/// Production RDS is Postgres 17. `testcontainers-modules` still defaults to +/// Postgres 11, which lacks the partitioning features the schema relies on. +const POSTGRES_TAG: &str = "17-alpine"; + +/// Hot class partitions for one UTC day, as created by the baseline migration. +const HOT_PARTITIONS_SQL: &str = "SELECT c.relname::text FROM pg_inherits i \ + JOIN pg_class c ON c.oid = i.inhrelid \ + WHERE i.inhparent = 'transaction_events_hot'::regclass \ + ORDER BY c.relname"; + +/// Committed schema that the migrations must produce, from any starting state. +const SCHEMA_SNAPSHOT_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/schema.sql"); + +/// Set to rewrite [`SCHEMA_SNAPSHOT_PATH`] instead of comparing against it. +const UPDATE_SCHEMA_SNAPSHOT_ENV: &str = "UPDATE_SCHEMA_SNAPSHOT"; + +const SCHEMA_SNAPSHOT_HEADER: &str = "-- Schema produced by crates/infra/audit/migrations, excluding dated day\n\ + -- partitions and _sqlx_migrations. Generated by the postgres_transaction_events\n\ + -- tests; regenerate with UPDATE_SCHEMA_SNAPSHOT=1 instead of editing by hand.\n"; struct PostgresHarness { + port: u16, database_url: String, - _container: testcontainers::ContainerAsync, + container: testcontainers::ContainerAsync, } impl PostgresHarness { @@ -34,7 +54,73 @@ impl PostgresHarness { let container = Postgres::default().with_tag(POSTGRES_TAG).start().await?; let port = container.get_host_port_ipv4(5432).await?; let database_url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); - Ok(Self { database_url, _container: container }) + Ok(Self { port, database_url, container }) + } + + fn url_for(&self, user: &str, password: &str) -> String { + format!("postgres://{user}:{password}@127.0.0.1:{}/postgres", self.port) + } + + /// Normalized schema-only `pg_dump` of the database. + /// + /// Day partitions are excluded because their names depend on the current + /// date. sqlx's history table and `pg_dump`'s version-specific preamble are + /// excluded because they are not part of the audit schema. + async fn schema_dump(&self) -> anyhow::Result { + let mut result = self + .container + .exec(ExecCommand::new([ + "pg_dump", + "--username=postgres", + "--dbname=postgres", + "--schema-only", + "--no-owner", + "--exclude-table=_sqlx_migrations", + "--exclude-table=transaction_events_*_2*", + ])) + .await?; + let stdout = String::from_utf8(result.stdout_to_vec().await?)?; + let stderr = String::from_utf8(result.stderr_to_vec().await?)?; + anyhow::ensure!(result.exit_code().await? == Some(0), "pg_dump failed: {stderr}"); + + let mut lines: Vec<&str> = Vec::new(); + for line in stdout.lines() { + let preamble = + ["--", "\\restrict", "\\unrestrict", "SET ", "SELECT pg_catalog.set_config"] + .iter() + .any(|prefix| line.starts_with(prefix)); + let repeated_blank = line.is_empty() && lines.last().is_none_or(|last| last.is_empty()); + if !preamble && !repeated_blank { + lines.push(line); + } + } + while lines.last().is_some_and(|last| last.is_empty()) { + lines.pop(); + } + Ok(format!("{SCHEMA_SNAPSHOT_HEADER}\n{}\n", lines.join("\n"))) + } + + /// Asserts the schema matches the committed snapshot, or rewrites the + /// snapshot when [`UPDATE_SCHEMA_SNAPSHOT_ENV`] is set. + async fn assert_schema_matches_snapshot(&self) -> anyhow::Result<()> { + let dump = self.schema_dump().await?; + if std::env::var_os(UPDATE_SCHEMA_SNAPSHOT_ENV).is_some() { + std::fs::write(SCHEMA_SNAPSHOT_PATH, dump)?; + return Ok(()); + } + let expected = std::fs::read_to_string(SCHEMA_SNAPSHOT_PATH)?; + assert_eq!( + dump, expected, + "schema differs from crates/infra/audit/schema.sql; if the change is intended, rerun with {UPDATE_SCHEMA_SNAPSHOT_ENV}=1 and review the diff" + ); + Ok(()) + } + + /// Creates the runtime role, so the migrations' grants appear in the schema. + async fn create_runtime_role(&self) -> anyhow::Result<()> { + let pool = PgPoolOptions::new().max_connections(1).connect(&self.database_url).await?; + pool.execute("CREATE ROLE audit_archiver NOLOGIN").await?; + Ok(()) } } @@ -67,12 +153,47 @@ fn event_with_type(event_id: &str, event_type: &str) -> TransactionEvent { .unwrap() } +fn utc_today_at(hour: u32, minute: u32, second: u32) -> chrono::DateTime { + Utc::now().date_naive().and_hms_opt(hour, minute, second).unwrap().and_utc() +} + async fn cleanup(pool: &PgPool, event_id: &str) { let _ = pool .execute(sqlx::query("DELETE FROM transaction_events WHERE event_id = $1").bind(event_id)) .await; } +async fn event_ids_like(pool: &PgPool, prefix: &str) -> anyhow::Result> { + Ok(sqlx::query_scalar( + "SELECT event_id FROM transaction_events WHERE event_id LIKE $1 ORDER BY event_id", + ) + .bind(format!("{prefix}-%")) + .fetch_all(pool) + .await?) +} + +async fn hot_partitions(pool: &PgPool) -> anyhow::Result> { + Ok(sqlx::query_scalar(HOT_PARTITIONS_SQL).fetch_all(pool).await?) +} + +/// Writes the legacy pre-partition migrations up to and including +/// `last_version` into a temporary directory, so tests can build a database +/// at an older schema. +fn legacy_migrations_through(last_version: i64) -> anyhow::Result { + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("legacy_migrations"); + let target = std::env::temp_dir().join(format!("audit-migrations-{}", unique_event_id())); + std::fs::create_dir_all(&target)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + let version: i64 = name.split('_').next().unwrap_or_default().parse()?; + if version <= last_version { + std::fs::copy(entry.path(), target.join(name))?; + } + } + Ok(target) +} + #[tokio::test] async fn transaction_events_ready_without_postgres_sink() { PgTransactionEventSink::check_optional_schema_ready(None).await.unwrap(); @@ -94,8 +215,10 @@ async fn transaction_events_unready_before_required_migration() -> anyhow::Resul async fn transaction_events_unready_when_migration_version_is_missing() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; - pool.execute(sqlx::query("CREATE TABLE _sqlx_migrations (version BIGINT, success BOOLEAN)")) - .await?; + pool.execute(sqlx::query( + "CREATE TABLE _sqlx_migrations (version BIGINT, success BOOLEAN, checksum BYTEA)", + )) + .await?; let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; let expected_version = PgTransactionEventSink::required_migration_version().map_err(anyhow::Error::msg)?; @@ -107,7 +230,7 @@ async fn transaction_events_unready_when_migration_version_is_missing() -> anyho required_version } if required_version == expected_version )); - assert!(err.to_string().contains("001_transaction_events.sql")); + assert!(err.to_string().contains("001_transaction_events_partitioned.sql")); Ok(()) } @@ -117,11 +240,28 @@ fn transaction_events_migration_version_matches_sqlx_migration_metadata() -> any assert_eq!( PgTransactionEventSink::required_migration_version().map_err(anyhow::Error::msg)?, 1, - "001_transaction_events.sql should resolve to sqlx migration version 1" + "001_transaction_events_partitioned.sql should resolve to sqlx migration version 1" ); Ok(()) } +/// Legacy 001 also recorded version 1, so readiness must check the checksum. +#[tokio::test] +async fn transaction_events_unready_on_pre_partition_schema() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + Migrator::new(legacy_migrations_through(4)?).await?.run(&pool).await?; + let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; + + let err = sink.check_schema_ready().await.unwrap_err(); + assert!( + matches!(err, TransactionEventSchemaReadinessError::RequiredMigrationMissing { .. }), + "new pods must not go ready against the unpartitioned table: {err}" + ); + + Ok(()) +} + #[tokio::test] async fn transaction_events_ready_after_required_migration() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; @@ -143,21 +283,217 @@ async fn transaction_events_ready_after_required_migration() -> anyhow::Result<( } #[tokio::test] -async fn postgres_retention_index_is_applied() -> anyhow::Result<()> { +async fn postgres_partition_migration_discards_pre_partition_rows() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + harness.create_runtime_role().await?; + let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + Migrator::new(legacy_migrations_through(4)?).await?.run(&pool).await?; + sqlx::query( + "INSERT INTO transaction_events \ + (event_id, schema_version, event_time, producer, event_type, network, data) \ + VALUES ('legacy-row', 'transaction-event/v1', now(), 'base-builder', \ + 'BUILDER_ACCEPTED', 'base-mainnet', '{}'::jsonb)", + ) + .execute(&pool) + .await?; + + PgTransactionEventSink::migrate(&harness.database_url).await?; + + let rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM transaction_events").fetch_one(&pool).await?; + assert_eq!(rows, 0, "the reset replaces the table instead of copying rows"); + let versions: Vec = + sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY version") + .fetch_all(&pool) + .await?; + assert_eq!(versions, vec![1], "legacy history is replaced by the baseline"); + PgTransactionEventSink::connect(&harness.database_url, 1).await?.check_schema_ready().await?; + harness.assert_schema_matches_snapshot().await?; + + Ok(()) +} + +#[tokio::test] +async fn postgres_fresh_database_only_runs_the_partitioned_baseline() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; PgTransactionEventSink::migrate(&harness.database_url).await?; let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS ( \ - SELECT 1 FROM pg_indexes \ - WHERE schemaname = 'public' \ - AND indexname = 'transaction_events_event_type_ingested_at_idx' \ - )", + let versions: Vec = + sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY version") + .fetch_all(&pool) + .await?; + assert_eq!(versions, vec![1]); + + Ok(()) +} + +/// Mainnet shape: 003 dropped the rejected index and recorded, then 004's +/// concurrent rebuild was killed before recording. Migrating must not run 004 +/// against the old table, and the invalid index must not survive. The result +/// must match a fresh database's schema. +#[tokio::test] +async fn postgres_migrates_past_an_unrecorded_004_with_an_invalid_index() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + harness.create_runtime_role().await?; + let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + Migrator::new(legacy_migrations_through(3)?).await?.run(&pool).await?; + sqlx::query( + "INSERT INTO transaction_events \ + (event_id, schema_version, event_time, producer, event_type, network, data) \ + SELECT 'legacy-' || n, 'transaction-event/v1', now(), 'base-builder', \ + 'BUILDER_REJECTED', 'base-mainnet', '{}'::jsonb \ + FROM generate_series(1, 2) AS n", + ) + .execute(&pool) + .await?; + // A failed concurrent build leaves an invalid index behind under the name + // 004 would skip with IF NOT EXISTS. + let failed = sqlx::query( + "CREATE UNIQUE INDEX CONCURRENTLY transaction_events_rejected_event_time_idx \ + ON transaction_events (event_type)", + ) + .execute(&pool) + .await; + assert!(failed.is_err(), "duplicate event types make the build fail"); + let invalid: bool = sqlx::query_scalar( + "SELECT NOT indisvalid FROM pg_index \ + WHERE indexrelid = 'transaction_events_rejected_event_time_idx'::regclass", + ) + .fetch_one(&pool) + .await?; + assert!(invalid); + + PgTransactionEventSink::migrate(&harness.database_url).await?; + + let versions: Vec = + sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY version") + .fetch_all(&pool) + .await?; + assert_eq!(versions, vec![1], "004 is never run, and legacy history is replaced"); + let (valid, partitioned): (bool, bool) = sqlx::query_as( + "SELECT i.indisvalid, c.relkind = 'I' FROM pg_index i \ + JOIN pg_class c ON c.oid = i.indexrelid \ + WHERE i.indexrelid = 'transaction_events_rejected_event_time_idx'::regclass", ) .fetch_one(&pool) .await?; - assert!(exists); + assert!( + valid && partitioned, + "the index is the baseline's partitioned index, not the leftover" + ); + PgTransactionEventSink::connect(&harness.database_url, 1).await?.check_schema_ready().await?; + harness.assert_schema_matches_snapshot().await?; + + Ok(()) +} + +#[tokio::test] +async fn postgres_migrate_rejects_unknown_applied_migrations() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + PgTransactionEventSink::migrate(&harness.database_url).await?; + let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + sqlx::query( + "INSERT INTO _sqlx_migrations \ + (version, description, success, checksum, execution_time) \ + VALUES (99, 'from a newer binary', true, '\\x00'::bytea, 0)", + ) + .execute(&pool) + .await?; + + let err = PgTransactionEventSink::migrate(&harness.database_url).await.unwrap_err(); + assert!(err.to_string().contains("99"), "{err}"); + + Ok(()) +} + +/// The reset deletes only history it recognizes: a legacy database that also +/// records an unknown migration is left untouched. +#[tokio::test] +async fn postgres_migrate_refuses_to_reset_unrecognized_history() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + Migrator::new(legacy_migrations_through(4)?).await?.run(&pool).await?; + sqlx::query( + "INSERT INTO _sqlx_migrations \ + (version, description, success, checksum, execution_time) \ + VALUES (99, 'from a newer binary', true, '\\x00'::bytea, 0)", + ) + .execute(&pool) + .await?; + + let err = PgTransactionEventSink::migrate(&harness.database_url).await.unwrap_err(); + assert!(err.to_string().contains("refusing to reset migration history"), "{err}"); + let versions: Vec = + sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY version") + .fetch_all(&pool) + .await?; + assert_eq!(versions, vec![1, 2, 3, 4, 99]); + let partitioned: bool = sqlx::query_scalar( + "SELECT relkind = 'p' FROM pg_class WHERE oid = 'transaction_events'::regclass", + ) + .fetch_one(&pool) + .await?; + assert!(!partitioned, "the legacy table is not dropped"); + + Ok(()) +} + +#[tokio::test] +async fn postgres_schema_matches_committed_snapshot() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + harness.create_runtime_role().await?; + PgTransactionEventSink::migrate(&harness.database_url).await?; + + harness.assert_schema_matches_snapshot().await?; + + Ok(()) +} + +#[tokio::test] +async fn postgres_schema_is_partitioned_by_class_then_day() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + PgTransactionEventSink::migrate(&harness.database_url).await?; + let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + + let class_partitions: Vec = sqlx::query_scalar( + "SELECT c.relname::text FROM pg_inherits i \ + JOIN pg_class c ON c.oid = i.inhrelid \ + WHERE i.inhparent = 'transaction_events'::regclass \ + ORDER BY c.relname", + ) + .fetch_all(&pool) + .await?; + assert_eq!( + class_partitions, + vec!["transaction_events_cold", "transaction_events_hot", "transaction_events_warm"] + ); + + // The baseline migration seeds each default retention window (hot is 3 days) + // through three days ahead. + let today = Utc::now().date_naive(); + let expected: Vec = (-3..=3) + .map(|offset| { + format!( + "transaction_events_hot_{}", + (today + chrono::Duration::days(offset)).format("%Y%m%d") + ) + }) + .collect(); + assert_eq!(hot_partitions(&pool).await?, expected); + + let dropped_indexes: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pg_indexes \ + WHERE schemaname = 'public' \ + AND indexname IN ( \ + 'transaction_events_payload_id_event_time_idx', \ + 'transaction_events_producer_event_type_event_time_idx', \ + 'transaction_events_event_type_ingested_at_idx' \ + )", + ) + .fetch_one(&pool) + .await?; + assert_eq!(dropped_indexes, 0, "unused indexes are not recreated"); Ok(()) } @@ -209,216 +545,215 @@ async fn postgres_sink_chunks_large_direct_inserts() -> anyhow::Result<()> { } #[tokio::test] -async fn postgres_retention_deletes_expired_rows_in_batches() -> anyhow::Result<()> { +async fn postgres_sink_dedupes_retried_events_and_routes_by_class() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; PgTransactionEventSink::migrate(&harness.database_url).await?; let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; let event_prefix = unique_event_id(); + let events = [ + event(&format!("{event_prefix}-hot")), + event_with_type(&format!("{event_prefix}-warm"), "INGRESS_RECEIVED"), + event_with_type(&format!("{event_prefix}-cold"), "SIMULATION_FAILED"), + ]; - let events = (0..3).map(|index| event(&format!("{event_prefix}-{index}"))).collect::>(); - sink.insert_events(&events).await?; - sqlx::query( - "UPDATE transaction_events SET ingested_at = now() - interval '10 days' \ - WHERE event_id IN ($1, $2)", - ) - .bind(format!("{event_prefix}-0")) - .bind(format!("{event_prefix}-1")) - .execute(&pool) - .await?; - - let outcome = sink - .expire_old_events(TransactionEventRetentionConfig { - hot_days: 7, - warm_days: 7, - cold_days: 7, - delete_batch_size: 1, - max_batches: 10, - ..Default::default() - }) - .await?; - assert_eq!(outcome.rows_deleted, 2); - assert_eq!(outcome.hot_rows_deleted, 2); - assert_eq!(outcome.batches, 5); + let first = sink.insert_events(&events).await?; + assert_eq!(first.inserted_event_ids.len(), 3); + let retry = sink.insert_events(&events).await?; + assert!(retry.inserted_event_ids.is_empty(), "retried events must conflict"); + + let mut same_day = events[0].clone(); + same_day.event_time = utc_today_at(0, 0, 10); + let mut later_same_day = same_day.clone(); + later_same_day.event_time = utc_today_at(0, 0, 20); + let mut next_day = same_day.clone(); + next_day.event_id = format!("{}-reemitted", unique_event_id()); + next_day.event_time = utc_today_at(0, 0, 10) - chrono::Duration::days(1); + let mut reemitted = next_day.clone(); + reemitted.event_time = utc_today_at(0, 0, 10); + assert_eq!( + sink.insert_events(&[same_day]).await?.inserted_event_ids.len(), + 0, + "an event_id already stored today dedupes regardless of event_time" + ); + assert!(sink.insert_events(&[later_same_day]).await?.inserted_event_ids.is_empty()); + assert_eq!(sink.insert_events(&[next_day]).await?.inserted_event_ids.len(), 1); + assert_eq!( + sink.insert_events(&[reemitted]).await?.inserted_event_ids.len(), + 1, + "dedupe is per UTC day, so a re-emission on another day stores a row" + ); - let remaining: Vec = sqlx::query_scalar( - "SELECT event_id FROM transaction_events WHERE event_id LIKE $1 ORDER BY event_id", + let classes: Vec<(String, String)> = sqlx::query_as( + "SELECT event_id, tableoid::regclass::text FROM transaction_events \ + WHERE event_id LIKE $1 ORDER BY event_id", ) .bind(format!("{event_prefix}-%")) .fetch_all(&pool) .await?; - assert_eq!(remaining, vec![format!("{event_prefix}-2")]); + let today = Utc::now().date_naive().format("%Y%m%d"); + assert_eq!( + classes, + vec![ + (format!("{event_prefix}-cold"), format!("transaction_events_cold_{today}")), + (format!("{event_prefix}-hot"), format!("transaction_events_hot_{today}")), + (format!("{event_prefix}-warm"), format!("transaction_events_warm_{today}")), + ] + ); Ok(()) } #[tokio::test] -async fn postgres_retention_uses_per_class_cutoffs() -> anyhow::Result<()> { +async fn postgres_seeded_partitions_accept_every_default_admitted_day() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; PgTransactionEventSink::migrate(&harness.database_url).await?; let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; - let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; let event_prefix = unique_event_id(); - let events = [ - event_with_type(&format!("{event_prefix}-hot-old"), "BUILDER_ACCEPTED"), - event_with_type(&format!("{event_prefix}-warm-mid"), "INGRESS_RECEIVED"), - event_with_type(&format!("{event_prefix}-cold-mid"), "SIMULATION_FAILED"), - event_with_type(&format!("{event_prefix}-hot-fresh"), "BUILDER_ACCEPTED"), - ]; - sink.insert_events(&events).await?; - sqlx::query( - "UPDATE transaction_events SET ingested_at = now() - interval '4 days' \ - WHERE event_id = $1", - ) - .bind(format!("{event_prefix}-hot-old")) - .execute(&pool) - .await?; - sqlx::query( - "UPDATE transaction_events SET ingested_at = now() - interval '8 days' \ - WHERE event_id IN ($1, $2)", - ) - .bind(format!("{event_prefix}-warm-mid")) - .bind(format!("{event_prefix}-cold-mid")) - .execute(&pool) - .await?; + // No maintenance pass has run: a pod that goes ready right after the + // migration must still store delayed events inside the default windows. + let mut old_hot = event(&format!("{event_prefix}-hot")); + old_hot.event_time = Utc::now() - chrono::Duration::days(3) + chrono::Duration::minutes(1); + let mut old_cold = event_with_type(&format!("{event_prefix}-cold"), "SIMULATION_FAILED"); + old_cold.event_time = Utc::now() - chrono::Duration::days(30) + chrono::Duration::minutes(1); + let mut ahead = event(&format!("{event_prefix}-ahead")); + ahead.event_time = Utc::now() + chrono::Duration::minutes(59); - let outcome = sink - .expire_old_events(TransactionEventRetentionConfig { - hot_days: 3, - warm_days: 7, - cold_days: 30, - delete_batch_size: 10, - max_batches: 10, - ..Default::default() - }) - .await?; - assert_eq!(outcome.hot_rows_deleted, 1); - assert_eq!(outcome.warm_rows_deleted, 1); - assert_eq!(outcome.cold_rows_deleted, 0); - assert_eq!(outcome.rows_deleted, 2); - - let remaining: Vec = sqlx::query_scalar( - "SELECT event_id FROM transaction_events WHERE event_id LIKE $1 ORDER BY event_id", - ) - .bind(format!("{event_prefix}-%")) - .fetch_all(&pool) - .await?; - assert_eq!( - remaining, - vec![format!("{event_prefix}-cold-mid"), format!("{event_prefix}-hot-fresh")] - ); + let outcome = sink.insert_events(&[old_hot, old_cold, ahead]).await?; + assert_eq!(outcome.inserted_event_ids.len(), 3); Ok(()) } #[tokio::test] -async fn postgres_expires_at_lifecycle_events_with_peer_classes() -> anyhow::Result<()> { +async fn postgres_maintenance_backfills_window_and_drops_expired_days() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; PgTransactionEventSink::migrate(&harness.database_url).await?; let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; - let event_prefix = unique_event_id(); + let now = Utc::now(); + + let first = sink.maintain_partitions_at(now).await?; + assert!(first.lock_acquired); + assert_eq!(first.partitions_dropped, 0); + // The migration already seeded the default windows through three days + // ahead. Only the future-skew hour can reach one more day. + assert!(first.partitions_created <= 3); + let second = sink.maintain_partitions_at(now).await?; + assert_eq!(second.partitions_created, 0, "maintenance is idempotent"); + let next_day = sink.maintain_partitions_at(now + chrono::Duration::days(1)).await?; + assert!(next_day.partitions_created > 0, "each pass extends the look-ahead"); - let events = [ - event_with_type(&format!("{event_prefix}-admission"), "TXPOOL_SEND_RAW_TRANSACTION"), - event_with_type( - &format!("{event_prefix}-validity-admission"), - "TXPOOL_SEND_RAW_TRANSACTION_VALIDITY", - ), - event_with_type(&format!("{event_prefix}-rejected"), "BUILDER_REJECTED"), - event_with_type(&format!("{event_prefix}-deferred"), "BUILDER_DEFERRED"), - event_with_type(&format!("{event_prefix}-expired"), "BUILDER_EXPIRED"), - event_with_type(&format!("{event_prefix}-included"), "BUILDER_INCLUDED"), - ]; - sink.insert_events(&events).await?; - sqlx::query( - "UPDATE transaction_events SET ingested_at = now() - interval '4 days' \ - WHERE event_id IN ($1, $2, $3)", - ) - .bind(format!("{event_prefix}-rejected")) - .bind(format!("{event_prefix}-deferred")) - .bind(format!("{event_prefix}-expired")) - .execute(&pool) - .await?; - sqlx::query( - "UPDATE transaction_events SET ingested_at = now() - interval '8 days' \ - WHERE event_id IN ($1, $2, $3)", - ) - .bind(format!("{event_prefix}-admission")) - .bind(format!("{event_prefix}-validity-admission")) - .bind(format!("{event_prefix}-included")) - .execute(&pool) + let event_prefix = unique_event_id(); + sink.insert_events(&[ + event(&format!("{event_prefix}-hot")), + event_with_type(&format!("{event_prefix}-warm"), "INGRESS_RECEIVED"), + ]) .await?; - let outcome = sink - .expire_old_events(TransactionEventRetentionConfig { - hot_days: 3, - warm_days: 7, - cold_days: 30, - delete_batch_size: 10, - max_batches: 10, - ..Default::default() - }) + // Five days later, today's hot partition is past its 3-day window plus + // grace; the warm partition is still inside its 7-day window. + let later = sink.maintain_partitions_at(now + chrono::Duration::days(5)).await?; + assert!(later.partitions_dropped > 0); + assert_eq!(event_ids_like(&pool, &event_prefix).await?, vec![format!("{event_prefix}-warm")]); + let today_hot = format!("transaction_events_hot_{}", now.date_naive().format("%Y%m%d")); + assert!(!hot_partitions(&pool).await?.contains(&today_hot)); + let leftover: Option = sqlx::query_scalar("SELECT to_regclass($1)::text") + .bind(&today_hot) + .fetch_one(&pool) .await?; - assert_eq!(outcome.hot_rows_deleted, 3); - assert_eq!(outcome.warm_rows_deleted, 2); - assert_eq!(outcome.cold_rows_deleted, 0); - - let remaining: Vec<(String, String)> = sqlx::query_as( - "SELECT event_id, event_type FROM transaction_events \ - WHERE event_id LIKE $1 ORDER BY event_id", - ) - .bind(format!("{event_prefix}-%")) - .fetch_all(&pool) - .await?; - assert_eq!(remaining, vec![(format!("{event_prefix}-included"), "BUILDER_INCLUDED".into())]); + assert_eq!(leftover, None, "dropped partitions are not left detached"); Ok(()) } #[tokio::test] -async fn postgres_expire_walks_keyset_across_batches() -> anyhow::Result<()> { +async fn postgres_maintenance_drops_leftover_detached_partitions() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; PgTransactionEventSink::migrate(&harness.database_url).await?; let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; - let event_prefix = unique_event_id(); - let events: Vec<_> = (0..5).map(|i| event(&format!("{event_prefix}-hot-{i}"))).collect(); - sink.insert_events(&events).await?; - sqlx::query( - "UPDATE transaction_events SET ingested_at = now() - interval '10 days' \ - WHERE event_id LIKE $1", - ) - .bind(format!("{event_prefix}-%")) - .execute(&pool) - .await?; + let ahead = Utc::now().date_naive() + chrono::Duration::days(3); + let detached: bool = + sqlx::query_scalar("SELECT transaction_events_detach_partition('hot', $1)") + .bind(ahead) + .fetch_one(&pool) + .await?; + assert!(detached); + + let outcome = sink.maintain_partitions().await?; + + assert!(outcome.partitions_dropped >= 1, "leftover detached table is dropped"); + let name = format!("transaction_events_hot_{}", ahead.format("%Y%m%d")); + assert!( + hot_partitions(&pool).await?.contains(&name), + "the in-window day is recreated as an attached partition" + ); - let outcome = sink - .expire_old_events(TransactionEventRetentionConfig { - hot_days: 7, - warm_days: 7, - cold_days: 7, - delete_batch_size: 2, - max_batches: 10, + Ok(()) +} + +#[tokio::test] +async fn postgres_maintenance_skips_ddl_that_hits_lock_timeout() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + PgTransactionEventSink::migrate(&harness.database_url).await?; + let sink = PgTransactionEventSink::connect(&harness.database_url, 1) + .await? + .with_retention_config(TransactionEventRetentionConfig { + partition_lock_timeout_ms: 200, ..Default::default() - }) + })?; + let pool = PgPoolOptions::new().max_connections(2).connect(&harness.database_url).await?; + let later = Utc::now() + chrono::Duration::days(10); + + // A long-running reader of the hot class blocks DETACH's ACCESS + // EXCLUSIVE lock but not ATTACH's SHARE UPDATE EXCLUSIVE lock. + let mut reader = pool.begin().await?; + sqlx::query("LOCK TABLE transaction_events_hot IN ACCESS SHARE MODE") + .execute(&mut *reader) .await?; - assert_eq!(outcome.hot_rows_deleted, 5); - assert!(outcome.batches >= 3, "expected multiple keyset pages, got {}", outcome.batches); + let before = hot_partitions(&pool).await?; - let remaining: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM transaction_events WHERE event_id LIKE $1") - .bind(format!("{event_prefix}-%")) - .fetch_one(&pool) - .await?; - assert_eq!(remaining, 0); + let started = Instant::now(); + let blocked = sink.maintain_partitions_at(later).await?; + assert!(blocked.lock_timeouts > 0, "blocked detaches are skipped, not fatal"); + assert!(started.elapsed() < Duration::from_secs(10)); + let during = hot_partitions(&pool).await?; + assert!(before.iter().all(|name| during.contains(name)), "no hot partition was detached"); + + reader.rollback().await?; + let retried = sink.maintain_partitions_at(later).await?; + assert_eq!(retried.lock_timeouts, 0); + let after = hot_partitions(&pool).await?; + assert!(before.iter().all(|name| !after.contains(name)), "the next pass drops them"); Ok(()) } #[tokio::test] -async fn postgres_retention_skips_when_another_replica_holds_lock() -> anyhow::Result<()> { +async fn postgres_maintenance_does_not_leak_lock_timeout() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + PgTransactionEventSink::migrate(&harness.database_url).await?; + let ingest_pool = + PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + let retention_pool = + PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + let sink = PgTransactionEventSink::new_with_retention_pool(ingest_pool, retention_pool.clone()); + sink.maintain_partitions().await?; + + let lock_timeout: String = + sqlx::query_scalar("SHOW lock_timeout").fetch_one(&retention_pool).await?; + assert_eq!( + lock_timeout, "0", + "SET LOCAL lock_timeout leaked onto the pooled connection: {lock_timeout}" + ); + + Ok(()) +} + +#[tokio::test] +async fn postgres_maintenance_skips_when_another_replica_holds_lock() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; PgTransactionEventSink::migrate(&harness.database_url).await?; let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; @@ -429,65 +764,91 @@ async fn postgres_retention_skips_when_another_replica_holds_lock() -> anyhow::R .await?; assert!(locked); - let outcome = sink.expire_old_events(TransactionEventRetentionConfig::default()).await?; - assert_eq!(outcome.rows_deleted, 0); - assert_eq!(outcome.batches, 0); - assert_eq!(outcome.hot_rows_deleted, 0); - assert_eq!(outcome.warm_rows_deleted, 0); - assert_eq!(outcome.cold_rows_deleted, 0); + let outcome = sink.maintain_partitions().await?; + assert!(!outcome.lock_acquired); + assert_eq!(outcome.partitions_created, 0); + assert_eq!(outcome.partitions_dropped, 0); transaction.rollback().await?; Ok(()) } #[tokio::test] -async fn postgres_retention_skips_locked_expired_rows() -> anyhow::Result<()> { +async fn postgres_maintenance_uses_retention_pool_when_ingest_is_busy() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; PgTransactionEventSink::migrate(&harness.database_url).await?; - let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; - let pool = PgPoolOptions::new().max_connections(2).connect(&harness.database_url).await?; - let event_prefix = unique_event_id(); + let ingest_pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_millis(500)) + .connect(&harness.database_url) + .await?; + let retention_pool = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(1)) + .connect(&harness.database_url) + .await?; + let sink = PgTransactionEventSink::new_with_retention_pool(ingest_pool.clone(), retention_pool); - let events = - [event(&format!("{event_prefix}-locked")), event(&format!("{event_prefix}-unlocked"))]; - sink.insert_events(&events).await?; - sqlx::query( - "UPDATE transaction_events SET ingested_at = now() - interval '10 days' \ - WHERE event_id IN ($1, $2)", - ) - .bind(format!("{event_prefix}-locked")) - .bind(format!("{event_prefix}-unlocked")) - .execute(&pool) - .await?; + let _held = ingest_pool.acquire().await?; + let outcome = + tokio::time::timeout(Duration::from_secs(10), sink.maintain_partitions()) + .await + .expect("maintenance should use the retention pool instead of waiting on ingest")?; + assert!(outcome.lock_acquired); - let mut held = pool.begin().await?; - sqlx::query("SELECT * FROM transaction_events WHERE event_id = $1 FOR UPDATE") - .bind(format!("{event_prefix}-locked")) - .execute(&mut *held) - .await?; + Ok(()) +} - let outcome = sink - .expire_old_events(TransactionEventRetentionConfig { - hot_days: 7, - warm_days: 7, - cold_days: 7, - delete_batch_size: 10, - max_batches: 10, - ..Default::default() - }) +/// Mirrors production roles: the migration role owns the schema and tables, +/// and the runtime role only has DML plus EXECUTE on the partition functions. +#[tokio::test] +async fn postgres_runtime_role_maintains_partitions_through_definer_functions() -> anyhow::Result<()> +{ + let harness = PostgresHarness::new().await?; + let admin = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + for statement in [ + "CREATE ROLE audit_archiver_migration LOGIN PASSWORD 'migration'", + "CREATE ROLE audit_archiver LOGIN PASSWORD 'runtime'", + "CREATE ROLE unrelated LOGIN PASSWORD 'unrelated'", + "ALTER SCHEMA public OWNER TO audit_archiver_migration", + ] { + admin.execute(statement).await?; + } + + PgTransactionEventSink::migrate(&harness.url_for("audit_archiver_migration", "migration")) .await?; - assert_eq!(outcome.hot_rows_deleted, 1); - assert_eq!(outcome.rows_deleted, 1); - let remaining: Vec = sqlx::query_scalar( - "SELECT event_id FROM transaction_events WHERE event_id LIKE $1 ORDER BY event_id", - ) - .bind(format!("{event_prefix}-%")) - .fetch_all(&pool) - .await?; - assert_eq!(remaining, vec![format!("{event_prefix}-locked")]); + let runtime_url = harness.url_for("audit_archiver", "runtime"); + let sink = PgTransactionEventSink::connect(&runtime_url, 1).await?; + sink.check_schema_ready().await?; + assert!(sink.maintain_partitions().await?.lock_acquired); + let tomorrow = sink.maintain_partitions_at(Utc::now() + chrono::Duration::days(1)).await?; + assert!(tomorrow.partitions_created > 0, "runtime role can create partitions"); + + let event_id = unique_event_id(); + sink.insert_events(&[event(&event_id)]).await?; + assert_eq!(sink.events_by_block_number(123, 10).await?.len(), 1); + + let later = sink.maintain_partitions_at(Utc::now() + chrono::Duration::days(5)).await?; + assert!(later.partitions_dropped > 0, "runtime role can drop expired partitions"); + + let runtime = PgPoolOptions::new().max_connections(1).connect(&runtime_url).await?; + let partition = format!( + "transaction_events_hot_{}", + (Utc::now().date_naive() + chrono::Duration::days(3)).format("%Y%m%d") + ); + let direct_drop = runtime.execute(format!("DROP TABLE {partition}").as_str()).await; + assert!(direct_drop.is_err(), "runtime role must not own partitions"); + + let unrelated = PgPoolOptions::new() + .max_connections(1) + .connect(&harness.url_for("unrelated", "unrelated")) + .await?; + let call = sqlx::query("SELECT transaction_events_detach_partition('hot', current_date)") + .execute(&unrelated) + .await; + assert!(call.is_err(), "partition functions are not executable by PUBLIC"); - held.rollback().await?; Ok(()) } @@ -503,11 +864,14 @@ async fn postgres_insert_fails_fast_when_conflicting_row_is_locked() -> anyhow:: let mut held = pool.begin().await?; sqlx::query( "INSERT INTO transaction_events \ - (event_id, schema_version, event_time, producer, event_type, network, data) \ - VALUES ($1, 'transaction-event/v1', now(), 'base-builder', 'BUILDER_ACCEPTED', \ - 'base-mainnet', '{}'::jsonb)", + (event_id, schema_version, event_time, event_date, retention_class, producer, \ + event_type, network, data) \ + VALUES ($1, 'transaction-event/v1', $2, $3, 'hot', 'base-builder', \ + 'BUILDER_ACCEPTED', 'base-mainnet', '{}'::jsonb)", ) .bind(&event_id) + .bind(pending.event_time) + .bind(pending.event_time.date_naive()) .execute(&mut *held) .await?; @@ -548,176 +912,6 @@ async fn postgres_insert_does_not_leak_lock_timeout() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] -async fn postgres_expire_does_not_leak_statement_timeout() -> anyhow::Result<()> { - let harness = PostgresHarness::new().await?; - PgTransactionEventSink::migrate(&harness.database_url).await?; - let ingest_pool = - PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; - let retention_pool = - PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; - let sink = PgTransactionEventSink::new_with_retention_pool(ingest_pool, retention_pool.clone()); - sink.expire_old_events(TransactionEventRetentionConfig { - max_batches: 3, - ..Default::default() - }) - .await?; - - let statement_timeout: String = - sqlx::query_scalar("SHOW statement_timeout").fetch_one(&retention_pool).await?; - assert_eq!( - statement_timeout, "0", - "SET LOCAL statement_timeout leaked onto the pooled connection: {statement_timeout}" - ); - - Ok(()) -} - -#[tokio::test] -async fn postgres_expire_statement_timeout_advances_to_later_classes() -> anyhow::Result<()> { - let harness = PostgresHarness::new().await?; - PgTransactionEventSink::migrate(&harness.database_url).await?; - let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; - let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; - let event_prefix = unique_event_id(); - - let events = [ - event(&format!("{event_prefix}-hot")), - event_with_type(&format!("{event_prefix}-warm"), "TXPOOL_SEND_RAW_TRANSACTION"), - event_with_type(&format!("{event_prefix}-cold"), "SIMULATION_FAILED"), - ]; - sink.insert_events(&events).await?; - sqlx::query( - "UPDATE transaction_events SET ingested_at = now() - interval '10 days' \ - WHERE event_id LIKE $1", - ) - .bind(format!("{event_prefix}-%")) - .execute(&pool) - .await?; - - let outcome = sink - .expire_old_events(TransactionEventRetentionConfig { - hot_days: 7, - warm_days: 7, - cold_days: 7, - delete_batch_size: 10, - max_batches: 10, - statement_timeout_ms: 200, - test_hot_sleep_ms: Some(2_000), - ..Default::default() - }) - .await?; - assert_eq!(outcome.hot_rows_deleted, 0, "timed-out hot class must not fail the pass"); - assert_eq!(outcome.warm_rows_deleted, 1); - assert_eq!(outcome.cold_rows_deleted, 1); - - let remaining: Vec = sqlx::query_scalar( - "SELECT event_id FROM transaction_events WHERE event_id LIKE $1 ORDER BY event_id", - ) - .bind(format!("{event_prefix}-%")) - .fetch_all(&pool) - .await?; - assert_eq!(remaining, vec![format!("{event_prefix}-hot")]); - - Ok(()) -} - -#[tokio::test] -async fn postgres_expire_timeout_then_limit_one_deletes_hot_rows() -> anyhow::Result<()> { - let harness = PostgresHarness::new().await?; - PgTransactionEventSink::migrate(&harness.database_url).await?; - let sink = PgTransactionEventSink::connect(&harness.database_url, 1).await?; - let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; - let event_prefix = unique_event_id(); - - let events = [ - event(&format!("{event_prefix}-hot-a")), - event(&format!("{event_prefix}-hot-b")), - event_with_type(&format!("{event_prefix}-warm"), "TXPOOL_SEND_RAW_TRANSACTION"), - ]; - sink.insert_events(&events).await?; - sqlx::query( - "UPDATE transaction_events SET ingested_at = now() - interval '10 days' \ - WHERE event_id LIKE $1", - ) - .bind(format!("{event_prefix}-%")) - .execute(&pool) - .await?; - - let outcome = sink - .expire_old_events(TransactionEventRetentionConfig { - hot_days: 7, - warm_days: 7, - cold_days: 7, - delete_batch_size: 10, - max_batches: 20, - statement_timeout_ms: 200, - test_hot_sleep_ms: Some(2_000), - test_hot_sleep_min_limit: Some(2), - ..Default::default() - }) - .await?; - assert_eq!(outcome.hot_rows_deleted, 2); - assert_eq!(outcome.warm_rows_deleted, 1); - - let remaining: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM transaction_events WHERE event_id LIKE $1") - .bind(format!("{event_prefix}-%")) - .fetch_one(&pool) - .await?; - assert_eq!(remaining, 0); - - Ok(()) -} - -#[tokio::test] -async fn postgres_expire_uses_retention_pool_when_ingest_is_busy() -> anyhow::Result<()> { - let harness = PostgresHarness::new().await?; - PgTransactionEventSink::migrate(&harness.database_url).await?; - let ingest_pool = PgPoolOptions::new() - .max_connections(1) - .acquire_timeout(Duration::from_millis(500)) - .connect(&harness.database_url) - .await?; - let retention_pool = PgPoolOptions::new() - .max_connections(1) - .acquire_timeout(Duration::from_secs(1)) - .connect(&harness.database_url) - .await?; - let sink = PgTransactionEventSink::new_with_retention_pool(ingest_pool.clone(), retention_pool); - let event_id = unique_event_id(); - sink.insert_events(&[event(&event_id)]).await?; - sqlx::query("UPDATE transaction_events SET ingested_at = now() - interval '10 days' WHERE event_id = $1") - .bind(&event_id) - .execute(&ingest_pool) - .await?; - - let _held = ingest_pool.acquire().await?; - let started = Instant::now(); - let outcome = tokio::time::timeout( - Duration::from_secs(5), - sink.expire_old_events(TransactionEventRetentionConfig { - hot_days: 7, - warm_days: 7, - cold_days: 7, - delete_batch_size: 10, - max_batches: 10, - ..Default::default() - }), - ) - .await - .expect("expire should acquire the retention pool instead of waiting on ingest")?; - assert!( - started.elapsed() < Duration::from_secs(2), - "expire waited too long with the ingest pool checked out: {:?}", - started.elapsed() - ); - assert_eq!(outcome.hot_rows_deleted, 1); - assert_eq!(outcome.rows_deleted, 1); - - Ok(()) -} - #[tokio::test] #[ignore = "requires a running Postgres (set DATABASE_URL)"] async fn postgres_sink_persists_and_dedupes_by_event_id() { diff --git a/docs/transaction-events/README.md b/docs/transaction-events/README.md index 4c9090cbbc..4746b63853 100644 --- a/docs/transaction-events/README.md +++ b/docs/transaction-events/README.md @@ -12,11 +12,13 @@ one event JSON object per line, not a wrapped JSON batch. ## Postgres Retention `audit-archiver` stores events in Postgres for operational queries. Postgres is -not the long-term archive. A background worker deletes rows by event type: -high-volume proxy and builder-decision events default to 3 days, ingress and -forwarding events default to 7 days, and failures, drops, inclusion, and -flashblock events default to 30 days. Autovacuum reclaims the resulting table -bloat. `TXPOOL_SEND_RAW_TRANSACTION_VALIDITY` uses the same warm window as +not the long-term archive. Events are partitioned by retention class and UTC +day of `event_time`, and a background worker drops whole day partitions once +they age out: high-volume proxy and builder-decision events default to 3 days, +ingress and forwarding events default to 7 days, and failures, drops, +inclusion, and flashblock events default to 30 days. Ingest rejects events +whose `event_time` is already outside its window or more than an hour in the +future. `TXPOOL_SEND_RAW_TRANSACTION_VALIDITY` uses the same warm window as `TXPOOL_SEND_RAW_TRANSACTION`. `BUILDER_DEFERRED` and `BUILDER_EXPIRED` use the same hot window as the other per-attempt builder decisions; deferral can fire once per flashblock for a parked validity transaction. From 1937d845043be5c026424fd00b4f3875ba77b73a Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Sat, 26 Sep 2026 16:57:48 +0000 Subject: [PATCH 132/299] feat(eip8130): carry value in calls and transfer it during execution (#5234) * feat(eip8130): carry value in calls and transfer it during execution Add a `value: U256` field to the EIP-8130 `Call` (wire form rlp([to, value, data])), which is part of the signed preimage. `value` is optional in JSON (defaults to zero) so pre-value RPC clients keep working. Thread `call.value` into the executor's top-level call frame so each dispatched call moves `value` wei from the sender to `call.to`, matching mainnet CALL semantics: a value the sender cannot afford reverts its phase like any other CALL (the transaction is still included and charged gas). Also add `TxEip8130::total_call_value()` and update the phased-warmth gas goldens (a zero value adds one 0x80 calldata byte per call to the DA payload). * fix(eip8130): set call value in the Everest harness and rustfmt value-in-calls fixtures * test(eip8130): lock gas for a value transfer to an empty account * fix(eip8130): price phase-0 call value in the coinbase tip check A self-call checks call.value against the sender before the vault tip moves, so a balance that covers only gas plus tip can revert the phase and pay nothing. The sender must cover the larger of the two. * fix(txpool): reserve EIP-8130 call value against the sender payer_max_cost added TxEip8130::value(), which is always zero, so the pool never counted the wei calls move out of the sender. A self-paying sender now reserves its total call value on top of gas, and a sponsored sender must hold its call value itself. Admission rejects either shortfall as InsufficientFunds. * fix(payload): pass the coinbase-tip sender hold as one amount unaffordable_tip took both the tip and the phase-0 call value, one argument over clippy's limit. CoinbaseTipAffordability::sender_hold now computes the max(tip, call_value) hold and unaffordable_tip takes that single amount. --- actions/harness/tests/everest/env.rs | 6 +- .../consensus/src/transaction/eip8130/call.rs | 25 +- .../src/transaction/eip8130/coinbase_tip.rs | 26 ++- .../src/transaction/eip8130/signed.rs | 1 + .../consensus/src/transaction/eip8130/tx.rs | 61 ++++- crates/common/evm/src/eip8130.rs | 218 +++++++++++++----- .../protocol/src/batch/transactions.rs | 5 +- .../protocol/src/batch/tx_data/eip8130.rs | 5 +- .../eip8130-rpc-node/tests/receipt.rs | 16 +- crates/execution/payload/src/affordability.rs | 145 ++++++++++-- crates/execution/txpool/src/ordering.rs | 1 + crates/execution/txpool/src/validator.rs | 65 +++++- etc/systems/tests/tx_forwarding.rs | 6 +- 13 files changed, 486 insertions(+), 94 deletions(-) diff --git a/actions/harness/tests/everest/env.rs b/actions/harness/tests/everest/env.rs index d1e71838f4..3586fad766 100644 --- a/actions/harness/tests/everest/env.rs +++ b/actions/harness/tests/everest/env.rs @@ -130,7 +130,11 @@ impl EverestTestEnv { max_fee_per_gas: 1_000_000_000, gas_limit: 200_000, account_changes: Vec::new(), - calls: vec![vec![Call { to: Account::Bob.address(), data: Bytes::new() }]], + calls: vec![vec![Call { + to: Account::Bob.address(), + value: U256::ZERO, + data: Bytes::new(), + }]], metadata: Bytes::new(), payer: None, }; diff --git a/crates/common/consensus/src/transaction/eip8130/call.rs b/crates/common/consensus/src/transaction/eip8130/call.rs index 8c10a8f980..a2506d94c0 100644 --- a/crates/common/consensus/src/transaction/eip8130/call.rs +++ b/crates/common/consensus/src/transaction/eip8130/call.rs @@ -2,14 +2,17 @@ //! //! [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 -use alloy_primitives::{Address, Bytes}; +use alloy_primitives::{Address, Bytes, U256}; use alloy_rlp::{RlpDecodable, RlpEncodable}; /// A single call dispatched by the protocol during AA transaction execution. /// -/// Spec wire form: `rlp([to, data])` where `to` is a 20-byte address and `data` -/// is the calldata. The dispatched call carries no value (`msg.value == 0`); -/// ETH transfers must be performed by the wallet bytecode via the `CALL` opcode. +/// Spec wire form: `rlp([to, value, data])` where `to` is a 20-byte address, +/// `value` is the wei transferred to `to` (a minimal big-endian RLP integer), +/// and `data` is the calldata. The dispatched call moves `value` from the +/// transaction `sender` to `call.to` with `tx.origin == sender`; a call whose +/// `value` exceeds the sender's spendable balance reverts its phase like any +/// other `CALL`. /// /// AA transactions group calls into phases (`Vec>`); see /// [`super::tx::TxEip8130::calls`]. @@ -20,12 +23,21 @@ use alloy_rlp::{RlpDecodable, RlpEncodable}; pub struct Call { /// Recipient address of the call. pub to: Address, + /// Wei transferred from the transaction sender to `to` when the call is + /// dispatched. Encoded as a minimal big-endian RLP integer. + /// + /// The RLP wire always carries this word (it is part of the signed + /// preimage), but it is optional in JSON: an RPC `calls` entry that omits + /// `value` defaults to zero, so pre-value clients keep working. + #[cfg_attr(feature = "serde", serde(default))] + pub value: U256, /// Calldata passed to the recipient. pub data: Bytes, } impl Call { - /// Heap bytes owned beyond the [`Call`] slot itself (`data` payload). + /// Heap bytes owned beyond the [`Call`] slot itself (`data` payload). The + /// `value` word is stored inline, so it adds no heap cost. pub fn heap_size(&self) -> usize { self.data.len() } @@ -42,6 +54,7 @@ mod tests { fn rlp_roundtrip() { let call = Call { to: address!("0x00000000000000000000000000000000000000aa"), + value: U256::from(1_000_000_000_000_000_000u64), data: bytes!("deadbeef"), }; let mut buf = Vec::new(); @@ -52,7 +65,7 @@ mod tests { #[test] fn rlp_roundtrip_empty_data() { - let call = Call { to: Address::ZERO, data: Bytes::new() }; + let call = Call { to: Address::ZERO, value: U256::ZERO, data: Bytes::new() }; let mut buf = Vec::new(); call.encode(&mut buf); let decoded = Call::decode(&mut buf.as_slice()).unwrap(); diff --git a/crates/common/consensus/src/transaction/eip8130/coinbase_tip.rs b/crates/common/consensus/src/transaction/eip8130/coinbase_tip.rs index 06438f582c..6970897326 100644 --- a/crates/common/consensus/src/transaction/eip8130/coinbase_tip.rs +++ b/crates/common/consensus/src/transaction/eip8130/coinbase_tip.rs @@ -18,8 +18,12 @@ impl CoinbaseTip { /// /// EIP-8130 `calls` are grouped into phases. A revert discards that phase /// and skips later ones, so only a tip in **phase 0** is statically - /// meaningful. Protocol calls carry no value (`call = rlp([to, data])`); - /// ETH moves only when wallet bytecode issues a `CALL`. + /// meaningful. A protocol call is `rlp([to, value, data])` and transfers + /// `value` from the sender to `to` before the callee runs. On this path + /// `to` is the sender, so that transfer is checked against the sender's + /// balance and then credited back to the same account. The decoded amount + /// is the inner `DefaultAccount` transfer, which moves ETH when that + /// bytecode issues its `CALL`. /// /// Returns [`Some`] when the sender uses /// [`Eip8130Contracts::DEFAULT_ACCOUNT`] (EOA auto-delegation, or an @@ -132,7 +136,7 @@ mod tests { } fn execute_call(to: Address) -> Call { - Call { to, data: encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[]) } + Call { to, value: U256::ZERO, data: encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[]) } } fn eoa_with_phase0(calls: Vec) -> TxEip8130 { @@ -159,6 +163,7 @@ mod tests { assert_eq!(CoinbaseTip::decode(&tx, SENDER), Some(U256::from(TIP_AMOUNT))); let other = eoa_with_phase0(vec![Call { to: SENDER, + value: U256::ZERO, data: encode_execute(OTHER_RECIPIENT, U256::from(TIP_AMOUNT), &[]), }]); assert_eq!(CoinbaseTip::decode(&other, SENDER), None); @@ -170,11 +175,13 @@ mod tests { fn coinbase_tip_eoa_execute_batch_single_send_returns_amount() { let tx = eoa_with_phase0(vec![Call { to: SENDER, + value: U256::ZERO, data: encode_execute_batch_one(COINBASE, U256::from(TIP_AMOUNT)), }]); assert_eq!(CoinbaseTip::decode(&tx, SENDER), Some(U256::from(TIP_AMOUNT))); let other = eoa_with_phase0(vec![Call { to: SENDER, + value: U256::ZERO, data: encode_execute_batch_one(OTHER_RECIPIENT, U256::from(TIP_AMOUNT)), }]); assert_eq!(CoinbaseTip::decode(&other, SENDER), None); @@ -247,11 +254,13 @@ mod tests { let with_calldata = eoa_with_phase0(vec![Call { to: SENDER, + value: U256::ZERO, data: encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[0x01]), }]); assert_eq!(CoinbaseTip::decode(&with_calldata, SENDER), None); - let wrong_selector = eoa_with_phase0(vec![Call { to: SENDER, data: bytes!("deadbeef") }]); + let wrong_selector = + eoa_with_phase0(vec![Call { to: SENDER, value: U256::ZERO, data: bytes!("deadbeef") }]); assert_eq!(CoinbaseTip::decode(&wrong_selector, SENDER), None); let configured = TxEip8130 { sender: Some(SENDER), ..Default::default() }; @@ -278,14 +287,19 @@ mod tests { fn coinbase_tip_rejects_truncated_or_dirty_calldata() { let mut truncated = encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[]).to_vec(); truncated.truncate(truncated.len().saturating_sub(32)); - let truncated_tx = eoa_with_phase0(vec![Call { to: SENDER, data: Bytes::from(truncated) }]); + let truncated_tx = eoa_with_phase0(vec![Call { + to: SENDER, + value: U256::ZERO, + data: Bytes::from(truncated), + }]); assert_eq!(CoinbaseTip::decode(&truncated_tx, SENDER), None); // High bytes of the address word must be zero; `abi_decode_validate` rejects // that dirty padding even though a lenient decoder would still yield a tip. let mut dirty = encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[]).to_vec(); dirty[4] = 0xff; - let dirty_tx = eoa_with_phase0(vec![Call { to: SENDER, data: Bytes::from(dirty) }]); + let dirty_tx = + eoa_with_phase0(vec![Call { to: SENDER, value: U256::ZERO, data: Bytes::from(dirty) }]); assert_eq!(CoinbaseTip::decode(&dirty_tx, SENDER), None); } } diff --git a/crates/common/consensus/src/transaction/eip8130/signed.rs b/crates/common/consensus/src/transaction/eip8130/signed.rs index 0b22aabbe6..7fcd73f2c9 100644 --- a/crates/common/consensus/src/transaction/eip8130/signed.rs +++ b/crates/common/consensus/src/transaction/eip8130/signed.rs @@ -645,6 +645,7 @@ mod tests { account_changes: vec![AccountChange::Delegation(Delegation { target: Address::ZERO })], calls: vec![vec![Call { to: address!("0x00000000000000000000000000000000000000bb"), + value: U256::ZERO, data: bytes!("01020304"), }]], metadata: bytes!("c0ffee"), diff --git a/crates/common/consensus/src/transaction/eip8130/tx.rs b/crates/common/consensus/src/transaction/eip8130/tx.rs index c4581db9f6..660b04d25b 100644 --- a/crates/common/consensus/src/transaction/eip8130/tx.rs +++ b/crates/common/consensus/src/transaction/eip8130/tx.rs @@ -378,6 +378,16 @@ impl TxEip8130 { .sum::() + self.metadata.len() } + + /// Total wei dispatched across every call in every phase. + /// + /// This is the sender-side ETH obligation the calls impose: each call moves + /// `call.value` from the sender when dispatched, so the sender must be able + /// to cover their sum (in addition to gas and any coinbase tip). The sum + /// saturates at [`U256::MAX`] rather than wrapping. + pub fn total_call_value(&self) -> U256 { + self.calls.iter().flatten().fold(U256::ZERO, |acc, call| acc.saturating_add(call.value)) + } } impl Encodable for TxEip8130 { @@ -628,6 +638,7 @@ mod tests { })], calls: vec![vec![Call { to: address!("0x00000000000000000000000000000000000000cc"), + value: U256::from(0x1234u64), data: bytes!("deadbeef"), }]], metadata: bytes!("c0ffee"), @@ -839,11 +850,11 @@ mod tests { let tx = TxEip8130 { chain_id: 1, calls: vec![ - vec![Call { to: Address::ZERO, data: bytes!("01") }], + vec![Call { to: Address::ZERO, value: U256::from(7u64), data: bytes!("01") }], vec![], vec![ - Call { to: Address::ZERO, data: bytes!("02") }, - Call { to: Address::ZERO, data: bytes!("03") }, + Call { to: Address::ZERO, value: U256::ZERO, data: bytes!("02") }, + Call { to: Address::ZERO, value: U256::ZERO, data: bytes!("03") }, ], ], ..Default::default() @@ -854,6 +865,42 @@ mod tests { assert_eq!(tx, decoded); } + #[test] + fn call_value_is_covered_by_signature_hash() { + let mut tx = TxEip8130 { + chain_id: 1, + calls: vec![vec![Call { to: Address::ZERO, value: U256::ZERO, data: bytes!("01") }]], + ..Default::default() + }; + let zero_value_hash = tx.sender_signature_hash(); + tx.calls[0][0].value = U256::from(1u64); + let nonzero_value_hash = tx.sender_signature_hash(); + assert_ne!( + zero_value_hash, nonzero_value_hash, + "call value must be part of the signed preimage", + ); + } + + #[test] + fn total_call_value_sums_every_phase() { + let tx = TxEip8130 { + chain_id: 1, + calls: vec![ + vec![ + Call { to: Address::ZERO, value: U256::from(3u64), data: Bytes::new() }, + Call { to: Address::ZERO, value: U256::from(4u64), data: Bytes::new() }, + ], + vec![Call { to: Address::ZERO, value: U256::from(5u64), data: Bytes::new() }], + ], + ..Default::default() + }; + assert_eq!(tx.total_call_value(), U256::from(12u64)); + assert_eq!( + TxEip8130 { calls: vec![], ..Default::default() }.total_call_value(), + U256::ZERO + ); + } + #[test] fn account_change_roundtrip_in_tx() { let tx = TxEip8130 { @@ -981,11 +1028,15 @@ mod tests { #[test] fn size_counts_call_data_heap() { let bare = TxEip8130 { - calls: vec![vec![Call { to: Address::ZERO, data: Bytes::new() }]], + calls: vec![vec![Call { to: Address::ZERO, value: U256::ZERO, data: Bytes::new() }]], ..Default::default() }; let with_data = TxEip8130 { - calls: vec![vec![Call { to: Address::ZERO, data: Bytes::from(vec![0xab; 4_096]) }]], + calls: vec![vec![Call { + to: Address::ZERO, + value: U256::ZERO, + data: Bytes::from(vec![0xab; 4_096]), + }]], ..Default::default() }; diff --git a/crates/common/evm/src/eip8130.rs b/crates/common/evm/src/eip8130.rs index 7613c2eb3f..e66c53a009 100644 --- a/crates/common/evm/src/eip8130.rs +++ b/crates/common/evm/src/eip8130.rs @@ -28,7 +28,8 @@ //! gate), that phase's state changes are discarded and every later phase is //! skipped, but the gas already consumed is still charged and the transaction is //! still included (nonce consumed, fee paid). Each call is dispatched from -//! `sender` to `call.to` with `msg.value == 0` and `tx.origin == sender`. +//! `sender` to `call.to`, transferring `call.value` wei (`msg.value == +//! call.value`, `tx.origin == sender`). //! //! # Scope //! @@ -1229,8 +1230,14 @@ impl Eip8130Executor { break; } - let frame = - Self::run_call(evm, outcome.sender, call.to, call.data.clone(), remaining)?; + let frame = Self::run_call( + evm, + outcome.sender, + call.to, + call.value, + call.data.clone(), + remaining, + )?; let gas = frame.gas(); // `run_call` caps the frame at `remaining`, so a call can never // report spending more than the pool held; treat a violation of @@ -1371,14 +1378,16 @@ impl Eip8130Executor { inspector.call_end(ctx, &inputs, &mut outcome); } - /// Dispatches a single protocol call (`from = sender`, `value = 0`) as a - /// top-level EVM call frame with `gas_limit` and runs it to completion, - /// returning the [`FrameResult`]. Reuses the Base handler's frame loop and - /// drives the configured inspector when inspection is enabled. + /// Dispatches a single protocol call (`from = sender`, transferring `value` + /// wei to `to`) as a top-level EVM call frame with `gas_limit` and runs it to + /// completion, returning the [`FrameResult`]. Reuses the Base handler's frame + /// loop and drives the configured inspector when inspection is enabled. A + /// `value` the caller cannot afford reverts the frame like any other `CALL`. fn run_call( evm: &mut BaseEvm, caller: Address, to: Address, + value: U256, data: Bytes, gas_limit: u64, ) -> Result> @@ -1423,15 +1432,17 @@ impl Eip8130Executor { known_bytecode, target_address: to, caller, - // `Transfer(ZERO)` is exactly what a zero-value `CALL` opcode lowers - // to (`Apparent` is reserved for `DELEGATECALL`), so this matches - // mainnet CALL semantics: `msg.value` reads as 0 and the target is - // touched. Touching is the correct CALL behaviour and, for an empty - // target, is a no-op under EIP-161 state-clear (touched-empty is - // erased at tx end). No new-account gas differs from `Apparent`: the - // classic 25000 charge lives at the CALL-opcode gas site (which this - // directly-built frame bypasses) and applies only when value > 0. - value: CallValue::Transfer(U256::ZERO), + // `Transfer(value)` matches mainnet CALL semantics (`Apparent` is + // reserved for `DELEGATECALL`): revm's frame init moves `value` from + // `caller` to `to`, reverting the frame with `InsufficientBalance` + // when the caller's spendable balance cannot cover it, and `msg.value` + // reads as `value` inside the callee. The value-transfer stipend and + // new-account (25000) gas that a `CALL` opcode would levy are not + // charged here: this directly-built top-level frame bypasses the + // opcode gas site, exactly as the zero-value path already did, so the + // sender's dispatched calls stay metered by the 8130 call-gas pool + // rather than the opcode gas model. + value: CallValue::Transfer(value), scheme: CallScheme::Call, is_static: false, charged_new_account_state_gas: false, @@ -1977,6 +1988,105 @@ mod tests { assert!(outcome.state.contains_key(&BENEFICIARY)); } + /// A dispatched call carrying `value` moves that wei from the sender to the + /// call target, matching mainnet `CALL` value semantics. + #[test] + fn eoa_call_transfers_value_to_recipient() { + let key = signing_key(0x51); + let sender = eoa_address(&key); + let recipient = address!("0x00000000000000000000000000000000000000e1"); + let transfer = U256::from(1_000_000u64); + + let mut tx = base_tx(); + tx.calls = vec![vec![Call { to: recipient, value: transfer, data: Bytes::new() }]]; + let signed = eoa_signed(tx, &key); + + let initial_balance = U256::from(10u64).pow(U256::from(18u64)); + let mut evm = evm_with_accounts(initial_balance, sender, &[(recipient, bytes!("00"))]); + let outcome = evm.transact_raw(into_base_tx(&signed)).expect("value transfer should run"); + + assert!(outcome.result.is_success(), "expected success, got {:?}", outcome.result); + let recipient_acc = outcome.state.get(&recipient).expect("recipient in state"); + assert_eq!(recipient_acc.info.balance, transfer, "recipient credited the call value"); + } + + /// A value transfer to an empty account does not pay the 25,000 + /// new-account gas. The protocol frame sets `charged_new_account_state_gas` + /// to false and bypasses the CALL opcode gas site, so revm neither charges + /// nor refunds that cost. Gas matches the same transfer to an existing + /// account. + #[test] + fn value_transfer_to_empty_account_matches_existing_account_gas() { + let key = signing_key(0x53); + let sender = eoa_address(&key); + let recipient = address!("0x00000000000000000000000000000000000000e3"); + let transfer = U256::from(1_000_000u64); + + let mut tx = base_tx(); + tx.calls = vec![vec![Call { to: recipient, value: transfer, data: Bytes::new() }]]; + let signed = eoa_signed(tx, &key); + let initial_balance = U256::from(10u64).pow(U256::from(18u64)); + + let mut empty = evm_with(initial_balance, sender); + let empty_outcome = + empty.transact_raw(into_base_tx(&signed)).expect("value transfer to an empty account"); + assert!( + empty_outcome.result.is_success(), + "expected success, got {:?}", + empty_outcome.result + ); + let created = empty_outcome.state.get(&recipient).expect("recipient in state"); + assert_eq!(created.info.balance, transfer, "empty account credited the call value"); + + let mut existing = evm_with(initial_balance, sender); + existing.ctx_mut().journal_mut().db_mut().insert_account_info( + recipient, + AccountInfo { balance: U256::from(1u64), ..Default::default() }, + ); + let existing_outcome = existing + .transact_raw(into_base_tx(&signed)) + .expect("value transfer to an existing account"); + assert!( + existing_outcome.result.is_success(), + "expected success, got {:?}", + existing_outcome.result + ); + + assert_eq!( + empty_outcome.result.gas().tx_gas_used(), + existing_outcome.result.gas().tx_gas_used(), + "value transfer to an empty account must not pay the 25,000 new-account gas" + ); + } + + /// A call whose `value` exceeds the sender's spendable balance (after the + /// self-pay gas prepay) reverts its phase like any other `CALL`: the + /// transaction is still included and charged gas, but the recipient is not + /// credited and the phase's state changes are rolled back. + #[test] + fn eoa_call_reverts_when_sender_cannot_cover_value() { + let key = signing_key(0x52); + let sender = eoa_address(&key); + let recipient = address!("0x00000000000000000000000000000000000000e2"); + + let mut tx = base_tx(); + // 1e18 wei — far more than the funded balance below. + let transfer = U256::from(10u64).pow(U256::from(18u64)); + tx.calls = vec![vec![Call { to: recipient, value: transfer, data: Bytes::new() }]]; + let signed = eoa_signed(tx, &key); + + // 1e16 wei covers worst-case gas (gas_limit 1e6 * max_fee 5e9 = 5e15) but + // is far below the 1e18 call value, so the value transfer cannot succeed. + let initial_balance = U256::from(10u64).pow(U256::from(16u64)); + let mut evm = evm_with_accounts(initial_balance, sender, &[(recipient, bytes!("00"))]); + let outcome = evm.transact_raw(into_base_tx(&signed)).expect("tx should be included"); + + assert!(!outcome.result.is_success(), "phase should revert on insufficient value"); + let recipient_balance = + outcome.state.get(&recipient).map_or(U256::ZERO, |acc| acc.info.balance); + assert!(recipient_balance.is_zero(), "recipient must not be credited on revert"); + } + /// Cross-chain-replay guard: an EIP-8130 envelope signed for a foreign chain /// must be rejected at inclusion, not just at pool admission. The sender and /// payer signature hashes commit to the transaction's embedded `chain_id`, so @@ -2207,7 +2317,7 @@ mod tests { let target = address!("0x00000000000000000000000000000000000000c5"); let mut tx = base_tx(); - tx.calls = vec![vec![Call { to: target, data: Bytes::new() }]]; + tx.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; let signed = eoa_signed(tx, &key); let initial = U256::from(10u64).pow(U256::from(18u64)); @@ -2322,7 +2432,7 @@ mod tests { // --- reference execution at a generous limit to obtain the net charge --- let mut tx_ref = base_tx(); - tx_ref.calls = vec![vec![Call { to: target, data: Bytes::new() }]]; + tx_ref.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; let signed_ref = eoa_signed(tx_ref, &key); let mut evm_ref = evm_with_accounts_and_storage( initial, @@ -2338,7 +2448,7 @@ mod tests { // --- estimate (simulation never commits state) --- let mut tx_sim = base_tx(); - tx_sim.calls = vec![vec![Call { to: target, data: Bytes::new() }]]; + tx_sim.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; let signed_sim = eoa_signed(tx_sim, &key); let mut evm_sim = evm_with_accounts_and_storage( initial, @@ -2364,7 +2474,7 @@ mod tests { // subtracted from pool but is not available during execution) --- let mut tx_low = base_tx(); tx_low.gas_limit = charge_gas; - tx_low.calls = vec![vec![Call { to: target, data: Bytes::new() }]]; + tx_low.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; let signed_low = eoa_signed(tx_low, &key); let mut evm_low = evm_with_accounts_and_storage( initial, @@ -2383,7 +2493,7 @@ mod tests { // --- execute at gas_limit = estimate_gas → must succeed --- let mut tx_ok = base_tx(); tx_ok.gas_limit = estimate_gas; - tx_ok.calls = vec![vec![Call { to: target, data: Bytes::new() }]]; + tx_ok.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; let signed_ok = eoa_signed(tx_ok, &key); let mut evm_ok = evm_with_accounts_and_storage(initial, sender, &[(target, sstore_clears)], &storage); @@ -2437,7 +2547,7 @@ mod tests { // --- reference execution at a generous limit for the net charge --- let mut tx_ref = base_tx(); - tx_ref.calls = vec![vec![Call { to: forwarder, data: Bytes::new() }]]; + tx_ref.calls = vec![vec![Call { to: forwarder, value: U256::ZERO, data: Bytes::new() }]]; let signed_ref = eoa_signed(tx_ref, &key); let mut evm_ref = evm_with_accounts( initial, @@ -2452,7 +2562,7 @@ mod tests { // --- estimate (binary search must go beyond ceiling_spent) --- let mut tx_sim = base_tx(); - tx_sim.calls = vec![vec![Call { to: forwarder, data: Bytes::new() }]]; + tx_sim.calls = vec![vec![Call { to: forwarder, value: U256::ZERO, data: Bytes::new() }]]; let signed_sim = eoa_signed(tx_sim, &key); let mut evm_sim = evm_with_accounts( initial, @@ -2475,7 +2585,7 @@ mod tests { // --- execute at gas_limit = charge_gas → must revert (sink OOGs) --- let mut tx_low = base_tx(); tx_low.gas_limit = charge_gas; - tx_low.calls = vec![vec![Call { to: forwarder, data: Bytes::new() }]]; + tx_low.calls = vec![vec![Call { to: forwarder, value: U256::ZERO, data: Bytes::new() }]]; let signed_low = eoa_signed(tx_low, &key); let mut evm_low = evm_with_accounts( initial, @@ -2492,7 +2602,7 @@ mod tests { // --- execute at gas_limit = estimate_gas → must succeed --- let mut tx_ok = base_tx(); tx_ok.gas_limit = estimate_gas; - tx_ok.calls = vec![vec![Call { to: forwarder, data: Bytes::new() }]]; + tx_ok.calls = vec![vec![Call { to: forwarder, value: U256::ZERO, data: Bytes::new() }]]; let signed_ok = eoa_signed(tx_ok, &key); let mut evm_ok = evm_with_accounts(initial, sender, &[(forwarder, fwd_code), (sink, sink_code)]); @@ -2590,7 +2700,7 @@ mod tests { // Simulate's apply path does not verify config auth. signature: Bytes::new(), })]; - tx.calls = vec![vec![Call { to: allowed, data: Bytes::new() }]]; + tx.calls = vec![vec![Call { to: allowed, value: U256::ZERO, data: Bytes::new() }]]; let signed = configured_signed(tx, &owner); let mut evm = evm_with_accounts( @@ -2656,7 +2766,7 @@ mod tests { let sender = eoa_address(&key); let mut tx = base_tx(); - tx.calls = vec![vec![Call { to: target, data: Bytes::new() }]]; + tx.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; let signed = eoa_signed(tx, &key); let mut evm = evm_with_accounts( @@ -2676,7 +2786,7 @@ mod tests { let sender = eoa_address(&key); let mut tx = base_tx(); - tx.calls = vec![vec![Call { to: target, data: Bytes::new() }]]; + tx.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; let signed = eoa_signed(tx, &key); let initial = U256::from(10u64).pow(U256::from(18u64)); @@ -2751,7 +2861,7 @@ mod tests { let sender = eoa_address(&key); let mut tx = base_tx(); - tx.calls = vec![vec![Call { to: target, data: Bytes::new() }]]; + tx.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; let signed = eoa_signed(tx, &key); // Target code: PUSH1 0x00, SLOAD, STOP. The `SLOAD` forces a storage read @@ -2819,7 +2929,7 @@ mod tests { let key = signing_key(signer); let sender = eoa_address(&key); let mut tx = base_tx(); - tx.calls = vec![vec![Call { to: target, data: Bytes::new() }]]; + tx.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; let signed = eoa_signed(tx, &key); let mut evm = evm_with_accounts( U256::from(10u64).pow(U256::from(18u64)), @@ -2859,7 +2969,7 @@ mod tests { ); let mut warm_tx = base_tx(); - warm_tx.calls = vec![vec![Call { to: loader, data: Bytes::new() }]]; + warm_tx.calls = vec![vec![Call { to: loader, value: U256::ZERO, data: Bytes::new() }]]; let warm_signed = eoa_signed(warm_tx, &key); let warm_outcome = evm.transact_raw(into_base_tx(&warm_signed)).expect("tx should be included"); @@ -2869,15 +2979,15 @@ mod tests { // Tx 1 already bumped the protocol nonce (0 -> 1) and delegated the sender // (both committed above), so tx 2 is a second-use transaction: // base AA_BASE_COST 15_000 - // payload EIP-2028 DA over the tx 1_700 + // payload EIP-2028 DA over the tx 1_716 // nonce_key existing channel 0: COLD_SLOAD 2_100 + SSTORE_RESET 2_900 5_000 // auto_delegation sender already delegated 0 // sender_auth ECRECOVER 3_000 + cold SLOAD 2_100 5_100 // call PUSH1 (3) + COLD SLOAD (2_100) + STOP 2_103 - // total 28_903 + // total 28_919 (the call's zero value adds one 0x80 calldata byte) let mut load_tx = base_tx(); load_tx.nonce_sequence = 1; - load_tx.calls = vec![vec![Call { to: loader, data: Bytes::new() }]]; + load_tx.calls = vec![vec![Call { to: loader, value: U256::ZERO, data: Bytes::new() }]]; let load_signed = eoa_signed(load_tx, &key); let load_outcome = evm.transact_raw(into_base_tx(&load_signed)).expect("tx should be included"); @@ -2885,7 +2995,7 @@ mod tests { assert_eq!( load_outcome.result.gas().tx_gas_used(), - 28_903, + 28_919, "loader SLOAD must be COLD (2_100); a warm read (100) would be 2_000 \ less, meaning tx 1's warmth leaked across the transaction boundary", ); @@ -2914,8 +3024,8 @@ mod tests { let mut tx = base_tx(); tx.calls = vec![ - vec![Call { to: loader, data: Bytes::new() }], - vec![Call { to: loader, data: Bytes::new() }], + vec![Call { to: loader, value: U256::ZERO, data: Bytes::new() }], + vec![Call { to: loader, value: U256::ZERO, data: Bytes::new() }], ]; let signed = eoa_signed(tx, &key); let outcome = evm.transact_raw(into_base_tx(&signed)).expect("tx should be included"); @@ -2923,16 +3033,17 @@ mod tests { // First-use, single-tx, two phases each calling `loader`: // base AA_BASE_COST 15_000 - // payload EIP-2028 DA over the two-phase tx 1_856 + // payload EIP-2028 DA over the two-phase tx 1_888 // nonce_key first use of channel 0: COLD_SLOAD 2_100 + SSTORE_SET 20_000 22_100 // auto_delegation codeless EOA -> DEFAULT_ACCOUNT 4_600 // sender_auth ECRECOVER 3_000 + cold SLOAD 2_100 5_100 // call phase 0 PUSH1 (3) + COLD SLOAD (2_100) + STOP 2_103 // call phase 1 PUSH1 (3) + WARM SLOAD (100) + STOP 103 - // total 50_862 + // total 50_894 (the two calls each carry a value word; a zero value adds + // one 0x80 calldata byte per call to the DA payload) assert_eq!( outcome.result.gas().tx_gas_used(), - 50_862, + 50_894, "phase 1's SLOAD must be WARM (100): committed phase 0 warmed \ (loader, slot 0). A cold read (2_100) would be 2_000 more, meaning \ the committed phase's warmth failed to carry across phases", @@ -2956,7 +3067,8 @@ mod tests { let mut invalid_tx = base_tx(); invalid_tx.nonce_sequence = 5; - invalid_tx.calls = vec![vec![Call { to: loader, data: bytes!("60006000fd") }]]; + invalid_tx.calls = + vec![vec![Call { to: loader, value: U256::ZERO, data: bytes!("60006000fd") }]]; let invalid_signed = eoa_signed(invalid_tx, &key); let invalid_outcome = evm.transact_raw(into_base_tx(&invalid_signed)).unwrap_err(); assert!( @@ -2965,7 +3077,7 @@ mod tests { ); let mut load_tx = base_tx(); - load_tx.calls = vec![vec![Call { to: loader, data: Bytes::new() }]]; + load_tx.calls = vec![vec![Call { to: loader, value: U256::ZERO, data: Bytes::new() }]]; let load_signed = eoa_signed(load_tx, &key); let load_outcome = evm.transact_raw(into_base_tx(&load_signed)).expect("tx should be included"); @@ -2975,17 +3087,17 @@ mod tests { // `loader` (PUSH1 0, SLOAD, STOP). Its gas splits into the EIP-8130 // sender-intrinsic charge (48_484) plus the dispatched call (2_103): // base AA_BASE_COST 15_000 - // payload EIP-2028 DA over the 122-byte tx 1_688 + // payload EIP-2028 DA over the 123-byte tx 1_716 // nonce_key first use of channel 0: COLD_SLOAD 2_100 + SSTORE_SET 20_000 22_100 // auto_delegation codeless EOA -> DEFAULT_ACCOUNT (200 x 23-byte indicator) 4_600 // sender_auth ECRECOVER 3_000 + cold SLOAD 2_100 5_100 // call PUSH1 (3) + SLOAD + STOP (0) 2_103 - // total 50_591 + // total 50_619 (the call's zero value adds one 0x80 calldata byte) assert_eq!( load_outcome.result.gas().tx_gas_used(), - 50_591, + 50_619, "loader SLOAD must be COLD (2_100); a warm read (100) would total \ - 48_591, meaning the discarded invalid tx leaked warmth", + 48_619, meaning the discarded invalid tx leaked warmth", ); } @@ -3000,8 +3112,8 @@ mod tests { let mut tx = base_tx(); tx.calls = vec![ - vec![Call { to: reverter, data: Bytes::new() }], - vec![Call { to: storer, data: Bytes::new() }], + vec![Call { to: reverter, value: U256::ZERO, data: Bytes::new() }], + vec![Call { to: storer, value: U256::ZERO, data: Bytes::new() }], ]; let signed = eoa_signed(tx, &key); @@ -3084,7 +3196,7 @@ mod tests { let mut tx = base_tx(); tx.sender = Some(account); - tx.calls = vec![vec![Call { to: forbidden, data: Bytes::new() }]]; + tx.calls = vec![vec![Call { to: forbidden, value: U256::ZERO, data: Bytes::new() }]]; let signed = configured_signed(tx, &signer); let mut evm = evm_with(U256::from(10u64).pow(U256::from(18u64)), account); @@ -3108,7 +3220,7 @@ mod tests { let mut tx = base_tx(); tx.sender = Some(account); - tx.calls = vec![vec![Call { to: allowed, data: Bytes::new() }]]; + tx.calls = vec![vec![Call { to: allowed, value: U256::ZERO, data: Bytes::new() }]]; let signed = configured_signed(tx, &signer); let mut evm = evm_with_accounts( @@ -3270,7 +3382,7 @@ mod tests { let (derived, signed) = counterfactual_create_signed( &key, bytes!("00"), - vec![vec![Call { to: target, data: Bytes::new() }]], + vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]], ); let initial_balance = U256::from(10u64).pow(U256::from(18u64)); @@ -3328,8 +3440,8 @@ mod tests { let mut tx = base_tx(); tx.calls = vec![vec![ - Call { to: target, data: Bytes::from(clear_data) }, - Call { to: target, data: Bytes::from(second_data) }, + Call { to: target, value: U256::ZERO, data: Bytes::from(clear_data) }, + Call { to: target, value: U256::ZERO, data: Bytes::from(second_data) }, ]]; let signed = eoa_signed(tx, &key); diff --git a/crates/consensus/protocol/src/batch/transactions.rs b/crates/consensus/protocol/src/batch/transactions.rs index 59081b217c..965ce59d23 100644 --- a/crates/consensus/protocol/src/batch/transactions.rs +++ b/crates/consensus/protocol/src/batch/transactions.rs @@ -644,6 +644,7 @@ mod tests { tx.max_fee_per_gas = 5_000_000_000; tx.calls = vec![vec![Call { to: address!("00000000000000000000000000000000000000dd"), + value: U256::ZERO, data: bytes!("deadbeef"), }]]; eip8130_raw(tx, Bytes::from_static(&[0xab; 65]), Bytes::new()) @@ -704,14 +705,16 @@ mod tests { vec![ Call { to: address!("0000000000000000000000000000000000000001"), + value: U256::ZERO, data: bytes!("11"), }, Call { to: address!("0000000000000000000000000000000000000002"), + value: U256::ZERO, data: bytes!("2222"), }, ], - vec![Call { to: Address::ZERO, data: Bytes::new() }], + vec![Call { to: Address::ZERO, value: U256::ZERO, data: Bytes::new() }], ]; tx.metadata = bytes!("decafbad"); let mut sender_auth = sender.as_slice().to_vec(); diff --git a/crates/consensus/protocol/src/batch/tx_data/eip8130.rs b/crates/consensus/protocol/src/batch/tx_data/eip8130.rs index 2240c0e507..d40add4846 100644 --- a/crates/consensus/protocol/src/batch/tx_data/eip8130.rs +++ b/crates/consensus/protocol/src/batch/tx_data/eip8130.rs @@ -258,11 +258,12 @@ mod tests { calls: vec![ vec![Call { to: address!("0x00000000000000000000000000000000000000dd"), + value: U256::from(0x5678u64), data: bytes!("deadbeef"), }], vec![ - Call { to: Address::ZERO, data: bytes!("01") }, - Call { to: Address::ZERO, data: bytes!("02") }, + Call { to: Address::ZERO, value: U256::ZERO, data: bytes!("01") }, + Call { to: Address::ZERO, value: U256::ZERO, data: bytes!("02") }, ], ], metadata: bytes!("c0ffee"), diff --git a/crates/execution/eip8130-rpc-node/tests/receipt.rs b/crates/execution/eip8130-rpc-node/tests/receipt.rs index 783eeec5fe..ffdd223b47 100644 --- a/crates/execution/eip8130-rpc-node/tests/receipt.rs +++ b/crates/execution/eip8130-rpc-node/tests/receipt.rs @@ -130,7 +130,11 @@ async fn eip8130_receipt_reports_phase_statuses() -> eyre::Result<()> { max_fee_per_gas: 1_000_000_000, gas_limit: 200_000, account_changes: Vec::new(), - calls: vec![vec![Call { to: Account::Bob.address(), data: Bytes::new() }]], + calls: vec![vec![Call { + to: Account::Bob.address(), + value: U256::ZERO, + data: Bytes::new(), + }]], metadata: Bytes::from_static(&[0xab, 0xcd]), payer: None, }; @@ -274,7 +278,11 @@ async fn two_eip8130_transactions_in_one_block_attribute_phase_statuses() -> eyr max_fee_per_gas: 1_000_000_000, gas_limit: 200_000, account_changes: Vec::new(), - calls: vec![vec![Call { to: Account::Charlie.address(), data: Bytes::new() }]], + calls: vec![vec![Call { + to: Account::Charlie.address(), + value: U256::ZERO, + data: Bytes::new(), + }]], metadata: Bytes::new(), payer: None, }; @@ -300,8 +308,8 @@ async fn two_eip8130_transactions_in_one_block_attribute_phase_statuses() -> eyr gas_limit: 200_000, account_changes: Vec::new(), calls: vec![ - vec![Call { to: Account::Charlie.address(), data: Bytes::new() }], - vec![Call { to: revert_addr, data: Bytes::new() }], + vec![Call { to: Account::Charlie.address(), value: U256::ZERO, data: Bytes::new() }], + vec![Call { to: revert_addr, value: U256::ZERO, data: Bytes::new() }], ], metadata: Bytes::new(), payer: None, diff --git a/crates/execution/payload/src/affordability.rs b/crates/execution/payload/src/affordability.rs index 90eb43775e..0c274448cc 100644 --- a/crates/execution/payload/src/affordability.rs +++ b/crates/execution/payload/src/affordability.rs @@ -11,8 +11,20 @@ use revm::Database; pub struct CoinbaseTipAffordability; impl CoinbaseTipAffordability { + /// The balance the sender must hold for a phase-0 self-call tip. + /// + /// `tip` is the inner vault transfer. `call_value` is the protocol call's + /// value on that same self-call. The value is checked against the sender + /// and then credited back to the sender before the vault transfer, so both + /// observe one balance and the sender must cover the larger of them. + #[must_use] + pub fn sender_hold(tip: U256, call_value: U256) -> U256 { + tip.max(call_value) + } + /// Returns `true` when `sender` and `payer` cannot cover worst-case gas plus - /// `tip` from the balances currently in `db`. + /// `sender_hold` (see [`Self::sender_hold`]) from the balances currently in + /// `db`. /// /// A failed account read is treated as affordable so a transient DB error /// does not drop an otherwise-valid candidate. @@ -22,7 +34,7 @@ impl CoinbaseTipAffordability { gas_limit: u64, payer_auth: u64, max_fee: u128, - tip: U256, + sender_hold: U256, db: &mut DB, ) -> bool { let Ok(payer_info) = db.basic(payer) else { @@ -44,13 +56,14 @@ impl CoinbaseTipAffordability { gas_limit, payer_auth, max_fee, - tip, + sender_hold, ) .is_err() } /// Returns `true` when the transaction declares a static coinbase tip that - /// the sender and gas payer cannot cover together with worst-case gas. + /// the sender and gas payer cannot cover together with worst-case gas and + /// the phase-0 self-call value. /// /// Transactions without a statically decoded tip are treated as affordable. pub fn unaffordable(tx: &T, payer_auth: u64, db: &mut DB) -> bool @@ -61,18 +74,29 @@ impl CoinbaseTipAffordability { let Some(signed) = tx.as_eip8130() else { return false; }; - let Some(tip) = CoinbaseTip::decode(signed.tx(), tx.sender()) else { + let body = signed.tx(); + let Some(tip) = CoinbaseTip::decode(body, tx.sender()) else { + return false; + }; + // `decode` only succeeds for a single phase-0 self-call, which is the + // value the sender must hold alongside the tip. + let Some(call_value) = body.calls.first().and_then(|phase| { + let [call] = phase.as_slice() else { + return None; + }; + Some(call.value) + }) else { return false; }; let sender = tx.sender(); - let payer = signed.tx().payer.unwrap_or(sender); + let payer = body.payer.unwrap_or(sender); Self::unaffordable_tip( sender, payer, tx.gas_limit(), payer_auth, tx.max_fee_per_gas(), - tip, + Self::sender_hold(tip, call_value), db, ) } @@ -134,7 +158,13 @@ mod tests { fn missing_account_cannot_cover_gas_plus_tip() { let mut db = InMemoryDB::default(); assert!(CoinbaseTipAffordability::unaffordable_tip( - SENDER, SENDER, 21_000, 0, 2, TIP, &mut db + SENDER, + SENDER, + 21_000, + 0, + 2, + CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), + &mut db )); } @@ -144,7 +174,13 @@ mod tests { // gas = 21_000 * 2 = 42_000; tip = 1_000. fund(&mut db, SENDER, 43_000); assert!(!CoinbaseTipAffordability::unaffordable_tip( - SENDER, SENDER, 21_000, 0, 2, TIP, &mut db + SENDER, + SENDER, + 21_000, + 0, + 2, + CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), + &mut db )); } @@ -153,7 +189,13 @@ mod tests { let mut db = InMemoryDB::default(); fund(&mut db, SENDER, 42_999); assert!(CoinbaseTipAffordability::unaffordable_tip( - SENDER, SENDER, 21_000, 0, 2, TIP, &mut db + SENDER, + SENDER, + 21_000, + 0, + 2, + CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), + &mut db )); } @@ -163,7 +205,13 @@ mod tests { fund(&mut db, PAYER, 42_000); fund(&mut db, SENDER, 999); assert!(CoinbaseTipAffordability::unaffordable_tip( - SENDER, PAYER, 21_000, 0, 2, TIP, &mut db + SENDER, + PAYER, + 21_000, + 0, + 2, + CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), + &mut db )); } @@ -173,7 +221,78 @@ mod tests { fund(&mut db, PAYER, 42_000); fund(&mut db, SENDER, 1_000); assert!(!CoinbaseTipAffordability::unaffordable_tip( - SENDER, PAYER, 21_000, 0, 2, TIP, &mut db + SENDER, + PAYER, + 21_000, + 0, + 2, + CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), + &mut db + )); + } + + #[test] + fn self_call_value_within_the_tip_does_not_raise_the_hold() { + let mut db = InMemoryDB::default(); + // gas = 42_000; tip = 1_000; call value = 500. The self-call credits + // the value back before the vault transfer, so gas + tip still covers it. + fund(&mut db, SENDER, 43_000); + assert!(!CoinbaseTipAffordability::unaffordable_tip( + SENDER, + SENDER, + 21_000, + 0, + 2, + CoinbaseTipAffordability::sender_hold(TIP, U256::from(500u64)), + &mut db + )); + } + + #[test] + fn self_call_value_above_the_tip_is_unaffordable() { + let mut db = InMemoryDB::default(); + // gas + tip = 43_000, but the self-call checks 1_500 before the tip moves. + fund(&mut db, SENDER, 43_000); + assert!(CoinbaseTipAffordability::unaffordable_tip( + SENDER, + SENDER, + 21_000, + 0, + 2, + CoinbaseTipAffordability::sender_hold(TIP, U256::from(1_500u64)), + &mut db + )); + } + + #[test] + fn self_call_value_above_the_tip_is_affordable_when_balance_covers_it() { + let mut db = InMemoryDB::default(); + // gas = 42_000; call value = 1_500 > tip. + fund(&mut db, SENDER, 43_500); + assert!(!CoinbaseTipAffordability::unaffordable_tip( + SENDER, + SENDER, + 21_000, + 0, + 2, + CoinbaseTipAffordability::sender_hold(TIP, U256::from(1_500u64)), + &mut db + )); + } + + #[test] + fn sponsored_sender_must_cover_call_value_when_it_exceeds_the_tip() { + let mut db = InMemoryDB::default(); + fund(&mut db, PAYER, 42_000); + fund(&mut db, SENDER, 1_000); + assert!(CoinbaseTipAffordability::unaffordable_tip( + SENDER, + PAYER, + 21_000, + 0, + 2, + CoinbaseTipAffordability::sender_hold(TIP, U256::from(1_500u64)), + &mut db )); } @@ -185,7 +304,7 @@ mod tests { 21_000, 0, 2, - TIP, + CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), &mut FailingDatabase )); } diff --git a/crates/execution/txpool/src/ordering.rs b/crates/execution/txpool/src/ordering.rs index cd642acee2..c32ba9cc00 100644 --- a/crates/execution/txpool/src/ordering.rs +++ b/crates/execution/txpool/src/ordering.rs @@ -430,6 +430,7 @@ mod tests { let calls = coinbase_tip.map_or_else(Vec::new, |amount| { vec![vec![Call { to: signer.address(), + value: U256::ZERO, data: encode_execute(Predeploys::SEQUENCER_FEE_VAULT, amount), }]] }); diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index b8fe084491..d391b761a9 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -1254,9 +1254,19 @@ where } else { U256::ZERO }; + // Calls move `call.value` out of the sender, not the payer. A self-paying + // sender reserves it alongside gas; a sponsored sender must hold it alone. + let call_value = signed.tx().total_call_value(); let payer_max_cost = gas_charge .saturating_add(additional_fee) - .saturating_add(if payer == sender { signed.tx().value() } else { U256::ZERO }); + .saturating_add(if payer == sender { call_value } else { U256::ZERO }); + let sender_obligation = if payer == sender { payer_max_cost } else { call_value }; + if sender_account.balance < sender_obligation { + return Err(InvalidTransactionError::InsufficientFunds( + GotExpected { got: sender_account.balance, expected: sender_obligation }.into(), + ) + .into()); + } // All three predicates are now inclusive block-timestamp *second* bounds // (`now <= bound`): the transaction's millisecond window is folded onto // the seconds axis by `tx_valid_before_secs`, which is nonce-mode-aware @@ -2164,7 +2174,7 @@ mod tests { use base_common_chains::ChainConfig; use base_common_consensus::{ AccountChange, AccountChangeChannel, BasePrimitives, BaseTransactionSigned, BaseTxEnvelope, - ChangeType, CreateEntry, Delegation, Eip8130Constants, Eip8130Signed, InitialActor, + Call, ChangeType, CreateEntry, Delegation, Eip8130Constants, Eip8130Signed, InitialActor, SignedAccountChanges, SignedChange, TxDeposit, TxEip8130, }; use base_execution_chainspec::{BaseChainSpec, BaseChainSpecBuilder}; @@ -3580,6 +3590,57 @@ mod tests { assert_eq!(state.manifest.payer_max_cost(), state.payer_max_cost); } + /// A self-paying sender reserves its call value on top of gas, and is + /// rejected when its balance cannot cover both. + #[test] + fn eip8130_self_pay_reserves_call_value() { + const BALANCE: u64 = 1_000_000_000_000; + let signer = PrivateKeySigner::random(); + let sender = signer.address(); + let recipient = Address::repeat_byte(0xee); + let signed_with_value = |value: u64| { + let tx = TxEip8130 { + gas_limit: 100_000, + calls: vec![vec![Call { + to: recipient, + value: U256::from(value), + data: Bytes::new(), + }]], + ..minimal_valid_eoa_tx() + }; + let signature = signer.sign_hash_sync(&tx.sender_signature_hash()).unwrap(); + Eip8130Signed::new(tx, Bytes::from(signature.as_bytes().to_vec()), Bytes::new()) + }; + let validator = + build_test_validator_with_account(sender, ExtendedAccount::new(0, U256::from(BALANCE))); + + let without_value = validator + .validate_eip8130_full(&signed_with_value(0)) + .expect("gas alone is affordable"); + let with_value = validator + .validate_eip8130_full(&signed_with_value(BALANCE / 2)) + .expect("gas plus half the balance is affordable"); + assert_eq!( + with_value.payer_max_cost - without_value.payer_max_cost, + U256::from(BALANCE / 2), + "the call value is reserved on top of gas" + ); + assert_eq!(with_value.manifest.payer_max_cost(), with_value.payer_max_cost); + + let err = validator + .validate_eip8130_full(&signed_with_value(BALANCE)) + .expect_err("gas plus the whole balance is not affordable"); + assert!( + matches!( + err, + InvalidPoolTransactionError::Consensus(InvalidTransactionError::InsufficientFunds( + _ + )) + ), + "expected InsufficientFunds, got {err:?}" + ); + } + #[test] fn nonce_free_manifest_uses_transaction_validity_window() { let chain_spec = everest_chain_spec(); diff --git a/etc/systems/tests/tx_forwarding.rs b/etc/systems/tests/tx_forwarding.rs index 14aad77d4a..ff5ed9ba78 100644 --- a/etc/systems/tests/tx_forwarding.rs +++ b/etc/systems/tests/tx_forwarding.rs @@ -133,7 +133,11 @@ fn create_signed_eip8130_tx( max_fee_per_gas: 1_000_000_000, gas_limit: 200_000, account_changes: Vec::new(), - calls: vec![vec![Call { to: Address::repeat_byte(0xde), data: Bytes::new() }]], + calls: vec![vec![Call { + to: Address::repeat_byte(0xde), + value: U256::ZERO, + data: Bytes::new(), + }]], metadata: Bytes::new(), payer: None, }; From dce93ec94a63b360d9c6f36571be727e9c0c2519 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Sun, 27 Sep 2026 22:05:02 +0000 Subject: [PATCH 133/299] feat(eip8130): delegation at 0x01 and k1-only authenticators (#5232) * feat(eip8130): carry value in calls and transfer it during execution Add a `value: U256` field to the EIP-8130 `Call` (wire form rlp([to, value, data])), which is part of the signed preimage. `value` is optional in JSON (defaults to zero) so pre-value RPC clients keep working. Thread `call.value` into the executor's top-level call frame so each dispatched call moves `value` wei from the sender to `call.to`, matching mainnet CALL semantics: a value the sender cannot afford reverts its phase like any other CALL (the transaction is still included and charged gas). Also add `TxEip8130::total_call_value()` and update the phased-warmth gas goldens (a zero value adds one 0x80 calldata byte per call to the DA payload). * fix(eip8130): set call value in the Everest harness and rustfmt value-in-calls fixtures * test(eip8130): lock gas for a value transfer to an empty account * fix(eip8130): price phase-0 call value in the coinbase tip check A self-call checks call.value against the sender before the vault tip moves, so a balance that covers only gas plus tip can revert the phase and pay nothing. The sender must cover the larger of the two. * fix(txpool): reserve EIP-8130 call value against the sender payer_max_cost added TxEip8130::value(), which is always zero, so the pool never counted the wei calls move out of the sender. A self-paying sender now reserves its total call value on top of gas, and a sponsored sender must hold its call value itself. Admission rejects either shortfall as InsufficientFunds. * fix(payload): pass the coinbase-tip sender hold as one amount unaffordable_tip took both the tip and the phase-0 call value, one argument over clippy's limit. CoinbaseTipAffordability::sender_hold now computes the max(tip, call_value) hold and unaffordable_tip takes that single amount. * feat(eip8130): renumber account-change type bytes for launch wire Delegation is the sole account-change on the initial launch wire, so it takes the low 0x01 slot. Create stays at 0x00 and ConfigChange moves to 0x02; both are keystore-gated and rejected while the (default-off) keystore feature is disabled. * feat(eip8130): accept only the k1 authenticator on the transaction path Admission rejects P256, WebAuthn, and delegate authenticators on sender_auth, payer_auth, and config-change auth. The keystore itself is removed in follow-up PRs. * docs(eip8130): correct the account-change type bytes in the wire comment * fix(eip8130): drop the dead delegate check and the keystore feature docs Admission already accepts only the k1 authenticator, so the delegate payload branch never ran. Create and ConfigChange stay decoded until follow-ups delete them, and they are not gated by a feature. * fix(eip8130): reject non-k1 authenticators in eth_call simulation Pool admission already accepts only k1. Pricing P256, WebAuthn, or delegate blobs in eth_estimateGas let a simulation succeed for a transaction the pool then refuses. * fix(eip8130): backtick WebAuthn in docs and update the auth-pricing estimate test Clippy's doc_markdown lint rejected the bare WebAuthn in the new simulation docs. The rpc-node estimate test still expected P-256 and WebAuthn blobs to be priced; simulation now rejects them, so the test checks that a longer k1 blob costs more and that the non-k1 authenticators are rejected. --- .../transaction/eip8130/account_changes.rs | 4 +- .../src/transaction/eip8130/constants.rs | 18 ++- crates/common/rpc-types/src/eip8130.rs | 30 ++++- crates/common/rpc-types/src/reth.rs | 106 +++++++++--------- .../rpc-types/src/transaction/request.rs | 41 +++---- .../execution/eip8130-rpc-node/tests/rpc.rs | 67 +++++------ crates/execution/txpool/src/validator.rs | 36 +++--- 7 files changed, 154 insertions(+), 148 deletions(-) diff --git a/crates/common/consensus/src/transaction/eip8130/account_changes.rs b/crates/common/consensus/src/transaction/eip8130/account_changes.rs index 0efcf67384..2a4dd317fa 100644 --- a/crates/common/consensus/src/transaction/eip8130/account_changes.rs +++ b/crates/common/consensus/src/transaction/eip8130/account_changes.rs @@ -350,8 +350,8 @@ pub struct Delegation { /// On the wire each entry is a single RLP list whose first element is the type /// byte, followed by the body fields inline (per [EIP-8130]): /// - `rlp([0x00, user_salt, code, initial_actors])` -> [`AccountChange::Create`] -/// - `rlp([0x01, channel, sequence, changes, signature])` -> [`AccountChange::ConfigChange`] -/// - `rlp([0x02, target])` -> [`AccountChange::Delegation`] +/// - `rlp([0x01, target])` -> [`AccountChange::Delegation`] +/// - `rlp([0x02, channel, sequence, changes, signature])` -> [`AccountChange::ConfigChange`] /// /// The type byte is a genuine list element (not an EIP-2718-style `type_byte || /// rlp(...)` prefix), so each entry is one self-contained RLP item and the diff --git a/crates/common/consensus/src/transaction/eip8130/constants.rs b/crates/common/consensus/src/transaction/eip8130/constants.rs index 5dcb7e52ec..924cdcc2c8 100644 --- a/crates/common/consensus/src/transaction/eip8130/constants.rs +++ b/crates/common/consensus/src/transaction/eip8130/constants.rs @@ -117,14 +117,24 @@ impl Eip8130Constants { pub const DELEGATION_INDICATOR_SIZE: usize = 23; /// `account_changes` entry type byte: account creation. + /// + /// Still decoded on this wire. A follow-up deletes `Create` rather than + /// gating it behind a feature. pub const ACCOUNT_CHANGE_TYPE_CREATE: u8 = 0x00; + /// `account_changes` entry type byte: code delegation. + /// + /// The launch wire keeps delegation, so it takes the low `0x01` slot. + /// `Create` stays at `0x00` and `ConfigChange` moves to `0x02` until the + /// follow-ups that delete them. + pub const ACCOUNT_CHANGE_TYPE_DELEGATION: u8 = 0x01; + /// `account_changes` entry type byte: a signed account-change batch /// (`SignedAccountChanges`, applied via `applySignedAccountChanges`). - pub const ACCOUNT_CHANGE_TYPE_CONFIG: u8 = 0x01; - - /// `account_changes` entry type byte: code delegation. - pub const ACCOUNT_CHANGE_TYPE_DELEGATION: u8 = 0x02; + /// + /// Still decoded on this wire. A follow-up deletes `ConfigChange` rather + /// than gating it behind a feature. + pub const ACCOUNT_CHANGE_TYPE_CONFIG: u8 = 0x02; /// `SignedAccountChanges.channel` byte: the Local channel (binds /// `block.chainid`; carries epoch + sequence and the unsequenced JIT mode). diff --git a/crates/common/rpc-types/src/eip8130.rs b/crates/common/rpc-types/src/eip8130.rs index 7e6ab64a63..b847cc8a38 100644 --- a/crates/common/rpc-types/src/eip8130.rs +++ b/crates/common/rpc-types/src/eip8130.rs @@ -88,6 +88,11 @@ impl BaseTransactionRequest { let (sender, sender_auth) = match &aa.sender_auth { Some(blob) => { + // A recognized non-k1 selector must not fall through to the bare + // EOA path: the pool rejects it, so the simulation does too. + if Self::is_disallowed_authenticator(blob) { + return None; + } let prefixed = Self::is_prefixed_auth(blob); Self::check_auth_len(blob, prefixed)?; (prefixed.then_some(account), blob.clone()) @@ -160,13 +165,26 @@ impl BaseTransactionRequest { (data_len as u64 <= u64::from(MAX_AUTH_SIZE)).then_some(()) } + /// Leading authenticator selector, when the blob is long enough to carry one. + fn authenticator_selector(blob: &Bytes) -> Option

{ + (blob.len() >= AUTHENTICATOR_SELECTOR_LEN) + .then(|| Address::from_slice(&blob[..AUTHENTICATOR_SELECTOR_LEN])) + } + + /// Whether the blob is a configured-account authorization the launch wire + /// accepts. Only the native k1 authenticator qualifies, matching txpool + /// admission. Other selectors are either rejected + /// ([`Self::is_disallowed_authenticator`]) or treated as a bare EOA signature. fn is_prefixed_auth(blob: &Bytes) -> bool { - if blob.len() < AUTHENTICATOR_SELECTOR_LEN { - return false; - } - let selector = Address::from_slice(&blob[..AUTHENTICATOR_SELECTOR_LEN]); - selector == Eip8130Constants::K1_AUTHENTICATOR - || Eip8130Contracts::is_canonical_authenticator(&selector) + Self::authenticator_selector(blob) + .is_some_and(|selector| selector == Eip8130Constants::K1_AUTHENTICATOR) + } + + /// Whether the blob names a canonical authenticator the launch wire does not + /// admit (P256, `WebAuthn`, or delegate). + fn is_disallowed_authenticator(blob: &Bytes) -> bool { + Self::authenticator_selector(blob) + .is_some_and(|selector| Eip8130Contracts::is_canonical_authenticator(&selector)) } fn stub_prefixed_auth(scheme: Eip8130AuthScheme, data_len: usize) -> Bytes { diff --git a/crates/common/rpc-types/src/reth.rs b/crates/common/rpc-types/src/reth.rs index 9d2e026bf4..01a5f61277 100644 --- a/crates/common/rpc-types/src/reth.rs +++ b/crates/common/rpc-types/src/reth.rs @@ -157,35 +157,31 @@ mod tests { } #[test] - fn prefixed_p256_auth_is_priced_verbatim() { - let tx = sim_tx(json!({ + fn prefixed_p256_sender_auth_is_rejected() { + let req: BaseTransactionRequest = serde_json::from_value(json!({ "sender": SENDER, "calls": [], "senderAuth": blob(Some(Eip8130Contracts::P256_AUTHENTICATOR), 128), - })); - let s = signed(&tx); - assert_eq!(s.tx().sender, Some(SENDER)); - let auth = s.sender_auth(); - assert_eq!( - &auth[..20], - Eip8130Contracts::P256_AUTHENTICATOR.as_slice(), - "the caller's blob is priced verbatim, prefix intact", + })) + .expect("valid request"); + assert!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + "a P256 sender authenticator is rejected, matching pool admission", ); - assert_eq!(auth.len(), 20 + 128, "selector + supplied data length"); } #[test] - fn prefixed_webauthn_auth_is_priced_verbatim() { - let tx = sim_tx(json!({ + fn prefixed_webauthn_sender_auth_is_rejected() { + let req: BaseTransactionRequest = serde_json::from_value(json!({ "sender": SENDER, "calls": [], "senderAuth": blob(Some(Eip8130Contracts::WEBAUTHN_AUTHENTICATOR), 512), - })); - let s = signed(&tx); - assert_eq!(s.tx().sender, Some(SENDER)); - let auth = s.sender_auth(); - assert_eq!(&auth[..20], Eip8130Contracts::WEBAUTHN_AUTHENTICATOR.as_slice()); - assert_eq!(auth.len(), 20 + 512, "the WebAuthn blob is priced at its supplied size"); + })) + .expect("valid request"); + assert!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + "a WebAuthn sender authenticator is rejected, matching pool admission", + ); } #[test] @@ -222,7 +218,7 @@ mod tests { let tx = sim_tx(json!({ "from": FROM, "calls": [], - "senderAuth": blob(Some(Eip8130Contracts::P256_AUTHENTICATOR), 128), + "senderAuth": blob(Some(Eip8130Constants::K1_AUTHENTICATOR), 65), })); assert_eq!( signed(&tx).tx().sender, @@ -263,53 +259,45 @@ mod tests { } #[test] - fn delegate_prefixed_sender_auth_is_the_configured_path() { - // `DELEGATE_AUTHENTICATOR` is a recognized prefix even though it isn't - // an `Eip8130AuthScheme` variant (it's a structured 3-segment blob, not - // a flat leaf) — `is_prefixed_auth` must still select the - // configured-account path for it, so a delegate-authenticated sender - // isn't misclassified as a bare EOA and flat-priced at k1. + fn delegate_prefixed_sender_auth_is_rejected() { let delegate_account = address!("0x00000000000000000000000000000000000000d4"); let mut nested = Eip8130Constants::K1_AUTHENTICATOR.to_vec(); nested.extend_from_slice(&[STUB_AUTH_FILL; 65]); let mut blob = Eip8130Contracts::DELEGATE_AUTHENTICATOR.to_vec(); blob.extend_from_slice(delegate_account.as_slice()); blob.extend_from_slice(&nested); - let tx = sim_tx(json!({ + let req: BaseTransactionRequest = serde_json::from_value(json!({ "sender": SENDER, "calls": [], "senderAuth": alloy_primitives::hex::encode_prefixed(&blob), - })); - let s = signed(&tx); - assert_eq!( - s.tx().sender, - Some(SENDER), - "a delegate-prefixed blob selects the configured-account path", + })) + .expect("valid request"); + assert!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + "a delegate sender authenticator is rejected, not priced as a bare EOA", ); - assert_eq!(s.sender_auth().as_ref(), blob.as_slice(), "priced verbatim"); } #[test] - fn delegate_prefixed_payer_auth_is_accepted() { - // Mirrors the sender-side case: a delegate-authenticated payer is a - // recognized prefix and must not be rejected as an unrecognized - // authenticator selector. + fn delegate_prefixed_payer_auth_is_rejected() { let payer = address!("0x00000000000000000000000000000000000000b2"); let delegate_account = address!("0x00000000000000000000000000000000000000d4"); - let mut nested = Eip8130Contracts::P256_AUTHENTICATOR.to_vec(); - nested.extend_from_slice(&[STUB_AUTH_FILL; 128]); + let mut nested = Eip8130Constants::K1_AUTHENTICATOR.to_vec(); + nested.extend_from_slice(&[STUB_AUTH_FILL; 65]); let mut blob = Eip8130Contracts::DELEGATE_AUTHENTICATOR.to_vec(); blob.extend_from_slice(delegate_account.as_slice()); blob.extend_from_slice(&nested); - let tx = sim_tx(json!({ + let req: BaseTransactionRequest = serde_json::from_value(json!({ "sender": SENDER, "calls": [], "payer": payer, "payerAuth": alloy_primitives::hex::encode_prefixed(&blob), - })); - let s = signed(&tx); - assert_eq!(s.tx().payer, Some(payer)); - assert_eq!(s.payer_auth().as_ref(), blob.as_slice(), "priced verbatim"); + })) + .expect("valid request"); + assert!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + "a delegate payer authenticator is rejected, matching pool admission", + ); } #[test] @@ -368,7 +356,7 @@ mod tests { let tx = sim_tx(json!({ "sender": SENDER, "calls": [], - "senderAuth": blob(Some(Eip8130Contracts::WEBAUTHN_AUTHENTICATOR), MAX_AUTH_SIZE as usize), + "senderAuth": blob(Some(Eip8130Constants::K1_AUTHENTICATOR), MAX_AUTH_SIZE as usize), })); let auth = signed(&tx).sender_auth(); assert_eq!( @@ -394,7 +382,7 @@ mod tests { let req: BaseTransactionRequest = serde_json::from_value(json!({ "sender": SENDER, "calls": [], - "senderAuth": blob(Some(Eip8130Contracts::WEBAUTHN_AUTHENTICATOR), MAX_AUTH_SIZE as usize + 1), + "senderAuth": blob(Some(Eip8130Constants::K1_AUTHENTICATOR), MAX_AUTH_SIZE as usize + 1), })) .expect("valid request"); assert!( @@ -423,7 +411,7 @@ mod tests { "sender": SENDER, "calls": [], "payer": payer, - "payerAuth": blob(Some(Eip8130Contracts::P256_AUTHENTICATOR), MAX_AUTH_SIZE as usize + 1), + "payerAuth": blob(Some(Eip8130Constants::K1_AUTHENTICATOR), MAX_AUTH_SIZE as usize + 1), })) .expect("valid request"); assert!( @@ -439,13 +427,29 @@ mod tests { "sender": SENDER, "calls": [], "payer": payer, - "payerAuth": blob(Some(Eip8130Contracts::P256_AUTHENTICATOR), 128), + "payerAuth": blob(Some(Eip8130Constants::K1_AUTHENTICATOR), 65), })); let s = signed(&tx); assert_eq!(s.tx().payer, Some(payer), "the payer is set on the transaction"); let auth = s.payer_auth(); - assert_eq!(&auth[..20], Eip8130Contracts::P256_AUTHENTICATOR.as_slice()); - assert_eq!(auth.len(), 20 + 128); + assert_eq!(&auth[..20], Eip8130Constants::K1_AUTHENTICATOR.as_slice()); + assert_eq!(auth.len(), 20 + 65); + } + + #[test] + fn prefixed_p256_payer_auth_is_rejected() { + let payer = address!("0x00000000000000000000000000000000000000b2"); + let req: BaseTransactionRequest = serde_json::from_value(json!({ + "sender": SENDER, + "calls": [], + "payer": payer, + "payerAuth": blob(Some(Eip8130Contracts::P256_AUTHENTICATOR), 128), + })) + .expect("valid request"); + assert!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + "a P256 payer authenticator is rejected, matching pool admission", + ); } #[test] diff --git a/crates/common/rpc-types/src/transaction/request.rs b/crates/common/rpc-types/src/transaction/request.rs index 9c86e02add..a82f0fec23 100644 --- a/crates/common/rpc-types/src/transaction/request.rs +++ b/crates/common/rpc-types/src/transaction/request.rs @@ -17,22 +17,12 @@ use serde::{Deserialize, Serialize}; use crate::Transaction; -/// An enshrined EIP-8130 authenticator an estimate can price as a flat leaf. +/// Named EIP-8130 authenticator selectors. /// -/// Estimation never verifies a signature: the scheme only selects which -/// enshrined authenticator the intrinsic-gas schedule charges (the -/// authenticator's execution gas plus the calldata cost of its authentication -/// payload), and provides the default secp256k1 authorization used when a -/// blob is absent. -/// -/// This does **not** enumerate every authenticator selector a `sender_auth` / -/// `payer_auth` blob's prefix may recognize — see -/// [`base_common_consensus::Eip8130Contracts::DELEGATE_AUTHENTICATOR`], a -/// recognized selector that is a structured 3-segment blob (delegate -/// account + a nested leaf authenticator) rather than a flat leaf, so it -/// can't be a variant here. Prefix recognition (`is_prefixed_auth` in -/// `crate::reth`) checks the protocol's actual canonical authenticator set -/// instead of this enum for that reason. +/// [`Self::Secp256k1`] sizes the default authorization when a blob is absent +/// and is the only selector launch-wire simulation prices. P256, `WebAuthn`, and +/// the delegate authenticator are rejected by `eth_call` / `eth_estimateGas`, +/// matching txpool admission. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Eip8130AuthScheme { @@ -77,11 +67,8 @@ impl Eip8130AuthScheme { /// `eip8130_auth_scheme_all_lists_every_variant` test for the /// compile-time guard that keeps this in sync with the enum. /// - /// This is *not* the full set of authenticator selectors a `sender_auth` - /// / `payer_auth` blob's prefix may recognize — see - /// [`base_common_consensus::Eip8130Contracts::DELEGATE_AUTHENTICATOR`], - /// which prefix recognition (`is_prefixed_auth` in `crate::reth`) checks - /// for separately since it isn't a flat leaf scheme. + /// Simulation prices only [`Self::Secp256k1`]. The other variants name + /// authenticators the launch wire rejects. pub const ALL: [Self; 3] = [Self::Secp256k1, Self::P256, Self::WebAuthn]; } @@ -146,10 +133,10 @@ pub struct Eip8130RequestFields { /// /// - A bare secp256k1 signature prices the default-EOA path: the account /// authenticates with a k1 key, exactly as for a 1559 transaction. - /// - `authenticator(20) || data` prefixed with a recognized enshrined - /// authenticator (k1, [`Eip8130AuthScheme::P256`] / `WebAuthn`, or - /// [`base_common_consensus::Eip8130Contracts::DELEGATE_AUTHENTICATOR`]) + /// - `authenticator(20) || data` prefixed with the native k1 authenticator /// prices the configured-account path. + /// - A prefix that names P256, `WebAuthn`, or the delegate authenticator is + /// rejected, matching txpool admission. /// /// An absent blob defaults by intent: a declared `sender` synthesizes a /// k1-prefixed configured-account authorization; a `from`-only request @@ -166,11 +153,9 @@ pub struct Eip8130RequestFields { /// Raw payer authentication blob (`authenticator(20) || data`) whose shape /// is priced when a `payer` is declared. Absent defaults to a representative /// secp256k1 payer authorization. Unlike `sender_auth`, a supplied blob is - /// always the prefixed form and its leading 20 bytes must be a recognized - /// enshrined authenticator selector (k1, [`Eip8130AuthScheme::P256`] / - /// `WebAuthn`, or - /// [`base_common_consensus::Eip8130Contracts::DELEGATE_AUTHENTICATOR`]); an - /// unrecognized selector is rejected as `INVALID_PARAMS` rather than priced. + /// always the prefixed form and its leading 20 bytes must be the native k1 + /// authenticator. Any other selector, including P256, `WebAuthn`, and the + /// delegate authenticator, is rejected as `INVALID_PARAMS` rather than priced. #[serde(default, skip_serializing_if = "Option::is_none")] pub payer_auth: Option, /// Optional acting-actor hint for simulation. Estimation never recovers a diff --git a/crates/execution/eip8130-rpc-node/tests/rpc.rs b/crates/execution/eip8130-rpc-node/tests/rpc.rs index 58f9c3bb43..7a2161db14 100644 --- a/crates/execution/eip8130-rpc-node/tests/rpc.rs +++ b/crates/execution/eip8130-rpc-node/tests/rpc.rs @@ -178,50 +178,37 @@ async fn estimate_gas_rejects_mismatched_from_and_sender() -> eyre::Result<()> { Ok(()) } -/// A supplied non-secp256k1 authentication blob must be priced into the -/// estimate: a P-256 sender costs strictly more than the default-EOA secp256k1 -/// path (its authenticator execution gas is higher and its authentication -/// payload is longer), and a longer `WebAuthn` blob costs more still. +/// A supplied secp256k1 authentication blob is priced by its own bytes: a +/// longer k1 blob costs more than a shorter one. P-256 and `WebAuthn` blobs are +/// rejected, matching txpool admission, rather than priced. #[tokio::test] async fn estimate_gas_prices_the_supplied_authentication_blob() -> eyre::Result<()> { let (_harness, client) = setup().await?; let alice: Address = Account::Alice.address(); - - let k1: U256 = client - .request("eth_estimateGas", (json!({ "from": alice, "calls": [] }), "latest")) - .await?; - let p256: U256 = client - .request( - "eth_estimateGas", - ( - json!({ - "from": alice, - "calls": [], - "senderAuth": auth_blob(Eip8130Contracts::P256_AUTHENTICATOR, 128), - }), - "latest", - ), - ) - .await?; - let webauthn: U256 = client - .request( - "eth_estimateGas", - ( - json!({ - "from": alice, - "calls": [], - "senderAuth": auth_blob(Eip8130Contracts::WEBAUTHN_AUTHENTICATOR, 1024), - }), - "latest", - ), - ) - .await?; - - assert!(p256 > k1, "P-256 auth ({p256}) must cost more than secp256k1 ({k1})"); - assert!( - webauthn > p256, - "a larger WebAuthn payload ({webauthn}) must cost more than P-256 ({p256})" - ); + let estimate = |sender_auth: String| { + let client = client.clone(); + async move { + client + .request::<_, U256>( + "eth_estimateGas", + (json!({ "from": alice, "calls": [], "senderAuth": sender_auth }), "latest"), + ) + .await + } + }; + + let short = estimate(auth_blob(Eip8130Constants::K1_AUTHENTICATOR, 65)).await?; + let long = estimate(auth_blob(Eip8130Constants::K1_AUTHENTICATOR, 200)).await?; + assert!(long > short, "a longer k1 blob ({long}) must cost more than a shorter one ({short})"); + + for authenticator in + [Eip8130Contracts::P256_AUTHENTICATOR, Eip8130Contracts::WEBAUTHN_AUTHENTICATOR] + { + assert!( + estimate(auth_blob(authenticator, 128)).await.is_err(), + "a non-k1 sender authenticator ({authenticator}) must be rejected" + ); + } Ok(()) } diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index d391b761a9..d9d61fe361 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -1804,28 +1804,19 @@ where } /// Returns `true` when an authenticator selector may be used directly on the - /// EIP-8130 transaction validation path. + /// EIP-8130 transaction validation path. Only the native k1 authenticator + /// is accepted. fn authenticator_allowed_for_tx_path(authenticator: &Address) -> bool { *authenticator == Eip8130Constants::K1_AUTHENTICATOR - || Eip8130Contracts::is_canonical_authenticator(authenticator) } - /// Performs cheap selector-specific wire checks that do not require running - /// an authenticator. Native k1 must carry exactly `r || s || v`; delegated - /// auth must be depth-1 and name a canonical nested authenticator. + /// Performs the cheap k1 wire check that does not require running an + /// authenticator. Native k1 must carry exactly `r || s || v`. + /// + /// Callers reject every other selector in [`Self::authenticator_allowed_for_tx_path`] + /// before this runs, so a delegate-authenticator shape is not checked here. fn authenticator_payload_well_formed(authenticator: &Address, data: &[u8]) -> bool { - if *authenticator == Eip8130Constants::K1_AUTHENTICATOR { - return data.len() == 65; - } - if *authenticator == Eip8130Contracts::DELEGATE_AUTHENTICATOR { - if data.len() < 40 { - return false; - } - let nested = Address::from_slice(&data[20..40]); - return nested != Eip8130Contracts::DELEGATE_AUTHENTICATOR - && Self::authenticator_allowed_for_tx_path(&nested); - } - true + *authenticator == Eip8130Constants::K1_AUTHENTICATOR && data.len() == 65 } /// Enforces the interim total-account-changes admission cap @@ -2845,6 +2836,17 @@ mod tests { assert_unsupported(TestValidator::validate_payer_auth(&signed)); } + /// A configured sender naming a canonical non-k1 authenticator is rejected + /// at admission. + #[test] + fn rejects_eip8130_non_k1_sender_authenticator() { + let tx = TxEip8130 { sender: Some(Address::repeat_byte(0xaa)), ..minimal_valid_eoa_tx() }; + let mut auth = Eip8130Contracts::P256_AUTHENTICATOR.as_slice().to_vec(); + auth.extend_from_slice(&[0u8; 64]); + let signed = Eip8130Signed::new(tx, Bytes::from(auth), Bytes::new()); + assert_unsupported(TestValidator::validate_sender_auth(&signed)); + } + /// Returns an authenticator address comfortably above the `K1_AUTHENTICATOR` /// floor. fn ok_authenticator() -> Address { From 0eb82ea8c3e1eff4804821685ab29b4260c3f6b4 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 28 Sep 2026 12:43:00 +0000 Subject: [PATCH 134/299] feat(eip8130): drop auto-delegation and the DelegationApplied log (#5255) * feat(eip8130): drop auto-delegation and the DelegationApplied log A code-less sender is no longer delegated to DEFAULT_ACCOUNT implicitly; delegation happens only through an explicit 0x01 entry, and the auto_delegation_cost intrinsic component is gone. Delegation updates no longer inject a DelegationApplied receipt log. With value in calls, the coinbase tip is a plain phase-0 value call to the Sequencer Fee Vault, so CoinbaseTip::decode no longer needs the resolved sender or DefaultAccount calldata decoding. * chore(ci): re-run Action Tests --- .../builder/core/src/flashblocks/context.rs | 3 +- .../src/transaction/eip8130/addresses.rs | 3 +- .../src/transaction/eip8130/coinbase_tip.rs | 293 +++--------------- crates/common/eip8130/src/intrinsic.rs | 119 +------ crates/common/eip8130/src/schedule.rs | 2 +- crates/common/evm/src/eip8130.rs | 187 +++-------- .../rpc-types/src/transaction/request.rs | 4 +- .../execution/eip8130-rpc-node/tests/rpc.rs | 3 +- crates/execution/eip8130/README.md | 12 +- crates/execution/eip8130/src/apply.rs | 15 +- crates/execution/eip8130/src/events.rs | 35 +-- crates/execution/eip8130/src/lib.rs | 4 +- crates/execution/payload/src/affordability.rs | 145 +-------- crates/execution/payload/src/builder.rs | 3 +- crates/execution/txpool/src/ordering.rs | 19 +- crates/execution/txpool/src/validator.rs | 20 +- 16 files changed, 128 insertions(+), 739 deletions(-) diff --git a/crates/builder/core/src/flashblocks/context.rs b/crates/builder/core/src/flashblocks/context.rs index b1aeb07688..d4b804c57a 100644 --- a/crates/builder/core/src/flashblocks/context.rs +++ b/crates/builder/core/src/flashblocks/context.rs @@ -874,8 +874,7 @@ impl BasePayloadBuilderCtx { let tx_hash = *tx.hash(); let replay_independent = tx.eip8130_replay_id().is_some(); let has_validity_predicates = !tx.validity_predicates().is_empty(); - let coinbase_tip = - tx.as_eip8130().and_then(|signed| CoinbaseTip::decode(signed.tx(), tx.sender())); + let coinbase_tip = tx.as_eip8130().and_then(|signed| CoinbaseTip::decode(signed.tx())); let has_coinbase_tip = coinbase_tip.is_some(); // Defer without evaluating once this flashblock's predicate-eval time budget is diff --git a/crates/common/consensus/src/transaction/eip8130/addresses.rs b/crates/common/consensus/src/transaction/eip8130/addresses.rs index f604d8e438..5b77584b0a 100644 --- a/crates/common/consensus/src/transaction/eip8130/addresses.rs +++ b/crates/common/consensus/src/transaction/eip8130/addresses.rs @@ -62,7 +62,8 @@ impl Eip8130Contracts { // Account implementations (init code embeds `ACCOUNT_CONFIG`) // ───────────────────────────────────────────────────────────────────────── - /// Default wallet implementation, used as the target of default EOA delegation. + /// Canonical wallet implementation. An EOA uses it only through an explicit + /// delegation entry; code-less senders are never delegated implicitly. pub const DEFAULT_ACCOUNT: Address = address!("0x81309c54D6Bc190FbBc0FA9f296ea4C6A539ADEf"); /// Per-contract mined CREATE2 salt for [`Self::DEFAULT_ACCOUNT`]. diff --git a/crates/common/consensus/src/transaction/eip8130/coinbase_tip.rs b/crates/common/consensus/src/transaction/eip8130/coinbase_tip.rs index 6970897326..a0a23c23b5 100644 --- a/crates/common/consensus/src/transaction/eip8130/coinbase_tip.rs +++ b/crates/common/consensus/src/transaction/eip8130/coinbase_tip.rs @@ -1,11 +1,8 @@ -//! Static decode of a phase-0 `DefaultAccount` transfer to the Sequencer Fee Vault. +//! Static decode of a phase-0 value transfer to the Sequencer Fee Vault. -use alloy_primitives::{Address, U256}; -use alloy_sol_types::SolCall; +use alloy_primitives::U256; -use super::{ - IDefaultAccount, TxEip8130, account_changes::AccountChange, addresses::Eip8130Contracts, -}; +use super::TxEip8130; use crate::Predeploys; /// Recovers a statically-analyzable phase-0 coinbase tip from an EIP-8130 body. @@ -18,288 +15,78 @@ impl CoinbaseTip { /// /// EIP-8130 `calls` are grouped into phases. A revert discards that phase /// and skips later ones, so only a tip in **phase 0** is statically - /// meaningful. A protocol call is `rlp([to, value, data])` and transfers - /// `value` from the sender to `to` before the callee runs. On this path - /// `to` is the sender, so that transfer is checked against the sender's - /// balance and then credited back to the same account. The decoded amount - /// is the inner `DefaultAccount` transfer, which moves ETH when that - /// bytecode issues its `CALL`. + /// meaningful. The protocol dispatches each call from the sender and moves + /// `call.value` to `call.to`, so the tip does not depend on the sender's + /// wallet code. /// - /// Returns [`Some`] when the sender uses - /// [`Eip8130Contracts::DEFAULT_ACCOUNT`] (EOA auto-delegation, or an - /// explicit delegation to that implementation), phase 0 contains exactly - /// one call invoked on `resolved_sender`, and the calldata is - /// `DefaultAccount.execute` (or a one-element `executeBatch`) transferring - /// ETH to [`Predeploys::SEQUENCER_FEE_VAULT`] with empty calldata. - /// - /// `resolved_sender` is the account that will dispatch the call: the - /// explicit [`TxEip8130::sender`] when present, otherwise the recovered EOA. - /// The protocol call's `to` must equal that address, matching execution - /// (`from` is the sender; `execute` must run on the sender itself). + /// Returns [`Some`] when phase 0 contains exactly one call: a non-zero + /// `value` sent to [`Predeploys::SEQUENCER_FEE_VAULT`] with empty calldata. #[must_use] - pub fn decode(tx: &TxEip8130, resolved_sender: Address) -> Option { - if !Self::statically_uses_default_account(tx) { - return None; - } - if tx.sender.is_some_and(|sender| sender != resolved_sender) { - return None; - } + pub fn decode(tx: &TxEip8130) -> Option { let [call] = tx.calls.first()?.as_slice() else { return None; }; - if call.to != resolved_sender { - return None; - } - let (recipient, amount) = Self::default_account_eth_transfer(&call.data)?; - (recipient == Predeploys::SEQUENCER_FEE_VAULT).then_some(amount) - } - - /// Whether the unsigned body statically implies - /// [`Eip8130Contracts::DEFAULT_ACCOUNT`] as the sender's wallet. - /// - /// A `Create` installs caller-supplied bytecode, so it is never the - /// default account. A `Delegation` is default only when its target is - /// [`Eip8130Contracts::DEFAULT_ACCOUNT`] (multiple delegations are invalid - /// and treated as not default). With neither entry, only the EOA path - /// (`sender == None`) auto-delegates; a configured sender's existing code - /// is not visible here. - fn statically_uses_default_account(tx: &TxEip8130) -> bool { - let mut explicit_target = None; - for change in &tx.account_changes { - match change { - AccountChange::Create(_) => return false, - AccountChange::Delegation(delegation) => { - if explicit_target.is_some() { - return false; - } - explicit_target = Some(delegation.target); - } - AccountChange::ConfigChange(_) => {} - } - } - explicit_target.map_or_else( - || tx.sender.is_none(), - |target| target == Eip8130Contracts::DEFAULT_ACCOUNT, - ) - } - - /// Recipient and amount of a pure ETH transfer encoded as - /// `execute(target, value, "")` or a one-element `executeBatch` of the same - /// shape. - fn default_account_eth_transfer(calldata: &[u8]) -> Option<(Address, U256)> { - if let Ok(call) = IDefaultAccount::executeCall::abi_decode_validate(calldata) { - return call.data.is_empty().then_some((call.target, call.value)); - } - let batch = IDefaultAccount::executeBatchCall::abi_decode_validate(calldata).ok()?; - let [inner] = batch.calls.as_slice() else { - return None; - }; - inner.data.is_empty().then_some((inner.target, inner.value)) + (call.to == Predeploys::SEQUENCER_FEE_VAULT + && call.data.is_empty() + && !call.value.is_zero()) + .then_some(call.value) } } #[cfg(test)] mod tests { - use alloy_primitives::{Address, B256, Bytes, U256, address, bytes}; - use alloy_sol_types::SolCall; + use alloy_primitives::{Address, U256, address, bytes}; use super::CoinbaseTip; use crate::{ Predeploys, - transaction::eip8130::{ - Call, IDefaultAccount, TxEip8130, - account_changes::{ - AccountChange, AccountChangeChannel, CreateEntry, Delegation, SignedAccountChanges, - }, - addresses::Eip8130Contracts, - }, + transaction::eip8130::{Call, TxEip8130}, }; - const SENDER: Address = address!("0x00000000000000000000000000000000000000bb"); const COINBASE: Address = Predeploys::SEQUENCER_FEE_VAULT; const OTHER_RECIPIENT: Address = address!("0x00000000000000000000000000000000000000cc"); const TIP_AMOUNT: u64 = 123; - fn encode_execute(target: Address, value: U256, data: &[u8]) -> Bytes { - Bytes::from( - IDefaultAccount::executeCall { target, value, data: data.to_vec().into() }.abi_encode(), - ) + fn tip_call(to: Address) -> Call { + Call { to, value: U256::from(TIP_AMOUNT), data: Default::default() } } - fn encode_execute_batch_one(target: Address, value: U256) -> Bytes { - Bytes::from( - IDefaultAccount::executeBatchCall { - calls: vec![IDefaultAccount::Call { target, value, data: Default::default() }], - } - .abi_encode(), - ) - } - - fn execute_call(to: Address) -> Call { - Call { to, value: U256::ZERO, data: encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[]) } - } - - fn eoa_with_phase0(calls: Vec) -> TxEip8130 { - TxEip8130 { sender: None, calls: vec![calls], ..Default::default() } + fn with_phase0(calls: Vec) -> TxEip8130 { + TxEip8130 { calls: vec![calls], ..Default::default() } } #[test] - fn coinbase_tip_execute_encoding_matches_canonical_abi() { + fn coinbase_tip_value_call_returns_amount() { assert_eq!( - encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[]).as_ref(), - bytes!("b61d27f60000000000000000000000004200000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000007b00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000") - .as_ref(), + CoinbaseTip::decode(&with_phase0(vec![tip_call(COINBASE)])), + Some(U256::from(TIP_AMOUNT)) ); - assert_eq!( - encode_execute_batch_one(COINBASE, U256::from(TIP_AMOUNT)).as_ref(), - bytes!("34fcd5be0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000004200000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000007b00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000") - .as_ref(), - ); - } - - #[test] - fn coinbase_tip_eoa_execute_returns_amount() { - let tx = eoa_with_phase0(vec![execute_call(SENDER)]); - assert_eq!(CoinbaseTip::decode(&tx, SENDER), Some(U256::from(TIP_AMOUNT))); - let other = eoa_with_phase0(vec![Call { - to: SENDER, - value: U256::ZERO, - data: encode_execute(OTHER_RECIPIENT, U256::from(TIP_AMOUNT), &[]), - }]); - assert_eq!(CoinbaseTip::decode(&other, SENDER), None); - let not_self = eoa_with_phase0(vec![execute_call(COINBASE)]); - assert_eq!(CoinbaseTip::decode(¬_self, SENDER), None); - } - - #[test] - fn coinbase_tip_eoa_execute_batch_single_send_returns_amount() { - let tx = eoa_with_phase0(vec![Call { - to: SENDER, - value: U256::ZERO, - data: encode_execute_batch_one(COINBASE, U256::from(TIP_AMOUNT)), - }]); - assert_eq!(CoinbaseTip::decode(&tx, SENDER), Some(U256::from(TIP_AMOUNT))); - let other = eoa_with_phase0(vec![Call { - to: SENDER, - value: U256::ZERO, - data: encode_execute_batch_one(OTHER_RECIPIENT, U256::from(TIP_AMOUNT)), - }]); - assert_eq!(CoinbaseTip::decode(&other, SENDER), None); - } - - #[test] - fn coinbase_tip_explicit_sender_requires_self_call_and_default_delegation() { - let delegated = TxEip8130 { - sender: Some(SENDER), - account_changes: vec![AccountChange::Delegation(Delegation { - target: Eip8130Contracts::DEFAULT_ACCOUNT, - })], - calls: vec![vec![execute_call(SENDER)]], - ..Default::default() + let configured = TxEip8130 { + sender: Some(address!("0x00000000000000000000000000000000000000bb")), + ..with_phase0(vec![tip_call(COINBASE)]) }; - assert_eq!(CoinbaseTip::decode(&delegated, SENDER), Some(U256::from(TIP_AMOUNT))); - - let not_self = TxEip8130 { calls: vec![vec![execute_call(COINBASE)]], ..delegated.clone() }; - assert_eq!(CoinbaseTip::decode(¬_self, SENDER), None); - assert_eq!(CoinbaseTip::decode(&delegated, OTHER_RECIPIENT), None); - - let no_delegation = TxEip8130 { account_changes: vec![], ..delegated }; - assert_eq!(CoinbaseTip::decode(&no_delegation, SENDER), None); + assert_eq!(CoinbaseTip::decode(&configured), Some(U256::from(TIP_AMOUNT))); } #[test] - fn coinbase_tip_rejects_non_default_account() { - let other_impl = TxEip8130 { - sender: Some(SENDER), - account_changes: vec![AccountChange::Delegation(Delegation { - target: Eip8130Contracts::CANONICAL_HIGH_RATE_PAYER_ACCOUNT, - })], - calls: vec![vec![execute_call(SENDER)]], - ..Default::default() - }; - assert_eq!(CoinbaseTip::decode(&other_impl, SENDER), None); + fn coinbase_tip_rejects_other_recipient_calldata_or_zero_value() { + assert_eq!(CoinbaseTip::decode(&with_phase0(vec![tip_call(OTHER_RECIPIENT)])), None); - let cleared = TxEip8130 { - account_changes: vec![AccountChange::Delegation(Delegation { target: Address::ZERO })], - ..other_impl - }; - assert_eq!(CoinbaseTip::decode(&cleared, SENDER), None); + let with_calldata = Call { data: bytes!("01"), ..tip_call(COINBASE) }; + assert_eq!(CoinbaseTip::decode(&with_phase0(vec![with_calldata])), None); - let created = TxEip8130 { - sender: None, - account_changes: vec![AccountChange::Create(CreateEntry { - user_salt: B256::ZERO, - code: bytes!("01"), - initial_actors: vec![], - })], - calls: vec![vec![execute_call(SENDER)]], - ..Default::default() - }; - assert_eq!(CoinbaseTip::decode(&created, SENDER), None); + let zero_value = Call { value: U256::ZERO, ..tip_call(COINBASE) }; + assert_eq!(CoinbaseTip::decode(&with_phase0(vec![zero_value])), None); } #[test] - fn coinbase_tip_requires_single_phase0_eth_transfer() { - let call = execute_call(SENDER); - - assert_eq!( - CoinbaseTip::decode(&eoa_with_phase0(vec![call.clone(), call.clone()]), SENDER), - None - ); - assert_eq!(CoinbaseTip::decode(&eoa_with_phase0(vec![]), SENDER), None); - - let later_phase = - TxEip8130 { sender: None, calls: vec![vec![], vec![call]], ..Default::default() }; - assert_eq!(CoinbaseTip::decode(&later_phase, SENDER), None); - - let with_calldata = eoa_with_phase0(vec![Call { - to: SENDER, - value: U256::ZERO, - data: encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[0x01]), - }]); - assert_eq!(CoinbaseTip::decode(&with_calldata, SENDER), None); - - let wrong_selector = - eoa_with_phase0(vec![Call { to: SENDER, value: U256::ZERO, data: bytes!("deadbeef") }]); - assert_eq!(CoinbaseTip::decode(&wrong_selector, SENDER), None); - - let configured = TxEip8130 { sender: Some(SENDER), ..Default::default() }; - assert_eq!(CoinbaseTip::decode(&configured, SENDER), None); - } - - #[test] - fn coinbase_tip_config_change_does_not_block_eoa_auto_delegation() { - let tx = TxEip8130 { - sender: None, - account_changes: vec![AccountChange::ConfigChange(SignedAccountChanges { - channel: AccountChangeChannel::Local, - sequence: 0, - changes: vec![], - signature: Bytes::new(), - })], - calls: vec![vec![execute_call(SENDER)]], - ..Default::default() - }; - assert_eq!(CoinbaseTip::decode(&tx, SENDER), Some(U256::from(TIP_AMOUNT))); - } - - #[test] - fn coinbase_tip_rejects_truncated_or_dirty_calldata() { - let mut truncated = encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[]).to_vec(); - truncated.truncate(truncated.len().saturating_sub(32)); - let truncated_tx = eoa_with_phase0(vec![Call { - to: SENDER, - value: U256::ZERO, - data: Bytes::from(truncated), - }]); - assert_eq!(CoinbaseTip::decode(&truncated_tx, SENDER), None); - - // High bytes of the address word must be zero; `abi_decode_validate` rejects - // that dirty padding even though a lenient decoder would still yield a tip. - let mut dirty = encode_execute(COINBASE, U256::from(TIP_AMOUNT), &[]).to_vec(); - dirty[4] = 0xff; - let dirty_tx = - eoa_with_phase0(vec![Call { to: SENDER, value: U256::ZERO, data: Bytes::from(dirty) }]); - assert_eq!(CoinbaseTip::decode(&dirty_tx, SENDER), None); + fn coinbase_tip_requires_single_phase0_call() { + let call = tip_call(COINBASE); + assert_eq!(CoinbaseTip::decode(&with_phase0(vec![call.clone(), call.clone()])), None); + assert_eq!(CoinbaseTip::decode(&with_phase0(vec![])), None); + assert_eq!(CoinbaseTip::decode(&TxEip8130::default()), None); + + let later_phase = TxEip8130 { calls: vec![vec![], vec![call]], ..Default::default() }; + assert_eq!(CoinbaseTip::decode(&later_phase), None); } } diff --git a/crates/common/eip8130/src/intrinsic.rs b/crates/common/eip8130/src/intrinsic.rs index 49ddfd5464..1ee325e896 100644 --- a/crates/common/eip8130/src/intrinsic.rs +++ b/crates/common/eip8130/src/intrinsic.rs @@ -64,9 +64,6 @@ pub struct IntrinsicGasInput { /// over the *reset* cost. Ignored for nonce-free (`NONCE_KEY_MAX`) /// transactions. pub nonce_key_first_use: bool, - /// Whether a code-less `sender` EOA is auto-delegated to `DEFAULT_ACCOUNT` - /// during block execution, incurring the delegation-indicator deposit. - pub sender_auto_delegated: bool, /// Whether sender authorization resolved a policy-bearing actor and therefore /// read its `policy_manager` slot in addition to its config/state slot. pub sender_policy_gated: bool, @@ -93,10 +90,9 @@ pub struct IntrinsicGasInput { impl IntrinsicGasInput { /// Creates the intrinsic-gas state hints. #[must_use] - pub const fn new(nonce_key_first_use: bool, sender_auto_delegated: bool) -> Self { + pub const fn new(nonce_key_first_use: bool) -> Self { Self { nonce_key_first_use, - sender_auto_delegated, sender_policy_gated: false, payer_policy_gated: false, revoke_discount_slots: 0, @@ -139,51 +135,11 @@ impl IntrinsicGasInput { /// drifting apart: both must feed [`IntrinsicGas::compute`] the *same* pinned /// input for the `estimate == admission >= execution` guarantee to hold. #[must_use] - pub const fn worst_case( - nonce_key_first_use: bool, - sender_auto_delegated: bool, - has_payer: bool, - ) -> Self { - Self::new(nonce_key_first_use, sender_auto_delegated) + pub const fn worst_case(nonce_key_first_use: bool, has_payer: bool) -> Self { + Self::new(nonce_key_first_use) .with_policy_gates(true, has_payer) .with_revoke_discount_slots(0) } - - /// Body-derivable worst-case for [`Self::sender_auto_delegated`], the single - /// classifier shared by estimation (`eth_estimateGas`), mempool admission, - /// and the execution auto-delegation state gate. - /// - /// Execution auto-delegates the sender (charging a `DELEGATION_DEPOSIT_COST`) - /// exactly when the transaction carries **neither** an - /// [`AccountChange::Delegation`] **nor** an [`AccountChange::Create`] entry - /// *and* the sender is code-less at inclusion. The entry conditions are - /// body-derivable; the code-less fact is non-monotonic — the sender's - /// on-chain code can flip between estimation and inclusion (e.g. a native - /// EIP-7702 revocation strips the delegation and re-arms auto-delegation). So - /// the safe body-derivable ceiling is "charge unless the transaction contains - /// a `Delegation` or `Create` entry": - /// - /// - A `Delegation` (zero or non-zero target) suppresses auto-delegation at - /// execution unconditionally — a zero target is an owner-authorized request - /// to remain undelegated — so it suppresses it here too. - /// - A `Create` always targets the sender account itself (EIP-8130 enforces - /// `created.address == sender`), establishing the sender's EIP-8130 account - /// and installing its code. A created account is not a plain code-less EOA, - /// so execution never auto-delegates it — hence it suppresses here too. - /// - A `ConfigChange` or a call-only transaction never installs sender code, - /// so it does not suppress either. - /// - /// Every path pins this same predicate — estimation and admission pin the gas - /// ceiling, and execution gates its (accurately repriced) state mutation on - /// it — so the `estimate == admission >= execution` guarantee holds; resolving - /// it from current code state on one path but not another silently breaks that - /// invariant. - #[must_use] - pub fn sender_auto_delegated(account_changes: &[AccountChange]) -> bool { - !account_changes - .iter() - .any(|change| matches!(change, AccountChange::Delegation(_) | AccountChange::Create(_))) - } } /// The EIP-8130 intrinsic-gas breakdown, one field per spec component. @@ -200,8 +156,6 @@ pub struct IntrinsicGas { pub bytecode: u64, /// `account_changes_cost` — config-change and delegation entries. pub account_changes: u64, - /// `auto_delegation_cost` — code-less sender auto-delegation. - pub auto_delegation: u64, /// `sender_auth_cost` — sender authenticator execution + its `authorize` /// SLOAD(s) (see `auth_sloads`). pub sender_auth: u64, @@ -219,7 +173,6 @@ impl IntrinsicGas { .saturating_add(self.nonce_key) .saturating_add(self.bytecode) .saturating_add(self.account_changes) - .saturating_add(self.auto_delegation) .saturating_add(self.sender_auth) .saturating_add(self.payer_auth) } @@ -369,12 +322,6 @@ impl IntrinsicGas { .saturating_mul(u64::from(discounted_slots)); account_changes = account_changes.saturating_sub(revoke_discount); - let auto_delegation = if input.sender_auto_delegated { - Eip8130GasSchedule::DELEGATION_DEPOSIT_COST - } else { - 0 - }; - // Only the empty-`sender` path (`sender == None`) is a bare 65-byte // signature parsed via native ecrecover; a configured sender (and every // payer) is an `authenticator || data` blob and must not be parsed as a @@ -400,7 +347,6 @@ impl IntrinsicGas { nonce_key, bytecode, account_changes, - auto_delegation, sender_auth, payer_auth, }) @@ -655,7 +601,7 @@ mod tests { const ACCOUNT: Address = address!("0x1111111111111111111111111111111111111111"); const K1: Address = Eip8130Constants::K1_AUTHENTICATOR; - const EXISTING_KEY: IntrinsicGasInput = IntrinsicGasInput::new(false, false); + const EXISTING_KEY: IntrinsicGasInput = IntrinsicGasInput::new(false); fn signed(tx: TxEip8130, sender_auth: Vec, payer_auth: Vec) -> Eip8130Signed { Eip8130Signed::new(tx, Bytes::from(sender_auth), Bytes::from(payer_auth)) @@ -681,52 +627,6 @@ mod tests { .expect("canonical authenticators are scheduled") } - fn create_entry() -> CreateEntry { - CreateEntry { - user_salt: Default::default(), - code: Bytes::from(vec![0x60u8; 4]), - initial_actors: vec![], - } - } - - #[test] - fn sender_auto_delegated_ceiling_matches_execution_upper_bound() { - // No account changes: execution auto-delegates a code-less sender, so the - // body ceiling must charge the deposit. - assert!(IntrinsicGasInput::sender_auto_delegated(&[])); - - // A call-only / `ConfigChange` transaction never installs sender code, so - // execution may still auto-delegate — charge the ceiling. - assert!(IntrinsicGasInput::sender_auto_delegated(&[AccountChange::ConfigChange( - SignedAccountChanges { - channel: AccountChangeChannel::Multichain, - sequence: 0, - changes: vec![], - signature: Bytes::new(), - } - )])); - - // A `Create` always targets the sender account itself (EIP-8130 enforces - // `created.address == sender`), establishing the sender's EIP-8130 account - // and installing its code. A created account is not a plain code-less EOA, - // so execution never auto-delegates it — the classifier must suppress here - // too, on every path, so admission and estimate match execution exactly. - assert!(!IntrinsicGasInput::sender_auto_delegated(&[ - AccountChange::Create(create_entry()) - ])); - - // Any `Delegation` entry — zero or non-zero target — sets - // `has_explicit_delegation`, which suppresses auto-delegation at execution - // unconditionally. Both must suppress the ceiling too (else admission - // over-budgets vs the estimate and rejects a `gas_limit == estimate` tx). - assert!(!IntrinsicGasInput::sender_auto_delegated(&[AccountChange::Delegation( - Delegation { target: Address::ZERO } - )])); - assert!(!IntrinsicGasInput::sender_auto_delegated(&[AccountChange::Delegation( - Delegation { target: Address::repeat_byte(0x11) } - )])); - } - alloy_sol_types::sol! { // Mirror of the contract's `ActorConfig` authorize payload, used only to // pin the byte offsets `authorize_attaches_policy` reads. @@ -786,7 +686,6 @@ mod tests { assert_eq!(gas.nonce_key, Eip8130GasSchedule::NONCE_KEY_EXISTING_COST); assert_eq!(gas.bytecode, 0); assert_eq!(gas.account_changes, 0); - assert_eq!(gas.auto_delegation, 0); // native k1 exec + 1 cold SLOAD. assert_eq!( gas.sender_auth, @@ -805,8 +704,7 @@ mod tests { assert_eq!(free.nonce_key, Eip8130GasSchedule::NONCE_FREE_COST); tx.nonce_key = U256::from(7u64); - let first = - intrinsic(&signed(tx, vec![0; 65], vec![]), &IntrinsicGasInput::new(true, false)); + let first = intrinsic(&signed(tx, vec![0; 65], vec![]), &IntrinsicGasInput::new(true)); assert_eq!(first.nonce_key, Eip8130GasSchedule::NONCE_KEY_FIRST_USE_COST); } @@ -1405,13 +1303,6 @@ mod tests { ); } - #[test] - fn auto_delegation_adds_indicator_deposit() { - let tx = TxEip8130::default(); - let gas = intrinsic(&signed(tx, vec![0; 65], vec![]), &IntrinsicGasInput::new(false, true)); - assert_eq!(gas.auto_delegation, Eip8130GasSchedule::DELEGATION_DEPOSIT_COST); - } - #[test] fn execution_gas_available_subtracts_sender_intrinsic() { let tx = TxEip8130::default(); diff --git a/crates/common/eip8130/src/schedule.rs b/crates/common/eip8130/src/schedule.rs index e44502ce59..32fadc1d82 100644 --- a/crates/common/eip8130/src/schedule.rs +++ b/crates/common/eip8130/src/schedule.rs @@ -73,7 +73,7 @@ impl Eip8130GasSchedule { const _DELEGATION_INDICATOR_FITS_U64: () = assert!(Eip8130Constants::DELEGATION_INDICATOR_SIZE <= u64::MAX as usize); /// Delegation-indicator deposit: `200 × 23` for the `0xef0100 || address` - /// indicator (`auto_delegation_cost` and per delegation entry). + /// indicator, charged per delegation entry. pub const DELEGATION_DEPOSIT_COST: u64 = Self::CODE_DEPOSIT_PER_BYTE * Eip8130Constants::DELEGATION_INDICATOR_SIZE as u64; diff --git a/crates/common/evm/src/eip8130.rs b/crates/common/evm/src/eip8130.rs index e66c53a009..feb0f903b1 100644 --- a/crates/common/evm/src/eip8130.rs +++ b/crates/common/evm/src/eip8130.rs @@ -28,13 +28,12 @@ //! gate), that phase's state changes are discarded and every later phase is //! skipped, but the gas already consumed is still charged and the transaction is //! still included (nonce consumed, fee paid). Each call is dispatched from -//! `sender` to `call.to`, transferring `call.value` wei (`msg.value == -//! call.value`, `tx.origin == sender`). +//! `sender` to `call.to` with `msg.value == call.value` and `tx.origin == sender`. //! //! # Scope //! //! Protocol-injected account-change logs (`ActorAuthorized`, `ActorRevoked`, -//! `AccountCreated`, `DelegationApplied`) are written to the journal during the +//! `AccountCreated`) are written to the journal during the //! pre-call apply step and surface in the transaction receipt ahead of any //! `calls` logs. Per-phase receipt status (`phaseStatuses`) is reported on the //! EIP-8130 receipt; the overall transaction status (all-phases-succeeded vs @@ -48,14 +47,11 @@ use alloc::{boxed::Box, rc::Rc, vec::Vec}; use alloy_evm::{Database as AlloyDatabase, EvmInternals}; use alloy_primitives::{Address, B256, Bytes, U256}; -use base_common_consensus::{ - AccountChange, Delegation, Eip8130Constants, Eip8130Contracts, Predeploys, -}; +use base_common_consensus::{AccountChange, Delegation, Eip8130Constants, Predeploys}; use base_common_precompiles::{NonceManagerStorage, TxContextStorage}; use base_execution_eip8130::{ - AccountChangeApplier, AccountConfigurationEvents, AccountConfigurationStorage, ApplyError, - DelegationEffect, FeeCheck, IntrinsicGas, IntrinsicGasInput, NonceMode, NonceValidator, - TransactionAuthorizer, + AccountChangeApplier, AccountConfigurationStorage, ApplyError, DelegationEffect, FeeCheck, + IntrinsicGas, IntrinsicGasInput, NonceMode, NonceValidator, TransactionAuthorizer, }; use base_precompile_storage::{JournalStorageProvider, StorageCtx}; use revm::{ @@ -380,7 +376,7 @@ impl Eip8130Executor { /// Read-only gas estimation for an EIP-8130 transaction — the /// `eth_estimateGas` / `eth_call` path. Runs the same account-change apply, - /// auto-delegation, intrinsic-gas, and phased-`calls` pipeline as + /// intrinsic-gas, and phased-`calls` pipeline as /// [`Self::execute`] to measure gas, then reverts every journal write so no /// state is committed. /// @@ -746,7 +742,7 @@ impl Eip8130Executor { /// Resolves the [`Eip8130Outcome`] for [`Self::simulate`]: applies account /// changes, then resolves the acting actor (an optional RPC hint, else the /// account's self-actor) and its policy from the post-apply journal — no - /// signature recovery — then auto-delegates and prices intrinsic gas without + /// signature recovery — then prices intrinsic gas without /// validating or advancing the nonce or checking the payer balance. The /// authentication gas for the sender's (and any payer's) declared /// authenticator is priced from the synthesized auth-blob shape via @@ -809,7 +805,7 @@ impl Eip8130Executor { // calls run against post-change code and create/delegation gas is // priced. Must precede actor/policy resolution so an actor // authorized in this same estimate request is visible. - let has_explicit_delegation = Self::apply_account_changes(signed, sctx, sender, now)?; + Self::apply_account_changes(signed, sctx, sender, now)?; // 3. Resolve the acting actor's real policy gate. No signature // recovery: the optional RPC hint names the intended actor (e.g. a @@ -846,26 +842,7 @@ impl Eip8130Executor { Address::ZERO }; - // 4. Auto-delegate a code-less sender in the simulation state (so the - // calls run against a delegated sender), but *price* auto-delegation - // from the body-derivable worst case, not the sim-state result. - // Auto-delegation is non-monotonic — the sender's on-chain code can - // flip between estimation and inclusion — so pinning the body - // ceiling keeps the estimate a safe upper bound and, crucially, - // identical to what mempool admission pins. Resolving it from - // current code state here (while admission pins the body ceiling) - // would let admission exceed the estimate and reject a - // `gas_limit == estimate` submission. The state mutation stays gated - // on the absence of an explicit delegation (a zero target is an - // owner-authorized request to remain undelegated), matching the - // classifier's suppression on any `Delegation` entry. - if !has_explicit_delegation { - Self::auto_delegate_codeless_sender(sctx, sender)?; - } - let sender_auto_delegated = - IntrinsicGasInput::sender_auto_delegated(&tx.account_changes); - - // 5. Intrinsic gas (auth gas is priced from the auth-blob shape, so a + // 4. Intrinsic gas (auth gas is priced from the auth-blob shape, so a // stub signature of the right authenticator type estimates exactly). // The estimate is a safe ceiling that execution can only meet or // undercharge. The non-monotonic, state-dependent costs are @@ -876,7 +853,6 @@ impl Eip8130Executor { // is not authenticable here in any case. // - zero revoke discount, so revokes are priced at the full // three-reset worst case regardless of which slots are empty. - // - auto-delegation pinned to the body ceiling above. // The monotonic, body-derivable nonce first-use cost stays resolved. // Execution reprices all of these precisely against the // authenticated actors and real state. @@ -884,15 +860,11 @@ impl Eip8130Executor { Self::resolve_execution_gas( signed, encoded, - &IntrinsicGasInput::worst_case( - nonce_key_first_use, - sender_auto_delegated, - tx.payer.is_some(), - ), + &IntrinsicGasInput::worst_case(nonce_key_first_use, tx.payer.is_some()), gas_limit, )?; - // 6. Publish the transaction context for the `TxContext` precompile. + // 5. Publish the transaction context for the `TxContext` precompile. TxContextStorage::new(sctx) .set_context(sender, payer, sender_actor_id) .map_err(BaseTransactionError::eip8130)?; @@ -915,7 +887,7 @@ impl Eip8130Executor { } /// Runs the storage-backed pre-call pipeline (authorize, nonce, intrinsic - /// gas, fee-cap check, account-change apply, auto-delegation) over a gas-free + /// gas, fee-cap check, account-change apply) over a gas-free /// journal view and publishes the transaction context, returning the resolved /// [`Eip8130Outcome`]. Storage writes land on the journal directly; the /// caller discards the transaction on error. @@ -986,7 +958,6 @@ impl Eip8130Executor { let applied_tx = TransactionAuthorizer::authorize_and_apply(signed, &mut acc, chain_id, now) .map_err(BaseTransactionError::eip8130)?; - let has_explicit_delegation = applied_tx.applied.delegation.is_some(); let sender_actor = applied_tx.actors.sender.resolved; let payer_policy_gated = applied_tx .actors @@ -1062,36 +1033,18 @@ impl Eip8130Executor { (current_nonce == 0, false) }; - // 4. Auto-delegate a code-less sender only when no explicit - // delegation owner change was supplied. A zero target deliberately - // clears the sender's delegation and must not be overwritten with - // `DEFAULT_ACCOUNT`. - // A create installs the account's own runtime, so it must never be - // auto-delegated to `DEFAULT_ACCOUNT`. `apply_create` rejects empty - // runtimes, so a created sender is never code-less here and - // `auto_delegate_codeless_sender` would already no-op; gating on the - // create explicitly keeps that invariant local rather than relying on - // the emptiness check, and matches the `sender_auto_delegated` - // intrinsic-gas classifier's own suppression on a create entry. - let has_create = applied_tx.applied.created.is_some(); - let sender_auto_delegated = if has_explicit_delegation || has_create { - false - } else { - Self::auto_delegate_codeless_sender(sctx, sender)? - }; - - // 5. Intrinsic gas under the EIP-8130 schedule. + // 4. Intrinsic gas under the EIP-8130 schedule. let (sender_intrinsic, payer_auth, execution_gas_available) = Self::resolve_execution_gas( signed, encoded, - &IntrinsicGasInput::new(nonce_key_first_use, sender_auto_delegated) + &IntrinsicGasInput::new(nonce_key_first_use) .with_policy_gates(sender_actor.is_policy_gated(), payer_policy_gated) .with_revoke_discount_slots(applied_tx.revoke_discount_slots), gas_limit, )?; - // 6. Fee caps and payer balance. + // 5. Fee caps and payer balance. FeeCheck::validate_fees(max_fee, max_priority, base_fee) .map_err(BaseTransactionError::eip8130)?; let payer_balance = sctx @@ -1100,7 +1053,7 @@ impl Eip8130Executor { FeeCheck::validate_balance(payer_balance, gas_limit, payer_auth, max_fee) .map_err(BaseTransactionError::eip8130)?; - // 8. Publish the transaction context (sender / payer / actor id) so it + // 6. Publish the transaction context (sender / payer / actor id) so it // is readable by the `TxContext` precompile during `calls`. TxContextStorage::new(sctx) .set_context(sender, payer, sender_actor.actor_id) @@ -1666,7 +1619,7 @@ impl Eip8130Executor { sctx: StorageCtx<'_>, sender: Address, now: u64, - ) -> Result { + ) -> Result<(), BaseTransactionError> { let mut acc_mut = AccountConfigurationStorage::new(sctx); let mut created_effect: Option<(Address, Bytes)> = None; let mut delegation_effect: Option = None; @@ -1717,11 +1670,10 @@ impl Eip8130Executor { if let Some((address, code)) = &created_effect { Self::install_created_code(sctx, *address, code)?; } - let has_explicit_delegation = delegation_effect.is_some(); if let Some(delegation) = delegation_effect { delegation.install(sctx).map_err(BaseTransactionError::eip8130)?; } - Ok(has_explicit_delegation) + Ok(()) } /// Installs a created account's runtime code, enforcing the CREATE2 collision @@ -1754,30 +1706,6 @@ impl Eip8130Executor { sctx.set_code(address, bytecode).map_err(BaseTransactionError::eip8130) } - /// Auto-delegates a code-less sender to [`Eip8130Contracts::DEFAULT_ACCOUNT`] - /// so the account can dispatch its `calls`, returning whether the delegation - /// was installed (which feeds the intrinsic-gas schedule). The verifying and - /// estimation paths call this only when the transaction has no explicit - /// delegation change; an owner-authorized zero target must remain cleared. - /// A configured account already has code, so this is otherwise a no-op for it. - fn auto_delegate_codeless_sender( - sctx: StorageCtx<'_>, - sender: Address, - ) -> Result { - let is_codeless = sctx - .with_account_info(sender, |info| Ok(info.is_empty_code_hash())) - .map_err(BaseTransactionError::eip8130)?; - if is_codeless { - let target = Eip8130Contracts::DEFAULT_ACCOUNT; - sctx.set_code(sender, Bytecode::new_eip7702(target)) - .map_err(BaseTransactionError::eip8130)?; - // Same protocol-injected receipt log as an explicit delegation entry. - AccountConfigurationEvents::emit_delegation_applied(sctx, sender, target) - .map_err(BaseTransactionError::eip8130)?; - } - Ok(is_codeless) - } - /// Computes the EIP-8130 intrinsic gas and the gas left for `calls`, returning /// `(sender_intrinsic, payer_auth, execution_gas_available)`. Shared by both /// pipelines so intrinsic pricing is computed identically for execution and @@ -1823,8 +1751,8 @@ mod tests { Eip8130Signed, InitialActor, Predeploys, SignedAccountChanges, SignedChange, TxEip8130, }; use base_common_precompiles::INonceManager; - use base_execution_eip8130::{AccountChangeApplier, DelegationApplied}; - use base_precompile_storage::{HashMapStorageProvider, StorageCtx}; + use base_execution_eip8130::AccountChangeApplier; + use base_precompile_storage::StorageCtx; use k256::ecdsa::SigningKey; use revm::{ Database, @@ -1976,11 +1904,13 @@ mod tests { assert!(result.is_success(), "expected success, got {result:?}"); assert!(result.gas().tx_gas_used() > 0); - // The sender's protocol nonce was bumped and a code-less EOA sender was - // auto-delegated to the default account. + // The sender's protocol nonce was bumped and, with no explicit delegation + // entry, the code-less EOA sender stays code-less. let sender_acc = outcome.state.get(&sender).expect("sender in state"); assert_eq!(sender_acc.info.nonce, 1); - assert!(!sender_acc.info.is_empty_code_hash(), "sender should be auto-delegated"); + assert!(sender_acc.info.is_empty_code_hash(), "sender must not be implicitly delegated"); + let ExecutionResult::Success { logs, .. } = &result else { unreachable!() }; + assert!(logs.is_empty(), "no delegation event without a delegation entry"); assert!(sender_acc.info.balance < initial_balance, "payer should be debited"); // Fees were routed: base fee to the vault, priority tip to the beneficiary. @@ -2134,35 +2064,6 @@ mod tests { assert!(sender_acc.info.balance < initial_balance); } - #[test] - fn auto_delegate_codeless_sender_delegates_to_default_account() { - // A codeless sender is auto-delegated to `DEFAULT_ACCOUNT` so it can - // dispatch its calls; a sender that already has code is left untouched. - let plain = address!("0x00000000000000000000000000000000000000c1"); - let coded = address!("0x00000000000000000000000000000000000000c2"); - let mut provider = HashMapStorageProvider::new(CHAIN_ID); - StorageCtx::enter(&mut provider, |ctx| { - assert!( - Eip8130Executor::auto_delegate_codeless_sender(ctx, plain).unwrap(), - "codeless EOA must be auto-delegated" - ); - ctx.set_code(coded, Bytecode::new_raw(Bytes::from_static(&[0x60, 0x00]))).unwrap(); - assert!( - !Eip8130Executor::auto_delegate_codeless_sender(ctx, coded).unwrap(), - "a sender with code must not be auto-delegated" - ); - }); - - assert_eq!( - provider - .get_account_info(plain) - .and_then(|info| info.code.as_ref()) - .and_then(Bytecode::eip7702_address), - Some(Eip8130Contracts::DEFAULT_ACCOUNT), - "codeless sender must delegate to DEFAULT_ACCOUNT" - ); - } - #[test] fn two_dimensional_nonce_is_incremented() { let key = signing_key(0x2a); @@ -2240,7 +2141,8 @@ mod tests { seed_account_code( &mut evm, sender, - Bytecode::new_eip7702(Eip8130Contracts::DEFAULT_ACCOUNT).original_bytes(), + Bytecode::new_eip7702(address!("0x00000000000000000000000000000000000000dd")) + .original_bytes(), ); let outcome = evm.transact_raw(into_base_tx(&signed)).expect("clear should execute"); @@ -2250,11 +2152,7 @@ mod tests { let ExecutionResult::Success { logs, .. } = &outcome.result else { panic!("expected successful clear, got {:?}", outcome.result); }; - assert_eq!(logs.len(), 1, "clear must not be followed by auto-delegation"); - assert_eq!(logs[0].address, AccountConfigurationStorage::ADDRESS); - let event = DelegationApplied::decode_log_data(&logs[0].data).unwrap(); - assert_eq!(event.account, sender); - assert_eq!(event.target, Address::ZERO); + assert!(logs.is_empty(), "delegation updates emit no protocol logs"); } #[test] @@ -2285,7 +2183,7 @@ mod tests { } #[test] - fn simulate_explicit_zero_delegation_emits_only_clear() { + fn simulate_explicit_zero_delegation_clears_without_logs() { let key = signing_key(0x26); let sender = eoa_address(&key); let mut tx = base_tx(); @@ -2295,7 +2193,8 @@ mod tests { seed_account_code( &mut evm, sender, - Bytecode::new_eip7702(Eip8130Contracts::DEFAULT_ACCOUNT).original_bytes(), + Bytecode::new_eip7702(address!("0x00000000000000000000000000000000000000dd")) + .original_bytes(), ); evm.ctx_mut().tx = into_base_tx(&signed); evm.ctx_mut().tx.base.caller = sender; @@ -2304,10 +2203,7 @@ mod tests { let ExecutionResult::Success { logs, .. } = result else { panic!("expected successful simulation, got {result:?}"); }; - assert_eq!(logs.len(), 1, "clear must not be followed by auto-delegation"); - let event = DelegationApplied::decode_log_data(&logs[0].data).unwrap(); - assert_eq!(event.account, sender); - assert_eq!(event.target, Address::ZERO); + assert!(logs.is_empty(), "delegation updates emit no protocol logs"); } #[test] @@ -2976,12 +2872,11 @@ mod tests { assert!(matches!(warm_outcome.result, ExecutionResult::Success { .. })); revm::DatabaseCommit::commit(evm.ctx_mut().journal_mut().db_mut(), warm_outcome.state); - // Tx 1 already bumped the protocol nonce (0 -> 1) and delegated the sender - // (both committed above), so tx 2 is a second-use transaction: + // Tx 1 already bumped the protocol nonce (0 -> 1) (committed above), so + // tx 2 is a second-use transaction: // base AA_BASE_COST 15_000 // payload EIP-2028 DA over the tx 1_716 // nonce_key existing channel 0: COLD_SLOAD 2_100 + SSTORE_RESET 2_900 5_000 - // auto_delegation sender already delegated 0 // sender_auth ECRECOVER 3_000 + cold SLOAD 2_100 5_100 // call PUSH1 (3) + COLD SLOAD (2_100) + STOP 2_103 // total 28_919 (the call's zero value adds one 0x80 calldata byte) @@ -3035,15 +2930,14 @@ mod tests { // base AA_BASE_COST 15_000 // payload EIP-2028 DA over the two-phase tx 1_888 // nonce_key first use of channel 0: COLD_SLOAD 2_100 + SSTORE_SET 20_000 22_100 - // auto_delegation codeless EOA -> DEFAULT_ACCOUNT 4_600 // sender_auth ECRECOVER 3_000 + cold SLOAD 2_100 5_100 // call phase 0 PUSH1 (3) + COLD SLOAD (2_100) + STOP 2_103 // call phase 1 PUSH1 (3) + WARM SLOAD (100) + STOP 103 - // total 50_894 (the two calls each carry a value word; a zero value adds + // total 46_294 (the two calls each carry a value word; a zero value adds // one 0x80 calldata byte per call to the DA payload) assert_eq!( outcome.result.gas().tx_gas_used(), - 50_894, + 46_294, "phase 1's SLOAD must be WARM (100): committed phase 0 warmed \ (loader, slot 0). A cold read (2_100) would be 2_000 more, meaning \ the committed phase's warmth failed to carry across phases", @@ -3085,19 +2979,18 @@ mod tests { // `load_tx` is a self-paying EOA transaction with one phase calling // `loader` (PUSH1 0, SLOAD, STOP). Its gas splits into the EIP-8130 - // sender-intrinsic charge (48_484) plus the dispatched call (2_103): + // sender-intrinsic charge (43_916) plus the dispatched call (2_103): // base AA_BASE_COST 15_000 // payload EIP-2028 DA over the 123-byte tx 1_716 // nonce_key first use of channel 0: COLD_SLOAD 2_100 + SSTORE_SET 20_000 22_100 - // auto_delegation codeless EOA -> DEFAULT_ACCOUNT (200 x 23-byte indicator) 4_600 // sender_auth ECRECOVER 3_000 + cold SLOAD 2_100 5_100 // call PUSH1 (3) + SLOAD + STOP (0) 2_103 - // total 50_619 (the call's zero value adds one 0x80 calldata byte) + // total 46_019 (the call's zero value adds one 0x80 calldata byte) assert_eq!( load_outcome.result.gas().tx_gas_used(), - 50_619, + 46_019, "loader SLOAD must be COLD (2_100); a warm read (100) would total \ - 48_619, meaning the discarded invalid tx leaked warmth", + 44_019, meaning the discarded invalid tx leaked warmth", ); } diff --git a/crates/common/rpc-types/src/transaction/request.rs b/crates/common/rpc-types/src/transaction/request.rs index a82f0fec23..b75e4d9932 100644 --- a/crates/common/rpc-types/src/transaction/request.rs +++ b/crates/common/rpc-types/src/transaction/request.rs @@ -115,8 +115,8 @@ pub struct Eip8130RequestFields { #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata: Option, /// The EIP-8130 sender account the batch dispatches from — the wire-level - /// `sender` identity that drives actor resolution, policy lookup, and - /// auto-delegation. Interchangeable with the standard `from`: the estimate + /// `sender` identity that drives actor resolution and policy lookup. + /// Interchangeable with the standard `from`: the estimate /// resolves the account as `sender` or `from`, and rejects a request where /// both are present but disagree, or where neither is set, as /// `INVALID_PARAMS`. A declared `sender` also selects the configured-account diff --git a/crates/execution/eip8130-rpc-node/tests/rpc.rs b/crates/execution/eip8130-rpc-node/tests/rpc.rs index 7a2161db14..f0ee67a05e 100644 --- a/crates/execution/eip8130-rpc-node/tests/rpc.rs +++ b/crates/execution/eip8130-rpc-node/tests/rpc.rs @@ -261,8 +261,7 @@ async fn estimate_gas_for_eip8130_request_with_reverting_call_fails() -> eyre::R /// An EIP-8130 `eth_estimateGas` request that names no account (neither `from` /// nor `sender`) must be rejected rather than silently simulated from the zero -/// address: the sender identity drives actor resolution, policy lookup, and -/// auto-delegation. +/// address: the sender identity drives actor resolution and policy lookup. #[tokio::test] async fn estimate_gas_for_eip8130_request_without_account_is_rejected() -> eyre::Result<()> { let (_harness, client) = setup().await?; diff --git a/crates/execution/eip8130/README.md b/crates/execution/eip8130/README.md index f9e83c0947..c68086f88e 100644 --- a/crates/execution/eip8130/README.md +++ b/crates/execution/eip8130/README.md @@ -56,8 +56,8 @@ The crate keeps the protocol stages explicit while avoiding crate sprawl: sequence per applied entry — into one authorization verdict (`TransactionAuthorizer`), shared by mempool admission and block inclusion. It reads state but never mutates it; nonce, gas, and fee/balance checks remain separate stages. -- **Protocol logs** inject `ActorAuthorized` / `ActorRevoked` / `AccountCreated` / - `DelegationApplied` into the journal from the enshrined apply path +- **Protocol logs** inject `ActorAuthorized` / `ActorRevoked` / `AccountCreated` + into the journal from the enshrined apply path (`AccountConfigurationEvents`), matching the `IAccountConfiguration` event ABIs so indexers can enumerate actors from 8130 transaction receipts. @@ -68,8 +68,7 @@ formula: ```text intrinsic_gas = AA_BASE_COST + tx_payload_cost + nonce_key_cost + bytecode_cost - + account_changes_cost + auto_delegation_cost - + sender_auth_cost + payer_auth_cost + + account_changes_cost + sender_auth_cost + payer_auth_cost ``` | Component | Source | @@ -79,7 +78,6 @@ intrinsic_gas = AA_BASE_COST + tx_payload_cost + nonce_key_cost + bytecode_cost | `nonce_key` | nonce-free `13,000`; otherwise first-use `22,100` / existing `5,000` (a cold SLOAD plus an SSTORE set or reset) | | `bytecode` | per create entry: `32,000 + 200 · code_len` | | `account_changes` | per create entry: one fresh packed `account_state` write plus one fresh `actor_config` slot write per initial actor; policy actors set `policy_commitment`/`policy_manager`, while ungated actors pay the cold zero-to-zero touches that preserve access warming; per config-change entry: a packed `account_state` write covering the sequence advance and lock read — the first access to that slot in the transaction (create bootstrap or first config change) is a cold zero-to-nonzero write, later same-account bumps are only a warm SLOAD + dirty SSTORE (`200`, the slot was already modified earlier in the transaction) — its `auth` cost, and each mutated actor/policy slot; revokes conservatively price all three actor/policy resets, except each revoke slot execution resolves to be an empty zero-to-zero touch is discounted by the reset-vs-cold-noop delta (an inline secp256k1 self revoke discounts its always-empty `actor_config` slot plus each policy slot — `manager`/`commitment` — whose stored value is zero, so three empty slots when ungated and one to three when policy-gated, since the EIP permits a gated actor to carry a zero manager and/or commitment); a self-actor change adds no separate bump — its inline-self write is already covered by the config-change `account_state` cost and its `actor_config(self)` home by the per-change slot cost; per delegation entry: the `4,600` indicator deposit | -| `auto_delegation` | `4,600` when a code-less `sender` EOA is auto-delegated to `DEFAULT_ACCOUNT` | | `sender_auth` / `payer_auth` | authenticator execution gas + one cold config/state SLOAD, plus one cold `policy_manager` SLOAD when the resolved actor has `SCOPE_POLICY`; `payer_auth` is `0` for self-pay | `sender_intrinsic` excludes `payer_auth` (payer authentication is metered on top @@ -116,8 +114,8 @@ sponsored payment requires the payer to cover gas and the sender to cover the tip. The gas and fee layer is pure accounting: it reads no state and runs no EVM. The -state-derived inputs (whether the nonce channel is first-use, whether the sender -is auto-delegated, and whether sender/payer actors are policy-gated) are supplied +state-derived inputs (whether the nonce channel is first-use and whether +sender/payer actors are policy-gated) are supplied by the caller via `IntrinsicGasInput`. It does not advance nonces, debit balances, or execute calls; cold/warm and set/reset refinements that depend on intra-transaction access order are finalized by the diff --git a/crates/execution/eip8130/src/apply.rs b/crates/execution/eip8130/src/apply.rs index d01f0978c0..72ccb8f6e6 100644 --- a/crates/execution/eip8130/src/apply.rs +++ b/crates/execution/eip8130/src/apply.rs @@ -271,9 +271,6 @@ impl DelegationEffect { Bytecode::new_eip7702(self.target) }; sctx.set_code(self.account, code)?; - // Protocol-injected: the Solidity contract never emits this on the EVM - // path; EIP-8130 requires the receipt log for successful delegation updates. - AccountConfigurationEvents::emit_delegation_applied(sctx, self.account, self.target)?; Ok(()) } @@ -1085,7 +1082,7 @@ mod tests { use revm::state::Bytecode; use super::*; - use crate::{AccountCreated, ActorAuthorized, ActorRevoked, DelegationApplied}; + use crate::{AccountCreated, ActorAuthorized, ActorRevoked}; const ACCOUNT: Address = address!("0x00000000000000000000000000000000000000a1"); const K1: Address = Eip8130Constants::K1_AUTHENTICATOR; @@ -2549,7 +2546,7 @@ mod tests { } #[test] - fn delegation_install_emits_delegation_applied() { + fn delegation_install_sets_indicator_without_logs() { let target = Address::repeat_byte(0x33); let mut storage = HashMapStorageProvider::new(1); StorageCtx::enter(&mut storage, |sctx| { @@ -2557,10 +2554,8 @@ mod tests { }) .unwrap(); - let events = storage.get_events(AccountConfigurationStorage::ADDRESS); - assert_eq!(events.len(), 1); - let applied = DelegationApplied::decode_log_data(&events[0]).unwrap(); - assert_eq!(applied.account, ACCOUNT); - assert_eq!(applied.target, target); + let code = storage.get_account_info(ACCOUNT).and_then(|info| info.code.as_ref()); + assert_eq!(code.and_then(Bytecode::eip7702_address), Some(target)); + assert!(storage.get_events(AccountConfigurationStorage::ADDRESS).is_empty()); } } diff --git a/crates/execution/eip8130/src/events.rs b/crates/execution/eip8130/src/events.rs index f27f79f11b..4dee8c11b1 100644 --- a/crates/execution/eip8130/src/events.rs +++ b/crates/execution/eip8130/src/events.rs @@ -3,15 +3,14 @@ //! //! The enshrined apply path mutates `AccountConfiguration` storage outside an //! EVM call frame, so it must inject the same events the Solidity contract -//! would emit (`ActorAuthorized`, `ActorRevoked`, `AccountCreated`) — plus -//! `DelegationApplied`, which the contract documents as protocol-injected only -//! (never emitted on the EVM path). Logs are written to the journal at +//! would emit (`ActorAuthorized`, `ActorRevoked`, `AccountCreated`). Logs are +//! written to the journal at //! [`Eip8130Contracts::ACCOUNT_CONFIG`] and surface in the transaction receipt //! ahead of any `calls` logs. use alloy_primitives::{Address, B256, Bytes}; use alloy_sol_types::{SolEvent, sol}; -use base_precompile_storage::{ContractStorage, Result as StorageResult, StorageCtx}; +use base_precompile_storage::{ContractStorage, Result as StorageResult}; use crate::{AccountConfigurationStorage, ActorConfig}; @@ -24,15 +23,10 @@ sol! { event ActorRevoked(address indexed account, bytes32 indexed actorId); /// Emitted when a counterfactual account is created. event AccountCreated(address indexed account, bytes32 userSalt, bytes32 codeHash); - /// Protocol-injected receipt log for a successful delegation update - /// (not emitted by the Solidity contract on the EVM path). - event DelegationApplied(address indexed account, address target); } } -pub use IAccountConfigurationEvents::{ - AccountCreated, ActorAuthorized, ActorRevoked, DelegationApplied, -}; +pub use IAccountConfigurationEvents::{AccountCreated, ActorAuthorized, ActorRevoked}; /// Helpers for packing and emitting EIP-8130 protocol-injected account-change /// logs. @@ -121,27 +115,6 @@ impl AccountConfigurationEvents { AccountCreated { account, userSalt: user_salt, codeHash: code_hash }.encode_log_data(), ) } - - /// Emits [`DelegationApplied`] from the Account Configuration address. - /// - /// Takes a raw [`StorageCtx`] (unlike the other emit helpers) because the - /// call sites — [`crate::DelegationEffect::install`] and auto-delegation — - /// do not hold an [`AccountConfigurationStorage`] view. The log address is - /// still [`AccountConfigurationStorage::ADDRESS`] so it cannot drift from - /// the other emit helpers. - /// - /// Used for both explicit delegation entries and auto-delegation of a - /// code-less sender to `DEFAULT_ACCOUNT`. - pub fn emit_delegation_applied( - sctx: StorageCtx<'_>, - account: Address, - target: Address, - ) -> StorageResult<()> { - sctx.emit_event( - AccountConfigurationStorage::ADDRESS, - DelegationApplied { account, target }.encode_log_data(), - ) - } } #[cfg(test)] diff --git a/crates/execution/eip8130/src/lib.rs b/crates/execution/eip8130/src/lib.rs index a4bdd448f4..bc93da5c15 100644 --- a/crates/execution/eip8130/src/lib.rs +++ b/crates/execution/eip8130/src/lib.rs @@ -53,9 +53,7 @@ mod validate; pub use validate::{NonceMode, NonceStatus, NonceValidator}; mod events; -pub use events::{ - AccountConfigurationEvents, AccountCreated, ActorAuthorized, ActorRevoked, DelegationApplied, -}; +pub use events::{AccountConfigurationEvents, AccountCreated, ActorAuthorized, ActorRevoked}; mod apply; pub use apply::{ diff --git a/crates/execution/payload/src/affordability.rs b/crates/execution/payload/src/affordability.rs index 0c274448cc..10096c27a1 100644 --- a/crates/execution/payload/src/affordability.rs +++ b/crates/execution/payload/src/affordability.rs @@ -11,20 +11,8 @@ use revm::Database; pub struct CoinbaseTipAffordability; impl CoinbaseTipAffordability { - /// The balance the sender must hold for a phase-0 self-call tip. - /// - /// `tip` is the inner vault transfer. `call_value` is the protocol call's - /// value on that same self-call. The value is checked against the sender - /// and then credited back to the sender before the vault transfer, so both - /// observe one balance and the sender must cover the larger of them. - #[must_use] - pub fn sender_hold(tip: U256, call_value: U256) -> U256 { - tip.max(call_value) - } - /// Returns `true` when `sender` and `payer` cannot cover worst-case gas plus - /// `sender_hold` (see [`Self::sender_hold`]) from the balances currently in - /// `db`. + /// `tip` from the balances currently in `db`. /// /// A failed account read is treated as affordable so a transient DB error /// does not drop an otherwise-valid candidate. @@ -34,7 +22,7 @@ impl CoinbaseTipAffordability { gas_limit: u64, payer_auth: u64, max_fee: u128, - sender_hold: U256, + tip: U256, db: &mut DB, ) -> bool { let Ok(payer_info) = db.basic(payer) else { @@ -56,14 +44,13 @@ impl CoinbaseTipAffordability { gas_limit, payer_auth, max_fee, - sender_hold, + tip, ) .is_err() } /// Returns `true` when the transaction declares a static coinbase tip that - /// the sender and gas payer cannot cover together with worst-case gas and - /// the phase-0 self-call value. + /// the sender and gas payer cannot cover together with worst-case gas. /// /// Transactions without a statically decoded tip are treated as affordable. pub fn unaffordable(tx: &T, payer_auth: u64, db: &mut DB) -> bool @@ -74,29 +61,18 @@ impl CoinbaseTipAffordability { let Some(signed) = tx.as_eip8130() else { return false; }; - let body = signed.tx(); - let Some(tip) = CoinbaseTip::decode(body, tx.sender()) else { - return false; - }; - // `decode` only succeeds for a single phase-0 self-call, which is the - // value the sender must hold alongside the tip. - let Some(call_value) = body.calls.first().and_then(|phase| { - let [call] = phase.as_slice() else { - return None; - }; - Some(call.value) - }) else { + let Some(tip) = CoinbaseTip::decode(signed.tx()) else { return false; }; let sender = tx.sender(); - let payer = body.payer.unwrap_or(sender); + let payer = signed.tx().payer.unwrap_or(sender); Self::unaffordable_tip( sender, payer, tx.gas_limit(), payer_auth, tx.max_fee_per_gas(), - Self::sender_hold(tip, call_value), + tip, db, ) } @@ -158,13 +134,7 @@ mod tests { fn missing_account_cannot_cover_gas_plus_tip() { let mut db = InMemoryDB::default(); assert!(CoinbaseTipAffordability::unaffordable_tip( - SENDER, - SENDER, - 21_000, - 0, - 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), - &mut db + SENDER, SENDER, 21_000, 0, 2, TIP, &mut db )); } @@ -174,13 +144,7 @@ mod tests { // gas = 21_000 * 2 = 42_000; tip = 1_000. fund(&mut db, SENDER, 43_000); assert!(!CoinbaseTipAffordability::unaffordable_tip( - SENDER, - SENDER, - 21_000, - 0, - 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), - &mut db + SENDER, SENDER, 21_000, 0, 2, TIP, &mut db )); } @@ -189,13 +153,7 @@ mod tests { let mut db = InMemoryDB::default(); fund(&mut db, SENDER, 42_999); assert!(CoinbaseTipAffordability::unaffordable_tip( - SENDER, - SENDER, - 21_000, - 0, - 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), - &mut db + SENDER, SENDER, 21_000, 0, 2, TIP, &mut db )); } @@ -205,13 +163,7 @@ mod tests { fund(&mut db, PAYER, 42_000); fund(&mut db, SENDER, 999); assert!(CoinbaseTipAffordability::unaffordable_tip( - SENDER, - PAYER, - 21_000, - 0, - 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), - &mut db + SENDER, PAYER, 21_000, 0, 2, TIP, &mut db )); } @@ -221,78 +173,7 @@ mod tests { fund(&mut db, PAYER, 42_000); fund(&mut db, SENDER, 1_000); assert!(!CoinbaseTipAffordability::unaffordable_tip( - SENDER, - PAYER, - 21_000, - 0, - 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), - &mut db - )); - } - - #[test] - fn self_call_value_within_the_tip_does_not_raise_the_hold() { - let mut db = InMemoryDB::default(); - // gas = 42_000; tip = 1_000; call value = 500. The self-call credits - // the value back before the vault transfer, so gas + tip still covers it. - fund(&mut db, SENDER, 43_000); - assert!(!CoinbaseTipAffordability::unaffordable_tip( - SENDER, - SENDER, - 21_000, - 0, - 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::from(500u64)), - &mut db - )); - } - - #[test] - fn self_call_value_above_the_tip_is_unaffordable() { - let mut db = InMemoryDB::default(); - // gas + tip = 43_000, but the self-call checks 1_500 before the tip moves. - fund(&mut db, SENDER, 43_000); - assert!(CoinbaseTipAffordability::unaffordable_tip( - SENDER, - SENDER, - 21_000, - 0, - 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::from(1_500u64)), - &mut db - )); - } - - #[test] - fn self_call_value_above_the_tip_is_affordable_when_balance_covers_it() { - let mut db = InMemoryDB::default(); - // gas = 42_000; call value = 1_500 > tip. - fund(&mut db, SENDER, 43_500); - assert!(!CoinbaseTipAffordability::unaffordable_tip( - SENDER, - SENDER, - 21_000, - 0, - 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::from(1_500u64)), - &mut db - )); - } - - #[test] - fn sponsored_sender_must_cover_call_value_when_it_exceeds_the_tip() { - let mut db = InMemoryDB::default(); - fund(&mut db, PAYER, 42_000); - fund(&mut db, SENDER, 1_000); - assert!(CoinbaseTipAffordability::unaffordable_tip( - SENDER, - PAYER, - 21_000, - 0, - 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::from(1_500u64)), - &mut db + SENDER, PAYER, 21_000, 0, 2, TIP, &mut db )); } @@ -304,7 +185,7 @@ mod tests { 21_000, 0, 2, - CoinbaseTipAffordability::sender_hold(TIP, U256::ZERO), + TIP, &mut FailingDatabase )); } diff --git a/crates/execution/payload/src/builder.rs b/crates/execution/payload/src/builder.rs index 66cf516f13..b21f79e978 100644 --- a/crates/execution/payload/src/builder.rs +++ b/crates/execution/payload/src/builder.rs @@ -987,8 +987,7 @@ where } let has_validity_predicates = !tx.validity_predicates().is_empty(); - let coinbase_tip = - tx.as_eip8130().and_then(|signed| CoinbaseTip::decode(signed.tx(), tx.sender())); + let coinbase_tip = tx.as_eip8130().and_then(|signed| CoinbaseTip::decode(signed.tx())); let has_coinbase_tip = coinbase_tip.is_some(); if has_validity_predicates { validity_consideration_index += 1; diff --git a/crates/execution/txpool/src/ordering.rs b/crates/execution/txpool/src/ordering.rs index c32ba9cc00..b1f5cc4181 100644 --- a/crates/execution/txpool/src/ordering.rs +++ b/crates/execution/txpool/src/ordering.rs @@ -186,7 +186,7 @@ where }; let predicates = transaction.validity_predicates().len(); if let Some(signed) = transaction.as_eip8130() - && let Some(tip) = CoinbaseTip::decode(signed.tx(), transaction.sender()) + && let Some(tip) = CoinbaseTip::decode(signed.tx()) { return Priority::Value(UnifiedTipPriority::new( tip, @@ -297,14 +297,13 @@ where #[cfg(test)] mod tests { use alloy_eips::eip2718::Encodable2718; - use alloy_primitives::{Address, Bytes, U256}; + use alloy_primitives::{Bytes, U256}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; - use alloy_sol_types::SolCall; use base_common_chains::ChainConfig; use base_common_consensus::{ BasePooledTransaction as ConsensusPooledTransaction, BaseTransactionSigned, Call, - Eip8130Signed, IDefaultAccount, Predeploys, TxEip8130, + Eip8130Signed, Predeploys, TxEip8130, }; use base_test_utils::Account; use reth_primitives_traits::Recovered; @@ -414,12 +413,6 @@ mod tests { BasePooledTransaction::new(recovered, len) } - fn encode_execute(target: Address, value: U256) -> Bytes { - Bytes::from( - IDefaultAccount::executeCall { target, value, data: Default::default() }.abi_encode(), - ) - } - fn eip8130_pooled( max_fee_per_gas: u128, max_priority_fee_per_gas: u128, @@ -429,9 +422,9 @@ mod tests { let signer = PrivateKeySigner::random(); let calls = coinbase_tip.map_or_else(Vec::new, |amount| { vec![vec![Call { - to: signer.address(), - value: U256::ZERO, - data: encode_execute(Predeploys::SEQUENCER_FEE_VAULT, amount), + to: Predeploys::SEQUENCER_FEE_VAULT, + value: amount, + data: Bytes::new(), }]] }); let tx = TxEip8130 { diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index d9d61fe361..97e7a92e07 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -1084,20 +1084,6 @@ where let (nonce_key_first_use, sender_nonce) = self.eip8130_nonce_state(&*state, local_chain_id, now, signed, sender, protocol_nonce)?; - // Pin auto-delegation to the body-derivable worst case - // ([`IntrinsicGasInput::sender_auto_delegated`]), the *same* classifier the - // `eth_estimateGas` estimate uses. It intentionally ignores the sender's - // current on-chain code state: a sender already delegated (has code) at - // admission time may lose its delegation before inclusion (e.g. a native - // EIP-7702 revocation), so always budgeting `DELEGATION_DEPOSIT_COST` in - // `gas_limit` prevents a hard intrinsic-gas error at block production. The - // overestimate is safe: if execution finds the sender already has code, - // `auto_delegate_codeless_sender` is a no-op and the reserved gas flows into - // execution gas instead. Sharing the classifier with estimation keeps - // admission from exceeding the estimate (which would reject a - // `gas_limit == estimate` submission with `GasTooLow`). - let sender_auto_delegated = - IntrinsicGasInput::sender_auto_delegated(&signed.tx().account_changes); let encoded = self.eip8130_encoded(signed); // Admission uses the same safe ceiling as `eth_estimateGas`, so a tx whose // `gas_limit` was set from the estimate is never rejected here and can @@ -1107,11 +1093,7 @@ where let intrinsic = IntrinsicGas::compute( signed, encoded.as_ref(), - &IntrinsicGasInput::worst_case( - nonce_key_first_use, - sender_auto_delegated, - signed.tx().payer.is_some(), - ), + &IntrinsicGasInput::worst_case(nonce_key_first_use, signed.tx().payer.is_some()), ) .map_err(|_| Self::eip8130_error("intrinsic gas computation failed"))?; if intrinsic.execution_gas_available(signed.tx().gas_limit).is_none() { From 8d358639af2e961e490eaa024914bfbedea615d4 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Mon, 28 Sep 2026 13:50:25 +0000 Subject: [PATCH 135/299] fix(batcher): follow the parity validator's sync status in shadow mode (#5356) * fix(batcher): follow the parity validator's sync status in shadow mode * refactor(batcher): hand the parity monitor its provider and tighten the shadow docs --- Cargo.lock | 1 + crates/batcher/cli/README.md | 7 +- crates/batcher/cli/src/cli.rs | 8 +- crates/batcher/core/src/derivation_status.rs | 19 +--- crates/batcher/core/src/driver.rs | 19 ++-- .../batcher/core/src/test_utils/pipeline.rs | 8 +- crates/batcher/core/tests/heads.rs | 8 +- crates/batcher/core/tests/throttle.rs | 6 +- crates/batcher/encoder/src/encoder.rs | 15 +-- crates/batcher/encoder/src/pipeline.rs | 6 +- crates/batcher/service/Cargo.toml | 1 + crates/batcher/service/src/config.rs | 73 +++++++++++- .../service/src/derivation_status_poller.rs | 58 ++++++---- crates/batcher/service/src/service.rs | 106 +++++++++--------- etc/docker/README.md | 3 +- etc/docker/docker-compose.yml | 7 +- 16 files changed, 205 insertions(+), 140 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1742c921f..ecde0e2d30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3131,6 +3131,7 @@ dependencies = [ "httpmock", "jsonrpsee", "metrics", + "rstest", "tokio", "tokio-util", "tracing", diff --git a/crates/batcher/cli/README.md b/crates/batcher/cli/README.md index 526f4f2ca4..64b1dacae9 100644 --- a/crates/batcher/cli/README.md +++ b/crates/batcher/cli/README.md @@ -44,5 +44,8 @@ the shadow submissions. Do not add permanent production consensus bypass logic just to support this rollout. Shadow deployments use an isolated parity validator to derive their submitted -data. The batcher compares its derived L2 block hashes with the canonical -sequencer through `--parity-validator-l2-rpc-url`. +data. `--rollup-rpc-url` points at the validator's rollup node, because the +batcher follows the derivation of the inbox it posts to. The batcher refuses to +start if that node derives another inbox. `--parity-validator-l2-rpc-url` points +at the validator's L2 RPC, whose derived blocks the batcher compares with the +canonical sequencer's. diff --git a/crates/batcher/cli/src/cli.rs b/crates/batcher/cli/src/cli.rs index d4bf050f4e..e3f3715079 100644 --- a/crates/batcher/cli/src/cli.rs +++ b/crates/batcher/cli/src/cli.rs @@ -46,13 +46,17 @@ pub struct BatcherArgs { /// Parity validator L2 RPC endpoint for shadow mode. /// /// Required with `--dangerously-override-batch-inbox-address` and rejected - /// without it. Its safe L2 head anchors shadow batcher recovery, and its - /// derived block hashes are compared with the sequencer. + /// without it. The validator's derived block hashes are compared with the + /// sequencer's. #[arg(long = "parity-validator-l2-rpc-url", env = "BASE_BATCHER_PARITY_VALIDATOR_L2_RPC_URL")] pub parity_validator_l2_rpc_url: Option, /// Rollup node RPC endpoint(s). /// + /// The batcher reads the rollup config of this node and follows its + /// derivation, so the node must derive the inbox the batcher posts to. In + /// shadow mode it is the parity validator's rollup node. + /// /// Accepts a comma-separated list with the same connection-time failover /// semantics as `--l1-rpc-url`. #[arg( diff --git a/crates/batcher/core/src/derivation_status.rs b/crates/batcher/core/src/derivation_status.rs index f1aef9f4ad..8f9ca2ee10 100644 --- a/crates/batcher/core/src/derivation_status.rs +++ b/crates/batcher/core/src/derivation_status.rs @@ -5,20 +5,9 @@ use base_protocol::BlockInfo; /// A coherent snapshot of the derivation progress relevant to the batcher. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DerivationStatus { - /// The trusted safe L2 head reported by the derivation provider. + /// The safe L2 head derivation has reached. pub safe_l2: BlockInfo, - /// The L1 block currently being processed by derivation, when available. - pub current_l1: Option, -} - -impl DerivationStatus { - /// Creates a status with both the safe L2 head and derivation cursor. - pub const fn new(safe_l2: BlockInfo, current_l1: BlockInfo) -> Self { - Self { safe_l2, current_l1: Some(current_l1) } - } - - /// Creates a status for a provider that only exposes its safe L2 head. - pub const fn from_safe_l2(safe_l2: BlockInfo) -> Self { - Self { safe_l2, current_l1: None } - } + /// The L1 block derivation is processing. Every earlier L1 block, and the batcher data it + /// carried, has been read. + pub current_l1: BlockInfo, } diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 3f6b09b8d4..76f7776144 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -297,10 +297,7 @@ where return; } - match self - .pipeline - .reconcile_derivation(head, status.current_l1.map(|current_l1| current_l1.number)) - { + match self.pipeline.reconcile_derivation(head, status.current_l1.number) { DerivationReconciliation::Consistent => {} DerivationReconciliation::SafeHeadMismatch => { warn!( @@ -312,7 +309,7 @@ where } DerivationReconciliation::StalledChannel => { warn!( - current_l1 = ?status.current_l1.map(|current_l1| current_l1.number), + current_l1 = %status.current_l1.number, safe_l2 = %head.number, "rollup node passed a fully confirmed channel without deriving it, resetting pipeline" ); @@ -798,7 +795,11 @@ mod tests { let mut queued = queued_driver(ctx.clone(), [6], [], ImmediateConfirmTxManager { l1_block: 42 }); queued.driver.pipeline.submissions.push_back(SubmissionStub::stub()); - queued.status_tx.send(DerivationStatus::from_safe_l2(safe_head(5))).await.unwrap(); + queued + .status_tx + .send(DerivationStatus { safe_l2: safe_head(5), current_l1: safe_head(1) }) + .await + .unwrap(); let handle = ctx.spawn(queued.driver.run()); ctx.sleep(Duration::from_millis(10)).await; @@ -822,7 +823,11 @@ mod tests { Runner::start(Config::seeded(0), |ctx| async move { let queued = queued_driver(ctx.clone(), [], [9], ImmediateConfirmTxManager { l1_block: 1 }); - queued.status_tx.send(DerivationStatus::from_safe_l2(safe_head(5))).await.unwrap(); + queued + .status_tx + .send(DerivationStatus { safe_l2: safe_head(5), current_l1: safe_head(1) }) + .await + .unwrap(); let handle = ctx.spawn(queued.driver.run()); ctx.sleep(Duration::from_millis(10)).await; diff --git a/crates/batcher/core/src/test_utils/pipeline.rs b/crates/batcher/core/src/test_utils/pipeline.rs index 70ac568213..7584a37054 100644 --- a/crates/batcher/core/src/test_utils/pipeline.rs +++ b/crates/batcher/core/src/test_utils/pipeline.rs @@ -172,11 +172,7 @@ impl BatchPipeline for TrackingPipeline { } } - fn reconcile_derivation( - &mut self, - safe_l2: BlockInfo, - _: Option, - ) -> DerivationReconciliation { + fn reconcile_derivation(&mut self, safe_l2: BlockInfo, _: u64) -> DerivationReconciliation { let mut recorded = self.recorded.lock().unwrap(); recorded.safe_numbers.push(safe_l2.number); recorded.calls.push(PipelineCall::ReconcileDerivation); @@ -240,7 +236,7 @@ impl BatchPipeline for ReorgPipeline { Ok(()) } fn advance_l1_head(&mut self, _: u64) {} - fn reconcile_derivation(&mut self, _: BlockInfo, _: Option) -> DerivationReconciliation { + fn reconcile_derivation(&mut self, _: BlockInfo, _: u64) -> DerivationReconciliation { DerivationReconciliation::Consistent } diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index 1d02f44123..1d8f0b6ac2 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -72,9 +72,9 @@ fn test_safe_head_conflicts_reset_pipeline_and_source() { let regressed = safe_head(5); let replacement = BlockInfo { hash: B256::repeat_byte(0xff), number: 5, ..Default::default() }; - status_tx.send(DerivationStatus::from_safe_l2(regressed)).await.unwrap(); - status_tx.send(DerivationStatus::from_safe_l2(replacement)).await.unwrap(); - status_tx.send(DerivationStatus::from_safe_l2(safe_head(10))).await.unwrap(); + for safe_l2 in [regressed, replacement, safe_head(10)] { + status_tx.send(DerivationStatus { safe_l2, current_l1: safe_head(1) }).await.unwrap(); + } ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -102,7 +102,7 @@ fn test_derivation_cursor_advance_replays_stalled_channel() { let handle = ctx.spawn(driver.run()); let status_tx = handles.derivation_status_tx; - status_tx.send(DerivationStatus::new(safe_l2, safe_head(50))).await.unwrap(); + status_tx.send(DerivationStatus { safe_l2, current_l1: safe_head(50) }).await.unwrap(); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); diff --git a/crates/batcher/core/tests/throttle.rs b/crates/batcher/core/tests/throttle.rs index a9d3cec9fe..8a879b4a74 100644 --- a/crates/batcher/core/tests/throttle.rs +++ b/crates/batcher/core/tests/throttle.rs @@ -194,11 +194,7 @@ fn test_throttle_transitions_from_active_to_inactive() { Ok(()) } fn advance_l1_head(&mut self, _: u64) {} - fn reconcile_derivation( - &mut self, - _: BlockInfo, - _: Option, - ) -> DerivationReconciliation { + fn reconcile_derivation(&mut self, _: BlockInfo, _: u64) -> DerivationReconciliation { DerivationReconciliation::Consistent } fn reset(&mut self) {} diff --git a/crates/batcher/encoder/src/encoder.rs b/crates/batcher/encoder/src/encoder.rs index 843f8c5f2d..37562b70f1 100644 --- a/crates/batcher/encoder/src/encoder.rs +++ b/crates/batcher/encoder/src/encoder.rs @@ -635,12 +635,12 @@ impl BatchPipeline for BatchEncoder { fn reconcile_derivation( &mut self, safe_l2: BlockInfo, - current_l1: Option, + current_l1: u64, ) -> DerivationReconciliation { if !self.prune_safe(safe_l2) { return DerivationReconciliation::SafeHeadMismatch; } - if current_l1.is_some_and(|current_l1| self.is_derivation_stalled(current_l1, safe_l2)) { + if self.is_derivation_stalled(current_l1, safe_l2) { return DerivationReconciliation::StalledChannel; } DerivationReconciliation::Consistent @@ -1734,24 +1734,19 @@ mod tests { let previous_safe_l2 = BlockInfo { number: 100, ..Default::default() }; assert_eq!( - encoder.reconcile_derivation(previous_safe_l2, None), - DerivationReconciliation::Consistent, - "providers without a derivation cursor cannot prove the channel stalled", - ); - assert_eq!( - encoder.reconcile_derivation(previous_safe_l2, Some(1_000)), + encoder.reconcile_derivation(previous_safe_l2, 1_000), DerivationReconciliation::Consistent, "the current L1 block may still be processing", ); assert_eq!( - encoder.reconcile_derivation(previous_safe_l2, Some(1_001)), + encoder.reconcile_derivation(previous_safe_l2, 1_001), DerivationReconciliation::StalledChannel, "passing the last inclusion without making the channel safe requires replay", ); assert_eq!( encoder.reconcile_derivation( BlockInfo { hash: block_hash, number: 101, ..Default::default() }, - Some(1_001), + 1_001, ), DerivationReconciliation::Consistent, "a safe head covering the channel does not require replay", diff --git a/crates/batcher/encoder/src/pipeline.rs b/crates/batcher/encoder/src/pipeline.rs index 6e853af9f6..1189765423 100644 --- a/crates/batcher/encoder/src/pipeline.rs +++ b/crates/batcher/encoder/src/pipeline.rs @@ -104,12 +104,12 @@ pub trait BatchPipeline: Send { /// Prune blocks at or below `safe_l2`. /// /// [`DerivationReconciliation::SafeHeadMismatch`] if the head is not on the buffered chain. - /// [`DerivationReconciliation::StalledChannel`] if `current_l1` (derivation cursor) passed a fully - /// confirmed channel whose tail is not yet safe. `None` skips that check. + /// [`DerivationReconciliation::StalledChannel`] if `current_l1`, the L1 block derivation is + /// processing, passed a fully confirmed channel whose tail is not yet safe. fn reconcile_derivation( &mut self, safe_l2: BlockInfo, - current_l1: Option, + current_l1: u64, ) -> DerivationReconciliation; /// Estimated DA bytes still awaiting confirmation. diff --git a/crates/batcher/service/Cargo.toml b/crates/batcher/service/Cargo.toml index f5ddcae806..b1e507f5a3 100644 --- a/crates/batcher/service/Cargo.toml +++ b/crates/batcher/service/Cargo.toml @@ -41,6 +41,7 @@ alloy-primitives = { workspace = true, features = ["serde", "std"] } jsonrpsee = { workspace = true, features = ["http-client", "macros"] } [dev-dependencies] +rstest.workspace = true httpmock.workspace = true alloy-node-bindings.workspace = true base-runtime = { workspace = true, features = ["test-utils"] } diff --git a/crates/batcher/service/src/config.rs b/crates/batcher/service/src/config.rs index fb2647daec..8c60d52b03 100644 --- a/crates/batcher/service/src/config.rs +++ b/crates/batcher/service/src/config.rs @@ -39,11 +39,15 @@ pub struct BatcherConfig { /// Parity validator L2 RPC endpoint for shadow mode. /// /// Required with [`batch_inbox_override`](Self::batch_inbox_override) and - /// rejected without it. Its safe L2 head anchors shadow batcher recovery, - /// and its derived block hashes are compared with the sequencer. + /// rejected without it. The validator's derived block hashes are compared + /// with the sequencer's. pub parity_validator_l2_rpc_url: Option, /// Rollup node RPC endpoint(s). /// + /// The batcher reads the rollup config of this node and follows its derivation, so the + /// node must derive the inbox the batcher posts to. In shadow mode it is the parity + /// validator's rollup node. + /// /// Same connection-time failover semantics as [`l1_rpc_url`](Self::l1_rpc_url). /// Must be non-empty. pub rollup_rpc_url: Vec, @@ -58,10 +62,9 @@ pub struct BatcherConfig { pub metrics_enabled: bool, /// Dangerous shadow-mode batch inbox override. /// - /// When set, the batcher still reads the canonical rollup config from the rollup - /// RPC, but submits L1 transactions to this address instead of - /// `rollup_config.batch_inbox_address`. This is only intended for explicit - /// shadow deployments; canonical deployments must leave it unset. + /// When set, the batcher submits L1 transactions to this address instead of the + /// canonical inbox. This is only intended for explicit shadow deployments. Canonical + /// deployments must leave it unset. pub batch_inbox_override: Option
, /// L2 block polling interval. pub poll_interval: Duration, @@ -137,3 +140,61 @@ impl Default for BatcherConfig { } } } + +impl BatcherConfig { + /// Returns the inbox the batcher posts to, [`batch_inbox_override`](Self::batch_inbox_override) + /// when set and otherwise `derived_inbox`, the inbox the rollup node derives. + /// + /// # Errors + /// + /// Returns an error when the override differs from `derived_inbox`, because the batcher + /// follows the derivation of the [`rollup_rpc_url`](Self::rollup_rpc_url) node. + pub fn batch_inbox(&self, derived_inbox: Address) -> eyre::Result
{ + match self.batch_inbox_override { + Some(inbox) if inbox != derived_inbox => eyre::bail!( + "the rollup node derives inbox {derived_inbox} instead of the shadow inbox \ + {inbox}, point the rollup RPC at the parity validator's rollup node" + ), + Some(inbox) => Ok(inbox), + None => Ok(derived_inbox), + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + const CANONICAL_INBOX: Address = Address::repeat_byte(0xca); + const SHADOW_INBOX: Address = Address::repeat_byte(0x5a); + + #[rstest] + #[case::canonical(None, CANONICAL_INBOX)] + #[case::shadow(Some(SHADOW_INBOX), SHADOW_INBOX)] + fn batch_inbox_is_the_inbox_the_rollup_node_derives( + #[case] batch_inbox_override: Option
, + #[case] derived_inbox: Address, + ) { + let config = BatcherConfig { batch_inbox_override, ..BatcherConfig::default() }; + + assert_eq!(config.batch_inbox(derived_inbox).unwrap(), derived_inbox); + } + + #[test] + fn batch_inbox_rejects_a_rollup_node_that_derives_another_inbox() { + let config = + BatcherConfig { batch_inbox_override: Some(SHADOW_INBOX), ..BatcherConfig::default() }; + + let error = config.batch_inbox(CANONICAL_INBOX).unwrap_err(); + + assert_eq!( + error.to_string(), + format!( + "the rollup node derives inbox {CANONICAL_INBOX} instead of the shadow inbox \ + {SHADOW_INBOX}, point the rollup RPC at the parity validator's rollup node" + ) + ); + } +} diff --git a/crates/batcher/service/src/derivation_status_poller.rs b/crates/batcher/service/src/derivation_status_poller.rs index f24069e0ae..1818ef26ac 100644 --- a/crates/batcher/service/src/derivation_status_poller.rs +++ b/crates/batcher/service/src/derivation_status_poller.rs @@ -2,7 +2,6 @@ use std::{future::Future, time::Duration}; -use alloy_rpc_types_eth::BlockNumberOrTag; use base_batcher_core::DerivationStatus; use base_consensus_rpc::RollupNodeApiClient; use base_protocol::BlockInfo; @@ -12,7 +11,7 @@ use tracing::warn; /// Fetches the derivation progress relevant to the batcher. pub trait DerivationStatusProvider: Send + Sync + 'static { - /// Return the current safe L2 head and derivation cursor, when available. + /// Return the safe L2 head and the L1 block derivation is processing. fn derivation_status( &self, ) -> impl Future>> @@ -20,36 +19,23 @@ pub trait DerivationStatusProvider: Send + Sync + 'static { + '_; } +/// Reads the derivation status from a rollup node's `optimism_syncStatus`. impl DerivationStatusProvider for jsonrpsee::http_client::HttpClient { async fn derivation_status( &self, ) -> Result> { let status = self.sync_status().await?; - Ok(DerivationStatus::new(status.local_safe_l2.block_info, status.current_l1)) - } -} - -impl DerivationStatusProvider for crate::RpcL2BlockProvider { - async fn derivation_status( - &self, - ) -> Result> { - let block = - self.provider.get_block_by_number(BlockNumberOrTag::Safe).await?.ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - "safe L2 block unavailable from parity validator", - ) - })?; - Ok(DerivationStatus::from_safe_l2(BlockInfo { - hash: block.header.hash, - number: block.header.number, - parent_hash: block.header.parent_hash, - timestamp: block.header.timestamp, - })) + Ok(DerivationStatus { + safe_l2: status.local_safe_l2.block_info, + current_l1: status.current_l1, + }) } } /// 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. #[derive(Debug)] pub struct DerivationStatusPoller { provider: C, @@ -87,6 +73,9 @@ 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 != self.last_status => { tokio::select! { biased; @@ -143,7 +132,7 @@ mod tests { } fn status(safe_l2: u64, current_l1: u64) -> DerivationStatus { - DerivationStatus::new(head(safe_l2), head(current_l1)) + DerivationStatus { safe_l2: head(safe_l2), current_l1: head(current_l1) } } #[test] @@ -173,4 +162,25 @@ mod tests { handle.await.unwrap(); }); } + + /// The poller skips the empty safe head a starting node reports until its engine is + /// bootstrapped. + #[test] + fn skips_a_status_without_a_safe_head() { + 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 provider = MockProvider { + statuses: Mutex::new(VecDeque::from([starting, starting])), + fallback: status(10, 2), + }; + let poller = + DerivationStatusPoller::new(provider, Duration::from_secs(1), status(5, 1), tx); + let handle = ctx.spawn(poller.run(ctx.clone())); + + assert_eq!(rx.recv().await, Some(status(10, 2))); + ctx.cancel(); + handle.await.unwrap(); + }); + } } diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index 08f7345dd6..d02a3cb19b 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -407,14 +407,8 @@ impl BatcherService { eyre::bail!("check_recent_txs_depth requires wait_node_sync"); } match (self.config.batch_inbox_override, self.config.parity_validator_l2_rpc_url.as_ref()) { - (None, Some(_)) => { - eyre::bail!("parity validator L2 RPC URL requires shadow mode") - } - (Some(_), None) => { - eyre::bail!( - "shadow mode requires a parity validator L2 RPC URL for its safe L2 head" - ) - } + (None, Some(_)) => eyre::bail!("parity validator L2 RPC URL requires shadow mode"), + (Some(_), None) => eyre::bail!("shadow mode requires a parity validator L2 RPC URL"), _ => {} } @@ -478,19 +472,11 @@ impl BatcherService { }) .await?; let rollup_config = Arc::new(rollup_config); - let effective_batch_inbox = - self.config.batch_inbox_override.unwrap_or(rollup_config.batch_inbox_address); + let batch_inbox = self.config.batch_inbox(rollup_config.batch_inbox_address)?; if self.config.batch_inbox_override.is_some() { - warn!( - configured_inbox = %effective_batch_inbox, - rollup_config_inbox = %rollup_config.batch_inbox_address, - "using dangerous shadow batch inbox override" - ); + warn!(inbox = %batch_inbox, "using dangerous shadow batch inbox override"); } else { - info!( - inbox = %effective_batch_inbox, - "rollup config loaded" - ); + info!(inbox = %batch_inbox, "rollup config loaded"); } let validator_provider = if let Some(url) = &self.config.parity_validator_l2_rpc_url { @@ -555,17 +541,11 @@ impl BatcherService { Self::rpc_retry("l1-head", retry, rpc_timeout, || l1_provider.get_block_number()) .await?; - let initial_derivation_status = if let Some(provider) = validator_provider.as_ref() { - Self::rpc_retry("parity-validator-safe-l2", retry, rpc_timeout, || { - provider.derivation_status() - }) - .await? - } else { + let initial_derivation_status = Self::rpc_retry("optimism_syncStatus", retry, rpc_timeout, || { rollup_client.derivation_status() }) - .await? - }; + .await?; let safe_l2 = initial_derivation_status.safe_l2; if safe_l2 == BlockInfo::default() { eyre::bail!("safe L2 head is empty"); @@ -608,10 +588,10 @@ impl BatcherService { ); } - if let Some(validator_provider) = validator_provider.as_ref() { + if let Some(validator_provider) = validator_provider { let handle = L2BlockParityMonitor::new( RpcL2BlockProvider::new(Arc::clone(&l2_provider)), - validator_provider.clone(), + validator_provider, L2BlockParityMonitorConfig::new( safe_l2.number.saturating_add(1), self.config.poll_interval, @@ -687,30 +667,15 @@ impl BatcherService { let (derivation_status_tx, derivation_status_rx) = mpsc::channel(1); - // Canonical mode follows the rollup node's LocalSafeL2. Shadow mode - // follows the parity validator's safe label so canonical DA progress - // cannot cause shadow-only gaps to be skipped. - let derivation_status_handle = if let Some(provider) = validator_provider { - tokio::spawn( - DerivationStatusPoller::new( - provider, - self.config.poll_interval, - initial_derivation_status, - derivation_status_tx, - ) - .run(runtime.clone()), - ) - } else { - tokio::spawn( - DerivationStatusPoller::new( - rollup_client, - self.config.poll_interval, - initial_derivation_status, - derivation_status_tx, - ) - .run(runtime.clone()), + let derivation_status_handle = tokio::spawn( + DerivationStatusPoller::new( + rollup_client, + self.config.poll_interval, + initial_derivation_status, + derivation_status_tx, ) - }; + .run(runtime.clone()), + ); background_tasks.push(("derivation status poller", derivation_status_handle)); // Build the driver. @@ -720,7 +685,7 @@ impl BatcherService { encoder, tx_manager, base_batcher_core::BatchDriverConfig { - inbox: effective_batch_inbox, + inbox: batch_inbox, max_pending_transactions: self.config.max_pending_transactions, drain_timeout, force_blobs_when_throttling: self.config.force_blobs_when_throttling, @@ -756,6 +721,8 @@ mod tests { use std::sync::atomic::{AtomicU8, Ordering}; use alloy_node_bindings::Anvil; + use alloy_primitives::Address; + use rstest::rstest; use super::*; @@ -807,6 +774,39 @@ mod tests { ); } + /// Shadow mode requires the parity validator L2 RPC URL and canonical mode rejects it. + #[rstest] + #[case::shadow_without_url( + Some(Address::ZERO), + None, + "shadow mode requires a parity validator L2 RPC URL" + )] + #[case::canonical_with_url( + None, + Some("http://127.0.0.1:1"), + "parity validator L2 RPC URL requires shadow mode" + )] + #[tokio::test] + async fn setup_rejects_a_parity_validator_url_that_does_not_match_shadow_mode( + #[case] batch_inbox_override: Option
, + #[case] parity_validator_l2_rpc_url: Option<&str>, + #[case] expected: &str, + ) { + let config = BatcherConfig { + batch_inbox_override, + parity_validator_l2_rpc_url: parity_validator_l2_rpc_url + .map(|url| url.parse().unwrap()), + ..BatcherConfig::default() + }; + + let error = BatcherService::new(config) + .setup(TokioRuntime::new()) + .await + .expect_err("a batcher whose endpoints do not match its mode must not start"); + + assert_eq!(error.to_string(), expected); + } + #[tokio::test] async fn l1_head_stream_outlives_its_builder() { let anvil = Anvil::new().spawn(); diff --git a/etc/docker/README.md b/etc/docker/README.md index c2e94897dc..17e513f9b9 100644 --- a/etc/docker/README.md +++ b/etc/docker/README.md @@ -91,7 +91,8 @@ infrastructure: - **Shadow DA — `base-batcher`.** The Rust `base batcher` runs in `--shadow-mode`, posting to `SHADOW_BATCH_INBOX_ADDRESS` from `SHADOW_BATCHER_ADDR` — a distinct, funded dev account, so its L1 nonces never collide with the op-batcher's. It - anchors recovery on the shadow validator via `--parity-validator-l2-rpc-url`. + follows the shadow validator's derivation through `--rollup-rpc-url` and compares + blocks with it through `--parity-validator-l2-rpc-url`. - **Shadow validator — `base-shadow-validator`.** A validator-mode `rpc` node that overrides the batch inbox and batcher sender (`--l1.dangerously-override-da-batch-inbox`, diff --git a/etc/docker/docker-compose.yml b/etc/docker/docker-compose.yml index dfccb1e03f..7c4298767d 100644 --- a/etc/docker/docker-compose.yml +++ b/etc/docker/docker-compose.yml @@ -397,7 +397,8 @@ services: # Shadow DA: the Rust base batcher runs in --shadow-mode alongside the # op-batcher, posting to SHADOW_BATCH_INBOX_ADDRESS from SHADOW_BATCHER_ADDR # (a distinct L1 sender, so its nonces never collide with the op-batcher's). - # It anchors recovery on base-shadow-validator via --parity-validator-l2-rpc-url. + # It follows the derivation of base-shadow-validator, whose rollup node derives the + # shadow inbox, and compares its blocks with the builder's. base-batcher: image: base:local build: @@ -410,8 +411,10 @@ services: depends_on: base-builder: condition: service_healthy + base-shadow-validator: + condition: service_healthy environment: - - BASE_BATCHER_ROLLUP_RPC_URL=http://base-builder:${L2_BUILDER_CL_RPC_PORT} + - BASE_BATCHER_ROLLUP_RPC_URL=http://base-shadow-validator:${L2_SHADOW_CL_RPC_PORT} command: - batcher - --l1-rpc-url=http://l1-el:${L1_HTTP_PORT} From 3a3c3818bf7ff234725b04fcb8f1a0fe96eddb97 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Mon, 28 Sep 2026 13:54:52 +0000 Subject: [PATCH 136/299] fix(tx-manager): keep waiting for a receipt when a fee bump fails (#5355) * fix(tx-manager): keep waiting for a receipt when a fee bump fails * docs(tx-manager): tighten the comments of the fee bump fix * docs(tx-manager): say that sends ignore the confirmation timeout * refactor(tx-manager): tighten the fee bump log and receipt polling docs --- crates/utilities/tx-manager/src/config.rs | 55 ++- crates/utilities/tx-manager/src/macros.rs | 6 +- crates/utilities/tx-manager/src/manager.rs | 366 +++--------------- crates/utilities/tx-manager/src/send_state.rs | 2 +- .../utilities/tx-manager/tests/common/mod.rs | 54 ++- .../tx-manager/tests/send_lifecycle.rs | 110 +++++- 6 files changed, 275 insertions(+), 318 deletions(-) diff --git a/crates/utilities/tx-manager/src/config.rs b/crates/utilities/tx-manager/src/config.rs index 98f16f2c70..8f342e9ef0 100644 --- a/crates/utilities/tx-manager/src/config.rs +++ b/crates/utilities/tx-manager/src/config.rs @@ -123,7 +123,10 @@ pub struct TxManagerConfig { pub tx_send_timeout: Duration, /// Mempool appearance timeout (zero = disabled). pub tx_not_in_mempool_timeout: Duration, - /// Maximum time to poll for a transaction that is not mined before giving up. + /// Maximum time [`wait_mined`](crate::SimpleTxManager::wait_mined) and + /// [`wait_for_tx`](crate::SimpleTxManager::wait_for_tx) poll for a transaction that is + /// not mined. `send` and `send_async` ignore it and poll every version they publish + /// until the send ends. pub confirmation_timeout: Duration, /// Minimum blob base fee (in wei) to use for blob transactions. pub min_blob_fee: u128, @@ -156,7 +159,7 @@ impl TxManagerConfig { /// /// # Errors /// - /// Returns [`ConfigError::OutOfRange`] if any required field is zero: + /// Returns [`ConfigError::OutOfRange`] if a field is out of range: /// - `num_confirmations` must be >= 1 /// - `safe_abort_nonce_too_low_count` must be >= 1 /// - `fee_limit_multiplier` must be >= 1 @@ -167,6 +170,9 @@ impl TxManagerConfig { /// - `publish_retry_delay` must be > 0 /// - `receipt_query_interval` must be > 0 /// - `confirmation_timeout` must be > 0 + /// - `(safe_abort_nonce_too_low_count - 1) * resubmission_timeout` must exceed + /// `receipt_query_interval`, so a receipt poll runs before a send aborts on + /// refused fee bumps pub fn validate(&self) -> Result<(), ConfigError> { macro_rules! reject_zero { ($($field:ident),+ $(,)?) => {$( @@ -206,6 +212,22 @@ impl TxManagerConfig { confirmation_timeout, ); reject_zero!(min_blob_fee); + let refusals_before_abort = + u32::try_from(self.safe_abort_nonce_too_low_count - 1).unwrap_or(u32::MAX); + if self.resubmission_timeout.saturating_mul(refusals_before_abort) + <= self.receipt_query_interval + { + return Err(ConfigError::OutOfRange { + field: "safe_abort_nonce_too_low_count", + constraint: "(count - 1) * resubmission_timeout > receipt_query_interval", + value: format!( + "count {}, resubmission_timeout {:?}, receipt_query_interval {:?}", + self.safe_abort_nonce_too_low_count, + self.resubmission_timeout, + self.receipt_query_interval + ), + }); + } Ok(()) } } @@ -310,6 +332,35 @@ mod tests { ); } + #[rstest] + #[case::one_refusal(1, 48, 12, false)] + #[case::polls_at_the_abort(3, 6, 12, false)] + #[case::polls_before_the_abort(3, 7, 12, true)] + fn validation_requires_a_receipt_poll_before_an_abort( + #[case] safe_abort_nonce_too_low_count: u64, + #[case] resubmission_secs: u64, + #[case] receipt_query_secs: u64, + #[case] valid: bool, + ) { + let config = TxManagerConfig { + safe_abort_nonce_too_low_count, + resubmission_timeout: Duration::from_secs(resubmission_secs), + receipt_query_interval: Duration::from_secs(receipt_query_secs), + ..TxManagerConfig::default() + }; + let result = config.validate(); + assert_eq!(result.is_ok(), valid, "{result:?}"); + if let Err(err) = result { + assert!( + matches!( + err, + ConfigError::OutOfRange { field: "safe_abort_nonce_too_low_count", .. } + ), + "expected OutOfRange for safe_abort_nonce_too_low_count, got: {err}" + ); + } + } + #[test] fn validation_rejects_zero_min_blob_fee() { let config = TxManagerConfig { min_blob_fee: 0, ..TxManagerConfig::default() }; diff --git a/crates/utilities/tx-manager/src/macros.rs b/crates/utilities/tx-manager/src/macros.rs index bed9e8d4da..850aa9b0ee 100644 --- a/crates/utilities/tx-manager/src/macros.rs +++ b/crates/utilities/tx-manager/src/macros.rs @@ -173,8 +173,10 @@ macro_rules! define_tx_manager_cli { )] pub tx_not_in_mempool_timeout: ::std::time::Duration, - /// Maximum time to poll for a transaction that is not mined before - /// giving up (e.g., "5m", "300s"). + /// Maximum time `wait_mined` and `wait_for_tx` poll for a + /// transaction that is not mined (e.g., "5m", "300s"). `send` and + /// `send_async` ignore it and poll every version they publish until + /// the send ends. #[arg( long = "tx-manager.confirmation-timeout", env = concat!($prefix, "_", "CONFIRMATION_TIMEOUT"), diff --git a/crates/utilities/tx-manager/src/manager.rs b/crates/utilities/tx-manager/src/manager.rs index 759cfb347a..f884d36a20 100644 --- a/crates/utilities/tx-manager/src/manager.rs +++ b/crates/utilities/tx-manager/src/manager.rs @@ -113,9 +113,8 @@ impl PreparedTx { /// Mutable fee-bump state tracked across iterations in the send loop. /// -/// Groups the values that [`SimpleTxManager::try_fee_bump`] and -/// [`SimpleTxManager::apply_bump_result`] update on each successful -/// fee bump so they can be passed as a single argument. +/// Groups the values that [`SimpleTxManager::try_fee_bump`] updates on each +/// successful fee bump so they can be passed as a single argument. #[derive(Debug)] struct BumpState { /// Current maximum priority fee per gas (tip). @@ -1147,11 +1146,7 @@ where // for the next resubmission timer tick. take_bump_fees clears // the flag atomically so a failed bump does not re-trigger. if send_state.take_bump_fees() { - if let Some(abort) = - self.try_fee_bump(candidate, send_state, &receipt_tx, &mut bump).await - { - return Err(abort); - } + self.try_fee_bump(candidate, send_state, &receipt_tx, &mut bump).await; // Reset the bump ticker so we get a full interval // before the next timer-driven bump. bump_ticker = self.runtime.interval(self.config.resubmission_timeout); @@ -1187,11 +1182,7 @@ where }; // Bump tick fired — run fee bump logic. - if let Some(abort) = - self.try_fee_bump(candidate, send_state, &receipt_tx, &mut bump).await - { - return Err(abort); - } + self.try_fee_bump(candidate, send_state, &receipt_tx, &mut bump).await; bump_ticker = self.runtime.interval(self.config.resubmission_timeout); bump_ticker.next().await; } @@ -1199,65 +1190,29 @@ where /// Performs a fee bump attempt and applies the result to the tracked state. /// - /// Returns `Some(error)` if the send loop must abort, `None` to continue. - /// - /// When a bump attempt yields a non-retryable error but the - /// receipt-polling task has already recorded a mined tx on - /// `send_state`, the abort is suppressed so the send loop can - /// deliver the receipt on the next iteration. This avoids a - /// false-negative failure report when, for example, a fee bump - /// publish gets `NonceTooLow` because the original tx was already - /// confirmed. + /// A failed bump is logged and the send keeps waiting, because a version published + /// earlier may still be mined. Aborting is left to [`SendState::critical_error_at`]. async fn try_fee_bump( &self, candidate: &TxCandidate, send_state: &Arc, receipt_tx: &mpsc::Sender, state: &mut BumpState, - ) -> Option { - // If the receipt-polling task has already detected the original tx + ) { + // If a receipt-polling task has already detected a published version // on-chain, skip the fee bump entirely. This avoids a race where // `estimate_gas` on the replacement tx reverts because the - // original calldata has already executed (e.g. `GameAlreadyExists`), + // calldata has already executed (e.g. `GameAlreadyExists`), // producing noisy error logs even though the proposal will succeed. if send_state.is_waiting_for_confirmation() { - info!("skipping fee bump — original tx already mined, awaiting confirmation"); - return None; + info!("skipping fee bump, a published tx is already mined and awaits confirmation"); + return; } - let result = self.handle_fee_bump(candidate, send_state, receipt_tx, state).await; - Self::apply_bump_result_with_suppression(result, state, send_state) - } - - /// Applies a fee bump result with mined-tx suppression. - /// - /// If the bump failed with a non-retryable error *but* the original - /// tx was already mined ([`SendState::is_waiting_for_confirmation`]), - /// the error is suppressed and logged at `info` level (not `error`). - /// This prevents false-positive alerts in the benign "tx confirmed - /// while we were bumping" case. - /// - /// On success or retryable error, delegates directly to - /// [`apply_bump_result`](Self::apply_bump_result). - fn apply_bump_result_with_suppression( - result: TxManagerResult, - state: &mut BumpState, - send_state: &SendState, - ) -> Option { - // Check for mined-tx suppression *before* apply_bump_result so - // the `error!` log inside that function is never emitted for the - // benign "tx already confirmed" case. - if let Err(ref e) = result - && !e.is_retryable() - && send_state.is_waiting_for_confirmation() - { - info!( - error = %e, - "fee bump failed but original tx already mined, suppressing abort" - ); - return None; + match self.handle_fee_bump(candidate, send_state, receipt_tx, state).await { + Ok(new_state) => *state = new_state, + Err(error) => warn!(error = %error, "fee bump failed, will retry next tick"), } - Self::apply_bump_result(result, state) } /// Handles a single fee bump iteration. @@ -1332,32 +1287,6 @@ where Ok(BumpState::from_prepared(prepared, new_hash)) } - /// Applies the result of a fee bump attempt, updating the tracked fee - /// state on success or logging the error on failure. - /// - /// Returns `Some(error)` when the caller must abort the send loop - /// (non-retryable error), or `None` when the loop should continue - /// (success or retryable error). - fn apply_bump_result( - result: TxManagerResult, - state: &mut BumpState, - ) -> Option { - match result { - Ok(new_state) => { - *state = new_state; - None - } - Err(e) if !e.is_retryable() => { - error!(error = %e, "non-retryable error during fee bump"); - Some(e) - } - Err(e) => { - warn!(error = %e, "fee bump failed, will retry next tick"); - None - } - } - } - /// Publishes the initial transaction, retrying a transient nonce gap. /// /// Concurrent sends reserve nonces in order but may reach different RPC @@ -1460,6 +1389,10 @@ where warn!(error = %classified, "publish failed, will retry"); } else if matches!(classified, TxManagerError::ExecutionReverted { .. }) { error!(error = %classified, "transaction execution reverted"); + } else if matches!(classified, TxManagerError::NonceTooLow) + && send_state.has_published() + { + warn!(error = %classified, "nonce too low, a published version may be mined"); } else { error!(error = %classified, "publish failed with non-retryable error"); } @@ -1480,8 +1413,8 @@ where /// sends it through the provided channel once confirmed. /// /// The task delegates to [`wait_mined`](Self::wait_mined), which polls - /// internally until the transaction is confirmed or the manager shuts - /// down. + /// internally until the transaction is confirmed, the manager shuts down + /// or `confirmation_timeout` passes with the transaction still not mined. /// /// Returns the [`JoinHandle`](tokio::task::JoinHandle) of the spawned /// task so callers can await its completion if needed. @@ -1527,7 +1460,11 @@ where }) } - /// Spawns a receipt polling task on the configured runtime. + /// Spawns a task on the configured runtime that polls the receipt of one published + /// version of the transaction. + /// + /// The task runs until the version is confirmed, the send ends or the manager shuts + /// down. It has no timeout because the version can be mined at any point of the send. fn spawn_wait_for_tx( &self, send_state: Arc, @@ -1564,6 +1501,7 @@ where &self.provider, tx_hash, &self.config, + None, &self.closed, ) .await @@ -1589,24 +1527,29 @@ where provider, tx_hash, config, + Some(config.confirmation_timeout), closed, ) .await } /// Runtime-backed implementation of [`wait_mined`](Self::wait_mined). + /// + /// Polls until the transaction is confirmed or the manager is closed, or gives up once + /// `timeout` has passed with the transaction still not mined. async fn wait_mined_using_runtime( runtime: &Rt, send_state: &SendState, provider: &P, tx_hash: B256, config: &TxManagerConfig, + timeout: Option, closed: &AtomicBool, ) -> Option where Rt: Runtime, { - let deadline = runtime.now() + config.confirmation_timeout; + let started = runtime.now(); let mut poll_immediately = true; loop { @@ -1637,15 +1580,13 @@ where } } - // Give up at the deadline only if the transaction is not mined. Once it is mined - // the send loop stops fee bumping and relies on this poller alone, so keep polling - // until the confirmation depth is reached. - if runtime.now() >= deadline && !send_state.is_mined(tx_hash) { - warn!( - tx_hash = %tx_hash, - timeout = ?config.confirmation_timeout, - "confirmation timeout exceeded", - ); + // Give up at the deadline only if the transaction is not mined, so a mined one + // is polled until it is confirmed. + if let Some(timeout) = timeout + && runtime.now() >= started + timeout + && !send_state.is_mined(tx_hash) + { + warn!(tx_hash = %tx_hash, timeout = ?timeout, "confirmation timeout exceeded"); return None; } @@ -1862,7 +1803,7 @@ mod tests { use rstest::rstest; use tokio::sync::mpsc; - use super::{BumpState, PreparedTx, SimpleTxManager, TxEnvelope}; + use super::{PreparedTx, SimpleTxManager, TxEnvelope}; use crate::{ GasPriceCaps, NonceManager, NoopTxMetrics, SendState, TxCandidate, TxManagerConfig, TxManagerError, @@ -2036,17 +1977,28 @@ mod tests { } } + /// A send-loop poller ignores `confirmation_timeout` and delivers a version mined + /// after it. #[test] - fn wait_mined_confirmation_timeout_uses_virtual_time() { + fn send_loop_poller_outlives_the_confirmation_timeout() { Runner::start(Config::seeded(0), |ctx| async move { + let tx_hash = B256::with_last_byte(1); + let (receipt, block) = mined_receipt(tx_hash, 10); + + // Report the transaction unmined for 4 polls, then mined and confirmed at 4 s, + // after the 3 s timeout. let asserter = Asserter::new(); - for _ in 0..5 { + for _ in 0..4 { asserter.push_success(&1u64); - asserter.push_success(&Option::::None); + asserter.push_success(&Option::::None); } + asserter.push_success(&14u64); + asserter.push_success(&Some(&receipt)); + asserter.push_success(&Some(&block)); let provider = ProviderBuilder::new().connect_mocked_client(asserter); let send_state = SendState::new(3).expect("send state should be valid"); let config = TxManagerConfig { + num_confirmations: 5, receipt_query_interval: Duration::from_secs(1), confirmation_timeout: Duration::from_secs(3), network_timeout: Duration::from_secs(30), @@ -2070,10 +2022,10 @@ mod tests { metrics: Arc::new(NoopTxMetrics), }; - let receipt = manager.wait_mined_for_tx(&send_state, B256::with_last_byte(1)).await; + let confirmed = manager.wait_mined_for_tx(&send_state, tx_hash).await; - assert!(receipt.is_none(), "receipt polling should stop at confirmation timeout"); - assert_eq!(ctx.now(), Duration::from_secs(3)); + assert_eq!(confirmed.map(|receipt| receipt.transaction_hash), Some(tx_hash)); + assert_eq!(ctx.now(), Duration::from_secs(4)); }); } @@ -2141,6 +2093,7 @@ mod tests { &provider, tx_hash, &config, + Some(config.confirmation_timeout), &AtomicBool::new(false), ) .await; @@ -2171,7 +2124,6 @@ mod tests { let config = TxManagerConfig { num_confirmations: 5, receipt_query_interval: Duration::from_secs(1), - confirmation_timeout: Duration::from_secs(3), network_timeout: Duration::from_secs(30), ..TxManagerConfig::default() }; @@ -2206,208 +2158,6 @@ mod tests { }); } - // ── apply_bump_result ───────────────────────────────────────────── - - #[rstest] - #[case::success_updates_state( - Ok(BumpState { tip: 200, fee_cap: 2000, blob_fee_cap: None, gas_limit: 50_000, tx_hash: B256::with_last_byte(0x42), nonce: 0, sidecar: None }), - false, 200, 2000, None, 50_000, B256::with_last_byte(0x42), - )] - #[case::success_with_blob_fee_cap( - Ok(BumpState { tip: 300, fee_cap: 3000, blob_fee_cap: Some(10_000), gas_limit: 60_000, tx_hash: B256::with_last_byte(0x43), nonce: 1, sidecar: None }), - false, 300, 3000, Some(10_000), 60_000, B256::with_last_byte(0x43), - )] - #[case::non_retryable_returns_abort( - Err(TxManagerError::FeeLimitExceeded { fee: 500, ceiling: 100 }), - true, 100, 1000, Some(5_000), 21_000, B256::ZERO, - )] - #[case::retryable_continues( - Err(TxManagerError::Rpc("transient error".to_string())), - false, 100, 1000, Some(5_000), 21_000, B256::ZERO, - )] - fn apply_bump_result( - #[case] input: Result, - #[case] abort_expected: bool, - #[case] expected_tip: u128, - #[case] expected_fee_cap: u128, - #[case] expected_blob_fee_cap: Option, - #[case] expected_gas_limit: u64, - #[case] expected_hash: B256, - ) { - let mut state = BumpState { - tip: 100, - fee_cap: 1000, - blob_fee_cap: Some(5_000), - gas_limit: 21_000, - tx_hash: B256::ZERO, - nonce: 0, - sidecar: None, - }; - - let abort = SimpleTxManager::::apply_bump_result(input, &mut state); - - assert_eq!(abort.is_some(), abort_expected); - assert_eq!(state.tip, expected_tip); - assert_eq!(state.fee_cap, expected_fee_cap); - assert_eq!(state.blob_fee_cap, expected_blob_fee_cap); - assert_eq!(state.gas_limit, expected_gas_limit); - assert_eq!(state.tx_hash, expected_hash); - } - - // ── apply_bump_result_with_suppression ─────────────────────────── - // - // Tests for `apply_bump_result_with_suppression`, which is the - // extracted helper that `try_fee_bump` calls. When a non-retryable - // error occurs but the original tx is already mined, the abort is - // suppressed so the send loop can deliver the receipt. - - /// Threshold used for `SendState` construction in suppression tests. - const SUPPRESSION_NONCE_TOO_LOW_THRESHOLD: u64 = 3; - - /// Hash recorded as a mined tx when simulating a confirmed original. - const MINED_TX_HASH: B256 = B256::repeat_byte(0xAA); - - /// Default initial `BumpState` for suppression tests. - fn default_bump_state() -> BumpState { - BumpState { - tip: 100, - fee_cap: 1000, - blob_fee_cap: None, - gas_limit: 21_000, - tx_hash: B256::ZERO, - nonce: 0, - sidecar: None, - } - } - - /// Creates a `SendState` and optionally marks a tx as mined. - fn suppression_send_state(tx_mined: bool) -> SendState { - let state = - SendState::new(SUPPRESSION_NONCE_TOO_LOW_THRESHOLD).expect("should create send state"); - if tx_mined { - state.tx_mined(MINED_TX_HASH); - } - state - } - - /// Non-retryable errors are suppressed when the original tx is - /// mined, and propagated when it is not. - #[rstest] - #[case::nonce_too_low_mined_suppressed( - TxManagerError::NonceTooLow, - true, // tx is mined - false, // abort suppressed → no abort - )] - #[case::nonce_too_low_not_mined_propagates( - TxManagerError::NonceTooLow, - false, // tx is NOT mined - true, // abort propagates - )] - #[case::fee_limit_exceeded_mined_suppressed( - TxManagerError::FeeLimitExceeded { fee: 500, ceiling: 100 }, - true, - false, - )] - #[case::fee_limit_exceeded_not_mined_propagates( - TxManagerError::FeeLimitExceeded { fee: 500, ceiling: 100 }, - false, - true, - )] - #[case::insufficient_funds_mined_suppressed(TxManagerError::InsufficientFunds, true, false)] - #[case::insufficient_funds_not_mined_propagates(TxManagerError::InsufficientFunds, false, true)] - #[case::execution_reverted_mined_suppressed( - TxManagerError::ExecutionReverted { reason: Some("revert".into()), data: None }, - true, - false, - )] - #[case::execution_reverted_not_mined_propagates( - TxManagerError::ExecutionReverted { reason: Some("revert".into()), data: None }, - false, - true, - )] - #[case::channel_closed_mined_suppressed(TxManagerError::ChannelClosed, true, false)] - #[case::channel_closed_not_mined_propagates(TxManagerError::ChannelClosed, false, true)] - fn bump_result_with_suppression_non_retryable( - #[case] error: TxManagerError, - #[case] tx_mined: bool, - #[case] abort_expected: bool, - ) { - let send_state = suppression_send_state(tx_mined); - assert_eq!(send_state.is_waiting_for_confirmation(), tx_mined); - - let mut state = default_bump_state(); - - let abort = SimpleTxManager::::apply_bump_result_with_suppression( - Err(error), - &mut state, - &send_state, - ); - - assert_eq!( - abort.is_some(), - abort_expected, - "abort_expected={abort_expected}, tx_mined={tx_mined}", - ); - } - - /// Retryable errors are never aborted by `apply_bump_result`, so - /// suppression is irrelevant — the send loop continues regardless. - #[rstest] - #[case::rpc_error_mined(TxManagerError::Rpc("transient".into()), true)] - #[case::rpc_error_not_mined(TxManagerError::Rpc("transient".into()), false)] - #[case::underpriced_mined(TxManagerError::Underpriced, true)] - #[case::underpriced_not_mined(TxManagerError::Underpriced, false)] - #[case::replacement_underpriced_mined(TxManagerError::ReplacementUnderpriced, true)] - #[case::fee_too_low_not_mined(TxManagerError::FeeTooLow, false)] - fn bump_result_with_suppression_retryable_never_aborts( - #[case] error: TxManagerError, - #[case] tx_mined: bool, - ) { - let send_state = suppression_send_state(tx_mined); - let mut state = default_bump_state(); - - let abort = SimpleTxManager::::apply_bump_result_with_suppression( - Err(error), - &mut state, - &send_state, - ); - - assert!(abort.is_none(), "retryable errors should never cause an abort"); - } - - /// Success results pass through to `apply_bump_result` and update - /// state, regardless of mined state. - #[rstest] - #[case::success_not_mined(false)] - #[case::success_mined(true)] - fn bump_result_with_suppression_success_never_aborts(#[case] tx_mined: bool) { - let send_state = suppression_send_state(tx_mined); - - let new_state = BumpState { - tip: 200, - fee_cap: 2000, - blob_fee_cap: None, - gas_limit: 50_000, - tx_hash: B256::with_last_byte(0x42), - nonce: 0, - sidecar: None, - }; - - let mut state = default_bump_state(); - - let abort = SimpleTxManager::::apply_bump_result_with_suppression( - Ok(new_state), - &mut state, - &send_state, - ); - - assert!(abort.is_none(), "success should never cause an abort"); - // State should be updated to the new values. - assert_eq!(state.tip, 200); - assert_eq!(state.fee_cap, 2000); - assert_eq!(state.tx_hash, B256::with_last_byte(0x42)); - } - // ── should_reset_nonce_on_send_error ─────────────────────────────── #[rstest] diff --git a/crates/utilities/tx-manager/src/send_state.rs b/crates/utilities/tx-manager/src/send_state.rs index 72592f67b4..38606f9d5d 100644 --- a/crates/utilities/tx-manager/src/send_state.rs +++ b/crates/utilities/tx-manager/src/send_state.rs @@ -143,7 +143,7 @@ impl SendState { /// successfully published, returns /// [`TxManagerError::MempoolDeadlineExpired`]. Once a publish has /// succeeded the tx is in the mempool and the deadline's purpose is - /// served — further waiting is governed by the receipt timeout and + /// served — further waiting is governed by `tx_send_timeout` and the /// bump cycle, not by aborting (which would leak a pre-reserved nonce /// and stall publication on lower nonces still live on L1). /// 6. Otherwise, returns `None`. diff --git a/crates/utilities/tx-manager/tests/common/mod.rs b/crates/utilities/tx-manager/tests/common/mod.rs index af01f7026e..b44ff51ea9 100644 --- a/crates/utilities/tx-manager/tests/common/mod.rs +++ b/crates/utilities/tx-manager/tests/common/mod.rs @@ -4,9 +4,10 @@ //! binary uses every item. #![allow(dead_code, unreachable_pub)] -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use alloy_consensus::SignableTransaction; +use alloy_eips::BlockNumberOrTag; use alloy_network::{EthereumWallet, TxSigner}; use alloy_node_bindings::Anvil; use alloy_primitives::{Address, B256, Bytes, Signature, U256}; @@ -21,7 +22,12 @@ pub const SAFE_ABORT_DEPTH: u64 = 3; /// Spawns an Anvil instance and returns the provider, default wallet, and /// instance handle. pub fn setup_anvil() -> (RootProvider, EthereumWallet, alloy_node_bindings::AnvilInstance) { - let anvil = Anvil::new().spawn(); + spawn_anvil(Anvil::new()) +} + +/// Spawns `anvil` and returns the provider, default wallet, and instance handle. +fn spawn_anvil(anvil: Anvil) -> (RootProvider, EthereumWallet, alloy_node_bindings::AnvilInstance) { + let anvil = anvil.spawn(); let provider = RootProvider::new_http(anvil.endpoint_url()); let signer: PrivateKeySigner = anvil.keys()[0].clone().into(); let wallet = EthereumWallet::from(signer); @@ -33,6 +39,25 @@ pub async fn setup_with_config( config: TxManagerConfig, ) -> (SimpleTxManager, alloy_node_bindings::AnvilInstance) { let (provider, wallet, anvil) = setup_anvil(); + setup_manager(provider, wallet, anvil, config).await +} + +/// Creates a [`SimpleTxManager`] backed by a fresh Anvil instance with automine off, so +/// transactions stay in the mempool until [`mine_block`]. +pub async fn setup_without_automine( + config: TxManagerConfig, +) -> (SimpleTxManager, alloy_node_bindings::AnvilInstance) { + let (provider, wallet, anvil) = spawn_anvil(Anvil::new().arg("--no-mining")); + setup_manager(provider, wallet, anvil, config).await +} + +/// Creates a [`SimpleTxManager`] over `provider`, signing with `wallet`. +async fn setup_manager( + provider: RootProvider, + wallet: EthereumWallet, + anvil: alloy_node_bindings::AnvilInstance, + config: TxManagerConfig, +) -> (SimpleTxManager, alloy_node_bindings::AnvilInstance) { let manager = SimpleTxManager::from_wallet( provider, wallet, @@ -45,6 +70,31 @@ pub async fn setup_with_config( (manager, anvil) } +/// Waits until the first transaction of `sender` is in the mempool, since `send_async` +/// returns before it publishes. +pub async fn wait_for_publication(provider: &RootProvider, sender: Address) { + tokio::time::timeout(Duration::from_secs(5), async { + while provider.get_transaction_count(sender).pending().await.expect("tx count") == 0 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the transaction should reach the mempool within 5 s"); +} + +/// Returns the only transaction in Anvil's mempool. +pub async fn pending_transaction(provider: &RootProvider) -> alloy_rpc_types_eth::Transaction { + let block = provider + .get_block_by_number(BlockNumberOrTag::Pending) + .full() + .await + .expect("should fetch the pending block") + .expect("anvil serves a pending block"); + let transactions = block.transactions.into_transactions_vec(); + let [transaction] = transactions.try_into().expect("one transaction in the mempool"); + transaction +} + /// Force-mine a block on Anvil so receipts are committed before queries. pub async fn mine_block(provider: &RootProvider) { provider diff --git a/crates/utilities/tx-manager/tests/send_lifecycle.rs b/crates/utilities/tx-manager/tests/send_lifecycle.rs index c91be016e8..dd8dd06a9f 100644 --- a/crates/utilities/tx-manager/tests/send_lifecycle.rs +++ b/crates/utilities/tx-manager/tests/send_lifecycle.rs @@ -8,16 +8,19 @@ mod common; use std::{ sync::{Arc, atomic::AtomicBool}, - time::Duration, + time::{Duration, Instant}, }; use alloy_consensus::Transaction; +use alloy_network::TransactionBuilder; use alloy_primitives::B256; use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_types_eth::TransactionRequest; use base_tx_manager::{SendState, SimpleTxManager, TxManager, TxManagerConfig, TxManagerError}; use common::{ - SAFE_ABORT_DEPTH, mine_block, publish_simple_tx, setup_with_config, setup_with_failing_signer, - simple_tx_candidate, + SAFE_ABORT_DEPTH, TEST_RECIPIENT, mine_block, pending_transaction, publish_simple_tx, + setup_with_config, setup_with_failing_signer, setup_without_automine, simple_tx_candidate, + wait_for_publication, }; use rstest::rstest; use tokio::sync::mpsc; @@ -42,6 +45,24 @@ fn fast_send_config() -> TxManagerConfig { } } +/// Fee bump interval of [`bump_before_next_poll_config`]. +const BUMP_INTERVAL: Duration = Duration::from_secs(1); + +/// Fees per gas of the foreign transaction, high enough to replace the manager's. +const FOREIGN_FEES_PER_GAS: u128 = 100_000_000_000; + +/// Config where fees are bumped more often than receipts are polled, so a mined transaction +/// can be bumped before its receipt is seen. +fn bump_before_next_poll_config() -> TxManagerConfig { + TxManagerConfig { + num_confirmations: 1, + resubmission_timeout: BUMP_INTERVAL, + receipt_query_interval: BUMP_INTERVAL + BUMP_INTERVAL / 2, + safe_abort_nonce_too_low_count: SAFE_ABORT_DEPTH, + ..TxManagerConfig::default() + } +} + /// Config that requires many confirmations so mining never satisfies it, /// useful for testing shutdown and timeout exit paths. fn unconfirmable_config() -> TxManagerConfig { @@ -367,6 +388,89 @@ async fn query_receipt_returns_error_on_unreachable_provider() { assert!(result.is_err(), "query_receipt should fail when provider is unreachable"); } +// ── refused fee bumps ───────────────────────────────────────────────── + +/// A fee bump refused because the transaction is already mined does not fail the send. +#[tokio::test] +async fn bump_refused_for_a_mined_transaction_still_delivers_its_receipt() { + let (manager, _anvil) = setup_without_automine(bump_before_next_poll_config()).await; + + // Mine after the first receipt poll, which runs on publication, and before the first bump. + let handle = manager.send_async(simple_tx_candidate()).await; + wait_for_publication(manager.provider(), manager.sender_address()).await; + let published = pending_transaction(manager.provider()).await; + tokio::time::sleep(BUMP_INTERVAL / 2).await; + mine_block(manager.provider()).await; + + let receipt = tokio::time::timeout(Duration::from_secs(10), handle) + .await + .expect("send_async should complete within 10 s") + .expect("a refused bump must not fail a mined transaction"); + assert_eq!(receipt.transaction_hash, *published.inner.tx_hash()); + assert_eq!(receipt.block_number, Some(1)); +} + +/// A fee bump refused because a replacement is already mined does not fail the send, which +/// delivers the replacement's receipt. +#[tokio::test] +async fn bump_refused_for_a_mined_replacement_still_delivers_its_receipt() { + let (manager, _anvil) = setup_without_automine(bump_before_next_poll_config()).await; + + // Mine after the first bump and before the second. + let handle = manager.send_async(simple_tx_candidate()).await; + wait_for_publication(manager.provider(), manager.sender_address()).await; + let original = pending_transaction(manager.provider()).await; + tokio::time::sleep(BUMP_INTERVAL + BUMP_INTERVAL / 2).await; + let replacement = pending_transaction(manager.provider()).await; + assert_ne!(replacement.inner.tx_hash(), original.inner.tx_hash(), "a bump happened"); + mine_block(manager.provider()).await; + + let receipt = tokio::time::timeout(Duration::from_secs(10), handle) + .await + .expect("send_async should complete within 10 s") + .expect("a refused bump must not fail a mined replacement"); + assert_eq!(receipt.transaction_hash, *replacement.inner.tx_hash()); +} + +/// A send whose nonce is taken by a transaction it did not publish fails, but only after +/// `safe_abort_nonce_too_low_count` refused bumps. +#[tokio::test] +async fn bumps_refused_for_a_foreign_transaction_fail_the_send() { + let (manager, _anvil) = setup_without_automine(bump_before_next_poll_config()).await; + let sender = manager.sender_address(); + let started = Instant::now(); + let handle = manager.send_async(simple_tx_candidate()).await; + wait_for_publication(manager.provider(), sender).await; + + // Replace the transaction with a better-paid one that Anvil signs, then mine it. + let foreign = TransactionRequest::default() + .with_from(sender) + .with_to(TEST_RECIPIENT) + .with_nonce(0) + .with_gas_limit(21_000) + .with_max_fee_per_gas(FOREIGN_FEES_PER_GAS) + .with_max_priority_fee_per_gas(FOREIGN_FEES_PER_GAS); + let foreign_hash = *manager + .provider() + .send_transaction(foreign) + .await + .expect("anvil accepts the replacement") + .tx_hash(); + mine_block(manager.provider()).await; + + let error = tokio::time::timeout(Duration::from_secs(10), handle) + .await + .expect("send_async should complete within 10 s") + .expect_err("a nonce consumed by another transaction must fail the send"); + assert!(matches!(error, TxManagerError::NonceTooLow), "got {error:?}"); + assert!( + started.elapsed() >= SAFE_ABORT_DEPTH as u32 * BUMP_INTERVAL, + "the send must not fail before the counted refusals" + ); + let mined = manager.provider().get_transaction_receipt(foreign_hash).await.unwrap(); + assert!(mined.is_some(), "the foreign transaction took the nonce"); +} + // ── send_async() nonce ordering ─────────────────────────────────────── /// Sequential `send_async()` calls receive monotonically increasing nonces, From 39f4f309a120813a316b5bf34a8645940be0be3e Mon Sep 17 00:00:00 2001 From: xenoliss Date: Mon, 28 Sep 2026 14:48:44 +0000 Subject: [PATCH 137/299] refactor(tx-manager): remove the confirmation timeout (#5358) * refactor(tx-manager): remove the confirmation timeout * docs(tx-manager): drop a repeated wait_for_tx sentence --- .../infra/zk-fork-dispute/src/fork_dispute.rs | 1 - crates/utilities/tx-manager/src/config.rs | 19 ----- crates/utilities/tx-manager/src/macros.rs | 13 --- crates/utilities/tx-manager/src/manager.rs | 82 ++----------------- crates/utilities/tx-manager/src/send_state.rs | 7 -- .../tx-manager/tests/custom_prefix.rs | 1 - .../tx-manager/tests/send_lifecycle.rs | 24 ------ 7 files changed, 8 insertions(+), 139 deletions(-) diff --git a/crates/infra/zk-fork-dispute/src/fork_dispute.rs b/crates/infra/zk-fork-dispute/src/fork_dispute.rs index 61bb703ce3..af97e00cda 100644 --- a/crates/infra/zk-fork-dispute/src/fork_dispute.rs +++ b/crates/infra/zk-fork-dispute/src/fork_dispute.rs @@ -190,7 +190,6 @@ impl ZkForkDispute { receipt_query_interval: Duration::from_secs(1), tx_send_timeout: Duration::from_secs(180), tx_not_in_mempool_timeout: Duration::from_secs(30), - confirmation_timeout: Duration::from_secs(120), ..Default::default() } } diff --git a/crates/utilities/tx-manager/src/config.rs b/crates/utilities/tx-manager/src/config.rs index 8f342e9ef0..09130d98d0 100644 --- a/crates/utilities/tx-manager/src/config.rs +++ b/crates/utilities/tx-manager/src/config.rs @@ -123,11 +123,6 @@ pub struct TxManagerConfig { pub tx_send_timeout: Duration, /// Mempool appearance timeout (zero = disabled). pub tx_not_in_mempool_timeout: Duration, - /// Maximum time [`wait_mined`](crate::SimpleTxManager::wait_mined) and - /// [`wait_for_tx`](crate::SimpleTxManager::wait_for_tx) poll for a transaction that is - /// not mined. `send` and `send_async` ignore it and poll every version they publish - /// until the send ends. - pub confirmation_timeout: Duration, /// Minimum blob base fee (in wei) to use for blob transactions. pub min_blob_fee: u128, } @@ -148,7 +143,6 @@ impl Default for TxManagerConfig { receipt_query_interval: Duration::from_secs(12), tx_send_timeout: Duration::ZERO, tx_not_in_mempool_timeout: Duration::from_secs(120), - confirmation_timeout: Duration::from_secs(300), min_blob_fee: 1_000_000_000, // 1 gwei } } @@ -169,7 +163,6 @@ impl TxManagerConfig { /// - `publish_max_retries` must be >= 1 /// - `publish_retry_delay` must be > 0 /// - `receipt_query_interval` must be > 0 - /// - `confirmation_timeout` must be > 0 /// - `(safe_abort_nonce_too_low_count - 1) * resubmission_timeout` must exceed /// `receipt_query_interval`, so a receipt poll runs before a send aborts on /// refused fee bumps @@ -209,7 +202,6 @@ impl TxManagerConfig { resubmission_timeout, publish_retry_delay, receipt_query_interval, - confirmation_timeout, ); reject_zero!(min_blob_fee); let refusals_before_abort = @@ -321,17 +313,6 @@ mod tests { assert_eq!(TxManagerConfig::default().min_blob_fee, 1_000_000_000); } - #[test] - fn validation_rejects_zero_confirmation_timeout() { - let config = - TxManagerConfig { confirmation_timeout: Duration::ZERO, ..TxManagerConfig::default() }; - let err = config.validate().unwrap_err(); - assert!( - matches!(err, ConfigError::OutOfRange { field: "confirmation_timeout", .. }), - "expected OutOfRange for confirmation_timeout, got: {err}" - ); - } - #[rstest] #[case::one_refusal(1, 48, 12, false)] #[case::polls_at_the_abort(3, 6, 12, false)] diff --git a/crates/utilities/tx-manager/src/macros.rs b/crates/utilities/tx-manager/src/macros.rs index 850aa9b0ee..dd2a8a105d 100644 --- a/crates/utilities/tx-manager/src/macros.rs +++ b/crates/utilities/tx-manager/src/macros.rs @@ -173,18 +173,6 @@ macro_rules! define_tx_manager_cli { )] pub tx_not_in_mempool_timeout: ::std::time::Duration, - /// Maximum time `wait_mined` and `wait_for_tx` poll for a - /// transaction that is not mined (e.g., "5m", "300s"). `send` and - /// `send_async` ignore it and poll every version they publish until - /// the send ends. - #[arg( - long = "tx-manager.confirmation-timeout", - env = concat!($prefix, "_", "CONFIRMATION_TIMEOUT"), - default_value = "5m", - value_parser = ::humantime::parse_duration - )] - pub confirmation_timeout: ::std::time::Duration, - /// Minimum blob base fee (in gwei) to use for blob transactions. /// Accepts decimal strings (e.g. `"1"`, `"0.5"`). /// Stored as wei after parsing. @@ -221,7 +209,6 @@ macro_rules! define_tx_manager_cli { receipt_query_interval: cli.receipt_query_interval, tx_send_timeout: cli.tx_send_timeout, tx_not_in_mempool_timeout: cli.tx_not_in_mempool_timeout, - confirmation_timeout: cli.confirmation_timeout, min_blob_fee: cli.min_blob_fee, }; config.validate()?; diff --git a/crates/utilities/tx-manager/src/manager.rs b/crates/utilities/tx-manager/src/manager.rs index f884d36a20..fcb278ad0e 100644 --- a/crates/utilities/tx-manager/src/manager.rs +++ b/crates/utilities/tx-manager/src/manager.rs @@ -1413,8 +1413,7 @@ where /// sends it through the provided channel once confirmed. /// /// The task delegates to [`wait_mined`](Self::wait_mined), which polls - /// internally until the transaction is confirmed, the manager shuts down - /// or `confirmation_timeout` passes with the transaction still not mined. + /// internally until the transaction is confirmed or the manager shuts down. /// /// Returns the [`JoinHandle`](tokio::task::JoinHandle) of the spawned /// task so callers can await its completion if needed. @@ -1464,7 +1463,7 @@ where /// version of the transaction. /// /// The task runs until the version is confirmed, the send ends or the manager shuts - /// down. It has no timeout because the version can be mined at any point of the send. + /// down. fn spawn_wait_for_tx( &self, send_state: Arc, @@ -1501,7 +1500,6 @@ where &self.provider, tx_hash, &self.config, - None, &self.closed, ) .await @@ -1511,9 +1509,8 @@ where /// the transaction is mined and confirmed to the required depth. /// /// Returns `Some(receipt)` when the transaction reaches - /// `num_confirmations` depth, or `None` if the manager is closed or - /// the transaction is still not mined when the `confirmation_timeout` - /// deadline is exceeded. + /// `num_confirmations` depth, or `None` if the manager is closed. Callers + /// that need a bound wrap it in a timeout. pub async fn wait_mined( send_state: &SendState, provider: &P, @@ -1527,29 +1524,23 @@ where provider, tx_hash, config, - Some(config.confirmation_timeout), closed, ) .await } /// Runtime-backed implementation of [`wait_mined`](Self::wait_mined). - /// - /// Polls until the transaction is confirmed or the manager is closed, or gives up once - /// `timeout` has passed with the transaction still not mined. async fn wait_mined_using_runtime( runtime: &Rt, send_state: &SendState, provider: &P, tx_hash: B256, config: &TxManagerConfig, - timeout: Option, closed: &AtomicBool, ) -> Option where Rt: Runtime, { - let started = runtime.now(); let mut poll_immediately = true; loop { @@ -1580,16 +1571,6 @@ where } } - // Give up at the deadline only if the transaction is not mined, so a mined one - // is polled until it is confirmed. - if let Some(timeout) = timeout - && runtime.now() >= started + timeout - && !send_state.is_mined(tx_hash) - { - warn!(tx_hash = %tx_hash, timeout = ?timeout, "confirmation timeout exceeded"); - return None; - } - // Check shutdown state each iteration to support cancellation. if closed.load(Ordering::Acquire) { debug!(tx_hash = %tx_hash, "manager closed, stopping receipt polling"); @@ -1798,7 +1779,7 @@ mod tests { use alloy_transport::mock::Asserter; use base_runtime::{ Clock, - deterministic::{Config, Context, Runner}, + deterministic::{Config, Runner}, }; use rstest::rstest; use tokio::sync::mpsc; @@ -1977,16 +1958,14 @@ mod tests { } } - /// A send-loop poller ignores `confirmation_timeout` and delivers a version mined - /// after it. + /// A poller keeps polling an unmined transaction and delivers it once it is mined. #[test] - fn send_loop_poller_outlives_the_confirmation_timeout() { + fn receipt_polling_waits_for_the_transaction_to_be_mined() { Runner::start(Config::seeded(0), |ctx| async move { let tx_hash = B256::with_last_byte(1); let (receipt, block) = mined_receipt(tx_hash, 10); - // Report the transaction unmined for 4 polls, then mined and confirmed at 4 s, - // after the 3 s timeout. + // Report the transaction unmined for 4 polls, then mined and confirmed at 4 s. let asserter = Asserter::new(); for _ in 0..4 { asserter.push_success(&1u64); @@ -2000,7 +1979,6 @@ mod tests { let config = TxManagerConfig { num_confirmations: 5, receipt_query_interval: Duration::from_secs(1), - confirmation_timeout: Duration::from_secs(3), network_timeout: Duration::from_secs(30), ..TxManagerConfig::default() }; @@ -2059,50 +2037,6 @@ mod tests { (receipt, block) } - /// A mined transaction that reaches the confirmation depth only after the - /// confirmation timeout must still be delivered: once it is mined nothing - /// else watches it, so abandoning it would leave the send pending forever. - #[test] - fn wait_mined_keeps_polling_mined_tx_past_confirmation_timeout() { - Runner::start(Config::seeded(0), |ctx| async move { - let tx_hash = B256::with_last_byte(1); - let (receipt, block) = mined_receipt(tx_hash, 10); - - // Script the chain tip so the 5 required confirmations are only reached at tip 14, - // on the poll after the 3s timeout. - let asserter = Asserter::new(); - for tip in [10u64, 10, 10, 10, 14] { - asserter.push_success(&tip); - asserter.push_success(&Some(&receipt)); - asserter.push_success(&Some(&block)); - } - let provider = ProviderBuilder::new().connect_mocked_client(asserter); - - let send_state = SendState::new(3).expect("send state should be valid"); - let config = TxManagerConfig { - num_confirmations: 5, - receipt_query_interval: Duration::from_secs(1), - confirmation_timeout: Duration::from_secs(3), - network_timeout: Duration::from_secs(30), - ..TxManagerConfig::default() - }; - - let confirmed = SimpleTxManager::<_, Context>::wait_mined_using_runtime( - &ctx, - &send_state, - &provider, - tx_hash, - &config, - Some(config.confirmation_timeout), - &AtomicBool::new(false), - ) - .await; - - assert_eq!(confirmed.map(|receipt| receipt.transaction_hash), Some(tx_hash)); - assert_eq!(ctx.now(), Duration::from_secs(4)); - }); - } - /// A poller stops querying L1 once the receipt receiver is dropped, as happens when the /// send is cancelled, even if its transaction is mined and not yet confirmed. #[test] diff --git a/crates/utilities/tx-manager/src/send_state.rs b/crates/utilities/tx-manager/src/send_state.rs index 38606f9d5d..35a868c93b 100644 --- a/crates/utilities/tx-manager/src/send_state.rs +++ b/crates/utilities/tx-manager/src/send_state.rs @@ -191,13 +191,6 @@ impl SendState { None } - /// Returns `true` when the given transaction is currently recorded as mined. - #[must_use] - pub fn is_mined(&self, tx_hash: B256) -> bool { - let inner = self.inner.lock().expect("SendState mutex poisoned"); - inner.mined_txs.contains(&tx_hash) - } - /// Returns `true` when there are mined transactions awaiting confirmation. #[must_use] pub fn is_waiting_for_confirmation(&self) -> bool { diff --git a/crates/utilities/tx-manager/tests/custom_prefix.rs b/crates/utilities/tx-manager/tests/custom_prefix.rs index 602cecc82b..f13c06629c 100644 --- a/crates/utilities/tx-manager/tests/custom_prefix.rs +++ b/crates/utilities/tx-manager/tests/custom_prefix.rs @@ -31,7 +31,6 @@ fn env_vars_use_custom_prefix() { ("tx-manager.receipt-query-interval", "CUSTOM_PREFIX_RECEIPT_QUERY_INTERVAL"), ("tx-manager.tx-send-timeout", "CUSTOM_PREFIX_TX_SEND_TIMEOUT"), ("tx-manager.tx-not-in-mempool-timeout", "CUSTOM_PREFIX_TX_NOT_IN_MEMPOOL_TIMEOUT"), - ("tx-manager.confirmation-timeout", "CUSTOM_PREFIX_CONFIRMATION_TIMEOUT"), ("tx-manager.min-blob-fee", "CUSTOM_PREFIX_MIN_BLOB_FEE"), ]; diff --git a/crates/utilities/tx-manager/tests/send_lifecycle.rs b/crates/utilities/tx-manager/tests/send_lifecycle.rs index dd8dd06a9f..7ab726aeca 100644 --- a/crates/utilities/tx-manager/tests/send_lifecycle.rs +++ b/crates/utilities/tx-manager/tests/send_lifecycle.rs @@ -266,30 +266,6 @@ async fn wait_mined_returns_none_on_shutdown() { assert!(receipt.is_none(), "should return None when closed"); } -/// `wait_mined` returns `None` when the transaction is still not mined at the -/// confirmation timeout. -#[tokio::test] -async fn wait_mined_returns_none_on_timeout() { - let config = TxManagerConfig { - confirmation_timeout: Duration::from_millis(200), - ..fast_polling_config() - }; - let (manager, _anvil) = setup_with_config(config).await; - let send_state = SendState::new(SAFE_ABORT_DEPTH).expect("should create send state"); - let closed = AtomicBool::new(false); - - let receipt = SimpleTxManager::::wait_mined( - &send_state, - manager.provider(), - B256::with_last_byte(1), - manager.config(), - &closed, - ) - .await; - - assert!(receipt.is_none(), "should return None when timeout exceeded"); -} - // ── wait_for_tx() ───────────────────────────────────────────────────── /// `wait_for_tx` delivers a receipt through the mpsc channel. From 495761f6c895805fab129a4e4ada250ba71ff1d9 Mon Sep 17 00:00:00 2001 From: Haardik Date: Mon, 28 Sep 2026 15:02:03 +0000 Subject: [PATCH 138/299] feat(download): verify extracted proofs files against manifest checksums (#5360) --- Cargo.lock | 2 + crates/execution/cli/Cargo.toml | 2 + crates/execution/cli/src/commands/download.rs | 264 +++++++++++++++++- 3 files changed, 260 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ecde0e2d30..2b6a4736bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4300,12 +4300,14 @@ dependencies = [ "base-txpool-rpc", "base-txpool-tracing", "base-upgrade-signal", + "blake3", "clap", "eyre", "futures", "humantime", "jsonrpsee", "proptest", + "rayon", "reqwest 0.13.4", "reth-chainspec", "reth-cli", diff --git a/crates/execution/cli/Cargo.toml b/crates/execution/cli/Cargo.toml index ed4e09cbfc..ae21b12fcd 100644 --- a/crates/execution/cli/Cargo.toml +++ b/crates/execution/cli/Cargo.toml @@ -64,6 +64,8 @@ base-upgrade-signal = { workspace = true, features = ["metrics"] } tar.workspace = true zstd.workspace = true eyre.workspace = true +rayon.workspace = true +blake3.workspace = true humantime.workspace = true url.workspace = true backon.workspace = true diff --git a/crates/execution/cli/src/commands/download.rs b/crates/execution/cli/src/commands/download.rs index 15e40ede71..d60b57b676 100644 --- a/crates/execution/cli/src/commands/download.rs +++ b/crates/execution/cli/src/commands/download.rs @@ -7,7 +7,7 @@ use std::{ ffi::OsString, io::{Read, SeekFrom}, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, sync::{ Arc, atomic::{AtomicU64, Ordering}, @@ -16,10 +16,11 @@ use std::{ }; use base_execution_chainspec::BaseChainSpec; -use base_reth_cli::ProgressDisplay; +use base_reth_cli::{OutputFileChecksum, ProgressDisplay}; use clap::Parser; use eyre::Result; use futures::{StreamExt, future::try_join_all}; +use rayon::prelude::*; use reth_chainspec::EthChainSpec; use reth_cli::chainspec::ChainSpecParser; use reth_cli_commands::download::{DownloadCommand, DownloadDefaults}; @@ -28,7 +29,7 @@ use tokio::{ io::{AsyncSeekExt, AsyncWriteExt}, sync::Mutex, }; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; /// Download Base node snapshots from R2 storage. /// @@ -380,6 +381,8 @@ struct ProofsManifestEntry { file_name: String, expected_size: u64, archive_url: String, + /// Files the archive must extract to, relative to the target datadir. + output_files: Vec, } /// Consecutive Range-resume attempts that write no new bytes before failing. @@ -435,7 +438,7 @@ impl ProofsDownloader { let archive_path = Self::download_archive(&entry, &cache_dir, concurrency).await?; - Self::extract_and_cleanup(&archive_path, target_dir, &cache_dir).await + Self::extract_and_cleanup(&archive_path, target_dir, &cache_dir, entry.output_files).await } /// Fetches the manifest and extracts the proofs component metadata. @@ -491,7 +494,22 @@ impl ProofsDownloader { let archive_url = format!("{archive_base_url}/{file_name}"); - Ok(ProofsManifestEntry { file_name, expected_size, archive_url }) + let output_files: Vec = match proofs_component.get("output_files") { + Some(files) => serde_json::from_value(files.clone()).map_err(|e| { + eyre::eyre!("invalid 'output_files' for proofs component in manifest: {e}") + })?, + None => Vec::new(), + }; + if let Some(file) = output_files.iter().find(|file| { + file.path.is_empty() + || Path::new(&file.path) + .components() + .any(|component| !matches!(component, Component::Normal(_))) + }) { + eyre::bail!("invalid proofs output file path in manifest: {}", file.path); + } + + Ok(ProofsManifestEntry { file_name, expected_size, archive_url, output_files }) } /// Downloads the proofs archive with in-process resume and size verification. @@ -994,18 +1012,24 @@ impl ProofsDownloader { } } - /// Extracts the archive and cleans up the cache directory. + /// Extracts the archive, verifies the extracted files, and cleans up the cache directory. + /// + /// The cache is left in place when extraction or verification fails. async fn extract_and_cleanup( archive_path: &Path, target_dir: &Path, cache_dir: &Path, + output_files: Vec, ) -> Result<()> { info!(target: "reth::cli", "Extracting proofs archive"); let extract_target = target_dir.to_path_buf(); let extract_path = archive_path.to_path_buf(); - tokio::task::spawn_blocking(move || Self::extract_tar_zst(&extract_path, &extract_target)) - .await??; + tokio::task::spawn_blocking(move || { + Self::extract_tar_zst(&extract_path, &extract_target)?; + Self::verify_output_files(&extract_target, &output_files) + }) + .await??; tokio::fs::remove_file(archive_path).await.ok(); tokio::fs::remove_dir_all(cache_dir).await.ok(); @@ -1025,6 +1049,58 @@ impl ProofsDownloader { archive.unpack(target_dir)?; Ok(()) } + + /// Verifies that every manifest output file exists under `target_dir` with the expected size + /// and BLAKE3 hash. + /// + /// Files are hashed in parallel on the Rayon pool. Manifests without `output_files` are + /// accepted with a warning, since they carry nothing to verify against. + fn verify_output_files(target_dir: &Path, output_files: &[OutputFileChecksum]) -> Result<()> { + if output_files.is_empty() { + warn!(target: "reth::cli", "Manifest lists no proofs output files, skipping verification"); + return Ok(()); + } + + let total_bytes: u64 = output_files.iter().map(|file| file.size).sum(); + info!( + target: "reth::cli", + files = output_files.len(), + size = %ProgressDisplay::bytes(total_bytes as f64), + "Verifying extracted proofs files" + ); + let started = Instant::now(); + + output_files.par_iter().try_for_each(|expected| { + let path = target_dir.join(&expected.path); + let file = std::fs::File::open(&path) + .map_err(|e| eyre::eyre!("missing proofs file {}: {e}", expected.path))?; + let size = file.metadata()?.len(); + if size != expected.size { + eyre::bail!( + "proofs file {} has size {size}, manifest expects {}", + expected.path, + expected.size + ); + } + let hash = blake3::Hasher::new().update_reader(file)?.finalize().to_hex(); + if !hash.as_str().eq_ignore_ascii_case(&expected.blake3) { + eyre::bail!( + "proofs file {} has BLAKE3 {hash}, manifest expects {}", + expected.path, + expected.blake3 + ); + } + Ok(()) + })?; + + info!( + target: "reth::cli", + files = output_files.len(), + elapsed = ?started.elapsed(), + "Verified extracted proofs files" + ); + Ok(()) + } } #[cfg(test)] @@ -1045,6 +1121,7 @@ mod tests { response::IntoResponse, routing::get, }; + use base_reth_cli::{ManifestGenerationParams, SnapshotGenerator}; use clap::Parser; use futures::stream; @@ -1700,6 +1777,165 @@ mod tests { handle.abort(); } + /// Packages `proofs` with the snapshotter's manifest generator and returns the generated + /// manifest JSON together with the proofs archive bytes. + fn generate_proofs_snapshot(proofs: &[(&str, &[u8])]) -> (serde_json::Value, Vec) { + let source = tempfile::tempdir().unwrap(); + let output = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(source.path().join("db")).unwrap(); + std::fs::write(source.path().join("db/mdbx.dat"), b"state-data").unwrap(); + for (path, data) in proofs { + let path = source.path().join("proofs").join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, data).unwrap(); + } + + let remote_static_files = std::collections::HashMap::new(); + SnapshotGenerator::generate_manifest(&ManifestGenerationParams { + source_datadir: source.path(), + output_dir: Some(output.path()), + chain_id: 8453, + base_url: None, + block: Some(0), + blocks_per_file: None, + remote_static_files: &remote_static_files, + previous_manifest: None, + upload_proofs: true, + }) + .unwrap(); + + let manifest = + serde_json::from_slice(&std::fs::read(output.path().join("manifest.json")).unwrap()) + .unwrap(); + let archive = std::fs::read(output.path().join("proofs.tar.zst")).unwrap(); + (manifest, archive) + } + + #[tokio::test] + async fn full_pipeline_verifies_generated_snapshot() { + let (manifest, archive) = generate_proofs_snapshot(&[ + ("CURRENT", b"MANIFEST-000014\n"), + ("000060.sst", b"sst-data"), + ("nested/000801.log", b"wal-data"), + ]); + assert_eq!(manifest["components"]["proofs"]["output_files"].as_array().unwrap().len(), 3); + + let (manifest_url, handle) = start_test_server(manifest, archive).await; + let target = tempfile::tempdir().unwrap(); + + ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) + .await + .expect("generated snapshot should download and verify"); + + assert_eq!(std::fs::read(target.path().join("proofs/000060.sst")).unwrap(), b"sst-data"); + assert_eq!( + std::fs::read(target.path().join("proofs/nested/000801.log")).unwrap(), + b"wal-data" + ); + assert!(!target.path().join(".snapshot-cache").exists(), "cache should be cleaned up"); + + handle.abort(); + } + + #[tokio::test] + async fn full_pipeline_rejects_hash_mismatch() { + let (mut manifest, archive) = + generate_proofs_snapshot(&[("000060.sst", b"sst-data"), ("CURRENT", b"current")]); + let output_files = manifest["components"]["proofs"]["output_files"].as_array_mut().unwrap(); + let sst = output_files.iter_mut().find(|file| file["path"] == "proofs/000060.sst").unwrap(); + sst["blake3"] = serde_json::Value::String("00".repeat(32)); + + let (manifest_url, handle) = start_test_server(manifest, archive).await; + let target = tempfile::tempdir().unwrap(); + + let error = ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) + .await + .expect_err("hash mismatch should fail the download"); + + assert!(error.to_string().contains("proofs/000060.sst"), "error: {error}"); + assert!( + target.path().join(".snapshot-cache/proofs.tar.zst").exists(), + "archive should be kept for inspection or retry" + ); + + handle.abort(); + } + + #[tokio::test] + async fn full_pipeline_rejects_size_mismatch() { + let (mut manifest, archive) = generate_proofs_snapshot(&[("000060.sst", b"sst-data")]); + manifest["components"]["proofs"]["output_files"][0]["size"] = serde_json::json!(9); + + let (manifest_url, handle) = start_test_server(manifest, archive).await; + let target = tempfile::tempdir().unwrap(); + + let error = ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) + .await + .expect_err("size mismatch should fail the download"); + + assert!(error.to_string().contains("size 8"), "error: {error}"); + + handle.abort(); + } + + #[tokio::test] + async fn full_pipeline_rejects_archive_missing_manifest_file() { + // The manifest lists a file that the archive does not contain, as happens when an + // archive is cut short at a tar entry boundary. + let (mut manifest, archive) = + generate_proofs_snapshot(&[("000060.sst", b"sst-data"), ("CURRENT", b"current")]); + let output_files = manifest["components"]["proofs"]["output_files"].as_array_mut().unwrap(); + let mut missing = output_files[0].clone(); + missing["path"] = serde_json::Value::String("proofs/000061.sst".to_string()); + output_files.push(missing); + + let (manifest_url, handle) = start_test_server(manifest, archive).await; + let target = tempfile::tempdir().unwrap(); + + let error = ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) + .await + .expect_err("missing file should fail the download"); + + assert!( + error.to_string().contains("missing proofs file proofs/000061.sst"), + "error: {error}" + ); + + handle.abort(); + } + + #[tokio::test] + async fn fetch_manifest_entry_rejects_output_file_traversal() { + for path in ["../escape", "/etc/passwd", "proofs/../../escape", ""] { + let manifest = serde_json::json!({ + "block": 100, + "chain_id": 8453, + "storage_version": 2, + "timestamp": 1700000000, + "components": { + "proofs": { + "file": "proofs.tar.zst", + "size": 100, + "decompressed_size": 0, + "output_files": [{ "path": path, "size": 1, "blake3": "00" }] + } + } + }); + + let (manifest_url, handle) = start_test_server(manifest, vec![]).await; + let error = ProofsDownloader::fetch_manifest_entry(&manifest_url) + .await + .expect_err("unsafe output path should be rejected"); + + assert!( + error.to_string().contains("invalid proofs output file path"), + "path {path:?}: {error}" + ); + + handle.abort(); + } + } + #[test] fn extract_tar_zst_creates_files() { let src = tempfile::tempdir().unwrap(); @@ -1773,6 +2009,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await.unwrap(); @@ -1795,6 +2032,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1) @@ -1841,6 +2079,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1) @@ -1887,6 +2126,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) @@ -1972,6 +2212,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) @@ -2010,6 +2251,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) @@ -2053,6 +2295,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1) @@ -2095,6 +2338,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) @@ -2121,6 +2365,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) @@ -2174,6 +2419,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await.unwrap(); @@ -2213,6 +2459,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await.unwrap(); @@ -2254,6 +2501,7 @@ mod tests { file_name: "proofs.tar.zst".to_string(), expected_size: archive.len() as u64 + 999, archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), }; let result = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await; From da999ae2fbe7121d7969b5e6aeee5f53b348f462 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Mon, 28 Sep 2026 16:45:00 +0000 Subject: [PATCH 139/299] fix(proofs): retain earliest pending revert target (#5363) --- crates/execution/exex/src/sync_target.rs | 44 +++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/execution/exex/src/sync_target.rs b/crates/execution/exex/src/sync_target.rs index f2505a16e0..ea84fb7298 100644 --- a/crates/execution/exex/src/sync_target.rs +++ b/crates/execution/exex/src/sync_target.rs @@ -53,11 +53,19 @@ impl SyncTargetState { // If we are just syncing to tip already, replace with the new state. (Self::SyncUpTo { .. }, new) => new, - // If the new state is a revert, replace with the new state. - (_, Self::Revert { revert_to }) => Self::Revert { revert_to }, - (_, Self::RevertThenSync { revert_to, sync_to }) => { - Self::RevertThenSync { revert_to, sync_to } - } + // A new revert supersedes any pending sync, but retain the earliest revert target. + ( + Self::RevertThenSync { revert_to: current, .. } + | Self::Revert { revert_to: current }, + Self::Revert { revert_to: new }, + ) => Self::Revert { revert_to: earliest_revert(current, new) }, + + // A reorg updates the sync target while retaining the earliest pending revert. + ( + Self::RevertThenSync { revert_to: current, .. } + | Self::Revert { revert_to: current }, + Self::RevertThenSync { revert_to: new, sync_to }, + ) => Self::RevertThenSync { revert_to: earliest_revert(current, new), sync_to }, // If we're currently reverting, replace the sync to value with the new // state. @@ -69,6 +77,11 @@ impl SyncTargetState { } } +/// Returns the first block that must be unwound from two pending revert targets. +const fn earliest_revert(current: &BlockWithParent, new: BlockWithParent) -> BlockWithParent { + if current.block.number <= new.block.number { *current } else { new } +} + /// Sync target that buffers trie data from recent exex notifications. /// /// Routes all notification types (committed, reverted, reorged) through a @@ -307,14 +320,29 @@ mod tests { } #[test] - fn apply_next_revert_then_sync_replaced_by_new_revert() { + fn apply_next_revert_then_sync_keeps_earliest_revert() { let revert_to = block_with_parent(5); let mut state = SyncTargetState::RevertThenSync { revert_to, sync_to: 10 }; - let new_revert = block_with_parent(3); + let new_revert = block_with_parent(7); state.apply_next(SyncTargetState::Revert { revert_to: new_revert }); assert!(matches!( state, - SyncTargetState::Revert { revert_to } if revert_to.block.number == 3 + SyncTargetState::Revert { revert_to } if revert_to.block.number == 5 + )); + } + + #[test] + fn apply_next_reorg_keeps_earliest_revert_and_latest_sync_target() { + let mut state = + SyncTargetState::RevertThenSync { revert_to: block_with_parent(5), sync_to: 8 }; + state.apply_next(SyncTargetState::RevertThenSync { + revert_to: block_with_parent(9), + sync_to: 11, + }); + assert!(matches!( + state, + SyncTargetState::RevertThenSync { revert_to, sync_to: 11 } + if revert_to.block.number == 5 )); } From f76113c53c0f21f9a97c1774f341949353413a69 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 28 Sep 2026 16:54:31 +0000 Subject: [PATCH 140/299] fix(proofs): retain 15 days of 200ms blocks by default (#5362) * fix(proofs): retain 15 days of 200ms blocks by default Amp-Thread-ID: https://ampcode.com/threads/T-01a0db16-d204-7709-a10b-c661e471a435 Co-authored-by: Amp * fix(proofs): allow proof-history windows of at least one block Amp-Thread-ID: https://ampcode.com/threads/T-01a0db16-d204-7709-a10b-c661e471a435 Co-authored-by: Amp --------- Co-authored-by: Amp --- .../cli/src/commands/base_proofs/prune.rs | 35 ++++++++++++++++--- crates/execution/exex/README.md | 2 +- crates/execution/exex/src/lib.rs | 6 ++-- crates/execution/node/src/args.rs | 29 ++++++--------- crates/execution/node/src/lib.rs | 2 +- 5 files changed, 46 insertions(+), 28 deletions(-) diff --git a/crates/execution/cli/src/commands/base_proofs/prune.rs b/crates/execution/cli/src/commands/base_proofs/prune.rs index c4ba747565..0682f1ad86 100644 --- a/crates/execution/cli/src/commands/base_proofs/prune.rs +++ b/crates/execution/cli/src/commands/base_proofs/prune.rs @@ -10,7 +10,6 @@ use base_execution_trie::{ }; use base_node_core::{ DEFAULT_PROOFS_HISTORY_WINDOW_BLOCKS, ProofsHistoryDbBackend, ProofsHistoryRocksdbArgs, - TWELVE_HOURS_IN_BLOCKS, }; use clap::Parser; use reth_cli::chainspec::ChainSpecParser; @@ -47,16 +46,16 @@ pub struct PruneCommand { pub proofs_history_rocksdb: ProofsHistoryRocksdbArgs, /// The window to span blocks for proofs history. Value is the number of blocks. - /// Default is 1 month of blocks based on 2 seconds block time. - /// 30 * 24 * 60 * 60 / 2 = `1_296_000` + /// Default is 15 days of blocks based on 200ms block time. + /// 15 * 24 * 60 * 60 * 5 = `6_480_000` /// - /// Must be greater than 12 hours of blocks based on 2 seconds block time. + /// Must be at least 1 block. #[arg( long = "proofs-history.window", visible_alias = "proofs.window", default_value_t = DEFAULT_PROOFS_HISTORY_WINDOW_BLOCKS, value_name = "PROOFS_HISTORY_WINDOW", - value_parser = clap::value_parser!(u64).range((TWELVE_HOURS_IN_BLOCKS + 1)..) + value_parser = clap::value_parser!(u64).range(1..) )] pub proofs_history_window: u64, @@ -171,3 +170,29 @@ impl PruneCommand { Some(&self.env.chain) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::chainspec::BaseChainSpecParser; + + #[test] + fn proofs_history_window_accepts_one_block_but_rejects_zero() { + let args = ["prune", "--proofs-history.storage-path", "/tmp/proofs"]; + let command = PruneCommand::::parse_from(args); + assert_eq!(command.proofs_history_window, 6_480_000); + for flag in ["--proofs-history.window", "--proofs.window"] { + let command = PruneCommand::::try_parse_from( + args.into_iter().chain([flag, "1"]), + ) + .unwrap(); + assert_eq!(command.proofs_history_window, 1); + assert!( + PruneCommand::::try_parse_from( + args.into_iter().chain([flag, "0"]), + ) + .is_err() + ); + } + } +} diff --git a/crates/execution/exex/README.md b/crates/execution/exex/README.md index 2117b9091c..5d392afdce 100644 --- a/crates/execution/exex/README.md +++ b/crates/execution/exex/README.md @@ -78,7 +78,7 @@ Once `latest` tracks the chain tip, `eth_getProof` calls for every block within base-reth-node proofs prune \ --datadir /path/to/reth-datadir \ --proofs-history.storage-path /path/to/proofs-db \ - --proofs-history.window 1296000 + --proofs-history.window 6480000 ``` **Unwind** — recover from corruption by reverting to a specific block: diff --git a/crates/execution/exex/src/lib.rs b/crates/execution/exex/src/lib.rs index 11b7e3b385..803ed29825 100644 --- a/crates/execution/exex/src/lib.rs +++ b/crates/execution/exex/src/lib.rs @@ -39,8 +39,8 @@ const DEFAULT_MAX_PRUNE_BLOCKS_STARTUP: u64 = 100_000; /// How many blocks to process in a single sync turn before yielding. const SYNC_BLOCKS_PER_TURN: usize = 50; -/// Default proofs history window: 1 month of blocks at 2s block time -const DEFAULT_PROOFS_HISTORY_WINDOW: u64 = 1_296_000; +/// Default proofs history window: 15 days of blocks at 200ms block time. +const DEFAULT_PROOFS_HISTORY_WINDOW: u64 = 6_480_000; /// Default interval between proof-storage prune runs. Default is 15 seconds. const DEFAULT_PRUNE_INTERVAL: Duration = Duration::from_secs(15); @@ -157,7 +157,7 @@ where /// # ).into(); /// /// let storage_exec = storage.clone(); -/// let proofs_history_window = 1_296_000u64; +/// let proofs_history_window = 6_480_000u64; /// let proofs_history_prune_interval = Duration::from_secs(3600); /// /// // Verification interval: perform full execution every N blocks diff --git a/crates/execution/node/src/args.rs b/crates/execution/node/src/args.rs index 4094fe2d4d..53eeb45155 100644 --- a/crates/execution/node/src/args.rs +++ b/crates/execution/node/src/args.rs @@ -13,11 +13,8 @@ use base_execution_txpool::{DEFAULT_PAYMENT_LIMIT, DEFAULT_SIGNATURE_LIMIT}; use base_upgrade_signal::{UpgradeSignalArgs, UpgradeSignalL1RpcArgs}; use clap::{ArgAction, ValueEnum, builder::ArgPredicate}; -/// Default proofs history window: 1 month of blocks at 2s block time. -pub const DEFAULT_PROOFS_HISTORY_WINDOW_BLOCKS: u64 = 1_296_000; - -/// Twelve hours of blocks at 2s block time. -pub const TWELVE_HOURS_IN_BLOCKS: u64 = 21_600; +/// Default proofs history window: 15 days of blocks at 200ms block time. +pub const DEFAULT_PROOFS_HISTORY_WINDOW_BLOCKS: u64 = 6_480_000; const MIB: u64 = 1024 * 1024; const DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_MIB: u64 = 1024; @@ -432,16 +429,16 @@ pub struct RollupArgs { pub proofs_history_mdbx: ProofsHistoryMdbxArgs, /// The window to span blocks for proofs history. Value is the number of blocks. - /// Default is 1 month of blocks based on 2 seconds block time. - /// 30 * 24 * 60 * 60 / 2 = `1_296_000` + /// Default is 15 days of blocks based on 200ms block time. + /// 15 * 24 * 60 * 60 * 5 = `6_480_000` /// - /// Must be greater than 12 hours of blocks based on 2 seconds block time. + /// Must be at least 1 block. #[arg( long = "proofs-history.window", visible_alias = "proofs.window", default_value_t = DEFAULT_PROOFS_HISTORY_WINDOW_BLOCKS, value_name = "PROOFS_HISTORY_WINDOW", - value_parser = clap::value_parser!(u64).range((TWELVE_HOURS_IN_BLOCKS + 1)..) + value_parser = clap::value_parser!(u64).range(1..) )] pub proofs_history_window: u64, @@ -866,18 +863,14 @@ mod tests { #[test] fn test_parse_proofs_history_window() { let args = - CommandParser::::parse_from(["reth", "--proofs-history.window", "21601"]) - .args; - assert_eq!(args.proofs_history_window, 21_601); + CommandParser::::parse_from(["reth", "--proofs-history.window", "1"]).args; + assert_eq!(args.proofs_history_window, 1); } #[test] - fn test_parse_proofs_history_window_rejects_twelve_hours_or_less() { - let result = CommandParser::::try_parse_from([ - "reth", - "--proofs-history.window", - "21600", - ]); + fn test_parse_proofs_history_window_rejects_zero() { + let result = + CommandParser::::try_parse_from(["reth", "--proofs-history.window", "0"]); assert!(result.is_err()); } } diff --git a/crates/execution/node/src/lib.rs b/crates/execution/node/src/lib.rs index ae533b5f71..b81b5b4651 100644 --- a/crates/execution/node/src/lib.rs +++ b/crates/execution/node/src/lib.rs @@ -14,7 +14,7 @@ use reth_db_api as _; pub mod args; pub use args::{ DEFAULT_PROOFS_HISTORY_WINDOW_BLOCKS, HasRollupArgs, ProofsHistoryDbBackend, - ProofsHistoryRocksdbArgs, RollupArgs, TWELVE_HOURS_IN_BLOCKS, TxpoolOrdering, + ProofsHistoryRocksdbArgs, RollupArgs, TxpoolOrdering, }; /// Exports Base-specific implementations of the [`EngineTypes`](reth_node_api::EngineTypes) From 559167467dc1bc36be9e59b1b70831cc5858657a Mon Sep 17 00:00:00 2001 From: Haardik Date: Mon, 28 Sep 2026 17:46:39 +0000 Subject: [PATCH 141/299] feat(download): extract the proofs archive while it downloads (#5365) * feat(download): extract the proofs archive while it downloads * chore: drop proofs download bench recipe from Justfile --- Cargo.lock | 1 + crates/execution/cli/Cargo.toml | 5 + .../execution/cli/benches/proofs_download.rs | 231 ++ crates/execution/cli/src/commands/download.rs | 2251 +---------------- .../download/proofs/archive_download.rs | 941 +++++++ .../download/proofs/archive_extract.rs | 479 ++++ .../commands/download/proofs/downloader.rs | 560 ++++ .../src/commands/download/proofs/manifest.rs | 398 +++ .../cli/src/commands/download/proofs/mod.rs | 33 + .../commands/download/proofs/test_utils.rs | 293 +++ 10 files changed, 2956 insertions(+), 2236 deletions(-) create mode 100644 crates/execution/cli/benches/proofs_download.rs create mode 100644 crates/execution/cli/src/commands/download/proofs/archive_download.rs create mode 100644 crates/execution/cli/src/commands/download/proofs/archive_extract.rs create mode 100644 crates/execution/cli/src/commands/download/proofs/downloader.rs create mode 100644 crates/execution/cli/src/commands/download/proofs/manifest.rs create mode 100644 crates/execution/cli/src/commands/download/proofs/mod.rs create mode 100644 crates/execution/cli/src/commands/download/proofs/test_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 2b6a4736bd..8a6e009490 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4302,6 +4302,7 @@ dependencies = [ "base-upgrade-signal", "blake3", "clap", + "criterion", "eyre", "futures", "humantime", diff --git a/crates/execution/cli/Cargo.toml b/crates/execution/cli/Cargo.toml index ae21b12fcd..147149db03 100644 --- a/crates/execution/cli/Cargo.toml +++ b/crates/execution/cli/Cargo.toml @@ -90,12 +90,17 @@ alloy-primitives.workspace = true base-common-genesis.workspace = true rstest.workspace = true tempfile.workspace = true +criterion.workspace = true axum = { workspace = true, features = ["tokio", "http1"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } [build-dependencies] base-execution-chainspec = { workspace = true, features = ["std"] } +[[bench]] +name = "proofs_download" +harness = false + [features] default = [] diff --git a/crates/execution/cli/benches/proofs_download.rs b/crates/execution/cli/benches/proofs_download.rs new file mode 100644 index 0000000000..aad216d079 --- /dev/null +++ b/crates/execution/cli/benches/proofs_download.rs @@ -0,0 +1,231 @@ +//! Benchmarks for the proofs snapshot download on a synthetic archive. +//! +//! `proofs_extract` unpacks a fully downloaded archive, comparing +//! single-threaded decode and unpack with the decode/unpack thread pipeline. +//! +//! `proofs_download` fetches the archive from a local server throttled to +//! [`SERVER_BYTES_PER_SEC`], comparing a download followed by extraction with +//! extraction while the download runs. + +use std::{ + fs::File, + io::{self, BufReader}, + net::SocketAddr, + path::Path, + sync::Arc, + time::Duration, +}; + +use axum::{ + Router, + body::{Body, Bytes}, + extract::State, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, + routing::get, +}; +use base_execution_cli::commands::download::{ + ProofsArchiveAvailability, ProofsArchiveDownload, ProofsArchiveExtractor, ProofsDownloader, + ProofsManifestEntry, +}; +use criterion::{BatchSize, Criterion, SamplingMode, Throughput, criterion_group, criterion_main}; +use futures::stream; +use tempfile::TempDir; +use tokio::runtime::Runtime; + +/// Files in the synthetic proofs database. +const FILE_COUNT: usize = 32; + +/// Size of each synthetic file. +const FILE_SIZE: usize = 8 << 20; + +/// Download piece size, small enough that the archive spans many pieces. +const PIECE_SIZE: u64 = 4 << 20; + +/// Parallel Range requests. +const CONCURRENCY: usize = 8; + +/// Aggregate rate of the throttled server across all connections. +const SERVER_BYTES_PER_SEC: u64 = 256 << 20; + +/// Body chunk size of the throttled server. +const SERVER_CHUNK: usize = 256 << 10; + +/// Returns `len` bytes that zstd compresses about 2:1. +fn half_compressible(len: usize, seed: u64) -> Vec { + let mut state = seed | 1; + (0..len) + .map(|index| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + if index % 64 < 32 { state as u8 } else { 0 } + }) + .collect() +} + +/// Builds the synthetic `proofs.tar.zst` archive. +fn synthetic_archive() -> Vec { + let mut builder = tar::Builder::new(zstd::Encoder::new(Vec::new(), 3).unwrap()); + for index in 0..FILE_COUNT { + let data = half_compressible(FILE_SIZE, index as u64); + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, format!("proofs/{index:06}.sst"), &data[..]).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +/// Serves `/proofs.tar.zst` Range requests at [`SERVER_BYTES_PER_SEC`] shared +/// evenly across [`CONCURRENCY`] connections. +async fn serve_throttled(State(archive): State>>, headers: HeaderMap) -> Response { + let Some((start, end)) = headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()?.strip_prefix("bytes=")?.split_once('-')) + .and_then(|(start, end)| Some((start.parse::().ok()?, end.parse::().ok()?))) + else { + return StatusCode::RANGE_NOT_SATISFIABLE.into_response(); + }; + + let connection_rate = SERVER_BYTES_PER_SEC as f64 / CONCURRENCY as f64; + let chunk_delay = Duration::from_secs_f64(SERVER_CHUNK as f64 / connection_rate); + let total = archive.len(); + let body = stream::unfold(start, move |offset| { + let archive = Arc::clone(&archive); + async move { + if offset > end { + return None; + } + tokio::time::sleep(chunk_delay).await; + let next = (offset + SERVER_CHUNK).min(end + 1); + Some((Ok::<_, std::io::Error>(Bytes::copy_from_slice(&archive[offset..next])), next)) + } + }); + ( + StatusCode::PARTIAL_CONTENT, + [(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"))], + Body::from_stream(body), + ) + .into_response() +} + +/// Starts the throttled server on `runtime` and returns its address. +fn start_server(runtime: &Runtime, archive: Arc>) -> SocketAddr { + runtime.block_on(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = Router::new().route("/proofs.tar.zst", get(serve_throttled)).with_state(archive); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + addr + }) +} + +/// Unpacks `archive_path` with decoding and unpacking on one thread, reading +/// and draining the archive the same way as the pipeline. +fn extract_single_thread(archive_path: &Path, target_dir: &Path) { + let reader = BufReader::with_capacity(1 << 20, File::open(archive_path).unwrap()); + let mut decoder = zstd::Decoder::with_buffer(reader).unwrap(); + tar::Archive::new(&mut decoder).unpack(target_dir).unwrap(); + io::copy(&mut decoder, &mut io::sink()).unwrap(); +} + +/// Unpacks a fully downloaded `archive_path` with the decode/unpack pipeline. +fn extract_pipelined(archive_path: &Path, archive_len: u64, target_dir: &Path) { + let availability = Arc::new(ProofsArchiveAvailability::new(archive_len)); + availability.publish(archive_len); + ProofsArchiveExtractor::extract(archive_path, availability, target_dir).unwrap(); +} + +fn bench_extract(c: &mut Criterion) { + let archive = synthetic_archive(); + let source = TempDir::new().unwrap(); + let archive_path = source.path().join("proofs.tar.zst"); + std::fs::write(&archive_path, &archive).unwrap(); + let archive_len = archive.len() as u64; + + let mut group = c.benchmark_group("proofs_extract"); + group.sample_size(10).throughput(Throughput::Bytes((FILE_COUNT * FILE_SIZE) as u64)); + group.bench_function("single_thread", |b| { + b.iter_batched( + || TempDir::new().unwrap(), + |target| { + extract_single_thread(&archive_path, target.path()); + target + }, + BatchSize::PerIteration, + ); + }); + group.bench_function("pipelined", |b| { + b.iter_batched( + || TempDir::new().unwrap(), + |target| { + extract_pipelined(&archive_path, archive_len, target.path()); + target + }, + BatchSize::PerIteration, + ); + }); + group.finish(); +} + +fn bench_download(c: &mut Criterion) { + let runtime = Runtime::new().unwrap(); + let archive = Arc::new(synthetic_archive()); + let addr = start_server(&runtime, Arc::clone(&archive)); + let entry = ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("http://{addr}/proofs.tar.zst"), + output_files: Vec::new(), + }; + + let mut group = c.benchmark_group("proofs_download"); + group + .sample_size(10) + .sampling_mode(SamplingMode::Flat) + .measurement_time(Duration::from_secs(10)) + .throughput(Throughput::Bytes(archive.len() as u64)); + group.bench_function("download_then_extract", |b| { + b.iter_batched( + || TempDir::new().unwrap(), + |target| { + let cache_dir = target.path().join(".snapshot-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let download = + ProofsArchiveDownload::new(&entry, &cache_dir, CONCURRENCY, PIECE_SIZE); + runtime.block_on(async { + let pieces = download.prepare().await.unwrap(); + let availability = ProofsArchiveAvailability::new(entry.expected_size); + download.run(pieces, &availability).await.unwrap(); + }); + extract_pipelined(&download.part_path, entry.expected_size, target.path()); + std::fs::remove_dir_all(&cache_dir).unwrap(); + target + }, + BatchSize::PerIteration, + ); + }); + group.bench_function("extract_while_downloading", |b| { + b.iter_batched( + || TempDir::new().unwrap(), + |target| { + runtime + .block_on(ProofsDownloader::download_and_extract( + &entry, + target.path(), + CONCURRENCY, + PIECE_SIZE, + )) + .unwrap(); + target + }, + BatchSize::PerIteration, + ); + }); + group.finish(); +} + +criterion_group!(benches, bench_extract, bench_download); +criterion_main!(benches); diff --git a/crates/execution/cli/src/commands/download.rs b/crates/execution/cli/src/commands/download.rs index d60b57b676..d8cfdf5970 100644 --- a/crates/execution/cli/src/commands/download.rs +++ b/crates/execution/cli/src/commands/download.rs @@ -1,35 +1,26 @@ //! Download command wrapper that extends reth's `DownloadCommand` with `--proofs`. //! -//! Delegates all standard snapshot components to reth's download pipeline and -//! handles the Base-specific proofs database download separately using the -//! same snapshot source and manifest. +//! Delegates all standard snapshot components to reth's download pipeline. The +//! Base-specific proofs database is downloaded separately by [`ProofsDownloader`] +//! from the same snapshot source and manifest. -use std::{ - ffi::OsString, - io::{Read, SeekFrom}, - path::{Component, Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, - time::{Duration, Instant}, -}; +use std::{ffi::OsString, path::PathBuf, sync::Arc}; use base_execution_chainspec::BaseChainSpec; -use base_reth_cli::{OutputFileChecksum, ProgressDisplay}; use clap::Parser; use eyre::Result; -use futures::{StreamExt, future::try_join_all}; -use rayon::prelude::*; use reth_chainspec::EthChainSpec; use reth_cli::chainspec::ChainSpecParser; -use reth_cli_commands::download::{DownloadCommand, DownloadDefaults}; +use reth_cli_commands::download::DownloadCommand; use reth_node_core::args::DatadirArgs; -use tokio::{ - io::{AsyncSeekExt, AsyncWriteExt}, - sync::Mutex, +use tracing::info; + +mod proofs; +pub use proofs::{ + ProofsArchiveAvailability, ProofsArchiveDownload, ProofsArchiveExtractor, ProofsArchiveReader, + ProofsArchiveSizeMismatch, ProofsAvailabilityAbortGuard, ProofsDecodedChunks, ProofsDownloader, + ProofsExtractionProgress, ProofsManifest, ProofsManifestEntry, ProofsPieceMap, }; -use tracing::{debug, info, warn}; /// Download Base node snapshots from R2 storage. /// @@ -75,7 +66,8 @@ impl> BaseDownloadCommand { if let (Some(data_dir), Some(chain_id)) = (data_dir, chain_id) { let target_dir = data_dir.data_dir().to_path_buf(); let concurrency = resolve_download_concurrency_arg(std::env::args_os()); - ProofsDownloader::run(&target_dir, chain_id, concurrency).await?; + let manifest_url = resolve_manifest_url_arg(std::env::args_os()); + ProofsDownloader::run(&target_dir, chain_id, concurrency, manifest_url).await?; } Ok(()) @@ -109,9 +101,6 @@ fn resolve_datadir_args(args: impl IntoIterator) -> DatadirArgs /// Matches reth's `--download-concurrency` default. const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 8; -/// Interval between proofs download and extraction progress logs. -const PROOFS_PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(3); - /// Extracts `--download-concurrency` so proofs use the same parallel budget as reth. fn resolve_download_concurrency_arg(args: impl IntoIterator) -> usize { let mut concurrency = DEFAULT_DOWNLOAD_CONCURRENCY; @@ -139,145 +128,6 @@ fn resolve_download_concurrency_arg(args: impl IntoIterator) -> concurrency.max(1) } -/// Splits `total` bytes into `parts` contiguous half-open ranges. -fn split_byte_ranges(total: u64, parts: usize) -> Vec> { - let parts = parts.max(1); - if total == 0 { - return Vec::new(); - } - - let parts = parts.min(total as usize); - let chunk = total / parts as u64; - let rem = total % parts as u64; - let mut ranges = Vec::with_capacity(parts); - let mut start = 0u64; - for i in 0..parts { - let extra = u64::from((i as u64) < rem); - let end = start + chunk + extra; - ranges.push(start..end); - start = end; - } - ranges -} - -/// Writes parallel-range resume state next to the `.part` file. -async fn persist_range_sidecar( - path: &Path, - concurrency: usize, - expected_size: u64, - written: &[u64], -) -> Result<()> { - let mut line = format!("{concurrency} {expected_size}"); - for amount in written { - line.push(' '); - line.push_str(&amount.to_string()); - } - tokio::fs::write(path, line).await?; - Ok(()) -} - -/// Delay before an idle proofs download retry. -/// -/// Stream drops stay at 1s. HTTP 429/5xx use exponential backoff so a -/// rate-limiting CDN is not hammered. -fn retry_delay(idle_attempts: u32, throttled: bool) -> Duration { - if !throttled { - return Duration::from_secs(1); - } - - let multiplier = 1u64 << idle_attempts.saturating_sub(1).min(4); - Duration::from_secs((2 * multiplier).min(30)) -} - -/// Logs proofs download progress using the same fields as snapshot compression. -fn log_proofs_download_progress(downloaded: u64, expected: u64, started: Instant, baseline: u64) { - let elapsed = started.elapsed(); - let session_done = downloaded.saturating_sub(baseline); - let session_total = expected.saturating_sub(baseline); - let speed = session_done as f64 / elapsed.as_secs_f64(); - let eta = ProgressDisplay::eta(session_done, session_total, elapsed) - .map_or_else(|| "unknown".to_string(), |eta| eta.to_string()); - info!( - target: "reth::cli", - progress = %ProgressDisplay::human_byte_progress(downloaded, expected), - speed = %ProgressDisplay::speed(speed), - eta = %eta, - elapsed = %ProgressDisplay::duration(elapsed), - "Proofs download progress" - ); -} - -/// Reports extraction progress as compressed archive bytes are consumed. -struct ExtractionProgress { - inner: R, - processed: u64, - total: u64, - started: Instant, - last_log: Instant, -} - -impl ExtractionProgress { - fn new(inner: R, total: u64) -> Self { - let now = Instant::now(); - Self { inner, processed: 0, total, started: now, last_log: now } - } -} - -impl Read for ExtractionProgress { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let read = self.inner.read(buf)?; - self.processed = self.processed.saturating_add(read as u64).min(self.total); - - if read > 0 && self.last_log.elapsed() >= PROOFS_PROGRESS_LOG_INTERVAL { - let elapsed = self.started.elapsed(); - let speed = self.processed as f64 / elapsed.as_secs_f64(); - let eta = ProgressDisplay::eta(self.processed, self.total, elapsed) - .map_or_else(|| "unknown".to_string(), |eta| eta.to_string()); - info!( - target: "reth::cli", - progress = %ProgressDisplay::human_byte_progress(self.processed, self.total), - speed = %ProgressDisplay::speed(speed), - eta = %eta, - elapsed = %ProgressDisplay::duration(elapsed), - "Proofs extraction progress" - ); - self.last_log = Instant::now(); - } - - Ok(read) - } -} - -/// Loads parallel-range resume state when it matches this download. -fn load_range_sidecar(path: &Path, concurrency: usize, expected_size: u64) -> Option> { - let text = std::fs::read_to_string(path).ok()?; - let mut parts = text.split_whitespace(); - let stored_concurrency = parts.next()?.parse::().ok()?; - let stored_expected = parts.next()?.parse::().ok()?; - if stored_concurrency != concurrency || stored_expected != expected_size { - return None; - } - - let written = parts.map(|part| part.parse().ok()).collect::>>()?; - (written.len() == concurrency).then_some(written) -} - -/// Trusts a sidecar only when the `.part` file is already the declared size. -/// -/// A missing or resized `.part` means the sidecar describes bytes that are -/// no longer on disk, so resume must start from zero. -fn load_trusted_range_sidecar( - path: &Path, - concurrency: usize, - expected_size: u64, - part_len: u64, -) -> Option> { - if part_len != expected_size { - return None; - } - load_range_sidecar(path, concurrency, expected_size) -} - /// Extracts `--manifest-url` so proofs reuse the same snapshot reth downloaded. fn resolve_manifest_url_arg(args: impl IntoIterator) -> Option { let mut args = args.into_iter(); @@ -296,78 +146,6 @@ fn resolve_manifest_url_arg(args: impl IntoIterator) -> Option< None } -/// Reads a snapshot API field that may be a JSON number or a numeric string. -fn json_u64(value: Option<&serde_json::Value>) -> Option { - match value { - Some(serde_json::Value::Number(n)) => n.as_u64(), - Some(serde_json::Value::String(s)) => s.parse().ok(), - _ => None, - } -} - -/// Discovers the latest modular snapshot manifest URL for `chain_id`. -/// -/// Reth's download pipeline queries the snapshot API (`metadataUrl`) rather than -/// concatenating `{default_base_url}/{chain_id}/manifest.json`. Each chain publishes -/// manifests under its own bucket (`https://zeronet-v2-snapshots.base.org/...`), so -/// deriving a URL from the snapshot root instead produces 404s. -async fn discover_latest_manifest_url(api_url: &str, chain_id: u64) -> Result { - info!( - target: "reth::cli", - api_url = %api_url, - chain_id, - "Discovering latest snapshot manifest for proofs" - ); - - let client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(30)) - .timeout(std::time::Duration::from_secs(60)) - .build()?; - - let listing: serde_json::Value = client - .get(api_url) - .send() - .await - .map_err(|e| eyre::eyre!("failed to fetch snapshot listing from {api_url}: {e}"))? - .error_for_status() - .map_err(|e| eyre::eyre!("failed to fetch snapshot listing from {api_url}: {e}"))? - .json() - .await - .map_err(|e| eyre::eyre!("failed to parse snapshot listing from {api_url}: {e}"))?; - - let entries = listing - .as_array() - .ok_or_else(|| eyre::eyre!("snapshot listing from {api_url} is not a JSON array"))?; - - let (block, metadata_url) = entries - .iter() - .filter_map(|entry| { - let id = json_u64(entry.get("chainId"))?; - if id != chain_id { - return None; - } - let metadata_url = entry.get("metadataUrl").and_then(|v| v.as_str())?; - if !metadata_url.ends_with("manifest.json") { - return None; - } - let block = json_u64(entry.get("block"))?; - Some((block, metadata_url.to_string())) - }) - .max_by_key(|(block, _)| *block) - .ok_or_else(|| { - eyre::eyre!("no modular snapshot manifest found for chain {chain_id} at {api_url}") - })?; - - info!( - target: "reth::cli", - block, - url = %metadata_url, - "Found latest snapshot manifest for proofs" - ); - - Ok(metadata_url) -} - impl BaseDownloadCommand { /// Returns the underlying chain spec. pub fn chain_spec(&self) -> Option<&Arc> { @@ -375,755 +153,11 @@ impl BaseDownloadCommand { } } -/// Metadata parsed from the manifest's `proofs` component. -#[derive(Debug)] -struct ProofsManifestEntry { - file_name: String, - expected_size: u64, - archive_url: String, - /// Files the archive must extract to, relative to the target datadir. - output_files: Vec, -} - -/// Consecutive Range-resume attempts that write no new bytes before failing. -/// -/// Resets on progress so a multi-hundred-GiB Cloudflare GET can drop many -/// times without aborting the process. -const MAX_IDLE_DOWNLOAD_ATTEMPTS: u32 = 8; - -/// Idle read timeout so a stalled CDN socket fails into retry instead of hanging. -const DOWNLOAD_READ_TIMEOUT: Duration = Duration::from_secs(120); - -/// Shared write-progress for one parallel proofs download. -#[derive(Clone)] -struct RangeProgress { - sidecar_path: PathBuf, - concurrency: usize, - expected_size: u64, - written: Arc>>, - progress: Arc, -} - -/// Downloads the proofs database from a snapshot manifest. -/// -/// Encapsulates the full pipeline: manifest fetch → archive download with -/// resume → size verification → tar+zstd extraction → cache cleanup. -#[derive(Debug)] -struct ProofsDownloader; - -impl ProofsDownloader { - /// Runs the full proofs download pipeline for the given chain. - async fn run(target_dir: &Path, chain_id: u64, concurrency: usize) -> Result<()> { - let manifest_url = match resolve_manifest_url_arg(std::env::args_os()) { - Some(url) => url, - None => { - let api_url = DownloadDefaults::get_global().snapshot_api_url.as_ref(); - discover_latest_manifest_url(api_url, chain_id).await? - } - }; - - Self::run_from_manifest(target_dir, &manifest_url, concurrency).await - } - - /// Runs the full proofs download pipeline from a manifest URL. - async fn run_from_manifest( - target_dir: &Path, - manifest_url: &str, - concurrency: usize, - ) -> Result<()> { - let entry = Self::fetch_manifest_entry(manifest_url).await?; - - let cache_dir = target_dir.join(".snapshot-cache"); - tokio::fs::create_dir_all(&cache_dir).await?; - - let archive_path = Self::download_archive(&entry, &cache_dir, concurrency).await?; - - Self::extract_and_cleanup(&archive_path, target_dir, &cache_dir, entry.output_files).await - } - - /// Fetches the manifest and extracts the proofs component metadata. - async fn fetch_manifest_entry(manifest_url: &str) -> Result { - info!(target: "reth::cli", manifest_url = %manifest_url, "Fetching manifest for proofs component"); - - let client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(30)) - .timeout(std::time::Duration::from_secs(60)) - .build()?; - - let manifest: serde_json::Value = client - .get(manifest_url) - .send() - .await - .map_err(|e| eyre::eyre!("failed to fetch manifest from {manifest_url}: {e}"))? - .error_for_status() - .map_err(|e| eyre::eyre!("failed to fetch manifest from {manifest_url}: {e}"))? - .json() - .await - .map_err(|e| eyre::eyre!("failed to parse manifest from {manifest_url}: {e}"))?; - - let proofs_component = - manifest.get("components").and_then(|c| c.get("proofs")).ok_or_else(|| { - eyre::eyre!( - "manifest has no 'proofs' component — this snapshot does not include proofs" - ) - })?; - - let file_name = proofs_component - .get("file") - .and_then(|f| f.as_str()) - .ok_or_else(|| eyre::eyre!("proofs component missing 'file' field in manifest"))? - .to_string(); - - let expected_size = proofs_component - .get("size") - .and_then(|s| s.as_u64()) - .ok_or_else(|| eyre::eyre!("proofs component missing 'size' field in manifest"))?; - - let file_path = std::path::Path::new(&file_name); - if file_path.is_absolute() - || file_name.contains("..") - || file_path.components().count() != 1 - { - eyre::bail!("invalid proofs file name in manifest: {file_name}"); - } - - let archive_base_url = manifest_url - .rsplit_once('/') - .map(|(base, _)| base.to_string()) - .ok_or_else(|| eyre::eyre!("malformed manifest URL: {manifest_url}"))?; - - let archive_url = format!("{archive_base_url}/{file_name}"); - - let output_files: Vec = match proofs_component.get("output_files") { - Some(files) => serde_json::from_value(files.clone()).map_err(|e| { - eyre::eyre!("invalid 'output_files' for proofs component in manifest: {e}") - })?, - None => Vec::new(), - }; - if let Some(file) = output_files.iter().find(|file| { - file.path.is_empty() - || Path::new(&file.path) - .components() - .any(|component| !matches!(component, Component::Normal(_))) - }) { - eyre::bail!("invalid proofs output file path in manifest: {}", file.path); - } - - Ok(ProofsManifestEntry { file_name, expected_size, archive_url, output_files }) - } - - /// Downloads the proofs archive with in-process resume and size verification. - /// - /// `concurrency > 1` splits the archive into parallel Range streams. An - /// existing sequential `.part` leftover is finished as a single stream so - /// an interrupted one-stream download is not thrown away. - async fn download_archive( - entry: &ProofsManifestEntry, - cache_dir: &Path, - concurrency: usize, - ) -> Result { - let dest_path = cache_dir.join(&entry.file_name); - let part_path = cache_dir.join(format!("{}.part", entry.file_name)); - let sidecar_path = cache_dir.join(format!("{}.part.ranges", entry.file_name)); - let concurrency = concurrency.max(1); - - let existing_size = tokio::fs::metadata(&part_path).await.map(|m| m.len()).unwrap_or(0); - let has_sidecar = tokio::fs::try_exists(&sidecar_path).await.unwrap_or(false); - - if existing_size == entry.expected_size && !has_sidecar { - info!(target: "reth::cli", "Part file already matches expected size, skipping download"); - tokio::fs::rename(&part_path, &dest_path).await?; - return Ok(dest_path); - } - - if existing_size > entry.expected_size { - info!( - target: "reth::cli", - existing_size, - expected_size = entry.expected_size, - "Part file exceeds expected size, restarting proofs download" - ); - tokio::fs::remove_file(&part_path).await.ok(); - tokio::fs::remove_file(&sidecar_path).await.ok(); - } - - let sequential_leftover = - existing_size > 0 && existing_size < entry.expected_size && !has_sidecar; - - if concurrency == 1 || sequential_leftover { - if sequential_leftover && concurrency > 1 { - info!( - target: "reth::cli", - existing_size, - "Finishing existing sequential proofs .part before using parallel ranges" - ); - } - return Self::download_archive_sequential(entry, cache_dir).await; - } - - Self::download_archive_parallel(entry, &part_path, &dest_path, &sidecar_path, concurrency) - .await - } - - /// Downloads the proofs archive as a single resumable stream. - /// - /// Stream drops and truncated bodies retry from the current `.part` offset. - /// A complete HTTP entity whose size does not match the manifest is still - /// a hard error. - async fn download_archive_sequential( - entry: &ProofsManifestEntry, - cache_dir: &Path, - ) -> Result { - let dest_path = cache_dir.join(&entry.file_name); - let part_path = cache_dir.join(format!("{}.part", entry.file_name)); - let sidecar_path = cache_dir.join(format!("{}.part.ranges", entry.file_name)); - - if tokio::fs::try_exists(&sidecar_path).await.unwrap_or(false) { - info!( - target: "reth::cli", - "Discarding parallel range sidecar and preallocated .part before sequential download" - ); - tokio::fs::remove_file(&sidecar_path).await.ok(); - tokio::fs::remove_file(&part_path).await.ok(); - } - - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(30)) - .read_timeout(DOWNLOAD_READ_TIMEOUT) - .build()?; - - info!(target: "reth::cli", url = %entry.archive_url, "Downloading proofs database"); - - let mut idle_attempts = 0u32; - loop { - let mut existing_size = - tokio::fs::metadata(&part_path).await.map(|m| m.len()).unwrap_or(0); - - if existing_size == entry.expected_size { - info!(target: "reth::cli", "Part file already matches expected size, skipping download"); - tokio::fs::rename(&part_path, &dest_path).await?; - return Ok(dest_path); - } - - if existing_size > entry.expected_size { - info!( - target: "reth::cli", - existing_size, - expected_size = entry.expected_size, - "Part file exceeds expected size, restarting proofs download" - ); - tokio::fs::remove_file(&part_path).await.ok(); - existing_size = 0; - } - - let mut request = client.get(&entry.archive_url); - if existing_size > 0 { - request = request.header("Range", format!("bytes={existing_size}-")); - info!(target: "reth::cli", resume_from = existing_size, "Resuming proofs download"); - } - - let response = match request.send().await { - Ok(response) => response, - Err(error) => { - Self::wait_before_retry( - &mut idle_attempts, - false, - false, - &format!("failed to download proofs from {}: {error}", entry.archive_url), - ) - .await?; - continue; - } - }; - let status = response.status(); - - if !status.is_success() { - if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS { - Self::wait_before_retry( - &mut idle_attempts, - false, - true, - &format!( - "proofs download failed with HTTP {status}: {}", - entry.archive_url - ), - ) - .await?; - continue; - } - eyre::bail!("proofs download failed with HTTP {status}: {}", entry.archive_url); - } - - let is_resume = status == reqwest::StatusCode::PARTIAL_CONTENT; - - if existing_size > 0 && !is_resume { - info!(target: "reth::cli", "Server returned full response despite range request, restarting download"); - tokio::fs::remove_file(&part_path).await.ok(); - } - - let start_size = if is_resume { existing_size } else { 0 }; - let content_length = response.content_length(); - - let mut file = tokio::fs::OpenOptions::new() - .create(true) - .append(is_resume) - .write(!is_resume) - .truncate(!is_resume) - .open(&part_path) - .await?; - - let mut downloaded = start_size; - let started = Instant::now(); - let mut last_log = started; - let mut stream_error = None; - - let mut stream = response.bytes_stream(); - while let Some(chunk_result) = stream.next().await { - match chunk_result { - Ok(chunk) => { - file.write_all(&chunk).await?; - downloaded += chunk.len() as u64; - if last_log.elapsed() >= PROOFS_PROGRESS_LOG_INTERVAL { - log_proofs_download_progress( - downloaded, - entry.expected_size, - started, - start_size, - ); - last_log = Instant::now(); - } - } - Err(error) => { - stream_error = Some(error); - break; - } - } - } - file.flush().await?; - file.shutdown().await?; - - let downloaded_size = tokio::fs::metadata(&part_path).await?.len(); - let written_this_attempt = downloaded_size.saturating_sub(start_size); - let entity_complete = stream_error.is_none() - && content_length.map_or(downloaded_size == entry.expected_size, |len| { - written_this_attempt >= len - }); - - if !entity_complete { - let reason = match (stream_error, content_length) { - (Some(error), _) => { - format!("stream interrupted downloading {}: {error}", entry.archive_url) - } - (None, Some(len)) => format!( - "truncated body downloading {}: received {written_this_attempt} of {len} bytes", - entry.archive_url - ), - (None, None) => format!( - "incomplete download without Content-Length {}: {downloaded_size}/{} bytes", - entry.archive_url, entry.expected_size - ), - }; - Self::wait_before_retry( - &mut idle_attempts, - downloaded_size > start_size, - false, - &reason, - ) - .await?; - continue; - } - - if downloaded_size != entry.expected_size { - tokio::fs::remove_file(&part_path).await.ok(); - eyre::bail!( - "proofs archive size mismatch: downloaded {downloaded_size} bytes, \ - manifest declares {} bytes — archive may be truncated or corrupt", - entry.expected_size - ); - } - - tokio::fs::rename(&part_path, &dest_path).await?; - return Ok(dest_path); - } - } - - /// Waits before retrying an interrupted proofs download. - /// - /// Progress resets the idle counter so a large Cloudflare GET can drop - /// many times. Eight idle attempts without new bytes is treated as stalled. - /// HTTP 429/5xx use exponential backoff; stream drops stay at 1s. - async fn wait_before_retry( - idle_attempts: &mut u32, - made_progress: bool, - throttled: bool, - reason: &str, - ) -> Result<()> { - if made_progress { - *idle_attempts = 0; - debug!(target: "reth::cli", error = %reason, "Proofs download interrupted, resuming"); - return Ok(()); - } - - *idle_attempts += 1; - if *idle_attempts >= MAX_IDLE_DOWNLOAD_ATTEMPTS { - eyre::bail!( - "proofs download stalled after {MAX_IDLE_DOWNLOAD_ATTEMPTS} attempts without progress: {reason}" - ); - } - - let delay = retry_delay(*idle_attempts, throttled); - debug!( - target: "reth::cli", - error = %reason, - idle_attempts = *idle_attempts, - retry_in_secs = delay.as_secs(), - "Proofs download interrupted without progress, retrying" - ); - tokio::time::sleep(delay).await; - Ok(()) - } - - /// Downloads the proofs archive with parallel Range streams. - async fn download_archive_parallel( - entry: &ProofsManifestEntry, - part_path: &Path, - dest_path: &Path, - sidecar_path: &Path, - concurrency: usize, - ) -> Result { - let ranges = split_byte_ranges(entry.expected_size, concurrency); - let part_len = tokio::fs::metadata(part_path).await.map(|m| m.len()).unwrap_or(0); - let written = - load_trusted_range_sidecar(sidecar_path, concurrency, entry.expected_size, part_len) - .unwrap_or_else(|| vec![0; ranges.len()]); - let initial_progress: u64 = written.iter().sum(); - - persist_range_sidecar(sidecar_path, concurrency, entry.expected_size, &written).await?; - - let file = tokio::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(part_path) - .await?; - file.set_len(entry.expected_size).await?; - drop(file); - - info!( - target: "reth::cli", - url = %entry.archive_url, - streams = ranges.len(), - resume = %ProgressDisplay::bytes(initial_progress as f64), - expected = %ProgressDisplay::bytes(entry.expected_size as f64), - "Downloading proofs database with parallel Range requests" - ); - - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(30)) - .read_timeout(DOWNLOAD_READ_TIMEOUT) - .pool_max_idle_per_host(concurrency) - .build()?; - let progress = Arc::new(AtomicU64::new(initial_progress)); - let written = Arc::new(Mutex::new(written)); - - let ticker = { - let progress = Arc::clone(&progress); - let expected_size = entry.expected_size; - let started = Instant::now(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(PROOFS_PROGRESS_LOG_INTERVAL); - interval.tick().await; - loop { - interval.tick().await; - log_proofs_download_progress( - progress.load(Ordering::Relaxed), - expected_size, - started, - initial_progress, - ); - } - }) - }; - - let range_progress = RangeProgress { - sidecar_path: sidecar_path.to_path_buf(), - concurrency, - expected_size: entry.expected_size, - written, - progress, - }; - - let downloads = ranges.iter().enumerate().map(|(range_idx, range)| { - let client = client.clone(); - let url = entry.archive_url.clone(); - let part_path = part_path.to_path_buf(); - let range_progress = range_progress.clone(); - let range = range.clone(); - async move { - Self::download_range(&client, &url, &part_path, range, range_idx, range_progress) - .await - } - }); - - let result = try_join_all(downloads).await; - ticker.abort(); - result?; - - let downloaded_size = tokio::fs::metadata(part_path).await?.len(); - if downloaded_size != entry.expected_size { - tokio::fs::remove_file(part_path).await.ok(); - tokio::fs::remove_file(sidecar_path).await.ok(); - eyre::bail!( - "proofs archive size mismatch: downloaded {downloaded_size} bytes, \ - manifest declares {} bytes — archive may be truncated or corrupt", - entry.expected_size - ); - } - - tokio::fs::remove_file(sidecar_path).await.ok(); - tokio::fs::rename(part_path, dest_path).await?; - Ok(dest_path.to_path_buf()) - } - - /// Downloads one byte range, retrying stream drops from the current offset. - async fn download_range( - client: &reqwest::Client, - url: &str, - part_path: &Path, - range: std::ops::Range, - range_idx: usize, - range_progress: RangeProgress, - ) -> Result<()> { - let range_len = range.end.saturating_sub(range.start); - let mut idle_attempts = 0u32; - - loop { - let already = range_progress.written.lock().await[range_idx]; - if already >= range_len { - return Ok(()); - } - - let abs_start = range.start + already; - let response = match client - .get(url) - .header("Range", format!("bytes={abs_start}-{}", range.end.saturating_sub(1))) - .send() - .await - { - Ok(response) => response, - Err(error) => { - Self::wait_before_retry( - &mut idle_attempts, - false, - false, - &format!( - "failed to download proofs range {abs_start}-{} from {url}: {error}", - range.end - ), - ) - .await?; - continue; - } - }; - - let status = response.status(); - if status != reqwest::StatusCode::PARTIAL_CONTENT { - if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS { - Self::wait_before_retry( - &mut idle_attempts, - false, - true, - &format!("proofs range download failed with HTTP {status}: {url}"), - ) - .await?; - continue; - } - eyre::bail!( - "expected HTTP 206 for proofs range {abs_start}-{}, got {status}: {url}", - range.end - ); - } - - let content_length = response.content_length(); - let mut file = tokio::fs::OpenOptions::new().write(true).open(part_path).await?; - file.seek(SeekFrom::Start(abs_start)).await?; - - let mut offset = abs_start; - let mut stream_error = None; - let mut stream = response.bytes_stream(); - while let Some(chunk_result) = stream.next().await { - match chunk_result { - Ok(chunk) => { - file.write_all(&chunk).await?; - offset += chunk.len() as u64; - range_progress.progress.fetch_add(chunk.len() as u64, Ordering::Relaxed); - } - Err(error) => { - stream_error = Some(error); - break; - } - } - } - file.flush().await?; - - let done = offset.saturating_sub(range.start).min(range_len); - { - let mut written = range_progress.written.lock().await; - written[range_idx] = done; - persist_range_sidecar( - &range_progress.sidecar_path, - range_progress.concurrency, - range_progress.expected_size, - &written, - ) - .await?; - } - - if done >= range_len { - return Ok(()); - } - - let written_this_attempt = offset.saturating_sub(abs_start); - let truncated = content_length.is_some_and(|len| written_this_attempt < len); - let reason = match stream_error { - Some(error) => { - format!( - "stream interrupted downloading proofs range {abs_start}-{} from {url}: {error}", - range.end - ) - } - None if truncated => { - format!( - "truncated proofs range {abs_start}-{} from {url}: received {written_this_attempt} of {} bytes", - range.end, - content_length.unwrap_or(0) - ) - } - None => { - format!( - "incomplete proofs range {abs_start}-{} from {url}: {done}/{range_len} bytes", - range.end - ) - } - }; - - Self::wait_before_retry(&mut idle_attempts, written_this_attempt > 0, false, &reason) - .await?; - } - } - - /// Extracts the archive, verifies the extracted files, and cleans up the cache directory. - /// - /// The cache is left in place when extraction or verification fails. - async fn extract_and_cleanup( - archive_path: &Path, - target_dir: &Path, - cache_dir: &Path, - output_files: Vec, - ) -> Result<()> { - info!(target: "reth::cli", "Extracting proofs archive"); - - let extract_target = target_dir.to_path_buf(); - let extract_path = archive_path.to_path_buf(); - tokio::task::spawn_blocking(move || { - Self::extract_tar_zst(&extract_path, &extract_target)?; - Self::verify_output_files(&extract_target, &output_files) - }) - .await??; - - tokio::fs::remove_file(archive_path).await.ok(); - tokio::fs::remove_dir_all(cache_dir).await.ok(); - - info!(target: "reth::cli", "Proofs database download complete"); - Ok(()) - } - - /// Extracts a `.tar.zst` archive into the target directory. - fn extract_tar_zst(archive_path: &Path, target_dir: &Path) -> Result<()> { - let file = std::fs::File::open(archive_path) - .map_err(|e| eyre::eyre!("failed to open {}: {e}", archive_path.display()))?; - let archive_size = file.metadata()?.len(); - let progress = ExtractionProgress::new(file, archive_size); - let decoder = zstd::Decoder::new(progress)?; - let mut archive = tar::Archive::new(decoder); - archive.unpack(target_dir)?; - Ok(()) - } - - /// Verifies that every manifest output file exists under `target_dir` with the expected size - /// and BLAKE3 hash. - /// - /// Files are hashed in parallel on the Rayon pool. Manifests without `output_files` are - /// accepted with a warning, since they carry nothing to verify against. - fn verify_output_files(target_dir: &Path, output_files: &[OutputFileChecksum]) -> Result<()> { - if output_files.is_empty() { - warn!(target: "reth::cli", "Manifest lists no proofs output files, skipping verification"); - return Ok(()); - } - - let total_bytes: u64 = output_files.iter().map(|file| file.size).sum(); - info!( - target: "reth::cli", - files = output_files.len(), - size = %ProgressDisplay::bytes(total_bytes as f64), - "Verifying extracted proofs files" - ); - let started = Instant::now(); - - output_files.par_iter().try_for_each(|expected| { - let path = target_dir.join(&expected.path); - let file = std::fs::File::open(&path) - .map_err(|e| eyre::eyre!("missing proofs file {}: {e}", expected.path))?; - let size = file.metadata()?.len(); - if size != expected.size { - eyre::bail!( - "proofs file {} has size {size}, manifest expects {}", - expected.path, - expected.size - ); - } - let hash = blake3::Hasher::new().update_reader(file)?.finalize().to_hex(); - if !hash.as_str().eq_ignore_ascii_case(&expected.blake3) { - eyre::bail!( - "proofs file {} has BLAKE3 {hash}, manifest expects {}", - expected.path, - expected.blake3 - ); - } - Ok(()) - })?; - - info!( - target: "reth::cli", - files = output_files.len(), - elapsed = ?started.elapsed(), - "Verified extracted proofs files" - ); - Ok(()) - } -} - #[cfg(test)] mod tests { - use std::{ - path::Path, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - }; + use std::path::Path; - use axum::{ - Router, - body::{Body, Bytes}, - extract::State, - http::{HeaderMap, StatusCode}, - response::IntoResponse, - routing::get, - }; - use base_reth_cli::{ManifestGenerationParams, SnapshotGenerator}; use clap::Parser; - use futures::stream; use super::*; use crate::chainspec::BaseChainSpecParser; @@ -1134,249 +168,6 @@ mod tests { args: BaseDownloadCommand, } - fn create_proofs_archive(content_pairs: &[(&str, &[u8])]) -> Vec { - let mut buf = Vec::new(); - let encoder = zstd::Encoder::new(&mut buf, 0).unwrap(); - let mut builder = tar::Builder::new(encoder); - - for (path, data) in content_pairs { - let mut header = tar::Header::new_gnu(); - header.set_size(data.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder.append_data(&mut header, path, *data).unwrap(); - } - - let encoder = builder.into_inner().unwrap(); - encoder.finish().unwrap(); - buf - } - - async fn start_test_server( - manifest_json: serde_json::Value, - archive_bytes: Vec, - ) -> (String, tokio::task::JoinHandle<()>) { - let manifest_bytes = serde_json::to_vec(&manifest_json).unwrap(); - let manifest_clone = manifest_bytes.clone(); - let archive_clone = archive_bytes.clone(); - - let app = Router::new() - .route( - "/manifest.json", - get(move || { - let data = manifest_clone.clone(); - async move { ([(axum::http::header::CONTENT_TYPE, "application/json")], data) } - }), - ) - .route( - "/proofs.tar.zst", - get(move || { - let data = archive_clone.clone(); - async move { data } - }), - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let manifest_url = format!("http://127.0.0.1:{}/manifest.json", addr.port()); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - - (manifest_url, handle) - } - - async fn start_range_aware_server( - archive_bytes: Vec, - ) -> (String, tokio::task::JoinHandle<()>) { - let app = - Router::new().route("/proofs.tar.zst", get(handle_range)).with_state(archive_bytes); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let base_url = format!("http://127.0.0.1:{}", addr.port()); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - - (base_url, handle) - } - - fn parse_byte_range(headers: &HeaderMap, len: usize) -> Option<(usize, usize)> { - let spec = headers.get("Range")?.to_str().ok()?.strip_prefix("bytes=")?; - let (start, end) = spec.split_once('-')?; - let start = start.parse::().ok()?; - let end = if end.is_empty() { len.saturating_sub(1) } else { end.parse::().ok()? }; - let end = end.min(len.saturating_sub(1)); - (start < len && start <= end).then_some((start, end)) - } - - async fn handle_range(State(data): State>, headers: HeaderMap) -> impl IntoResponse { - if let Some((start, end)) = parse_byte_range(&headers, data.len()) { - return ( - StatusCode::PARTIAL_CONTENT, - [( - axum::http::header::CONTENT_RANGE, - format!("bytes {start}-{end}/{}", data.len()), - )], - data[start..=end].to_vec(), - ) - .into_response(); - } - (StatusCode::OK, data).into_response() - } - - #[derive(Clone)] - struct RecordingRangeState { - data: Vec, - requests: Arc>>, - } - - /// Serves Range GETs and records each inclusive byte range that was asked for. - async fn start_recording_range_server( - archive_bytes: Vec, - ) -> (String, Arc>>, tokio::task::JoinHandle<()>) { - let requests = Arc::new(Mutex::new(Vec::new())); - let app = Router::new().route("/proofs.tar.zst", get(handle_recording_range)).with_state( - RecordingRangeState { data: archive_bytes, requests: Arc::clone(&requests) }, - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let base_url = format!("http://127.0.0.1:{}", addr.port()); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - - (base_url, requests, handle) - } - - async fn handle_recording_range( - State(state): State, - headers: HeaderMap, - ) -> impl IntoResponse { - if let Some((start, end)) = parse_byte_range(&headers, state.data.len()) { - state.requests.lock().await.push((start as u64, end as u64)); - return ( - StatusCode::PARTIAL_CONTENT, - [( - axum::http::header::CONTENT_RANGE, - format!("bytes {start}-{end}/{}", state.data.len()), - )], - state.data[start..=end].to_vec(), - ) - .into_response(); - } - - state.requests.lock().await.push((0, state.data.len().saturating_sub(1) as u64)); - (StatusCode::OK, state.data).into_response() - } - - #[derive(Clone)] - struct DropThenRangeState { - data: Vec, - requests: Arc, - } - - async fn start_drop_then_range_server( - archive_bytes: Vec, - ) -> (String, Arc, tokio::task::JoinHandle<()>) { - let requests = Arc::new(AtomicUsize::new(0)); - let app = Router::new().route("/proofs.tar.zst", get(handle_drop_then_range)).with_state( - DropThenRangeState { data: archive_bytes, requests: Arc::clone(&requests) }, - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let base_url = format!("http://127.0.0.1:{}", addr.port()); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - - (base_url, requests, handle) - } - - async fn handle_drop_then_range( - State(state): State, - headers: HeaderMap, - ) -> impl IntoResponse { - let request_n = state.requests.fetch_add(1, Ordering::SeqCst); - let (start, end) = parse_byte_range(&headers, state.data.len()) - .unwrap_or_else(|| (0, state.data.len().saturating_sub(1))); - - if request_n == 0 { - let remaining = end.saturating_sub(start) + 1; - let drop_at = start + remaining / 2; - let first = Bytes::from(state.data[start..drop_at].to_vec()); - let body = Body::from_stream(stream::iter([ - Ok::<_, std::io::Error>(first), - Err(std::io::Error::other("error decoding response body")), - ])); - let status = if headers.get("Range").is_some() { - StatusCode::PARTIAL_CONTENT - } else { - StatusCode::OK - }; - return (status, body).into_response(); - } - - ( - StatusCode::PARTIAL_CONTENT, - [( - axum::http::header::CONTENT_RANGE, - format!("bytes {start}-{end}/{}", state.data.len()), - )], - state.data[start..=end].to_vec(), - ) - .into_response() - } - - #[derive(Clone)] - struct ConcurrentRangeState { - data: Vec, - in_flight: Arc, - max_in_flight: Arc, - } - - async fn start_concurrent_range_server( - archive_bytes: Vec, - ) -> (String, Arc, tokio::task::JoinHandle<()>) { - let max_in_flight = Arc::new(AtomicUsize::new(0)); - let app = Router::new().route("/proofs.tar.zst", get(handle_concurrent_range)).with_state( - ConcurrentRangeState { - data: archive_bytes, - in_flight: Arc::new(AtomicUsize::new(0)), - max_in_flight: Arc::clone(&max_in_flight), - }, - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let base_url = format!("http://127.0.0.1:{}", addr.port()); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - - (base_url, max_in_flight, handle) - } - - async fn handle_concurrent_range( - State(state): State, - headers: HeaderMap, - ) -> impl IntoResponse { - let current = state.in_flight.fetch_add(1, Ordering::SeqCst) + 1; - state.max_in_flight.fetch_max(current, Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_millis(80)).await; - let response = handle_range(State(state.data), headers).await; - state.in_flight.fetch_sub(1, Ordering::SeqCst); - response - } - #[test] fn proofs_flag_is_parsed() { let cli = TestCli::parse_from(["test", "--proofs"]); @@ -1505,1016 +296,4 @@ mod tests { assert_eq!(concurrency, 1, "zero concurrency should be clamped to one stream"); } - - #[test] - fn split_byte_ranges_covers_the_whole_file() { - let ranges = split_byte_ranges(10, 4); - - assert_eq!( - ranges, - vec![0..3, 3..6, 6..8, 8..10], - "remainder should land on the first ranges" - ); - assert_eq!(ranges.last().map(|range| range.end), Some(10)); - } - - #[test] - fn split_byte_ranges_clamps_parts_to_file_size() { - let ranges = split_byte_ranges(3, 8); - - assert_eq!(ranges, vec![0..1, 1..2, 2..3], "cannot split into more streams than bytes"); - } - - #[test] - fn retry_delay_keeps_stream_drops_at_one_second() { - assert_eq!(retry_delay(1, false), std::time::Duration::from_secs(1)); - assert_eq!(retry_delay(8, false), std::time::Duration::from_secs(1)); - } - - #[test] - fn retry_delay_uses_exponential_backoff_when_throttled() { - assert_eq!(retry_delay(1, true), std::time::Duration::from_secs(2)); - assert_eq!(retry_delay(2, true), std::time::Duration::from_secs(4)); - assert_eq!(retry_delay(3, true), std::time::Duration::from_secs(8)); - assert_eq!( - retry_delay(5, true), - std::time::Duration::from_secs(30), - "backoff should cap at 30s" - ); - } - - async fn start_snapshot_api_server( - listing: serde_json::Value, - ) -> (String, tokio::task::JoinHandle<()>) { - let listing_bytes = serde_json::to_vec(&listing).unwrap(); - let app = Router::new().route( - "/api/snapshots", - get(move || { - let data = listing_bytes.clone(); - async move { ([(axum::http::header::CONTENT_TYPE, "application/json")], data) } - }), - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let api_url = format!("http://127.0.0.1:{}/api/snapshots", addr.port()); - - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - - (api_url, handle) - } - - #[tokio::test] - async fn discover_latest_manifest_url_picks_latest_modular_for_chain() { - let listing = serde_json::json!([ - { - "chainId": "8453", - "block": "100", - "metadataUrl": "https://mainnet-v2-snapshots.base.org/old/manifest.json" - }, - { - "chainId": "763360", - "block": "10", - "metadataUrl": "https://zeronet-v2-snapshots.base.org/old/manifest.json" - }, - { - "chainId": "763360", - "block": "20", - "metadataUrl": "https://zeronet-v2-snapshots.base.org/new/manifest.json" - }, - { - "chainId": "763360", - "block": "15", - "metadataUrl": "https://zeronet-v2-snapshots.base.org/not-a-manifest.tar.zst" - } - ]); - - let (api_url, handle) = start_snapshot_api_server(listing).await; - let url = discover_latest_manifest_url(&api_url, 763360).await.unwrap(); - - assert_eq!( - url, "https://zeronet-v2-snapshots.base.org/new/manifest.json", - "proofs must use the latest modular metadataUrl for the requested chain" - ); - - handle.abort(); - } - - #[tokio::test] - async fn discover_latest_manifest_url_accepts_numeric_ids() { - let listing = serde_json::json!([{ - "chainId": 763360, - "block": 3584106, - "metadataUrl": "https://zeronet-v2-snapshots.base.org/1789516802/manifest.json" - }]); - - let (api_url, handle) = start_snapshot_api_server(listing).await; - let url = discover_latest_manifest_url(&api_url, 763360).await.unwrap(); - - assert_eq!( - url, "https://zeronet-v2-snapshots.base.org/1789516802/manifest.json", - "snapshot API numeric chainId/block fields should be accepted" - ); - - handle.abort(); - } - - #[tokio::test] - async fn discover_latest_manifest_url_fails_when_chain_missing() { - let listing = serde_json::json!([{ - "chainId": "8453", - "block": "100", - "metadataUrl": "https://mainnet-v2-snapshots.base.org/old/manifest.json" - }]); - - let (api_url, handle) = start_snapshot_api_server(listing).await; - let result = discover_latest_manifest_url(&api_url, 763360).await; - - assert!(result.is_err(), "missing chain should fail discovery"); - assert!( - result.unwrap_err().to_string().contains("no modular snapshot manifest"), - "error should name the missing modular snapshot" - ); - - handle.abort(); - } - - #[tokio::test] - async fn fetch_manifest_entry_extracts_proofs_metadata() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"data")]); - let manifest = serde_json::json!({ - "block": 1000000, - "chain_id": 8453, - "storage_version": 2, - "timestamp": 1700000000, - "components": { - "proofs": { - "file": "proofs.tar.zst", - "size": archive.len(), - "decompressed_size": 0, - "output_files": [] - } - } - }); - - let (manifest_url, handle) = start_test_server(manifest, archive.clone()).await; - let entry = ProofsDownloader::fetch_manifest_entry(&manifest_url).await.unwrap(); - - assert_eq!(entry.file_name, "proofs.tar.zst"); - assert_eq!(entry.expected_size, archive.len() as u64); - assert!(entry.archive_url.ends_with("/proofs.tar.zst")); - - handle.abort(); - } - - #[tokio::test] - async fn fetch_manifest_entry_rejects_path_traversal() { - let manifest = serde_json::json!({ - "block": 100, - "chain_id": 8453, - "storage_version": 2, - "timestamp": 1700000000, - "components": { - "proofs": { - "file": "../../etc/evil.tar.zst", - "size": 100, - "decompressed_size": 0, - "output_files": [] - } - } - }); - - let (manifest_url, handle) = start_test_server(manifest, vec![]).await; - let result = ProofsDownloader::fetch_manifest_entry(&manifest_url).await; - - assert!(result.is_err(), "path traversal should be rejected"); - assert!(result.unwrap_err().to_string().contains("invalid proofs file name")); - - handle.abort(); - } - - #[tokio::test] - async fn fetch_manifest_entry_fails_when_no_proofs() { - let manifest = serde_json::json!({ - "block": 100, - "chain_id": 8453, - "storage_version": 2, - "timestamp": 1700000000, - "components": { - "state": { "file": "state.tar.zst", "size": 100, "decompressed_size": 500, "output_files": [] } - } - }); - - let (manifest_url, handle) = start_test_server(manifest, vec![]).await; - let result = ProofsDownloader::fetch_manifest_entry(&manifest_url).await; - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("no 'proofs' component")); - - handle.abort(); - } - - #[tokio::test] - async fn fetch_manifest_entry_fails_when_size_missing() { - let manifest = serde_json::json!({ - "block": 100, - "chain_id": 8453, - "storage_version": 2, - "timestamp": 1700000000, - "components": { - "proofs": { "file": "proofs.tar.zst", "decompressed_size": 0, "output_files": [] } - } - }); - - let (manifest_url, handle) = start_test_server(manifest, vec![]).await; - let result = ProofsDownloader::fetch_manifest_entry(&manifest_url).await; - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("missing 'size'")); - - handle.abort(); - } - - #[tokio::test] - async fn full_pipeline_downloads_and_extracts() { - let archive = create_proofs_archive(&[ - ("proofs/data.mdb", b"real-proof-data-from-server"), - ("proofs/lock.mdb", b"lock-file"), - ]); - - let manifest = serde_json::json!({ - "block": 1000000, - "chain_id": 8453, - "storage_version": 2, - "timestamp": 1700000000, - "components": { - "proofs": { - "file": "proofs.tar.zst", - "size": archive.len(), - "decompressed_size": 0, - "output_files": [] - } - } - }); - - let (manifest_url, handle) = start_test_server(manifest, archive).await; - let target = tempfile::tempdir().unwrap(); - - ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) - .await - .expect("full pipeline should succeed"); - - assert_eq!( - std::fs::read(target.path().join("proofs/data.mdb")).unwrap(), - b"real-proof-data-from-server", - "extracted content should match" - ); - assert_eq!(std::fs::read(target.path().join("proofs/lock.mdb")).unwrap(), b"lock-file"); - assert!(!target.path().join(".snapshot-cache").exists(), "cache should be cleaned up"); - - handle.abort(); - } - - /// Packages `proofs` with the snapshotter's manifest generator and returns the generated - /// manifest JSON together with the proofs archive bytes. - fn generate_proofs_snapshot(proofs: &[(&str, &[u8])]) -> (serde_json::Value, Vec) { - let source = tempfile::tempdir().unwrap(); - let output = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(source.path().join("db")).unwrap(); - std::fs::write(source.path().join("db/mdbx.dat"), b"state-data").unwrap(); - for (path, data) in proofs { - let path = source.path().join("proofs").join(path); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, data).unwrap(); - } - - let remote_static_files = std::collections::HashMap::new(); - SnapshotGenerator::generate_manifest(&ManifestGenerationParams { - source_datadir: source.path(), - output_dir: Some(output.path()), - chain_id: 8453, - base_url: None, - block: Some(0), - blocks_per_file: None, - remote_static_files: &remote_static_files, - previous_manifest: None, - upload_proofs: true, - }) - .unwrap(); - - let manifest = - serde_json::from_slice(&std::fs::read(output.path().join("manifest.json")).unwrap()) - .unwrap(); - let archive = std::fs::read(output.path().join("proofs.tar.zst")).unwrap(); - (manifest, archive) - } - - #[tokio::test] - async fn full_pipeline_verifies_generated_snapshot() { - let (manifest, archive) = generate_proofs_snapshot(&[ - ("CURRENT", b"MANIFEST-000014\n"), - ("000060.sst", b"sst-data"), - ("nested/000801.log", b"wal-data"), - ]); - assert_eq!(manifest["components"]["proofs"]["output_files"].as_array().unwrap().len(), 3); - - let (manifest_url, handle) = start_test_server(manifest, archive).await; - let target = tempfile::tempdir().unwrap(); - - ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) - .await - .expect("generated snapshot should download and verify"); - - assert_eq!(std::fs::read(target.path().join("proofs/000060.sst")).unwrap(), b"sst-data"); - assert_eq!( - std::fs::read(target.path().join("proofs/nested/000801.log")).unwrap(), - b"wal-data" - ); - assert!(!target.path().join(".snapshot-cache").exists(), "cache should be cleaned up"); - - handle.abort(); - } - - #[tokio::test] - async fn full_pipeline_rejects_hash_mismatch() { - let (mut manifest, archive) = - generate_proofs_snapshot(&[("000060.sst", b"sst-data"), ("CURRENT", b"current")]); - let output_files = manifest["components"]["proofs"]["output_files"].as_array_mut().unwrap(); - let sst = output_files.iter_mut().find(|file| file["path"] == "proofs/000060.sst").unwrap(); - sst["blake3"] = serde_json::Value::String("00".repeat(32)); - - let (manifest_url, handle) = start_test_server(manifest, archive).await; - let target = tempfile::tempdir().unwrap(); - - let error = ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) - .await - .expect_err("hash mismatch should fail the download"); - - assert!(error.to_string().contains("proofs/000060.sst"), "error: {error}"); - assert!( - target.path().join(".snapshot-cache/proofs.tar.zst").exists(), - "archive should be kept for inspection or retry" - ); - - handle.abort(); - } - - #[tokio::test] - async fn full_pipeline_rejects_size_mismatch() { - let (mut manifest, archive) = generate_proofs_snapshot(&[("000060.sst", b"sst-data")]); - manifest["components"]["proofs"]["output_files"][0]["size"] = serde_json::json!(9); - - let (manifest_url, handle) = start_test_server(manifest, archive).await; - let target = tempfile::tempdir().unwrap(); - - let error = ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) - .await - .expect_err("size mismatch should fail the download"); - - assert!(error.to_string().contains("size 8"), "error: {error}"); - - handle.abort(); - } - - #[tokio::test] - async fn full_pipeline_rejects_archive_missing_manifest_file() { - // The manifest lists a file that the archive does not contain, as happens when an - // archive is cut short at a tar entry boundary. - let (mut manifest, archive) = - generate_proofs_snapshot(&[("000060.sst", b"sst-data"), ("CURRENT", b"current")]); - let output_files = manifest["components"]["proofs"]["output_files"].as_array_mut().unwrap(); - let mut missing = output_files[0].clone(); - missing["path"] = serde_json::Value::String("proofs/000061.sst".to_string()); - output_files.push(missing); - - let (manifest_url, handle) = start_test_server(manifest, archive).await; - let target = tempfile::tempdir().unwrap(); - - let error = ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) - .await - .expect_err("missing file should fail the download"); - - assert!( - error.to_string().contains("missing proofs file proofs/000061.sst"), - "error: {error}" - ); - - handle.abort(); - } - - #[tokio::test] - async fn fetch_manifest_entry_rejects_output_file_traversal() { - for path in ["../escape", "/etc/passwd", "proofs/../../escape", ""] { - let manifest = serde_json::json!({ - "block": 100, - "chain_id": 8453, - "storage_version": 2, - "timestamp": 1700000000, - "components": { - "proofs": { - "file": "proofs.tar.zst", - "size": 100, - "decompressed_size": 0, - "output_files": [{ "path": path, "size": 1, "blake3": "00" }] - } - } - }); - - let (manifest_url, handle) = start_test_server(manifest, vec![]).await; - let error = ProofsDownloader::fetch_manifest_entry(&manifest_url) - .await - .expect_err("unsafe output path should be rejected"); - - assert!( - error.to_string().contains("invalid proofs output file path"), - "path {path:?}: {error}" - ); - - handle.abort(); - } - } - - #[test] - fn extract_tar_zst_creates_files() { - let src = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - - let archive_path = src.path().join("proofs.tar.zst"); - std::fs::write( - &archive_path, - create_proofs_archive(&[("proofs/data.mdb", b"proof-data-contents")]), - ) - .unwrap(); - - ProofsDownloader::extract_tar_zst(&archive_path, dest.path()).unwrap(); - - let extracted = dest.path().join("proofs/data.mdb"); - assert!(extracted.exists(), "extracted file should exist"); - assert_eq!(std::fs::read(&extracted).unwrap(), b"proof-data-contents"); - } - - #[test] - fn extract_tar_zst_preserves_directory_structure() { - let src = tempfile::tempdir().unwrap(); - let dest = tempfile::tempdir().unwrap(); - - let archive_path = src.path().join("proofs.tar.zst"); - std::fs::write( - &archive_path, - create_proofs_archive(&[ - ("proofs/data.mdb", b"data"), - ("proofs/lock.mdb", b"lock"), - ("proofs/nested/deep.dat", b"deep"), - ]), - ) - .unwrap(); - - ProofsDownloader::extract_tar_zst(&archive_path, dest.path()).unwrap(); - - assert!(dest.path().join("proofs/data.mdb").exists()); - assert!(dest.path().join("proofs/lock.mdb").exists()); - assert!(dest.path().join("proofs/nested/deep.dat").exists()); - assert_eq!(std::fs::read(dest.path().join("proofs/nested/deep.dat")).unwrap(), b"deep"); - } - - #[test] - fn extract_tar_zst_fails_on_missing_archive() { - let dest = tempfile::tempdir().unwrap(); - let result = ProofsDownloader::extract_tar_zst( - &dest.path().join("nonexistent.tar.zst"), - dest.path(), - ); - assert!(result.is_err()); - } - - #[tokio::test] - async fn download_archive_resumes_from_partial_file() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"complete-proof-data")]); - let (base_url, handle) = start_range_aware_server(archive.clone()).await; - - let cache_dir = tempfile::tempdir().unwrap(); - let part_path = cache_dir.path().join("proofs.tar.zst.part"); - - let half = archive.len() / 2; - std::fs::write(&part_path, &archive[..half]).unwrap(); - assert_eq!( - std::fs::metadata(&part_path).unwrap().len(), - half as u64, - "part file should contain first half of archive" - ); - - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await.unwrap(); - let downloaded = std::fs::read(&dest).unwrap(); - - assert_eq!(downloaded.len(), archive.len(), "resumed download should produce full archive"); - assert_eq!(downloaded, archive, "resumed archive should match original byte-for-byte"); - - handle.abort(); - } - - #[tokio::test] - async fn download_archive_retries_after_stream_interrupt() { - let archive = - create_proofs_archive(&[("proofs/data.mdb", b"complete-proof-data-after-cf-drop")]); - let (base_url, requests, handle) = start_drop_then_range_server(archive.clone()).await; - - let cache_dir = tempfile::tempdir().unwrap(); - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1) - .await - .expect("stream drop should resume in-process instead of failing"); - let downloaded = std::fs::read(&dest).unwrap(); - - assert_eq!( - downloaded, archive, - "in-process resume after stream drop should yield the full archive" - ); - assert!( - requests.load(Ordering::SeqCst) >= 2, - "stream drop should trigger a Range retry, got {} requests", - requests.load(Ordering::SeqCst) - ); - assert!( - !cache_dir.path().join("proofs.tar.zst.part").exists(), - "completed download should rename .part into place" - ); - - handle.abort(); - } - - #[tokio::test] - async fn download_archive_retries_when_body_ends_without_content_length() { - let archive = - create_proofs_archive(&[("proofs/data.mdb", b"resume-after-silent-truncation")]); - let requests = Arc::new(AtomicUsize::new(0)); - let app = - Router::new().route("/proofs.tar.zst", get(handle_short_stream_then_range)).with_state( - DropThenRangeState { data: archive.clone(), requests: Arc::clone(&requests) }, - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let base_url = format!("http://127.0.0.1:{}", addr.port()); - let handle = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - - let cache_dir = tempfile::tempdir().unwrap(); - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1) - .await - .expect("a clean close without Content-Length should resume, not delete .part"); - - assert_eq!(std::fs::read(&dest).unwrap(), archive); - assert!( - requests.load(Ordering::SeqCst) >= 2, - "silent truncation should retry, got {} requests", - requests.load(Ordering::SeqCst) - ); - assert!( - !cache_dir.path().join("proofs.tar.zst.part").exists(), - "completed download should rename .part into place" - ); - - handle.abort(); - } - - async fn handle_short_stream_then_range( - State(state): State, - headers: HeaderMap, - ) -> impl IntoResponse { - let request_n = state.requests.fetch_add(1, Ordering::SeqCst); - if request_n == 0 { - let half = state.data.len() / 2; - let first = Bytes::from(state.data[..half].to_vec()); - let body = Body::from_stream(stream::iter([Ok::<_, std::io::Error>(first)])); - return (StatusCode::OK, body).into_response(); - } - - handle_range(State(state.data), headers).await.into_response() - } - - #[tokio::test] - async fn download_archive_uses_parallel_range_requests() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"parallel-range-proof-data")]); - let (base_url, max_in_flight, handle) = - start_concurrent_range_server(archive.clone()).await; - - let cache_dir = tempfile::tempdir().unwrap(); - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) - .await - .expect("parallel Range download should succeed"); - - assert_eq!( - std::fs::read(&dest).unwrap(), - archive, - "assembled ranges should match the archive" - ); - assert!( - max_in_flight.load(Ordering::SeqCst) >= 2, - "download-concurrency=4 should issue overlapping Range requests, max in-flight was {}", - max_in_flight.load(Ordering::SeqCst) - ); - assert!( - !cache_dir.path().join("proofs.tar.zst.part.ranges").exists(), - "range sidecar should be removed after a complete parallel download" - ); - - handle.abort(); - } - - fn write_test_sidecar(path: &Path, concurrency: usize, expected_size: u64, written: &[u64]) { - let mut line = format!("{concurrency} {expected_size}"); - for amount in written { - line.push(' '); - line.push_str(&amount.to_string()); - } - std::fs::write(path, line).unwrap(); - } - - #[test] - fn load_trusted_range_sidecar_rejects_missing_or_short_part() { - let dir = tempfile::tempdir().unwrap(); - let sidecar = dir.path().join("proofs.tar.zst.part.ranges"); - write_test_sidecar(&sidecar, 4, 100, &[25, 25, 25, 25]); - - assert_eq!( - load_trusted_range_sidecar(&sidecar, 4, 100, 0), - None, - "sidecar without a full-sized .part must not skip ranges" - ); - assert_eq!( - load_trusted_range_sidecar(&sidecar, 4, 100, 50), - None, - "sidecar with a resized .part must not skip ranges" - ); - assert_eq!( - load_trusted_range_sidecar(&sidecar, 4, 100, 100), - Some(vec![25, 25, 25, 25]), - "sidecar should be trusted only when .part is the declared size" - ); - } - - /// Verifies a crash-restart with a trusted sidecar only fetches unfinished ranges. - #[tokio::test] - async fn download_archive_parallel_resumes_from_trusted_sidecar() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"resume-two-of-four-ranges")]); - let ranges = split_byte_ranges(archive.len() as u64, 4); - assert_eq!(ranges.len(), 4, "archive must split into four ranges"); - - let mut part = vec![0u8; archive.len()]; - for range in &ranges[..2] { - let start = range.start as usize; - let end = range.end as usize; - part[start..end].copy_from_slice(&archive[start..end]); - } - - let (base_url, requests, handle) = start_recording_range_server(archive.clone()).await; - let cache_dir = tempfile::tempdir().unwrap(); - let part_path = cache_dir.path().join("proofs.tar.zst.part"); - std::fs::write(&part_path, &part).unwrap(); - write_test_sidecar( - &cache_dir.path().join("proofs.tar.zst.part.ranges"), - 4, - archive.len() as u64, - &[ranges[0].end - ranges[0].start, ranges[1].end - ranges[1].start, 0, 0], - ); - - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) - .await - .expect("trusted sidecar resume should finish the remaining ranges"); - - assert_eq!( - std::fs::read(&dest).unwrap(), - archive, - "resumed ranges plus already-written ranges should match the archive" - ); - - let mut got = requests.lock().await.clone(); - got.sort_unstable(); - assert_eq!( - got, - vec![(ranges[2].start, ranges[2].end - 1), (ranges[3].start, ranges[3].end - 1),], - "only the two incomplete ranges should be fetched" - ); - - handle.abort(); - } - - /// Verifies an in-flight sequential `.part` is finished as one stream, not `set_len`. - #[tokio::test] - async fn download_archive_finishes_sequential_leftover_before_parallel() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"finish-sequential-leftover")]); - let half = archive.len() / 2; - assert!(half > 0 && half < archive.len(), "archive must have a sequential midpoint"); - - let (base_url, requests, handle) = start_recording_range_server(archive.clone()).await; - let cache_dir = tempfile::tempdir().unwrap(); - std::fs::write(cache_dir.path().join("proofs.tar.zst.part"), &archive[..half]).unwrap(); - - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) - .await - .expect("sequential leftover should finish as one stream"); - - assert_eq!( - std::fs::read(&dest).unwrap(), - archive, - "sequential leftover resume should yield the full archive" - ); - assert_eq!( - requests.lock().await.clone(), - vec![(half as u64, archive.len() as u64 - 1)], - "existing sequential .part must resume with one open-ended Range, not parallel splits" - ); - assert!( - !cache_dir.path().join("proofs.tar.zst.part.ranges").exists(), - "sequential leftover must not write a parallel sidecar" - ); - - handle.abort(); - } - - #[tokio::test] - async fn download_archive_sequential_discards_stale_parallel_part() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"not-zeros-from-set-len")]); - let (base_url, handle) = start_range_aware_server(archive.clone()).await; - - let cache_dir = tempfile::tempdir().unwrap(); - let part_path = cache_dir.path().join("proofs.tar.zst.part"); - std::fs::write(&part_path, vec![0u8; archive.len()]).unwrap(); - write_test_sidecar( - &cache_dir.path().join("proofs.tar.zst.part.ranges"), - 4, - archive.len() as u64, - &[0, 0, 0, 0], - ); - - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1) - .await - .expect("sequential download should not accept a preallocated parallel .part"); - - assert_eq!( - std::fs::read(&dest).unwrap(), - archive, - "stale parallel .part filled with zeros must be re-downloaded" - ); - assert!( - !cache_dir.path().join("proofs.tar.zst.part.ranges").exists(), - "stale sidecar should be removed" - ); - - handle.abort(); - } - - #[tokio::test] - async fn download_archive_parallel_ignores_sidecar_without_part_file() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"download-over-missing-part")]); - let (base_url, _max_in_flight, handle) = - start_concurrent_range_server(archive.clone()).await; - - let cache_dir = tempfile::tempdir().unwrap(); - write_test_sidecar( - &cache_dir.path().join("proofs.tar.zst.part.ranges"), - 4, - archive.len() as u64, - &[ - archive.len() as u64 / 4, - archive.len() as u64 / 4, - archive.len() as u64 / 4, - archive.len() as u64 - 3 * (archive.len() as u64 / 4), - ], - ); - - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) - .await - .expect("missing .part should re-download instead of trusting the sidecar"); - - assert_eq!( - std::fs::read(&dest).unwrap(), - archive, - "sidecar without a matching .part must not leave zero-filled holes" - ); - - handle.abort(); - } - - #[tokio::test] - async fn download_archive_parallel_retries_after_stream_interrupt() { - let archive = - create_proofs_archive(&[("proofs/data.mdb", b"parallel-proof-data-after-cf-drop")]); - let (base_url, requests, handle) = start_drop_then_range_server(archive.clone()).await; - - let cache_dir = tempfile::tempdir().unwrap(); - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 4) - .await - .expect("parallel stream drop should resume the failed range in-process"); - - assert_eq!( - std::fs::read(&dest).unwrap(), - archive, - "parallel resume after stream drop should yield the full archive" - ); - assert!( - requests.load(Ordering::SeqCst) >= 2, - "dropped range should retry, got {} requests", - requests.load(Ordering::SeqCst) - ); - - handle.abort(); - } - - #[tokio::test] - async fn download_archive_restarts_when_server_ignores_range() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"fresh-data")]); - - let (base_url, handle) = { - use axum::{Router, routing::get}; - - let data = archive.clone(); - let app = Router::new().route( - "/proofs.tar.zst", - get(move || { - let d = data.clone(); - async move { d } - }), - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let url = format!("http://127.0.0.1:{}", addr.port()); - let h = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - (url, h) - }; - - let cache_dir = tempfile::tempdir().unwrap(); - let part_path = cache_dir.path().join("proofs.tar.zst.part"); - std::fs::write(&part_path, b"stale-garbage-data-from-old-snapshot").unwrap(); - - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await.unwrap(); - let downloaded = std::fs::read(&dest).unwrap(); - - assert_eq!(downloaded, archive, "should discard stale .part and download fresh archive"); - - handle.abort(); - } - - #[tokio::test] - async fn download_archive_uses_completed_part_file_without_requesting_range() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"already-complete")]); - - let (base_url, handle) = { - use axum::{Router, routing::get}; - - let app = Router::new().route( - "/proofs.tar.zst", - get(|| async { (StatusCode::RANGE_NOT_SATISFIABLE, Vec::::new()) }), - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let url = format!("http://127.0.0.1:{}", addr.port()); - let h = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - (url, h) - }; - - let cache_dir = tempfile::tempdir().unwrap(); - let part_path = cache_dir.path().join("proofs.tar.zst.part"); - std::fs::write(&part_path, &archive).unwrap(); - - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let dest = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await.unwrap(); - - assert_eq!(dest, cache_dir.path().join("proofs.tar.zst")); - assert_eq!(std::fs::read(&dest).unwrap(), archive); - assert!(!Path::new(&part_path).exists(), "completed .part should be renamed into place"); - - handle.abort(); - } - - #[tokio::test] - async fn download_archive_fails_on_size_mismatch() { - let archive = create_proofs_archive(&[("proofs/data.mdb", b"data")]); - - let (base_url, handle) = { - use axum::{Router, routing::get}; - - let data = archive.clone(); - let app = Router::new().route( - "/proofs.tar.zst", - get(move || { - let d = data.clone(); - async move { d } - }), - ); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let url = format!("http://127.0.0.1:{}", addr.port()); - let h = tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - (url, h) - }; - - let cache_dir = tempfile::tempdir().unwrap(); - let entry = ProofsManifestEntry { - file_name: "proofs.tar.zst".to_string(), - expected_size: archive.len() as u64 + 999, - archive_url: format!("{base_url}/proofs.tar.zst"), - output_files: Vec::new(), - }; - - let result = ProofsDownloader::download_archive(&entry, cache_dir.path(), 1).await; - - assert!(result.is_err(), "size mismatch should fail"); - let err = result.unwrap_err().to_string(); - assert!(err.contains("size mismatch"), "error should mention size mismatch, got: {err}"); - - assert!( - !cache_dir.path().join("proofs.tar.zst.part").exists(), - "corrupt .part file should be deleted on size mismatch" - ); - - handle.abort(); - } } diff --git a/crates/execution/cli/src/commands/download/proofs/archive_download.rs b/crates/execution/cli/src/commands/download/proofs/archive_download.rs new file mode 100644 index 0000000000..49743bd8f3 --- /dev/null +++ b/crates/execution/cli/src/commands/download/proofs/archive_download.rs @@ -0,0 +1,941 @@ +//! Resumable download of the proofs archive as fixed-size pieces. +//! +//! Pieces are handed out in file order to `concurrency` workers so the +//! contiguous prefix of the `.part` file grows while the download runs and +//! extraction can follow it. Completed pieces are recorded in a sidecar next to +//! the `.part` file so an interrupted download resumes where it left off. +//! +//! Resume is best-effort across process interruption: pieces are flushed but +//! not fsynced, so after a machine crash the sidecar can mark bytes that never +//! reached disk. Extraction then fails, and the archive is downloaded again on +//! the next run. + +use std::{ + fmt, + io::{self, SeekFrom}, + ops::Range, + path::{Path, PathBuf}, + pin::pin, + sync::atomic::{AtomicU64, AtomicUsize, Ordering}, + time::{Duration, Instant}, +}; + +use base_reth_cli::ProgressDisplay; +use eyre::Result; +use futures::{StreamExt, future::try_join_all}; +use reqwest::{ + StatusCode, + header::{CONTENT_RANGE, HeaderMap, RANGE}, +}; +use tokio::{ + io::{AsyncSeekExt, AsyncWriteExt}, + sync::Mutex, +}; +use tracing::{debug, info}; + +use super::{PROOFS_PROGRESS_LOG_INTERVAL, ProofsArchiveAvailability, ProofsManifestEntry}; + +/// Consecutive attempts at one piece that write no new bytes before failing. +/// +/// Resets on progress so Cloudflare can drop a connection many times over a +/// multi-hundred-GiB download without aborting the process. +const MAX_IDLE_DOWNLOAD_ATTEMPTS: u32 = 8; + +/// Idle read timeout so a stalled CDN socket fails into retry instead of hanging. +const DOWNLOAD_READ_TIMEOUT: Duration = Duration::from_secs(120); + +/// Header of the piece bitmap sidecar format. +const PIECE_SIDECAR_VERSION: &str = "pieces-v1"; + +/// Which fixed-size pieces of the proofs archive are written to the `.part` file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProofsPieceMap { + piece_size: u64, + expected_size: u64, + done: Vec, +} + +impl ProofsPieceMap { + /// Creates a map with no pieces written. + pub fn new(piece_size: u64, expected_size: u64) -> Self { + let piece_size = piece_size.max(1); + let count = expected_size.div_ceil(piece_size) as usize; + Self { piece_size, expected_size, done: vec![false; count] } + } + + /// Creates a map with every piece written. + pub fn complete(piece_size: u64, expected_size: u64) -> Self { + let mut pieces = Self::new(piece_size, expected_size); + pieces.done.fill(true); + pieces + } + + /// Byte range of piece `index`. + pub fn range(&self, index: usize) -> Range { + let start = index as u64 * self.piece_size; + start..(start + self.piece_size).min(self.expected_size) + } + + /// Records piece `index` as written. + pub fn mark_done(&mut self, index: usize) { + self.done[index] = true; + } + + /// Length of the archive prefix made only of written pieces. + pub fn contiguous_len(&self) -> u64 { + self.done + .iter() + .position(|done| !done) + .map_or(self.expected_size, |index| self.range(index).start) + } + + /// Total bytes in written pieces. + pub fn downloaded_len(&self) -> u64 { + self.pieces(true).map(|(_, range)| range.end - range.start).sum() + } + + /// Indices and byte ranges of pieces still to fetch, in file order. + pub fn pending(&self) -> Vec<(usize, Range)> { + self.pieces(false).collect() + } + + /// Returns whether every piece is written. + pub fn is_complete(&self) -> bool { + self.done.iter().all(|done| *done) + } + + /// Serializes the map as `pieces-v1 `. + pub fn encode(&self) -> String { + let bits: String = self.done.iter().map(|done| if *done { '1' } else { '0' }).collect(); + format!("{PIECE_SIDECAR_VERSION} {} {} {bits}", self.piece_size, self.expected_size) + } + + /// Parses a sidecar written for the same piece size and archive size. + /// + /// Anything else, including sidecars from older downloader versions, is + /// rejected so the download restarts instead of trusting unknown bytes. + pub fn decode(text: &str, piece_size: u64, expected_size: u64) -> Option { + let mut parts = text.split_whitespace(); + if parts.next()? != PIECE_SIDECAR_VERSION + || parts.next()?.parse::().ok()? != piece_size + || parts.next()?.parse::().ok()? != expected_size + { + return None; + } + + let mut pieces = Self::new(piece_size, expected_size); + let bits = parts.next().unwrap_or_default(); + if parts.next().is_some() || bits.len() != pieces.done.len() { + return None; + } + for (done, bit) in pieces.done.iter_mut().zip(bits.chars()) { + *done = match bit { + '0' => false, + '1' => true, + _ => return None, + }; + } + Some(pieces) + } + + fn pieces(&self, done: bool) -> impl Iterator)> + '_ { + self.done + .iter() + .enumerate() + .filter(move |(_, piece_done)| **piece_done == done) + .map(|(index, _)| (index, self.range(index))) + } +} + +/// The server's archive size differs from the manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProofsArchiveSizeMismatch { + /// Size declared by the manifest. + pub expected: u64, + /// Size reported by the server's `Content-Range`. + pub actual: u64, +} + +impl fmt::Display for ProofsArchiveSizeMismatch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "proofs archive size mismatch: server reports {} bytes, manifest declares {} bytes", + self.actual, self.expected + ) + } +} + +impl std::error::Error for ProofsArchiveSizeMismatch {} + +/// One resumable piece-queue download of the proofs archive into its `.part` file. +#[derive(Debug)] +pub struct ProofsArchiveDownload { + /// Archive URL. The server must answer Range requests with 206. + pub url: String, + /// File the archive is written to. It keeps this name until extraction + /// finishes because the extractor opens it by path while it downloads. + pub part_path: PathBuf, + /// Resume state: the archive URL on the first line, then the encoded + /// [`ProofsPieceMap`]. + pub sidecar_path: PathBuf, + sidecar_tmp_path: PathBuf, + expected_size: u64, + piece_size: u64, + concurrency: usize, +} + +impl ProofsArchiveDownload { + /// Piece size used for real downloads. + pub const DEFAULT_PIECE_SIZE: u64 = 64 << 20; + + /// Describes the download of `entry` into `cache_dir`. + pub fn new( + entry: &ProofsManifestEntry, + cache_dir: &Path, + concurrency: usize, + piece_size: u64, + ) -> Self { + Self { + url: entry.archive_url.clone(), + part_path: cache_dir.join(format!("{}.part", entry.file_name)), + sidecar_path: cache_dir.join(format!("{}.part.ranges", entry.file_name)), + sidecar_tmp_path: cache_dir.join(format!("{}.part.ranges.tmp", entry.file_name)), + expected_size: entry.expected_size, + piece_size: piece_size.max(1), + concurrency: concurrency.max(1), + } + } + + /// Loads resume state and preallocates the `.part` file. + /// + /// A full-size `.part` without a sidecar is a finished download. A sidecar + /// is trusted only when it names this archive URL and piece layout and the + /// `.part` is full size. Anything else restarts from zero. + pub async fn prepare(&self) -> Result { + let part_len = tokio::fs::metadata(&self.part_path).await.map(|m| m.len()).ok(); + let sidecar = match tokio::fs::read(&self.sidecar_path).await { + Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => { + eyre::bail!("failed to read {}: {error}", self.sidecar_path.display()) + } + }; + + let resumed = match (&sidecar, part_len) { + (None, Some(len)) if len == self.expected_size => { + info!(target: "reth::cli", "Proofs archive already downloaded, skipping download"); + return Ok(ProofsPieceMap::complete(self.piece_size, self.expected_size)); + } + (Some(text), Some(len)) if len == self.expected_size => { + text.split_once('\n').filter(|(url, _)| *url == self.url).and_then(|(_, map)| { + ProofsPieceMap::decode(map, self.piece_size, self.expected_size) + }) + } + _ => None, + }; + + let pieces = resumed.unwrap_or_else(|| { + if sidecar.is_some() || part_len.is_some() { + info!( + target: "reth::cli", + "Discarding incompatible partial proofs download, restarting from zero" + ); + } + ProofsPieceMap::new(self.piece_size, self.expected_size) + }); + + self.persist(&pieces).await?; + let file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&self.part_path) + .await?; + file.set_len(self.expected_size).await?; + Ok(pieces) + } + + /// Downloads every missing piece, publishing the contiguous prefix to + /// `availability` as it grows. + /// + /// Removes the sidecar once complete. A size mismatch with the server + /// deletes the `.part` file because none of it can be reused. + pub async fn run( + &self, + pieces: ProofsPieceMap, + availability: &ProofsArchiveAvailability, + ) -> Result<()> { + availability.publish(pieces.contiguous_len()); + if pieces.is_complete() { + tokio::fs::remove_file(&self.sidecar_path).await.ok(); + return Ok(()); + } + + let pending = pieces.pending(); + let initial = pieces.downloaded_len(); + info!( + target: "reth::cli", + url = %self.url, + streams = self.concurrency.min(pending.len()), + resume = %ProgressDisplay::bytes(initial as f64), + expected = %ProgressDisplay::bytes(self.expected_size as f64), + "Downloading proofs archive" + ); + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(30)) + .read_timeout(DOWNLOAD_READ_TIMEOUT) + .pool_max_idle_per_host(self.concurrency) + .build()?; + let progress = AtomicU64::new(initial); + let next = AtomicUsize::new(0); + let pieces = Mutex::new(pieces); + + let workers = (0..self.concurrency.min(pending.len())).map(|_| async { + while let Some((index, range)) = pending.get(next.fetch_add(1, Ordering::Relaxed)) { + self.download_piece(&client, range.clone(), &progress).await?; + let mut pieces = pieces.lock().await; + pieces.mark_done(*index); + self.persist(&pieces).await?; + availability.publish(pieces.contiguous_len()); + } + Ok::<_, eyre::Report>(()) + }); + let mut workers = pin!(try_join_all(workers)); + + let started = Instant::now(); + let mut ticker = tokio::time::interval(PROOFS_PROGRESS_LOG_INTERVAL); + ticker.tick().await; + let result = loop { + tokio::select! { + result = &mut workers => break result, + _ = ticker.tick() => Self::log_progress( + progress.load(Ordering::Relaxed), + self.expected_size, + started, + initial, + ), + } + }; + + if let Err(error) = result { + if error.downcast_ref::().is_some() { + tokio::fs::remove_file(&self.part_path).await.ok(); + tokio::fs::remove_file(&self.sidecar_path).await.ok(); + } + return Err(error); + } + + tokio::fs::remove_file(&self.sidecar_path).await?; + Ok(()) + } + + /// Atomically replaces the sidecar with `pieces`. + async fn persist(&self, pieces: &ProofsPieceMap) -> Result<()> { + tokio::fs::write(&self.sidecar_tmp_path, format!("{}\n{}", self.url, pieces.encode())) + .await?; + tokio::fs::rename(&self.sidecar_tmp_path, &self.sidecar_path).await?; + Ok(()) + } + + /// Downloads one piece, retrying stream drops from the current offset. + /// + /// Returns after the piece is flushed to the `.part` file. + async fn download_piece( + &self, + client: &reqwest::Client, + range: Range, + progress: &AtomicU64, + ) -> Result<()> { + let len = range.end - range.start; + let last = range.end - 1; + let url = &self.url; + let mut written = 0u64; + let mut idle_attempts = 0u32; + + while written < len { + let start = range.start + written; + let response = + match client.get(url).header(RANGE, format!("bytes={start}-{last}")).send().await { + Ok(response) => response, + Err(error) => { + Self::wait_before_retry( + &mut idle_attempts, + false, + false, + &format!( + "failed to download proofs range {start}-{last} from {url}: {error}" + ), + ) + .await?; + continue; + } + }; + + let status = response.status(); + if status != StatusCode::PARTIAL_CONTENT { + if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS { + Self::wait_before_retry( + &mut idle_attempts, + false, + true, + &format!("proofs range download failed with HTTP {status}: {url}"), + ) + .await?; + continue; + } + eyre::bail!( + "expected HTTP 206 for proofs range {start}-{last}, got {status}: {url} \ + (the server must support Range requests)" + ); + } + self.check_content_range(response.headers(), start, last)?; + + let content_length = response.content_length(); + let mut file = tokio::fs::OpenOptions::new().write(true).open(&self.part_path).await?; + file.seek(SeekFrom::Start(start)).await?; + + let attempt_start = written; + let mut stream_error = None; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(chunk) => chunk, + Err(error) => { + stream_error = Some(error); + break; + } + }; + if chunk.len() as u64 > len - written { + eyre::bail!( + "server sent more than the requested bytes for proofs range {start}-{last}: {url}" + ); + } + file.write_all(&chunk).await?; + written += chunk.len() as u64; + progress.fetch_add(chunk.len() as u64, Ordering::Relaxed); + } + file.flush().await?; + + if written >= len { + break; + } + + let received = written - attempt_start; + let reason = match (stream_error, content_length) { + (Some(error), _) => format!( + "stream interrupted downloading proofs range {start}-{last} from {url}: {error}" + ), + (None, Some(expected)) if received < expected => format!( + "truncated proofs range {start}-{last} from {url}: received {received} of {expected} bytes" + ), + (None, _) => format!( + "incomplete proofs range {start}-{last} from {url}: received {received} bytes" + ), + }; + Self::wait_before_retry(&mut idle_attempts, received > 0, false, &reason).await?; + } + + Ok(()) + } + + /// Rejects a 206 response unless its `Content-Range` covers exactly + /// `start..=last` of an archive with the size the manifest declares. + fn check_content_range(&self, headers: &HeaderMap, start: u64, last: u64) -> Result<()> { + let value = headers.get(CONTENT_RANGE).and_then(|value| value.to_str().ok()); + let parsed = value.and_then(|value| { + let (range, total) = value.strip_prefix("bytes ")?.split_once('/')?; + let (first, end) = range.split_once('-')?; + Some((first.parse::().ok()?, end.parse::().ok()?, total.parse::().ok()?)) + }); + let Some((first, end, total)) = parsed else { + eyre::bail!( + "invalid Content-Range {value:?} for proofs range {start}-{last}: {}", + self.url + ); + }; + + if total != self.expected_size { + return Err( + ProofsArchiveSizeMismatch { expected: self.expected_size, actual: total }.into() + ); + } + if (first, end) != (start, last) { + eyre::bail!( + "server returned proofs range {first}-{end}, requested {start}-{last}: {}", + self.url + ); + } + Ok(()) + } + + /// Waits before retrying an interrupted piece. + /// + /// Progress resets the idle counter. [`MAX_IDLE_DOWNLOAD_ATTEMPTS`] idle + /// attempts in a row is treated as stalled. + async fn wait_before_retry( + idle_attempts: &mut u32, + made_progress: bool, + throttled: bool, + reason: &str, + ) -> Result<()> { + if made_progress { + *idle_attempts = 0; + debug!(target: "reth::cli", error = %reason, "Proofs download interrupted, resuming"); + return Ok(()); + } + + *idle_attempts += 1; + if *idle_attempts >= MAX_IDLE_DOWNLOAD_ATTEMPTS { + eyre::bail!( + "proofs download stalled after {MAX_IDLE_DOWNLOAD_ATTEMPTS} attempts without progress: {reason}" + ); + } + + let delay = Self::retry_delay(*idle_attempts, throttled); + debug!( + target: "reth::cli", + error = %reason, + idle_attempts = *idle_attempts, + retry_in_secs = delay.as_secs(), + "Proofs download interrupted without progress, retrying" + ); + tokio::time::sleep(delay).await; + Ok(()) + } + + /// Delay before an idle retry. + /// + /// Stream drops stay at 1s. HTTP 429/5xx use exponential backoff so a + /// rate-limiting CDN is not hammered. + fn retry_delay(idle_attempts: u32, throttled: bool) -> Duration { + if !throttled { + return Duration::from_secs(1); + } + + let multiplier = 1u64 << idle_attempts.saturating_sub(1).min(4); + Duration::from_secs((2 * multiplier).min(30)) + } + + /// Logs download progress using the same fields as snapshot compression. + fn log_progress(downloaded: u64, expected: u64, started: Instant, baseline: u64) { + let elapsed = started.elapsed(); + let session_done = downloaded.saturating_sub(baseline); + let session_total = expected.saturating_sub(baseline); + let speed = session_done as f64 / elapsed.as_secs_f64(); + let eta = ProgressDisplay::eta(session_done, session_total, elapsed) + .map_or_else(|| "unknown".to_string(), |eta| eta.to_string()); + info!( + target: "reth::cli", + progress = %ProgressDisplay::human_byte_progress(downloaded, expected), + speed = %ProgressDisplay::speed(speed), + eta = %eta, + elapsed = %ProgressDisplay::duration(elapsed), + "Proofs download progress" + ); + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use axum::{Router, http::StatusCode, response::IntoResponse, routing::get}; + + use super::*; + use crate::commands::download::proofs::test_utils::{ + create_proofs_archive, proofs_entry, serve, start_concurrent_range_server, + start_drop_then_range_server, start_range_aware_server, start_recording_range_server, + start_short_then_range_server, + }; + + /// Piece size that splits the small test archives into several pieces. + const TEST_PIECE_SIZE: u64 = 16; + + /// Downloads `entry` into `cache_dir` and returns the `.part` path. + async fn download( + entry: &ProofsManifestEntry, + cache_dir: &Path, + concurrency: usize, + ) -> Result { + let download = ProofsArchiveDownload::new(entry, cache_dir, concurrency, TEST_PIECE_SIZE); + let pieces = download.prepare().await?; + download.run(pieces, &ProofsArchiveAvailability::new(entry.expected_size)).await?; + Ok(download.part_path) + } + + #[test] + fn retry_delay_keeps_stream_drops_at_one_second() { + assert_eq!(ProofsArchiveDownload::retry_delay(1, false), Duration::from_secs(1)); + assert_eq!(ProofsArchiveDownload::retry_delay(8, false), Duration::from_secs(1)); + } + + #[test] + fn retry_delay_uses_exponential_backoff_when_throttled() { + assert_eq!(ProofsArchiveDownload::retry_delay(1, true), Duration::from_secs(2)); + assert_eq!(ProofsArchiveDownload::retry_delay(2, true), Duration::from_secs(4)); + assert_eq!(ProofsArchiveDownload::retry_delay(3, true), Duration::from_secs(8)); + assert_eq!( + ProofsArchiveDownload::retry_delay(5, true), + Duration::from_secs(30), + "backoff should cap at 30s" + ); + } + + #[test] + fn piece_map_covers_the_whole_file() { + let pieces = ProofsPieceMap::new(4, 10); + + assert_eq!( + pieces.pending(), + vec![(0, 0..4), (1, 4..8), (2, 8..10)], + "the last piece should be short" + ); + } + + #[test] + fn piece_map_prefix_stops_at_first_missing_piece() { + let mut pieces = ProofsPieceMap::new(4, 10); + pieces.mark_done(1); + pieces.mark_done(2); + assert_eq!(pieces.contiguous_len(), 0, "a missing first piece hides later pieces"); + assert_eq!(pieces.downloaded_len(), 6); + + pieces.mark_done(0); + assert_eq!(pieces.contiguous_len(), 10); + assert!(pieces.is_complete()); + } + + #[test] + fn piece_map_sidecar_round_trips() { + let mut pieces = ProofsPieceMap::new(4, 10); + pieces.mark_done(2); + + assert_eq!(ProofsPieceMap::decode(&pieces.encode(), 4, 10), Some(pieces)); + } + + #[test] + fn piece_map_rejects_other_layouts_and_legacy_sidecars() { + let pieces = ProofsPieceMap::new(4, 10).encode(); + + assert_eq!(ProofsPieceMap::decode(&pieces, 8, 10), None, "different piece size"); + assert_eq!(ProofsPieceMap::decode(&pieces, 4, 11), None, "different archive size"); + assert_eq!( + ProofsPieceMap::decode("4 10 3 3 2 2", 4, 10), + None, + "per-range byte counts from older versions" + ); + assert_eq!(ProofsPieceMap::decode("pieces-v1 4 10 01", 4, 10), None, "short bitmap"); + assert_eq!(ProofsPieceMap::decode("pieces-v1 4 10 01x", 4, 10), None, "bad bit"); + } + + #[tokio::test] + async fn download_retries_after_stream_interrupt() { + let archive = + create_proofs_archive(&[("proofs/data.mdb", b"complete-proof-data-after-cf-drop")]); + let (base_url, requests, handle) = start_drop_then_range_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + + let part = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 1) + .await + .expect("stream drop should resume in-process instead of failing"); + + assert_eq!(std::fs::read(&part).unwrap(), archive); + assert!( + requests.load(Ordering::SeqCst) >= 2, + "stream drop should trigger a Range retry, got {} requests", + requests.load(Ordering::SeqCst) + ); + assert!(!cache_dir.path().join("proofs.tar.zst.part.ranges").exists()); + + handle.abort(); + } + + #[tokio::test] + async fn download_retries_when_body_ends_without_content_length() { + let archive = + create_proofs_archive(&[("proofs/data.mdb", b"resume-after-silent-truncation")]); + let (base_url, requests, handle) = start_short_then_range_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + + let part = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 1) + .await + .expect("a clean close without Content-Length should resume"); + + assert_eq!(std::fs::read(&part).unwrap(), archive); + assert!( + requests.load(Ordering::SeqCst) >= 2, + "silent truncation should retry, got {} requests", + requests.load(Ordering::SeqCst) + ); + + handle.abort(); + } + + #[tokio::test] + async fn download_uses_parallel_range_requests() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"parallel-range-proof-data")]); + let (base_url, max_in_flight, handle) = + start_concurrent_range_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + + let part = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 4) + .await + .expect("parallel Range download should succeed"); + + assert_eq!(std::fs::read(&part).unwrap(), archive, "pieces should assemble the archive"); + assert!( + max_in_flight.load(Ordering::SeqCst) >= 2, + "download-concurrency=4 should issue overlapping Range requests, max in-flight was {}", + max_in_flight.load(Ordering::SeqCst) + ); + assert!( + !cache_dir.path().join("proofs.tar.zst.part.ranges").exists(), + "sidecar should be removed after a complete download" + ); + + handle.abort(); + } + + #[tokio::test] + async fn download_resumes_only_missing_pieces_from_sidecar() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"resume-two-of-four-pieces")]); + let mut pieces = ProofsPieceMap::new(TEST_PIECE_SIZE, archive.len() as u64); + assert!(pieces.pending().len() >= 4, "archive must span at least four pieces"); + + let mut part = vec![0u8; archive.len()]; + for index in [0, 2] { + let range = pieces.range(index); + let range = range.start as usize..range.end as usize; + part[range.clone()].copy_from_slice(&archive[range]); + pieces.mark_done(index); + } + + let (base_url, requests, handle) = start_recording_range_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + std::fs::write(cache_dir.path().join("proofs.tar.zst.part"), &part).unwrap(); + std::fs::write( + cache_dir.path().join("proofs.tar.zst.part.ranges"), + format!("{base_url}/proofs.tar.zst\n{}", pieces.encode()), + ) + .unwrap(); + + let part = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 4) + .await + .expect("sidecar resume should finish the remaining pieces"); + + assert_eq!(std::fs::read(&part).unwrap(), archive); + let mut got = requests.lock().await.clone(); + got.sort_unstable(); + let expected: Vec<_> = + pieces.pending().into_iter().map(|(_, range)| (range.start, range.end - 1)).collect(); + assert_eq!(got, expected, "only pieces missing from the sidecar should be fetched"); + + handle.abort(); + } + + #[tokio::test] + async fn download_discards_partial_part_without_sidecar() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"discard-legacy-leftover")]); + let (base_url, requests, handle) = start_recording_range_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + let mut stale = archive[..archive.len() / 2].to_vec(); + stale.fill(0xAA); + std::fs::write(cache_dir.path().join("proofs.tar.zst.part"), &stale).unwrap(); + + let part = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 1) + .await + .expect("a legacy sequential leftover should restart the download"); + + assert_eq!(std::fs::read(&part).unwrap(), archive); + assert_eq!( + requests.lock().await.first(), + Some(&(0, TEST_PIECE_SIZE - 1)), + "the download should restart from the first piece" + ); + + handle.abort(); + } + + #[tokio::test] + async fn download_discards_legacy_range_sidecar() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"not-zeros-from-set-len")]); + let (base_url, handle) = start_range_aware_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + let len = archive.len() as u64; + std::fs::write(cache_dir.path().join("proofs.tar.zst.part"), vec![0u8; archive.len()]) + .unwrap(); + std::fs::write( + cache_dir.path().join("proofs.tar.zst.part.ranges"), + format!("4 {len} {} {} {} {}", len / 4, len / 4, len / 4, len - 3 * (len / 4)), + ) + .unwrap(); + + let part = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 4) + .await + .expect("a sidecar from an older version should restart the download"); + + assert_eq!( + std::fs::read(&part).unwrap(), + archive, + "a preallocated .part described by an old sidecar must be re-downloaded" + ); + + handle.abort(); + } + + #[tokio::test] + async fn download_ignores_sidecar_without_part_file() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"download-over-missing-part")]); + let (base_url, handle) = start_range_aware_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + std::fs::write( + cache_dir.path().join("proofs.tar.zst.part.ranges"), + format!( + "{base_url}/proofs.tar.zst\n{}", + ProofsPieceMap::complete(TEST_PIECE_SIZE, archive.len() as u64).encode() + ), + ) + .unwrap(); + + let part = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 4) + .await + .expect("missing .part should re-download instead of trusting the sidecar"); + + assert_eq!(std::fs::read(&part).unwrap(), archive); + + handle.abort(); + } + + #[tokio::test] + async fn download_discards_sidecar_for_another_archive_url() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"same-size-other-snapshot")]); + let (base_url, handle) = start_range_aware_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + std::fs::write(cache_dir.path().join("proofs.tar.zst.part"), vec![0u8; archive.len()]) + .unwrap(); + std::fs::write( + cache_dir.path().join("proofs.tar.zst.part.ranges"), + format!( + "https://example.com/old/proofs.tar.zst\n{}", + ProofsPieceMap::complete(TEST_PIECE_SIZE, archive.len() as u64).encode() + ), + ) + .unwrap(); + + let part = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 4) + .await + .expect("resume state from another archive should restart the download"); + + assert_eq!(std::fs::read(&part).unwrap(), archive); + + handle.abort(); + } + + #[tokio::test] + async fn download_rejects_bad_content_range() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"content-range-checks")]); + let len = archive.len(); + let cases = [ + (None, "invalid Content-Range"), + (Some(format!("bytes 1-{TEST_PIECE_SIZE}/{len}")), "server returned proofs range"), + ( + Some(format!("bytes 0-{}/{len}", TEST_PIECE_SIZE - 2)), + "server returned proofs range", + ), + (Some(format!("bytes 0-{}/*", TEST_PIECE_SIZE - 1)), "invalid Content-Range"), + ]; + + for (content_range, expected_error) in cases { + let body = archive[..TEST_PIECE_SIZE as usize].to_vec(); + let (base_url, handle) = serve(Router::new().route( + "/proofs.tar.zst", + get(move || { + let body = body.clone(); + let content_range = content_range.clone(); + async move { + let mut response = (StatusCode::PARTIAL_CONTENT, body).into_response(); + if let Some(value) = content_range { + response.headers_mut().insert(CONTENT_RANGE, value.parse().unwrap()); + } + response + } + }), + )) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + + let error = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 1) + .await + .expect_err("a response for other bytes must not fill a piece"); + + assert!(error.to_string().contains(expected_error), "error: {error}"); + handle.abort(); + } + } + + #[tokio::test] + async fn download_fails_when_server_ignores_range() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"fresh-data")]); + let data = archive.clone(); + let (base_url, handle) = serve(Router::new().route( + "/proofs.tar.zst", + get(move || { + let data = data.clone(); + async move { data } + }), + )) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + + let error = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 1) + .await + .expect_err("a 200 response to a Range request cannot be placed in a piece"); + + assert!(error.to_string().contains("expected HTTP 206"), "error: {error}"); + + handle.abort(); + } + + #[tokio::test] + async fn download_uses_completed_part_file_without_requests() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"already-complete")]); + let (base_url, handle) = serve(Router::new().route( + "/proofs.tar.zst", + get(|| async { (StatusCode::RANGE_NOT_SATISFIABLE, Vec::::new()) }), + )) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + std::fs::write(cache_dir.path().join("proofs.tar.zst.part"), &archive).unwrap(); + + let part = download(&proofs_entry(&base_url, &archive), cache_dir.path(), 1) + .await + .expect("a full-size .part without a sidecar is a finished download"); + + assert_eq!(std::fs::read(&part).unwrap(), archive); + + handle.abort(); + } + + #[tokio::test] + async fn download_fails_on_size_mismatch() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"data")]); + let (base_url, handle) = start_range_aware_server(archive.clone()).await; + let cache_dir = tempfile::tempdir().unwrap(); + let mut entry = proofs_entry(&base_url, &archive); + entry.expected_size += 999; + + let error = download(&entry, cache_dir.path(), 1) + .await + .expect_err("a server archive of a different size should fail"); + + assert!(error.to_string().contains("size mismatch"), "error: {error}"); + assert!( + !cache_dir.path().join("proofs.tar.zst.part").exists(), + ".part should be deleted on size mismatch" + ); + + handle.abort(); + } +} diff --git a/crates/execution/cli/src/commands/download/proofs/archive_extract.rs b/crates/execution/cli/src/commands/download/proofs/archive_extract.rs new file mode 100644 index 0000000000..2451782ee5 --- /dev/null +++ b/crates/execution/cli/src/commands/download/proofs/archive_extract.rs @@ -0,0 +1,479 @@ +//! Streaming extraction of the proofs archive while it is still downloading. +//! +//! [`ProofsArchiveDownload`](super::ProofsArchiveDownload) publishes the length +//! of the contiguous prefix of the `.part` file that is on disk. +//! [`ProofsArchiveReader`] blocks until more of that prefix is available, so +//! zstd decoding and tar unpacking run concurrently with the download. Decoding +//! and unpacking run on separate threads because unpacking alone is slower than +//! single-threaded zstd decoding. + +use std::{ + fs::File, + io::{self, BufRead, BufReader, Read}, + path::Path, + sync::{ + Arc, Condvar, Mutex, MutexGuard, + mpsc::{Receiver, SyncSender, sync_channel}, + }, + thread, + time::Instant, +}; + +use base_reth_cli::ProgressDisplay; +use eyre::Result; +use tracing::info; + +use super::PROOFS_PROGRESS_LOG_INTERVAL; + +/// Read buffer between the `.part` file and the zstd decoder. +/// +/// The decoder's default input buffer measurably slows decoding of multi-GiB archives. +const COMPRESSED_READ_BUFFER: usize = 1 << 20; + +/// Size of each decoded chunk handed from the decoder to the tar thread. +const DECODED_CHUNK_SIZE: usize = 1 << 20; + +/// Decoded chunks buffered between the decoder and the tar thread. +const DECODED_CHUNK_QUEUE: usize = 16; + +/// Length of the proofs archive prefix that is fully written to the `.part` file. +#[derive(Debug)] +pub struct ProofsArchiveAvailability { + total: u64, + /// Published prefix length, or `None` once the download stopped before completing. + available: Mutex>, + changed: Condvar, +} + +impl ProofsArchiveAvailability { + /// Creates availability for an archive of `total` bytes with nothing on disk yet. + pub const fn new(total: u64) -> Self { + Self { total, available: Mutex::new(Some(0)), changed: Condvar::new() } + } + + /// Publishes that the first `available` bytes are written and flushed. + /// + /// The prefix never shrinks, and nothing is published after an abort. + pub fn publish(&self, available: u64) { + let mut state = self.lock(); + if let Some(current) = state.as_mut() + && available > *current + { + *current = available.min(self.total); + self.changed.notify_all(); + } + } + + /// Fails readers because no more bytes will arrive. + /// + /// Does nothing once the whole archive is published, so a completed + /// download stays readable. + pub fn abort(&self) { + let mut state = self.lock(); + if *state != Some(self.total) { + *state = None; + self.changed.notify_all(); + } + } + + /// Returns a guard that aborts this availability when dropped, so a + /// blocked reader never hangs after the download fails or is cancelled. + pub fn abort_on_drop(self: &Arc) -> ProofsAvailabilityAbortGuard { + ProofsAvailabilityAbortGuard { availability: Arc::clone(self) } + } + + /// Returns whether every byte of the archive is published. + pub fn is_complete(&self) -> bool { + *self.lock() == Some(self.total) + } + + /// Blocks until bytes past `position` are available and returns the + /// available length, which equals `position` only at the end of the archive. + /// + /// Fails as soon as the download is aborted, even if published bytes remain + /// unread, so a doomed extraction stops promptly. + fn wait_past(&self, position: u64) -> io::Result { + let mut state = self.lock(); + loop { + match *state { + None => { + return Err(io::Error::other( + "proofs download stopped before the archive completed", + )); + } + Some(available) if available > position || available == self.total => { + return Ok(available); + } + Some(_) => { + state = + self.changed.wait(state).unwrap_or_else(|poisoned| poisoned.into_inner()); + } + } + } + } + + fn lock(&self) -> MutexGuard<'_, Option> { + self.available.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +/// Aborts a [`ProofsArchiveAvailability`] when dropped. +#[derive(Debug)] +pub struct ProofsAvailabilityAbortGuard { + availability: Arc, +} + +impl Drop for ProofsAvailabilityAbortGuard { + fn drop(&mut self) { + self.availability.abort(); + } +} + +/// Reads the published prefix of the proofs `.part` file, blocking at its end +/// until more bytes are published. +#[derive(Debug)] +pub struct ProofsArchiveReader { + file: File, + position: u64, + availability: Arc, +} + +impl ProofsArchiveReader { + /// Creates a reader over `file` starting at its beginning. + pub const fn new(file: File, availability: Arc) -> Self { + Self { file, position: 0, availability } + } +} + +impl Read for ProofsArchiveReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + + let available = self.availability.wait_past(self.position)?; + let readable = available - self.position; + if readable == 0 { + return Ok(0); + } + + let len = buf.len().min(usize::try_from(readable).unwrap_or(usize::MAX)); + let read = self.file.read(&mut buf[..len])?; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "proofs archive is shorter than its published length", + )); + } + self.position += read as u64; + Ok(read) + } +} + +/// Reports proofs extraction progress as compressed archive bytes are consumed. +#[derive(Debug)] +pub struct ProofsExtractionProgress { + inner: R, + processed: u64, + total: u64, + started: Instant, + last_log: Instant, +} + +impl ProofsExtractionProgress { + /// Wraps `inner`, an archive of `total` compressed bytes. + pub fn new(inner: R, total: u64) -> Self { + let now = Instant::now(); + Self { inner, processed: 0, total, started: now, last_log: now } + } +} + +impl Read for ProofsExtractionProgress { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let read = self.inner.read(buf)?; + self.processed = self.processed.saturating_add(read as u64).min(self.total); + + if read > 0 && self.last_log.elapsed() >= PROOFS_PROGRESS_LOG_INTERVAL { + let elapsed = self.started.elapsed(); + let speed = self.processed as f64 / elapsed.as_secs_f64(); + let eta = ProgressDisplay::eta(self.processed, self.total, elapsed) + .map_or_else(|| "unknown".to_string(), |eta| eta.to_string()); + info!( + target: "reth::cli", + progress = %ProgressDisplay::human_byte_progress(self.processed, self.total), + speed = %ProgressDisplay::speed(speed), + eta = %eta, + elapsed = %ProgressDisplay::duration(elapsed), + "Proofs extraction progress" + ); + self.last_log = Instant::now(); + } + + Ok(read) + } +} + +/// Presents decoded chunks received from the decoder thread as a byte stream, +/// returning each consumed chunk to the decoder for reuse. +#[derive(Debug)] +pub struct ProofsDecodedChunks { + chunks: Receiver>, + recycled: SyncSender>, + chunk: Vec, + offset: usize, +} + +impl Read for ProofsDecodedChunks { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + if self.offset == self.chunk.len() { + // Recycle before blocking so the decoder can reuse this buffer for + // the chunk being waited on. + let _ = self.recycled.try_send(std::mem::take(&mut self.chunk)); + let Ok(chunk) = self.chunks.recv() else { return Ok(0) }; + self.chunk = chunk; + self.offset = 0; + } + let len = buf.len().min(self.chunk.len() - self.offset); + buf[..len].copy_from_slice(&self.chunk[self.offset..self.offset + len]); + self.offset += len; + Ok(len) + } +} + +/// Decodes the proofs `.tar.zst` archive and unpacks it on separate threads. +#[derive(Debug)] +pub struct ProofsArchiveExtractor; + +impl ProofsArchiveExtractor { + /// Extracts the archive at `archive_path` into `target_dir` as its prefix + /// becomes available. + /// + /// Returns only after the whole archive is decoded, so the zstd checksum + /// and any bytes after the tar end marker are validated. + pub fn extract( + archive_path: &Path, + availability: Arc, + target_dir: &Path, + ) -> Result<()> { + let total = availability.total; + let reader = ProofsArchiveReader::new(File::open(archive_path)?, availability); + let reader = ProofsExtractionProgress::new(reader, total); + let decoder = + zstd::Decoder::with_buffer(BufReader::with_capacity(COMPRESSED_READ_BUFFER, reader))?; + + let (sender, chunks) = sync_channel(DECODED_CHUNK_QUEUE); + let (recycled, reusable) = sync_channel(DECODED_CHUNK_QUEUE); + let target_dir = target_dir.to_path_buf(); + let unpacker = + thread::Builder::new().name("proofs-untar".to_string()).spawn(move || { + Self::unpack( + ProofsDecodedChunks { chunks, recycled, chunk: Vec::new(), offset: 0 }, + &target_dir, + ) + })?; + + let decoded = Self::decode(decoder, &sender, &reusable); + drop(sender); + let unpacked = + unpacker.join().map_err(|_| eyre::eyre!("proofs tar unpack thread panicked"))?; + + match decoded { + Err(error) => Err(eyre::eyre!("failed to decode proofs archive: {error}")), + Ok(()) => unpacked, + } + } + + /// Sends decoded chunks until the end of the archive or until the unpack + /// thread stops receiving, which only happens when it failed. + fn decode( + mut decoder: zstd::Decoder<'static, R>, + sender: &SyncSender>, + reusable: &Receiver>, + ) -> io::Result<()> { + loop { + let mut chunk = reusable.try_recv().unwrap_or_default(); + chunk.resize(DECODED_CHUNK_SIZE, 0); + let read = loop { + match decoder.read(&mut chunk) { + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + result => break result?, + } + }; + if read == 0 { + return Ok(()); + } + chunk.truncate(read); + if sender.send(chunk).is_err() { + return Ok(()); + } + } + } + + /// Unpacks the tar stream, then drains the rest of the decoded stream so + /// the decoder always runs to the end of the archive. + fn unpack(mut chunks: ProofsDecodedChunks, target_dir: &Path) -> Result<()> { + tar::Archive::new(&mut chunks) + .unpack(target_dir) + .map_err(|error| eyre::eyre!("failed to unpack proofs archive: {error}"))?; + io::copy(&mut chunks, &mut io::sink())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, thread, time::Duration}; + + use super::*; + use crate::commands::download::proofs::test_utils::create_proofs_archive; + + /// Writes `archive` to a temp file and returns availability with every byte published. + fn write_complete_archive( + dir: &Path, + archive: &[u8], + ) -> (PathBuf, Arc) { + let path = dir.join("proofs.tar.zst.part"); + std::fs::write(&path, archive).unwrap(); + let availability = Arc::new(ProofsArchiveAvailability::new(archive.len() as u64)); + availability.publish(archive.len() as u64); + (path, availability) + } + + #[test] + fn extract_preserves_directory_structure() { + let src = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + let archive = create_proofs_archive(&[ + ("proofs/data.mdb", b"data"), + ("proofs/lock.mdb", b"lock"), + ("proofs/nested/deep.dat", b"deep"), + ]); + let (path, availability) = write_complete_archive(src.path(), &archive); + + ProofsArchiveExtractor::extract(&path, availability, dest.path()).unwrap(); + + assert_eq!(std::fs::read(dest.path().join("proofs/data.mdb")).unwrap(), b"data"); + assert_eq!(std::fs::read(dest.path().join("proofs/lock.mdb")).unwrap(), b"lock"); + assert_eq!(std::fs::read(dest.path().join("proofs/nested/deep.dat")).unwrap(), b"deep"); + } + + #[test] + fn extract_fails_on_missing_archive() { + let dest = tempfile::tempdir().unwrap(); + let availability = Arc::new(ProofsArchiveAvailability::new(16)); + availability.publish(16); + + let result = ProofsArchiveExtractor::extract( + &dest.path().join("nonexistent.tar.zst"), + availability, + dest.path(), + ); + + assert!(result.is_err()); + } + + #[test] + fn extract_rejects_trailing_garbage() { + let src = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + let mut archive = create_proofs_archive(&[("proofs/data.mdb", b"data")]); + archive.extend_from_slice(b"not-a-zstd-frame"); + let (path, availability) = write_complete_archive(src.path(), &archive); + + let error = ProofsArchiveExtractor::extract(&path, availability, dest.path()) + .expect_err("bytes after the last zstd frame must fail extraction"); + + assert!(error.to_string().contains("decode"), "error: {error}"); + } + + #[test] + fn extract_rejects_truncated_archive() { + let src = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + let archive = create_proofs_archive(&[("proofs/data.mdb", &[7u8; 64 * 1024])]); + let (path, availability) = + write_complete_archive(src.path(), &archive[..archive.len() - 8]); + + let result = ProofsArchiveExtractor::extract(&path, availability, dest.path()); + + assert!(result.is_err(), "an archive cut short must fail extraction"); + } + + #[test] + fn reader_waits_for_published_bytes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proofs.tar.zst.part"); + std::fs::write(&path, b"abcdef").unwrap(); + let availability = Arc::new(ProofsArchiveAvailability::new(6)); + availability.publish(2); + + let reader_availability = Arc::clone(&availability); + let reader = thread::spawn(move || { + let mut out = Vec::new(); + ProofsArchiveReader::new(File::open(path).unwrap(), reader_availability) + .read_to_end(&mut out) + .map(|_| out) + }); + + thread::sleep(Duration::from_millis(50)); + assert!(!reader.is_finished(), "reader must block at the published prefix"); + availability.publish(6); + + assert_eq!(reader.join().unwrap().unwrap(), b"abcdef"); + } + + #[test] + fn abort_wakes_blocked_reader() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proofs.tar.zst.part"); + std::fs::write(&path, b"abcdef").unwrap(); + let availability = Arc::new(ProofsArchiveAvailability::new(6)); + + let reader_availability = Arc::clone(&availability); + let reader = thread::spawn(move || { + let mut out = Vec::new(); + ProofsArchiveReader::new(File::open(path).unwrap(), reader_availability) + .read_to_end(&mut out) + }); + thread::sleep(Duration::from_millis(50)); + drop(availability.abort_on_drop()); + + assert!(reader.join().unwrap().is_err(), "abort must wake and fail a blocked reader"); + } + + #[test] + fn abort_stops_reads_of_unread_published_bytes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proofs.tar.zst.part"); + std::fs::write(&path, b"abcdef").unwrap(); + let availability = Arc::new(ProofsArchiveAvailability::new(6)); + availability.publish(3); + availability.abort(); + + let mut buf = [0u8; 6]; + let result = + ProofsArchiveReader::new(File::open(path).unwrap(), availability).read(&mut buf); + + assert!(result.is_err(), "an aborted download must not keep feeding the extractor"); + } + + #[test] + fn abort_after_completion_keeps_bytes_readable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proofs.tar.zst.part"); + std::fs::write(&path, b"abcdef").unwrap(); + let availability = Arc::new(ProofsArchiveAvailability::new(6)); + availability.publish(6); + availability.abort(); + + let mut out = Vec::new(); + ProofsArchiveReader::new(File::open(path).unwrap(), availability) + .read_to_end(&mut out) + .unwrap(); + + assert_eq!(out, b"abcdef"); + } +} diff --git a/crates/execution/cli/src/commands/download/proofs/downloader.rs b/crates/execution/cli/src/commands/download/proofs/downloader.rs new file mode 100644 index 0000000000..f39e4c21d6 --- /dev/null +++ b/crates/execution/cli/src/commands/download/proofs/downloader.rs @@ -0,0 +1,560 @@ +//! End-to-end proofs download: manifest, concurrent download and extraction, +//! verification, and cache cleanup. + +use std::{path::Path, pin::pin, sync::Arc, time::Instant}; + +use base_reth_cli::{OutputFileChecksum, ProgressDisplay}; +use eyre::Result; +use rayon::prelude::*; +use reth_cli_commands::download::DownloadDefaults; +use tracing::{info, warn}; + +use super::{ + ProofsArchiveAvailability, ProofsArchiveDownload, ProofsArchiveExtractor, ProofsManifest, + ProofsManifestEntry, +}; + +/// Downloads and extracts the proofs database from a snapshot manifest. +/// +/// Only the proofs component goes through this path. Every other snapshot +/// component is downloaded and extracted by reth's downloader. +#[derive(Debug)] +pub struct ProofsDownloader; + +impl ProofsDownloader { + /// Runs the full proofs download for `chain_id`, using `manifest_url` when + /// given and otherwise the latest manifest from the snapshot API. + pub async fn run( + target_dir: &Path, + chain_id: u64, + concurrency: usize, + manifest_url: Option, + ) -> Result<()> { + let manifest_url = match manifest_url { + Some(url) => url, + None => { + let api_url = DownloadDefaults::get_global().snapshot_api_url.as_ref(); + ProofsManifest::discover_latest_url(api_url, chain_id).await? + } + }; + + Self::run_from_manifest(target_dir, &manifest_url, concurrency).await + } + + /// Runs the full proofs download from a manifest URL. + pub async fn run_from_manifest( + target_dir: &Path, + manifest_url: &str, + concurrency: usize, + ) -> Result<()> { + let entry = ProofsManifest::fetch_entry(manifest_url).await?; + Self::download_and_extract( + &entry, + target_dir, + concurrency, + ProofsArchiveDownload::DEFAULT_PIECE_SIZE, + ) + .await + } + + /// Downloads the archive into `target_dir/.snapshot-cache` while + /// extracting its available prefix into `target_dir`, then verifies the + /// extracted files and removes the cache. + /// + /// A download error stops extraction, and an extraction error cancels the + /// download. On failure the cache is kept: an incomplete download resumes + /// on the next run, and a complete archive is renamed to its final name + /// for inspection and downloaded again next time. + pub async fn download_and_extract( + entry: &ProofsManifestEntry, + target_dir: &Path, + concurrency: usize, + piece_size: u64, + ) -> Result<()> { + let cache_dir = target_dir.join(".snapshot-cache"); + tokio::fs::create_dir_all(&cache_dir).await?; + let archive_path = cache_dir.join(&entry.file_name); + tokio::fs::remove_file(&archive_path).await.ok(); + + let download = ProofsArchiveDownload::new(entry, &cache_dir, concurrency, piece_size); + let pieces = download.prepare().await?; + let already_downloaded = pieces.is_complete(); + let availability = Arc::new(ProofsArchiveAvailability::new(entry.expected_size)); + let abort_guard = availability.abort_on_drop(); + + info!(target: "reth::cli", "Downloading and extracting proofs archive"); + let mut extraction = { + let part_path = download.part_path.clone(); + let target_dir = target_dir.to_path_buf(); + let availability = Arc::clone(&availability); + let output_files = entry.output_files.clone(); + tokio::task::spawn_blocking(move || { + ProofsArchiveExtractor::extract(&part_path, availability, &target_dir)?; + Self::verify_output_files(&target_dir, &output_files) + }) + }; + let mut downloading = pin!(download.run(pieces, &availability)); + + let result = tokio::select! { + downloaded = &mut downloading => match downloaded { + Ok(()) => Self::extraction_result(extraction.await), + Err(error) => { + availability.abort(); + extraction.await.ok(); + Err(error) + } + }, + extracted = &mut extraction => match Self::extraction_result(extracted) { + // Extraction reads to the end of the archive, so the download + // has published every byte and only needs to finish cleanup. + Ok(()) => (&mut downloading).await, + Err(error) => Err(error), + }, + }; + drop(abort_guard); + + if let Err(error) = result { + // Extraction can fail after the last piece is published but before + // the download removes its sidecar, or before the download of an + // already complete archive is first polled, so completeness is + // not read back from the files on disk. + if already_downloaded || availability.is_complete() { + tokio::fs::remove_file(&download.sidecar_path).await.ok(); + tokio::fs::rename(&download.part_path, &archive_path).await.ok(); + } + return Err(error); + } + + tokio::fs::remove_dir_all(&cache_dir).await.ok(); + info!(target: "reth::cli", "Proofs database download complete"); + Ok(()) + } + + /// Flattens the extraction task result, surfacing panics as errors. + fn extraction_result(joined: Result, tokio::task::JoinError>) -> Result<()> { + joined.map_err(|error| eyre::eyre!("proofs extraction task failed: {error}"))? + } + + /// Verifies that every manifest output file exists under `target_dir` with the expected size + /// and BLAKE3 hash. + /// + /// Files are hashed in parallel on the Rayon pool. Manifests without `output_files` are + /// accepted with a warning, since they carry nothing to verify against. + fn verify_output_files(target_dir: &Path, output_files: &[OutputFileChecksum]) -> Result<()> { + if output_files.is_empty() { + warn!(target: "reth::cli", "Manifest lists no proofs output files, skipping verification"); + return Ok(()); + } + + let total_bytes: u64 = output_files.iter().map(|file| file.size).sum(); + info!( + target: "reth::cli", + files = output_files.len(), + size = %ProgressDisplay::bytes(total_bytes as f64), + "Verifying extracted proofs files" + ); + let started = Instant::now(); + + output_files.par_iter().try_for_each(|expected| { + let path = target_dir.join(&expected.path); + let file = std::fs::File::open(&path) + .map_err(|e| eyre::eyre!("missing proofs file {}: {e}", expected.path))?; + let size = file.metadata()?.len(); + if size != expected.size { + eyre::bail!( + "proofs file {} has size {size}, manifest expects {}", + expected.path, + expected.size + ); + } + let hash = blake3::Hasher::new().update_reader(file)?.finalize().to_hex(); + if !hash.as_str().eq_ignore_ascii_case(&expected.blake3) { + eyre::bail!( + "proofs file {} has BLAKE3 {hash}, manifest expects {}", + expected.path, + expected.blake3 + ); + } + Ok(()) + })?; + + info!( + target: "reth::cli", + files = output_files.len(), + elapsed = ?started.elapsed(), + "Verified extracted proofs files" + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{ + path::PathBuf, + sync::atomic::{AtomicBool, Ordering}, + time::Duration, + }; + + use axum::{ + Router, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + }; + use tokio::sync::Mutex; + + use super::*; + use crate::commands::download::proofs::test_utils::{ + create_proofs_archive, generate_proofs_snapshot, parse_byte_range, partial_content, + proofs_entry, serve, start_test_server, + }; + + /// Piece size that splits the streaming test archives into many pieces. + const TEST_PIECE_SIZE: u64 = 16 * 1024; + + /// Bound on each streaming test so a hang fails instead of blocking the suite. + const TEST_TIMEOUT: Duration = Duration::from_secs(30); + + /// Returns `len` bytes that zstd cannot compress, so compressed offsets + /// track tar offsets. + fn incompressible(len: usize, seed: u64) -> Vec { + let mut state = seed | 1; + (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state as u8 + }) + .collect() + } + + #[tokio::test] + async fn full_pipeline_verifies_generated_snapshot() { + let (manifest, archive) = generate_proofs_snapshot(&[ + ("CURRENT", b"MANIFEST-000014\n"), + ("000060.sst", b"sst-data"), + ("nested/000801.log", b"wal-data"), + ]); + assert_eq!(manifest["components"]["proofs"]["output_files"].as_array().unwrap().len(), 3); + + let (manifest_url, handle) = start_test_server(manifest, archive).await; + let target = tempfile::tempdir().unwrap(); + + ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) + .await + .expect("generated snapshot should download and verify"); + + assert_eq!(std::fs::read(target.path().join("proofs/000060.sst")).unwrap(), b"sst-data"); + assert_eq!( + std::fs::read(target.path().join("proofs/nested/000801.log")).unwrap(), + b"wal-data" + ); + assert!(!target.path().join(".snapshot-cache").exists(), "cache should be cleaned up"); + + handle.abort(); + } + + #[tokio::test] + async fn full_pipeline_rejects_hash_mismatch() { + let (mut manifest, archive) = + generate_proofs_snapshot(&[("000060.sst", b"sst-data"), ("CURRENT", b"current")]); + let output_files = manifest["components"]["proofs"]["output_files"].as_array_mut().unwrap(); + let sst = output_files.iter_mut().find(|file| file["path"] == "proofs/000060.sst").unwrap(); + sst["blake3"] = serde_json::Value::String("00".repeat(32)); + + let (manifest_url, handle) = start_test_server(manifest, archive).await; + let target = tempfile::tempdir().unwrap(); + + let error = ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) + .await + .expect_err("hash mismatch should fail the download"); + + assert!(error.to_string().contains("proofs/000060.sst"), "error: {error}"); + assert!( + target.path().join(".snapshot-cache/proofs.tar.zst").exists(), + "archive should be kept for inspection" + ); + + handle.abort(); + } + + #[tokio::test] + async fn full_pipeline_rejects_size_mismatch() { + let (mut manifest, archive) = generate_proofs_snapshot(&[("000060.sst", b"sst-data")]); + manifest["components"]["proofs"]["output_files"][0]["size"] = serde_json::json!(9); + + let (manifest_url, handle) = start_test_server(manifest, archive).await; + let target = tempfile::tempdir().unwrap(); + + let error = ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) + .await + .expect_err("size mismatch should fail the download"); + + assert!(error.to_string().contains("size 8"), "error: {error}"); + + handle.abort(); + } + + #[tokio::test] + async fn full_pipeline_rejects_archive_missing_manifest_file() { + // The manifest lists a file that the archive does not contain, as happens when an + // archive is cut short at a tar entry boundary. + let (mut manifest, archive) = + generate_proofs_snapshot(&[("000060.sst", b"sst-data"), ("CURRENT", b"current")]); + let output_files = manifest["components"]["proofs"]["output_files"].as_array_mut().unwrap(); + let mut missing = output_files[0].clone(); + missing["path"] = serde_json::Value::String("proofs/000061.sst".to_string()); + output_files.push(missing); + + let (manifest_url, handle) = start_test_server(manifest, archive).await; + let target = tempfile::tempdir().unwrap(); + + let error = ProofsDownloader::run_from_manifest(target.path(), &manifest_url, 1) + .await + .expect_err("missing file should fail the download"); + + assert!( + error.to_string().contains("missing proofs file proofs/000061.sst"), + "error: {error}" + ); + + handle.abort(); + } + + #[derive(Clone)] + struct GatedLastPieceState { + data: Vec, + extracted_file: PathBuf, + extracted_len: u64, + saw_extracted_file: Arc, + } + + /// Holds the final piece until `extracted_file` is fully written, proving + /// extraction runs before the download finishes. + async fn handle_gated_last_piece( + State(state): State, + headers: HeaderMap, + ) -> Response { + let Some((start, end)) = parse_byte_range(&headers, state.data.len()) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + if end + 1 == state.data.len() { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let len = std::fs::metadata(&state.extracted_file).map(|m| m.len()).unwrap_or(0); + if len == state.extracted_len { + state.saw_extracted_file.store(true, Ordering::SeqCst); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + partial_content(&state.data, start, end) + } + + #[tokio::test] + async fn extraction_runs_while_download_is_in_progress() { + let first = incompressible(256 * 1024, 1); + let second = incompressible(256 * 1024, 2); + let archive = + create_proofs_archive(&[("proofs/first.bin", &first), ("proofs/second.bin", &second)]); + let target = tempfile::tempdir().unwrap(); + let saw_extracted_file = Arc::new(AtomicBool::new(false)); + let (base_url, handle) = + serve(Router::new().route("/proofs.tar.zst", get(handle_gated_last_piece)).with_state( + GatedLastPieceState { + data: archive.clone(), + extracted_file: target.path().join("proofs/first.bin"), + extracted_len: first.len() as u64, + saw_extracted_file: Arc::clone(&saw_extracted_file), + }, + )) + .await; + + tokio::time::timeout( + TEST_TIMEOUT, + ProofsDownloader::download_and_extract( + &proofs_entry(&base_url, &archive), + target.path(), + 2, + TEST_PIECE_SIZE, + ), + ) + .await + .expect("pipeline should not hang") + .expect("pipeline should succeed"); + + assert!( + saw_extracted_file.load(Ordering::SeqCst), + "the first file should be extracted before the last piece is served" + ); + assert_eq!(std::fs::read(target.path().join("proofs/first.bin")).unwrap(), first); + assert_eq!(std::fs::read(target.path().join("proofs/second.bin")).unwrap(), second); + + handle.abort(); + } + + #[derive(Clone)] + struct SlowFirstPieceState { + data: Vec, + served: Arc>>, + } + + /// Delays the first piece so later pieces land on disk before it. + async fn handle_slow_first_piece( + State(state): State, + headers: HeaderMap, + ) -> Response { + let Some((start, end)) = parse_byte_range(&headers, state.data.len()) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + if start == 0 { + tokio::time::sleep(Duration::from_millis(300)).await; + } + state.served.lock().await.push(start); + partial_content(&state.data, start, end) + } + + #[tokio::test] + async fn extraction_waits_for_pieces_that_arrive_out_of_order() { + let data = incompressible(128 * 1024, 3); + let archive = create_proofs_archive(&[("proofs/data.bin", &data)]); + let served = Arc::new(Mutex::new(Vec::new())); + let (base_url, handle) = + serve(Router::new().route("/proofs.tar.zst", get(handle_slow_first_piece)).with_state( + SlowFirstPieceState { data: archive.clone(), served: Arc::clone(&served) }, + )) + .await; + let target = tempfile::tempdir().unwrap(); + + tokio::time::timeout( + TEST_TIMEOUT, + ProofsDownloader::download_and_extract( + &proofs_entry(&base_url, &archive), + target.path(), + 4, + TEST_PIECE_SIZE, + ), + ) + .await + .expect("pipeline should not hang") + .expect("pipeline should succeed"); + + assert_ne!(served.lock().await.first(), Some(&0), "a later piece should be served first"); + assert_eq!(std::fs::read(target.path().join("proofs/data.bin")).unwrap(), data); + + handle.abort(); + } + + #[tokio::test] + async fn download_failure_stops_extraction() { + let data = incompressible(128 * 1024, 4); + let archive = create_proofs_archive(&[("proofs/data.bin", &data)]); + let len = archive.len(); + let served = archive.clone(); + let (base_url, handle) = serve(Router::new().route( + "/proofs.tar.zst", + get(move |headers: HeaderMap| { + let data = served.clone(); + async move { + match parse_byte_range(&headers, len) { + Some((start, end)) if start < len / 2 => partial_content(&data, start, end), + _ => StatusCode::NOT_FOUND.into_response(), + } + } + }), + )) + .await; + let target = tempfile::tempdir().unwrap(); + + let error = tokio::time::timeout( + TEST_TIMEOUT, + ProofsDownloader::download_and_extract( + &proofs_entry(&base_url, &archive), + target.path(), + 1, + TEST_PIECE_SIZE, + ), + ) + .await + .expect("a failed download must not leave extraction blocked") + .expect_err("a 404 mid-download should fail the pipeline"); + + assert!(error.to_string().contains("404"), "error: {error}"); + assert!( + target.path().join(".snapshot-cache/proofs.tar.zst.part.ranges").exists(), + "an incomplete download should keep its resume state" + ); + + handle.abort(); + } + + #[tokio::test] + async fn corrupt_cached_archive_is_moved_aside() { + let archive = incompressible(4 * 1024, 6); + let target = tempfile::tempdir().unwrap(); + let cache_dir = target.path().join(".snapshot-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(cache_dir.join("proofs.tar.zst.part"), &archive).unwrap(); + + let error = ProofsDownloader::download_and_extract( + &proofs_entry("http://127.0.0.1:9", &archive), + target.path(), + 1, + TEST_PIECE_SIZE, + ) + .await + .expect_err("a corrupt cached archive should fail extraction"); + + assert!(error.to_string().contains("decode"), "error: {error}"); + + assert!( + !cache_dir.join("proofs.tar.zst.part").exists(), + "the next run must download again instead of trusting the cached archive" + ); + assert!(cache_dir.join("proofs.tar.zst").exists(), "archive should be kept for inspection"); + } + + #[tokio::test] + async fn corrupt_archive_cancels_download() { + let archive = incompressible(256 * 1024, 5); + let served = archive.clone(); + let (base_url, handle) = serve(Router::new().route( + "/proofs.tar.zst", + get(move |headers: HeaderMap| { + let data = served.clone(); + async move { + let Some((start, end)) = parse_byte_range(&headers, data.len()) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + if start > 0 { + tokio::time::sleep(Duration::from_secs(3600)).await; + } + partial_content(&data, start, end) + } + }), + )) + .await; + let target = tempfile::tempdir().unwrap(); + + let error = tokio::time::timeout( + TEST_TIMEOUT, + ProofsDownloader::download_and_extract( + &proofs_entry(&base_url, &archive), + target.path(), + 2, + TEST_PIECE_SIZE, + ), + ) + .await + .expect("a corrupt archive should fail without waiting for the download") + .expect_err("a non-zstd archive should fail extraction"); + + assert!(error.to_string().contains("decode"), "error: {error}"); + + handle.abort(); + } +} diff --git a/crates/execution/cli/src/commands/download/proofs/manifest.rs b/crates/execution/cli/src/commands/download/proofs/manifest.rs new file mode 100644 index 0000000000..5559268282 --- /dev/null +++ b/crates/execution/cli/src/commands/download/proofs/manifest.rs @@ -0,0 +1,398 @@ +//! Discovery and parsing of the `proofs` component of a snapshot manifest. + +use std::{path::Path, time::Duration}; + +use base_reth_cli::OutputFileChecksum; +use eyre::Result; +use tracing::info; + +/// Metadata parsed from the manifest's `proofs` component. +#[derive(Debug, Clone)] +pub struct ProofsManifestEntry { + /// Archive file name, a single path component. + pub file_name: String, + /// Compressed archive size in bytes. + pub expected_size: u64, + /// URL of the archive, next to the manifest. + pub archive_url: String, + /// Files the archive must extract to, relative to the target datadir. + pub output_files: Vec, +} + +/// Locates and reads the proofs component of a snapshot manifest. +#[derive(Debug)] +pub struct ProofsManifest; + +impl ProofsManifest { + /// Discovers the latest modular snapshot manifest URL for `chain_id`. + /// + /// Reth's download pipeline queries the snapshot API (`metadataUrl`) rather than + /// concatenating `{default_base_url}/{chain_id}/manifest.json`. Each chain publishes + /// manifests under its own bucket (`https://zeronet-v2-snapshots.base.org/...`), so + /// deriving a URL from the snapshot root instead produces 404s. + pub async fn discover_latest_url(api_url: &str, chain_id: u64) -> Result { + info!( + target: "reth::cli", + api_url = %api_url, + chain_id, + "Discovering latest snapshot manifest for proofs" + ); + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(60)) + .build()?; + + let listing: serde_json::Value = client + .get(api_url) + .send() + .await + .map_err(|e| eyre::eyre!("failed to fetch snapshot listing from {api_url}: {e}"))? + .error_for_status() + .map_err(|e| eyre::eyre!("failed to fetch snapshot listing from {api_url}: {e}"))? + .json() + .await + .map_err(|e| eyre::eyre!("failed to parse snapshot listing from {api_url}: {e}"))?; + + let entries = listing + .as_array() + .ok_or_else(|| eyre::eyre!("snapshot listing from {api_url} is not a JSON array"))?; + + let (block, metadata_url) = entries + .iter() + .filter_map(|entry| { + let id = Self::json_u64(entry.get("chainId"))?; + if id != chain_id { + return None; + } + let metadata_url = entry.get("metadataUrl").and_then(|v| v.as_str())?; + if !metadata_url.ends_with("manifest.json") { + return None; + } + let block = Self::json_u64(entry.get("block"))?; + Some((block, metadata_url.to_string())) + }) + .max_by_key(|(block, _)| *block) + .ok_or_else(|| { + eyre::eyre!("no modular snapshot manifest found for chain {chain_id} at {api_url}") + })?; + + info!( + target: "reth::cli", + block, + url = %metadata_url, + "Found latest snapshot manifest for proofs" + ); + + Ok(metadata_url) + } + + /// Fetches the manifest and extracts the proofs component metadata. + pub async fn fetch_entry(manifest_url: &str) -> Result { + info!(target: "reth::cli", manifest_url = %manifest_url, "Fetching manifest for proofs component"); + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(60)) + .build()?; + + let manifest: serde_json::Value = client + .get(manifest_url) + .send() + .await + .map_err(|e| eyre::eyre!("failed to fetch manifest from {manifest_url}: {e}"))? + .error_for_status() + .map_err(|e| eyre::eyre!("failed to fetch manifest from {manifest_url}: {e}"))? + .json() + .await + .map_err(|e| eyre::eyre!("failed to parse manifest from {manifest_url}: {e}"))?; + + let proofs_component = + manifest.get("components").and_then(|c| c.get("proofs")).ok_or_else(|| { + eyre::eyre!( + "manifest has no 'proofs' component — this snapshot does not include proofs" + ) + })?; + + let file_name = proofs_component + .get("file") + .and_then(|f| f.as_str()) + .ok_or_else(|| eyre::eyre!("proofs component missing 'file' field in manifest"))? + .to_string(); + + let expected_size = proofs_component + .get("size") + .and_then(|s| s.as_u64()) + .ok_or_else(|| eyre::eyre!("proofs component missing 'size' field in manifest"))?; + + let file_path = Path::new(&file_name); + if file_path.is_absolute() + || file_name.contains("..") + || file_path.components().count() != 1 + { + eyre::bail!("invalid proofs file name in manifest: {file_name}"); + } + + let archive_base_url = manifest_url + .rsplit_once('/') + .map(|(base, _)| base.to_string()) + .ok_or_else(|| eyre::eyre!("malformed manifest URL: {manifest_url}"))?; + + let archive_url = format!("{archive_base_url}/{file_name}"); + + let output_files: Vec = match proofs_component.get("output_files") { + Some(files) => serde_json::from_value(files.clone()).map_err(|e| { + eyre::eyre!("invalid 'output_files' for proofs component in manifest: {e}") + })?, + None => Vec::new(), + }; + if let Some(file) = output_files.iter().find(|file| { + file.path.is_empty() + || Path::new(&file.path) + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + }) { + eyre::bail!("invalid proofs output file path in manifest: {}", file.path); + } + + Ok(ProofsManifestEntry { file_name, expected_size, archive_url, output_files }) + } + + /// Reads a snapshot API field that may be a JSON number or a numeric string. + fn json_u64(value: Option<&serde_json::Value>) -> Option { + match value { + Some(serde_json::Value::Number(n)) => n.as_u64(), + Some(serde_json::Value::String(s)) => s.parse().ok(), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use axum::{Router, routing::get}; + + use super::*; + use crate::commands::download::proofs::test_utils::{ + create_proofs_archive, serve, start_test_server, + }; + + async fn start_snapshot_api_server( + listing: serde_json::Value, + ) -> (String, tokio::task::JoinHandle<()>) { + let listing_bytes = serde_json::to_vec(&listing).unwrap(); + let app = Router::new().route( + "/api/snapshots", + get(move || { + let data = listing_bytes.clone(); + async move { ([(axum::http::header::CONTENT_TYPE, "application/json")], data) } + }), + ); + + let (base_url, handle) = serve(app).await; + (format!("{base_url}/api/snapshots"), handle) + } + + #[tokio::test] + async fn discover_latest_url_picks_latest_modular_for_chain() { + let listing = serde_json::json!([ + { + "chainId": "8453", + "block": "100", + "metadataUrl": "https://mainnet-v2-snapshots.base.org/old/manifest.json" + }, + { + "chainId": "763360", + "block": "10", + "metadataUrl": "https://zeronet-v2-snapshots.base.org/old/manifest.json" + }, + { + "chainId": "763360", + "block": "20", + "metadataUrl": "https://zeronet-v2-snapshots.base.org/new/manifest.json" + }, + { + "chainId": "763360", + "block": "15", + "metadataUrl": "https://zeronet-v2-snapshots.base.org/not-a-manifest.tar.zst" + } + ]); + + let (api_url, handle) = start_snapshot_api_server(listing).await; + let url = ProofsManifest::discover_latest_url(&api_url, 763360).await.unwrap(); + + assert_eq!( + url, "https://zeronet-v2-snapshots.base.org/new/manifest.json", + "proofs must use the latest modular metadataUrl for the requested chain" + ); + + handle.abort(); + } + + #[tokio::test] + async fn discover_latest_url_accepts_numeric_ids() { + let listing = serde_json::json!([{ + "chainId": 763360, + "block": 3584106, + "metadataUrl": "https://zeronet-v2-snapshots.base.org/1789516802/manifest.json" + }]); + + let (api_url, handle) = start_snapshot_api_server(listing).await; + let url = ProofsManifest::discover_latest_url(&api_url, 763360).await.unwrap(); + + assert_eq!( + url, "https://zeronet-v2-snapshots.base.org/1789516802/manifest.json", + "snapshot API numeric chainId/block fields should be accepted" + ); + + handle.abort(); + } + + #[tokio::test] + async fn discover_latest_url_fails_when_chain_missing() { + let listing = serde_json::json!([{ + "chainId": "8453", + "block": "100", + "metadataUrl": "https://mainnet-v2-snapshots.base.org/old/manifest.json" + }]); + + let (api_url, handle) = start_snapshot_api_server(listing).await; + let result = ProofsManifest::discover_latest_url(&api_url, 763360).await; + + assert!(result.is_err(), "missing chain should fail discovery"); + assert!( + result.unwrap_err().to_string().contains("no modular snapshot manifest"), + "error should name the missing modular snapshot" + ); + + handle.abort(); + } + + #[tokio::test] + async fn fetch_entry_extracts_proofs_metadata() { + let archive = create_proofs_archive(&[("proofs/data.mdb", b"data")]); + let manifest = serde_json::json!({ + "block": 1000000, + "chain_id": 8453, + "storage_version": 2, + "timestamp": 1700000000, + "components": { + "proofs": { + "file": "proofs.tar.zst", + "size": archive.len(), + "decompressed_size": 0, + "output_files": [] + } + } + }); + + let (manifest_url, handle) = start_test_server(manifest, archive.clone()).await; + let entry = ProofsManifest::fetch_entry(&manifest_url).await.unwrap(); + + assert_eq!(entry.file_name, "proofs.tar.zst"); + assert_eq!(entry.expected_size, archive.len() as u64); + assert!(entry.archive_url.ends_with("/proofs.tar.zst")); + + handle.abort(); + } + + #[tokio::test] + async fn fetch_entry_rejects_path_traversal() { + let manifest = serde_json::json!({ + "block": 100, + "chain_id": 8453, + "storage_version": 2, + "timestamp": 1700000000, + "components": { + "proofs": { + "file": "../../etc/evil.tar.zst", + "size": 100, + "decompressed_size": 0, + "output_files": [] + } + } + }); + + let (manifest_url, handle) = start_test_server(manifest, vec![]).await; + let result = ProofsManifest::fetch_entry(&manifest_url).await; + + assert!(result.is_err(), "path traversal should be rejected"); + assert!(result.unwrap_err().to_string().contains("invalid proofs file name")); + + handle.abort(); + } + + #[tokio::test] + async fn fetch_entry_fails_when_no_proofs() { + let manifest = serde_json::json!({ + "block": 100, + "chain_id": 8453, + "storage_version": 2, + "timestamp": 1700000000, + "components": { + "state": { "file": "state.tar.zst", "size": 100, "decompressed_size": 500, "output_files": [] } + } + }); + + let (manifest_url, handle) = start_test_server(manifest, vec![]).await; + let result = ProofsManifest::fetch_entry(&manifest_url).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no 'proofs' component")); + + handle.abort(); + } + + #[tokio::test] + async fn fetch_entry_fails_when_size_missing() { + let manifest = serde_json::json!({ + "block": 100, + "chain_id": 8453, + "storage_version": 2, + "timestamp": 1700000000, + "components": { + "proofs": { "file": "proofs.tar.zst", "decompressed_size": 0, "output_files": [] } + } + }); + + let (manifest_url, handle) = start_test_server(manifest, vec![]).await; + let result = ProofsManifest::fetch_entry(&manifest_url).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("missing 'size'")); + + handle.abort(); + } + + #[tokio::test] + async fn fetch_entry_rejects_output_file_traversal() { + for path in ["../escape", "/etc/passwd", "proofs/../../escape", ""] { + let manifest = serde_json::json!({ + "block": 100, + "chain_id": 8453, + "storage_version": 2, + "timestamp": 1700000000, + "components": { + "proofs": { + "file": "proofs.tar.zst", + "size": 100, + "decompressed_size": 0, + "output_files": [{ "path": path, "size": 1, "blake3": "00" }] + } + } + }); + + let (manifest_url, handle) = start_test_server(manifest, vec![]).await; + let error = ProofsManifest::fetch_entry(&manifest_url) + .await + .expect_err("unsafe output path should be rejected"); + + assert!( + error.to_string().contains("invalid proofs output file path"), + "path {path:?}: {error}" + ); + + handle.abort(); + } + } +} diff --git a/crates/execution/cli/src/commands/download/proofs/mod.rs b/crates/execution/cli/src/commands/download/proofs/mod.rs new file mode 100644 index 0000000000..3a01badb4e --- /dev/null +++ b/crates/execution/cli/src/commands/download/proofs/mod.rs @@ -0,0 +1,33 @@ +//! Download of the Base proofs database for `base download --proofs`. +//! +//! This covers only the `proofs` snapshot component. Every other component is +//! downloaded and extracted by reth's downloader, so extraction problems with +//! those components do not originate here. +//! +//! The archive is fetched as fixed-size pieces and extracted while it +//! downloads: [`ProofsArchiveDownload`] publishes the contiguous prefix written +//! to disk, and [`ProofsArchiveExtractor`] decodes and unpacks that prefix as +//! it grows. + +use std::time::Duration; + +/// Interval between proofs download and extraction progress logs. +const PROOFS_PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(3); + +mod archive_download; +pub use archive_download::{ProofsArchiveDownload, ProofsArchiveSizeMismatch, ProofsPieceMap}; + +mod archive_extract; +pub use archive_extract::{ + ProofsArchiveAvailability, ProofsArchiveExtractor, ProofsArchiveReader, + ProofsAvailabilityAbortGuard, ProofsDecodedChunks, ProofsExtractionProgress, +}; + +mod downloader; +pub use downloader::ProofsDownloader; + +mod manifest; +pub use manifest::{ProofsManifest, ProofsManifestEntry}; + +#[cfg(test)] +mod test_utils; diff --git a/crates/execution/cli/src/commands/download/proofs/test_utils.rs b/crates/execution/cli/src/commands/download/proofs/test_utils.rs new file mode 100644 index 0000000000..02ad8853d2 --- /dev/null +++ b/crates/execution/cli/src/commands/download/proofs/test_utils.rs @@ -0,0 +1,293 @@ +//! Archive builders and HTTP servers shared by the proofs download tests. + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use axum::{ + Router, + body::{Body, Bytes}, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, +}; +use base_reth_cli::{ManifestGenerationParams, SnapshotGenerator}; +use futures::stream; +use tokio::sync::Mutex; + +use super::ProofsManifestEntry; + +/// Builds a `.tar.zst` archive holding `content_pairs` as `(path, bytes)` entries. +pub(super) fn create_proofs_archive(content_pairs: &[(&str, &[u8])]) -> Vec { + let mut buf = Vec::new(); + let encoder = zstd::Encoder::new(&mut buf, 0).unwrap(); + let mut builder = tar::Builder::new(encoder); + + for (path, data) in content_pairs { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, path, *data).unwrap(); + } + + let encoder = builder.into_inner().unwrap(); + encoder.finish().unwrap(); + buf +} + +/// Packages `proofs` with the snapshotter's manifest generator and returns the generated +/// manifest JSON together with the proofs archive bytes. +pub(super) fn generate_proofs_snapshot(proofs: &[(&str, &[u8])]) -> (serde_json::Value, Vec) { + let source = tempfile::tempdir().unwrap(); + let output = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(source.path().join("db")).unwrap(); + std::fs::write(source.path().join("db/mdbx.dat"), b"state-data").unwrap(); + for (path, data) in proofs { + let path = source.path().join("proofs").join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, data).unwrap(); + } + + let remote_static_files = std::collections::HashMap::new(); + SnapshotGenerator::generate_manifest(&ManifestGenerationParams { + source_datadir: source.path(), + output_dir: Some(output.path()), + chain_id: 8453, + base_url: None, + block: Some(0), + blocks_per_file: None, + remote_static_files: &remote_static_files, + previous_manifest: None, + upload_proofs: true, + }) + .unwrap(); + + let manifest = + serde_json::from_slice(&std::fs::read(output.path().join("manifest.json")).unwrap()) + .unwrap(); + let archive = std::fs::read(output.path().join("proofs.tar.zst")).unwrap(); + (manifest, archive) +} + +/// Returns a manifest entry for `archive` served at `{base_url}/proofs.tar.zst`. +pub(super) fn proofs_entry(base_url: &str, archive: &[u8]) -> ProofsManifestEntry { + ProofsManifestEntry { + file_name: "proofs.tar.zst".to_string(), + expected_size: archive.len() as u64, + archive_url: format!("{base_url}/proofs.tar.zst"), + output_files: Vec::new(), + } +} + +/// Serves `app` on an ephemeral local port and returns its base URL. +pub(super) async fn serve(app: Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://127.0.0.1:{}", addr.port()); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + + (base_url, handle) +} + +/// Serves `manifest_json` at `/manifest.json` and a Range-aware archive at +/// `/proofs.tar.zst`, returning the manifest URL. +pub(super) async fn start_test_server( + manifest_json: serde_json::Value, + archive_bytes: Vec, +) -> (String, tokio::task::JoinHandle<()>) { + let manifest_bytes = serde_json::to_vec(&manifest_json).unwrap(); + + let app = Router::new() + .route( + "/manifest.json", + get(move || { + let data = manifest_bytes.clone(); + async move { ([(axum::http::header::CONTENT_TYPE, "application/json")], data) } + }), + ) + .route("/proofs.tar.zst", get(handle_range)) + .with_state(archive_bytes); + + let (base_url, handle) = serve(app).await; + (format!("{base_url}/manifest.json"), handle) +} + +/// Serves `archive_bytes` at `/proofs.tar.zst`, honoring Range requests. +pub(super) async fn start_range_aware_server( + archive_bytes: Vec, +) -> (String, tokio::task::JoinHandle<()>) { + serve(Router::new().route("/proofs.tar.zst", get(handle_range)).with_state(archive_bytes)).await +} + +/// Parses an inclusive `Range: bytes=start-end` header clamped to `len`. +pub(super) fn parse_byte_range(headers: &HeaderMap, len: usize) -> Option<(usize, usize)> { + let spec = headers.get("Range")?.to_str().ok()?.strip_prefix("bytes=")?; + let (start, end) = spec.split_once('-')?; + let start = start.parse::().ok()?; + let end = if end.is_empty() { len.saturating_sub(1) } else { end.parse::().ok()? }; + let end = end.min(len.saturating_sub(1)); + (start < len && start <= end).then_some((start, end)) +} + +/// Returns the inclusive byte range `start..=end` of `data` as a 206 response. +pub(super) fn partial_content(data: &[u8], start: usize, end: usize) -> Response { + ( + StatusCode::PARTIAL_CONTENT, + [(axum::http::header::CONTENT_RANGE, format!("bytes {start}-{end}/{}", data.len()))], + data[start..=end].to_vec(), + ) + .into_response() +} + +/// Answers Range requests with 206 and anything else with the whole body. +pub(super) async fn handle_range(State(data): State>, headers: HeaderMap) -> Response { + match parse_byte_range(&headers, data.len()) { + Some((start, end)) => partial_content(&data, start, end), + None => (StatusCode::OK, data).into_response(), + } +} + +/// State for [`start_recording_range_server`]. +#[derive(Clone)] +pub(super) struct RecordingRangeState { + data: Vec, + requests: Arc>>, +} + +/// Serves Range GETs and records each inclusive byte range that was asked for. +pub(super) async fn start_recording_range_server( + archive_bytes: Vec, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let requests = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route("/proofs.tar.zst", get(handle_recording_range)) + .with_state(RecordingRangeState { data: archive_bytes, requests: Arc::clone(&requests) }); + let (base_url, handle) = serve(app).await; + (base_url, requests, handle) +} + +async fn handle_recording_range( + State(state): State, + headers: HeaderMap, +) -> Response { + let Some((start, end)) = parse_byte_range(&headers, state.data.len()) else { + state.requests.lock().await.push((0, state.data.len().saturating_sub(1) as u64)); + return (StatusCode::OK, state.data).into_response(); + }; + state.requests.lock().await.push((start as u64, end as u64)); + partial_content(&state.data, start, end) +} + +/// State for servers that misbehave on the first request only. +#[derive(Clone)] +pub(super) struct FirstRequestState { + data: Vec, + requests: Arc, +} + +/// Serves Range GETs, but the first response errors halfway through its body. +pub(super) async fn start_drop_then_range_server( + archive_bytes: Vec, +) -> (String, Arc, tokio::task::JoinHandle<()>) { + start_first_request_server(archive_bytes, true).await +} + +/// Serves Range GETs, but the first response ends cleanly after half its +/// bytes without a `Content-Length`. +pub(super) async fn start_short_then_range_server( + archive_bytes: Vec, +) -> (String, Arc, tokio::task::JoinHandle<()>) { + start_first_request_server(archive_bytes, false).await +} + +async fn start_first_request_server( + archive_bytes: Vec, + fail_stream: bool, +) -> (String, Arc, tokio::task::JoinHandle<()>) { + let requests = Arc::new(AtomicUsize::new(0)); + let state = FirstRequestState { data: archive_bytes, requests: Arc::clone(&requests) }; + let app = Router::new() + .route( + "/proofs.tar.zst", + get(move |State(state): State, headers: HeaderMap| async move { + handle_first_request(state, headers, fail_stream) + }), + ) + .with_state(state); + let (base_url, handle) = serve(app).await; + (base_url, requests, handle) +} + +fn handle_first_request( + state: FirstRequestState, + headers: HeaderMap, + fail_stream: bool, +) -> Response { + let request_n = state.requests.fetch_add(1, Ordering::SeqCst); + let Some((start, end)) = parse_byte_range(&headers, state.data.len()) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + if request_n > 0 { + return partial_content(&state.data, start, end); + } + + let len = end - start + 1; + let drop_at = start + len / 2; + let first = Ok::<_, std::io::Error>(Bytes::from(state.data[start..drop_at].to_vec())); + let body = if fail_stream { + Body::from_stream(stream::iter([ + first, + Err(std::io::Error::other("error decoding response body")), + ])) + } else { + Body::from_stream(stream::iter([first])) + }; + ( + StatusCode::PARTIAL_CONTENT, + [(axum::http::header::CONTENT_RANGE, format!("bytes {start}-{end}/{}", state.data.len()))], + body, + ) + .into_response() +} + +/// State for [`start_concurrent_range_server`]. +#[derive(Clone)] +pub(super) struct ConcurrentRangeState { + data: Vec, + in_flight: Arc, + max_in_flight: Arc, +} + +/// Serves slow Range GETs and records the peak number of concurrent requests. +pub(super) async fn start_concurrent_range_server( + archive_bytes: Vec, +) -> (String, Arc, tokio::task::JoinHandle<()>) { + let max_in_flight = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route("/proofs.tar.zst", get(handle_concurrent_range)).with_state( + ConcurrentRangeState { + data: archive_bytes, + in_flight: Arc::new(AtomicUsize::new(0)), + max_in_flight: Arc::clone(&max_in_flight), + }, + ); + let (base_url, handle) = serve(app).await; + (base_url, max_in_flight, handle) +} + +async fn handle_concurrent_range( + State(state): State, + headers: HeaderMap, +) -> Response { + let current = state.in_flight.fetch_add(1, Ordering::SeqCst) + 1; + state.max_in_flight.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(80)).await; + let response = handle_range(State(state.data), headers).await; + state.in_flight.fetch_sub(1, Ordering::SeqCst); + response +} From 5407feceb6e5212aba462e2778118bb732779a34 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Mon, 28 Sep 2026 18:37:11 +0000 Subject: [PATCH 142/299] feat(bench): name versioned pages and fill latest from last week (#5319) * feat(bench): name versioned pages and fill latest from last week Make --versioned-pages label each version page with the client version and the latest alias as "Latest - {testName}". Latest now keeps the newest run from the last seven days per Scenario / block time / transaction payload, and stamps those copies 1ms after the newest selected run so the visualizer Latest option resolves to --latest. * fix(bench): document versioned-page helpers for clippy Workspace missing-docs is warn and CI clippy denies warnings. * fix(bench): repair versioned pages CI checks * fix(bench): omit version tag from latest pages * test(actions): shut down builder backend off runtime --- .../harness/src/sequencer/builder_backend.rs | 6 + docs/guides/SNAPSHOT_BENCHMARKS.md | 13 +- etc/systems/src/benchmark_cli.rs | 220 +++++++++++++++--- 3 files changed, 202 insertions(+), 37 deletions(-) diff --git a/actions/harness/src/sequencer/builder_backend.rs b/actions/harness/src/sequencer/builder_backend.rs index aeff40a7e3..c15c330f4c 100644 --- a/actions/harness/src/sequencer/builder_backend.rs +++ b/actions/harness/src/sequencer/builder_backend.rs @@ -419,6 +419,12 @@ mod tests { "genesis head hash must match the harness-derived genesis", ); + // `LocalInstance` owns a dedicated runtime. Drop it from a blocking thread so its + // runtime and node resources never shut down from this test runtime's async context. + tokio::task::spawn_blocking(move || drop(backend)) + .await + .expect("builder backend shutdown task must not panic"); + Ok(()) } } diff --git a/docs/guides/SNAPSHOT_BENCHMARKS.md b/docs/guides/SNAPSHOT_BENCHMARKS.md index 5ca0fb2420..b37a296671 100644 --- a/docs/guides/SNAPSHOT_BENCHMARKS.md +++ b/docs/guides/SNAPSHOT_BENCHMARKS.md @@ -104,6 +104,7 @@ selected list of its direct-child run directories: ```sh target/release/base-bench aggregate \ --output-dir /absolute/path/to/results \ + --versioned-pages \ /absolute/path/to/results/run-a \ /absolute/path/to/results/run-b ``` @@ -111,9 +112,15 @@ target/release/base-bench aggregate \ The command atomically writes `results/metadata.json`. It retains the latest `createdAt` entry for every unique report tag set, including the configured scenario and block-time tag. This preserves distinct scenarios, block cadences, -and client versions, but replaces an older repetition with the latest one. It -does not modify source metadata or scenario labels. `id` remains the unique raw -artifact identifier and `BenchmarkRun` remains the report cohort/page key. +and client versions, but replaces an older repetition with the latest one. +`--versioned-pages` then rewrites `BenchmarkRun` into one page per client +version plus a `{cohort}--latest` alias. Latest keeps the newest run from the +last seven days for each Scenario / block-time / transaction-payload identity, +renames pages so the visualizer dropdown can tell them apart, and stamps latest +1ms after the newest selected run so the visualizer Latest option resolves to +that page. It does not modify source metadata or scenario labels. `id` remains +the unique raw artifact identifier and `BenchmarkRun` remains the report +cohort/page key. ## Choose Comparable Runs diff --git a/etc/systems/src/benchmark_cli.rs b/etc/systems/src/benchmark_cli.rs index 49aa9b3cc8..aa2e919ee9 100644 --- a/etc/systems/src/benchmark_cli.rs +++ b/etc/systems/src/benchmark_cli.rs @@ -140,8 +140,11 @@ pub struct AggregateBenchmarkArgs { /// One or more self-contained snapshot run output directories to include. #[arg(required = true, value_name = "RUN_OUTPUT_DIR")] pub run_output_dirs: Vec, - /// Emit one report page per client version plus a stable `latest` page for - /// each configured benchmark cohort. + /// Emit one report page per client version plus a stable `latest` page. + /// Latest keeps the newest run from the last seven days for each + /// Scenario / block-time / transaction-payload identity and is stamped + /// 1ms after the newest selected run so the visualizer Latest option + /// resolves to this page. #[arg(long)] pub versioned_pages: bool, } @@ -767,6 +770,14 @@ impl AggregateBenchmarkArgs { /// a `latest` alias. Sidecar artifacts remain immutable and are referenced /// by both the version page and its latest alias. fn versioned_pages(runs: Vec) -> Result> { + Self::versioned_pages_at(runs, Utc::now()) + } + + /// Same as [`Self::versioned_pages`], using `now` as the week-window origin. + fn versioned_pages_at( + runs: Vec, + now: DateTime, + ) -> Result> { let mut by_cohort = BTreeMap::>::new(); for run in runs { let cohort = run @@ -778,21 +789,21 @@ impl AggregateBenchmarkArgs { by_cohort.entry(cohort).or_default().push(run); } + let window_start = now - chrono::TimeDelta::days(7); let mut pages = Vec::new(); for (cohort, cohort_runs) in by_cohort { - let latest_version = cohort_runs + let newest = cohort_runs .iter() - .max_by(|left, right| { - Self::parse_created_at(left) - .expect("selected run was validated") - .cmp(&Self::parse_created_at(right).expect("selected run was validated")) - .then_with(|| left.result.client_version.cmp(&right.result.client_version)) - }) - .map(|run| run.result.client_version.clone()) + .map(Self::parse_created_at) + .collect::>>()? + .into_iter() + .max() .expect("non-empty cohort"); + let latest_created_at = (newest + chrono::TimeDelta::milliseconds(1)) + .to_rfc3339_opts(SecondsFormat::Millis, true); - for run in cohort_runs.iter().cloned() { - let mut version_page = run; + for run in &cohort_runs { + let mut version_page = run.clone(); version_page.test_config.insert( "BenchmarkRun".to_owned(), serde_json::Value::String(format!( @@ -801,22 +812,75 @@ impl AggregateBenchmarkArgs { Self::page_component(&version_page.result.client_version) )), ); + version_page.test_name = + format!("{} - {}", run.test_name, run.result.client_version); pages.push(version_page); } - for run in - cohort_runs.into_iter().filter(|run| run.result.client_version == latest_version) - { + + let mut latest_by_benchmark = BTreeMap::::new(); + for run in cohort_runs { + let created_at = Self::parse_created_at(&run)?; + if created_at < window_start { + continue; + } + let identity = Self::latest_benchmark_key(&run)?; + let replace = latest_by_benchmark.get(&identity).is_none_or(|existing| { + created_at + .cmp(&Self::parse_created_at(existing).expect("selected run was validated")) + .then_with(|| { + run.result.client_version.cmp(&existing.result.client_version) + }) + .is_ge() + }); + if replace { + latest_by_benchmark.insert(identity, run); + } + } + + for run in latest_by_benchmark.into_values() { + let original_name = run.test_name.clone(); let mut latest_page = run; latest_page.test_config.insert( "BenchmarkRun".to_owned(), serde_json::Value::String(format!("{cohort}--latest")), ); + // `ClientVersion` intentionally stays only in the result metadata: it would + // otherwise make the visualizer split this stable latest page by version. + latest_page.test_config.remove("ClientVersion"); + latest_page.test_name = format!("Latest - {original_name}"); + latest_page.created_at = latest_created_at.clone(); pages.push(latest_page); } } Ok(pages) } + /// Identity used to pick one latest-page run per workload cell. + fn latest_benchmark_key(run: &VisualizerRun) -> Result { + let scenario = Self::config_component(run, "Scenario")?; + let block_time = Self::config_component(run, "BlockTimeMilliseconds")?; + let payload = Self::config_component(run, "TransactionPayload")?; + Ok(format!("{scenario}\0{block_time}\0{payload}")) + } + + /// Reads a string or numeric `testConfig` field as a stable identity component. + fn config_component(run: &VisualizerRun, key: &str) -> Result { + let value = run + .test_config + .get(key) + .ok_or_else(|| eyre::eyre!("run {} is missing testConfig.{key}", run.id))?; + if let Some(text) = value.as_str() { + return Ok(text.to_owned()); + } + if let Some(number) = value.as_i64() { + return Ok(number.to_string()); + } + if let Some(number) = value.as_u64() { + return Ok(number.to_string()); + } + Err(eyre::eyre!("run {} testConfig.{key} is not a string or number", run.id)) + } + fn page_component(value: &str) -> String { value .chars() @@ -1193,6 +1257,7 @@ mod tests { use alloy_primitives::Address; use base_load_tests::MetricsSummary; + use chrono::{DateTime, SecondsFormat, Utc}; use clap::Parser; use super::{ @@ -1277,6 +1342,15 @@ mod tests { assert!(output.path().is_dir()); assert!(!stale.exists()); } + fn rfc3339(ts: DateTime) -> String { + ts.to_rfc3339_opts(SecondsFormat::Millis, true) + } + + fn latest_page_runs(runs: &[serde_json::Value]) -> Vec<&serde_json::Value> { + runs.iter() + .filter(|run| run["testConfig"]["BenchmarkRun"] == "sepolia-transfer-100mgas--latest") + .collect() + } fn write_aggregate_run( root: &std::path::Path, @@ -1284,6 +1358,26 @@ mod tests { scenario: &str, client_version: &str, created_at: &str, + ) -> std::path::PathBuf { + write_aggregate_run_detailed( + root, + id, + scenario, + "transfer", + 200, + client_version, + created_at, + ) + } + + fn write_aggregate_run_detailed( + root: &std::path::Path, + id: &str, + scenario: &str, + transaction_payload: &str, + block_time_milliseconds: u64, + client_version: &str, + created_at: &str, ) -> std::path::PathBuf { let output = root.join(id); std::fs::create_dir_all(&output).unwrap(); @@ -1306,10 +1400,10 @@ mod tests { "BenchmarkRun": "sepolia-transfer-100mgas", "Scenario": scenario, "ChainId": 84532, - "BlockTimeMilliseconds": 200, + "BlockTimeMilliseconds": block_time_milliseconds, "GasLimit": 400000000, "NodeType": "base-reth-node", - "TransactionPayload": "transfer", + "TransactionPayload": transaction_payload, "ClientVersion": client_version }, "result": { @@ -1616,31 +1710,86 @@ real_token_setup: #[test] fn aggregate_versioned_pages_preserve_history_and_add_latest_alias() { let root = tempfile::tempdir().unwrap(); + let now = Utc::now(); let old = write_aggregate_run( root.path(), "old", "transfer-2s", "base/old", - "2026-09-23T00:00:00.000Z", + &rfc3339(now - chrono::TimeDelta::hours(2)), ); let newest_200ms = write_aggregate_run( root.path(), "new-200ms", "transfer-200ms", "base/new", - "2026-09-23T00:01:00.000Z", + &rfc3339(now - chrono::TimeDelta::hours(1)), + ); + let newest_2s = + write_aggregate_run(root.path(), "new-2s", "transfer-2s", "base/new", &rfc3339(now)); + + AggregateBenchmarkArgs { + output_dir: root.path().to_path_buf(), + run_output_dirs: vec![old, newest_200ms, newest_2s], + versioned_pages: true, + } + .run() + .unwrap(); + + let metadata: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.path().join("metadata.json")).unwrap()) + .unwrap(); + let runs = metadata["runs"].as_array().unwrap(); + assert_eq!(runs.len(), 5); + let latest = latest_page_runs(runs); + assert_eq!(latest.len(), 2); + assert!(latest.iter().all(|run| { + run["testName"] == "Latest - Base Sepolia snapshot throughput" + && run["createdAt"] == rfc3339(now + chrono::TimeDelta::milliseconds(1)) + && run["result"]["clientVersion"] == "base/new" + && run["testConfig"].get("ClientVersion").is_none() + })); + assert!(runs.iter().any(|run| { + run["testConfig"]["BenchmarkRun"] == "sepolia-transfer-100mgas--base-old" + && run["testName"] == "Base Sepolia snapshot throughput - base/old" + })); + } + + #[test] + fn aggregate_versioned_pages_latest_uses_week_window_per_benchmark() { + let root = tempfile::tempdir().unwrap(); + let now = Utc::now(); + let stale_eth = write_aggregate_run_detailed( + root.path(), + "stale-eth", + "unlimited", + "eth-transfer-existing", + 2000, + "base/old", + &rfc3339(now - chrono::TimeDelta::days(8)), ); - let newest_2s = write_aggregate_run( + let fresh_eth = write_aggregate_run_detailed( root.path(), - "new-2s", - "transfer-2s", + "fresh-eth", + "unlimited", + "eth-transfer-existing", + 2000, "base/new", - "2026-09-23T00:02:00.000Z", + &rfc3339(now), + ); + let week_b20 = write_aggregate_run_detailed( + root.path(), + "week-b20", + "unlimited", + "b20-transfer-existing", + 2000, + "base/old", + &rfc3339(now - chrono::TimeDelta::days(2)), ); AggregateBenchmarkArgs { output_dir: root.path().to_path_buf(), - run_output_dirs: vec![old, newest_200ms, newest_2s], + run_output_dirs: vec![stale_eth, fresh_eth, week_b20], versioned_pages: true, } .run() @@ -1651,17 +1800,20 @@ real_token_setup: .unwrap(); let runs = metadata["runs"].as_array().unwrap(); assert_eq!(runs.len(), 5); + let latest = latest_page_runs(runs); + assert_eq!(latest.len(), 2); + assert!(latest.iter().any(|run| { + run["id"] == "fresh-eth" && run["result"]["clientVersion"] == "base/new" + })); + assert!(latest.iter().any(|run| { + run["id"] == "week-b20" && run["result"]["clientVersion"] == "base/old" + })); + assert!(latest.iter().all(|run| run["id"] != "stale-eth")); + assert!(latest.iter().all(|run| run["testConfig"].get("ClientVersion").is_none())); assert!( - runs.iter() - .any(|run| run["testConfig"]["BenchmarkRun"] == "sepolia-transfer-100mgas--latest") - ); - assert!(runs.iter().any(|run| run["result"]["clientVersion"] == "base/old")); - assert!( - runs.iter() - .filter( - |run| run["testConfig"]["BenchmarkRun"] == "sepolia-transfer-100mgas--latest" - ) - .all(|run| run["result"]["clientVersion"] == "base/new") + latest.iter().all(|run| { + run["createdAt"] == rfc3339(now + chrono::TimeDelta::milliseconds(1)) + }) ); } From c928b129d45d823efbfe5631f1c5a68d695ac2fb Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Mon, 28 Sep 2026 19:27:32 +0000 Subject: [PATCH 143/299] fix(consensus): keep canonical L2 lookups fresh (#5364) --- actions/harness/src/providers/l2.rs | 12 ++++ crates/consensus/derive/src/pipeline/core.rs | 58 ++++++++++++------ .../derive/src/stages/batch/batch_queue.rs | 7 +++ .../src/stages/batch/batch_validator.rs | 7 +++ .../derive/src/test_utils/chain_providers.rs | 8 +++ .../src/test_utils/sys_config_fetcher.rs | 4 ++ crates/consensus/protocol/src/batch/traits.rs | 11 +++- crates/consensus/protocol/src/test_utils.rs | 10 +++- .../providers/src/l2_chain_provider.rs | 59 ++++++++++++++++++- crates/proof/proof/src/l2/chain_provider.rs | 6 ++ 10 files changed, 160 insertions(+), 22 deletions(-) diff --git a/actions/harness/src/providers/l2.rs b/actions/harness/src/providers/l2.rs index 864f060c59..26cf05f507 100644 --- a/actions/harness/src/providers/l2.rs +++ b/actions/harness/src/providers/l2.rs @@ -17,6 +17,9 @@ pub enum L2ProviderError { /// L2 block not found. #[error("L2 block not found: {0}")] BlockNotFound(u64), + /// L2 block not found by hash. + #[error("L2 block not found: {0}")] + BlockHashNotFound(B256), /// System config not found. #[error("system config not found for L2 block {0}")] SystemConfigNotFound(B256), @@ -127,6 +130,15 @@ impl BatchValidationProvider for ActionL2ChainProvider { .ok_or(L2ProviderError::BlockNotFound(number)) } + async fn l2_block_info_by_hash(&mut self, hash: B256) -> Result { + self.blocks_by_hash + .lock() + .expect("L2 blocks by hash lock poisoned") + .get(&hash) + .copied() + .ok_or(L2ProviderError::BlockHashNotFound(hash)) + } + async fn block_by_number(&mut self, number: u64) -> Result { self.base_blocks .lock() diff --git a/crates/consensus/derive/src/pipeline/core.rs b/crates/consensus/derive/src/pipeline/core.rs index 5ac950465f..fbdd3fcf9c 100644 --- a/crates/consensus/derive/src/pipeline/core.rs +++ b/crates/consensus/derive/src/pipeline/core.rs @@ -86,7 +86,7 @@ where } current = self .l2_chain_provider - .l2_block_info_by_number(current.block_info.number - 1) + .l2_block_info_by_hash(current.block_info.parent_hash) .await .map_err(Into::into)?; } @@ -260,7 +260,7 @@ where #[cfg(test)] mod tests { - use alloc::{string::ToString, sync::Arc}; + use alloc::{string::ToString, sync::Arc, vec}; use alloy_eips::BlockNumHash; use alloy_primitives::{Address, B256, address}; @@ -411,22 +411,45 @@ mod tests { #[tokio::test] async fn test_derivation_pipeline_initial_reset_walks_back() { - let rollup_config = Arc::new(RollupConfig { - // channel_timeout = 100 so the walk-back will stop at genesis (block 0) - ..Default::default() - }); + let rollup_config = Arc::new(RollupConfig { channel_timeout: 1, ..Default::default() }); let mut l2_chain_provider = TestL2ChainProvider::default(); + let genesis_hash = B256::ZERO; + let safe_head_hash = B256::with_last_byte(1); + l2_chain_provider.blocks = vec![ + L2BlockInfo { + block_info: BlockInfo { hash: genesis_hash, number: 0, ..Default::default() }, + l1_origin: BlockNumHash { number: 0, ..Default::default() }, + seq_num: 0, + }, + L2BlockInfo { + block_info: BlockInfo { + hash: safe_head_hash, + number: 1, + parent_hash: genesis_hash, + ..Default::default() + }, + l1_origin: BlockNumHash { number: 1, ..Default::default() }, + seq_num: 0, + }, + ]; l2_chain_provider.system_configs.insert(B256::ZERO, SystemConfig::default()); let attributes = TestNextAttributes::default(); let mut pipeline = DerivationPipeline::new(attributes, rollup_config, l2_chain_provider); - // With L2 safe head at genesis (block 0), initial_reset should stop at genesis. + // The safe head is non-genesis, so reset must follow its parent hash before stopping at + // genesis. let l2_safe_head = L2BlockInfo { - l1_origin: BlockNumHash { number: 5, hash: Default::default() }, - ..Default::default() + block_info: BlockInfo { + hash: safe_head_hash, + number: 1, + parent_hash: genesis_hash, + ..Default::default() + }, + l1_origin: BlockNumHash { number: 1, ..Default::default() }, + seq_num: 0, }; - let result = pipeline.initial_reset(l2_safe_head).await; - assert!(result.is_ok()); + let (origin, _) = pipeline.initial_reset(l2_safe_head).await.unwrap(); + assert_eq!(origin.number, 0); } #[tokio::test] @@ -474,6 +497,7 @@ mod tests { const CODE_STOP_L1_ORIGIN: u64 = L1_HEAD - RollupConfig::GRANITE_CHANNEL_TIMEOUT; const BATCHER_AT_SPEC_STOP: Address = address!("BB00000000000000000000000000000000000002"); const BATCHER_AT_CODE_STOP: Address = address!("AA00000000000000000000000000000000000001"); + let block_hash = |number: u64| B256::left_padding_from(&number.to_be_bytes()); let rollup_config = Arc::new(RollupConfig { block_time: 2, @@ -494,8 +518,8 @@ mod tests { l2_chain_provider.blocks.push(L2BlockInfo { block_info: BlockInfo { number: n, - hash: B256::with_last_byte((n & 0xff) as u8), - parent_hash: B256::ZERO, + hash: block_hash(n), + parent_hash: block_hash(n - 1), timestamp, }, l1_origin: BlockNumHash { @@ -506,11 +530,11 @@ mod tests { }); } l2_chain_provider.system_configs.insert( - B256::with_last_byte((SPEC_STOP_L1_ORIGIN & 0xff) as u8), + block_hash(SPEC_STOP_L1_ORIGIN), SystemConfig { batcher_address: BATCHER_AT_SPEC_STOP, ..Default::default() }, ); l2_chain_provider.system_configs.insert( - B256::with_last_byte((CODE_STOP_L1_ORIGIN & 0xff) as u8), + block_hash(CODE_STOP_L1_ORIGIN), SystemConfig { batcher_address: BATCHER_AT_CODE_STOP, ..Default::default() }, ); @@ -520,8 +544,8 @@ mod tests { let safe_head = L2BlockInfo { block_info: BlockInfo { number: L1_HEAD, - hash: B256::with_last_byte((L1_HEAD & 0xff) as u8), - parent_hash: B256::ZERO, + hash: block_hash(L1_HEAD), + parent_hash: block_hash(L1_HEAD - 1), timestamp: L2_SAFE_HEAD_TIMESTAMP, }, l1_origin: BlockNumHash { diff --git a/crates/consensus/derive/src/stages/batch/batch_queue.rs b/crates/consensus/derive/src/stages/batch/batch_queue.rs index 70a7032518..420b634ae0 100644 --- a/crates/consensus/derive/src/stages/batch/batch_queue.rs +++ b/crates/consensus/derive/src/stages/batch/batch_queue.rs @@ -536,6 +536,13 @@ mod tests { pending().await } + async fn l2_block_info_by_hash( + &mut self, + _: alloy_primitives::B256, + ) -> Result { + unreachable!() + } + async fn block_by_number(&mut self, _: u64) -> Result { unreachable!() } diff --git a/crates/consensus/derive/src/stages/batch/batch_validator.rs b/crates/consensus/derive/src/stages/batch/batch_validator.rs index eacef27e97..eb67e0cfaf 100644 --- a/crates/consensus/derive/src/stages/batch/batch_validator.rs +++ b/crates/consensus/derive/src/stages/batch/batch_validator.rs @@ -407,6 +407,13 @@ mod tests { Err(ResetError::BlockNotFound(BlockId::Number(number.into()))) } + async fn l2_block_info_by_hash( + &mut self, + hash: alloy_primitives::B256, + ) -> Result { + Err(ResetError::BlockNotFound(hash.into())) + } + async fn block_by_number(&mut self, number: u64) -> Result { Err(ResetError::BlockNotFound(BlockId::Number(number.into()))) } diff --git a/crates/consensus/derive/src/test_utils/chain_providers.rs b/crates/consensus/derive/src/test_utils/chain_providers.rs index 3579125352..d2ff1943e2 100644 --- a/crates/consensus/derive/src/test_utils/chain_providers.rs +++ b/crates/consensus/derive/src/test_utils/chain_providers.rs @@ -191,6 +191,14 @@ impl BatchValidationProvider for TestL2ChainProvider { .ok_or_else(|| TestProviderError::BlockNotFound) } + async fn l2_block_info_by_hash(&mut self, hash: B256) -> Result { + self.blocks + .iter() + .find(|b| b.block_info.hash == hash) + .copied() + .ok_or_else(|| TestProviderError::BlockNotFound) + } + async fn block_by_number(&mut self, number: u64) -> Result { self.base_blocks .iter() diff --git a/crates/consensus/derive/src/test_utils/sys_config_fetcher.rs b/crates/consensus/derive/src/test_utils/sys_config_fetcher.rs index eb61ec13d1..b8787d3807 100644 --- a/crates/consensus/derive/src/test_utils/sys_config_fetcher.rs +++ b/crates/consensus/derive/src/test_utils/sys_config_fetcher.rs @@ -58,6 +58,10 @@ impl BatchValidationProvider for TestSystemConfigL2Fetcher { async fn l2_block_info_by_number(&mut self, _: u64) -> Result { unimplemented!() } + + async fn l2_block_info_by_hash(&mut self, _: B256) -> Result { + unimplemented!() + } } #[async_trait] diff --git a/crates/consensus/protocol/src/batch/traits.rs b/crates/consensus/protocol/src/batch/traits.rs index c9b667d7f3..be142beb88 100644 --- a/crates/consensus/protocol/src/batch/traits.rs +++ b/crates/consensus/protocol/src/batch/traits.rs @@ -3,6 +3,7 @@ use alloc::boxed::Box; use core::fmt::Display; +use alloy_primitives::B256; use async_trait::async_trait; use base_common_consensus::BaseBlock; @@ -14,11 +15,19 @@ pub trait BatchValidationProvider { /// The error type for the [`BatchValidationProvider`]. type Error: Display; - /// Returns the [`L2BlockInfo`] given a block number. + /// Returns the canonical [`L2BlockInfo`] at the given block number. + /// + /// The returned block must reflect the canonical chain at this height. Prefer + /// [`Self::l2_block_info_by_hash`] when the caller already has the block hash. /// /// Errors if the block does not exist. async fn l2_block_info_by_number(&mut self, number: u64) -> Result; + /// Returns the [`L2BlockInfo`] with the given block hash. + /// + /// Errors if the block does not exist. + async fn l2_block_info_by_hash(&mut self, hash: B256) -> Result; + /// Returns the [`BaseBlock`] for a given number. /// /// Errors if no block is available for the given block number. diff --git a/crates/consensus/protocol/src/test_utils.rs b/crates/consensus/protocol/src/test_utils.rs index 434bb033e2..db9ea08f32 100644 --- a/crates/consensus/protocol/src/test_utils.rs +++ b/crates/consensus/protocol/src/test_utils.rs @@ -2,7 +2,7 @@ use alloc::{boxed::Box, format, string::String, sync::Arc, vec::Vec}; -use alloy_primitives::hex; +use alloy_primitives::{B256, hex}; use async_trait::async_trait; use base_common_consensus::BaseBlock; use spin::Mutex; @@ -77,6 +77,14 @@ impl BatchValidationProvider for TestBatchValidator { .ok_or_else(|| TestBatchValidatorError::BlockNotFound) } + async fn l2_block_info_by_hash(&mut self, hash: B256) -> Result { + self.blocks + .iter() + .find(|b| b.block_info.hash == hash) + .copied() + .ok_or_else(|| TestBatchValidatorError::BlockNotFound) + } + async fn block_by_number(&mut self, number: u64) -> Result { self.base_blocks .iter() diff --git a/crates/consensus/providers/src/l2_chain_provider.rs b/crates/consensus/providers/src/l2_chain_provider.rs index 51312f088d..3541befc54 100644 --- a/crates/consensus/providers/src/l2_chain_provider.rs +++ b/crates/consensus/providers/src/l2_chain_provider.rs @@ -200,6 +200,9 @@ pub enum AlloyL2ChainProviderError { /// Failed to construct [`L2BlockInfo`] from the block and genesis. #[error("Failed to construct L2BlockInfo from block {0} and genesis")] L2BlockInfoConstruction(u64), + /// Failed to construct [`L2BlockInfo`] from the block and genesis. + #[error("Failed to construct L2BlockInfo from block {0} and genesis")] + L2BlockInfoByHashConstruction(B256), /// Failed to convert the block into a [`SystemConfig`]. #[error("Failed to convert block {0} into SystemConfig")] SystemConfigConversion(B256), @@ -218,7 +221,8 @@ impl From for PipelineErrorKind { AlloyL2ChainProviderError::BlockHashNotFound(hash) => { ResetError::BlockNotFound(hash.into()).reset() } - AlloyL2ChainProviderError::L2BlockInfoConstruction(_) => Self::Temporary( + AlloyL2ChainProviderError::L2BlockInfoConstruction(_) + | AlloyL2ChainProviderError::L2BlockInfoByHashConstruction(_) => Self::Temporary( PipelineError::Provider("L2 block info construction failed".to_string()), ), AlloyL2ChainProviderError::SystemConfigConversion(_) => Self::Temporary( @@ -233,16 +237,34 @@ impl BatchValidationProvider for AlloyL2ChainProvider { type Error = AlloyL2ChainProviderError; async fn l2_block_info_by_number(&mut self, number: u64) -> Result { - let block = self.block_by_number(number).await?; + let block = self.fetch_block_by_number(number).await?; L2BlockInfo::from_block_and_genesis(&block, &self.rollup_config.genesis) .map_err(|_| AlloyL2ChainProviderError::L2BlockInfoConstruction(number)) } + async fn l2_block_info_by_hash(&mut self, hash: B256) -> Result { + let block = self.block_by_hash(hash).await?; + L2BlockInfo::from_block_and_genesis(&block, &self.rollup_config.genesis) + .map_err(|_| AlloyL2ChainProviderError::L2BlockInfoByHashConstruction(hash)) + } + async fn block_by_number(&mut self, number: u64) -> Result { if let Some(block) = self.block_by_number_cache.get(&number) { return Ok((**block).clone()); } + let block = self.fetch_block_by_number(number).await?; + self.block_by_number_cache.put(number, Arc::new(block.clone())); + Ok(block) + } +} + +impl AlloyL2ChainProvider { + /// Fetches the block canonical at `number` without using the number-keyed cache. + async fn fetch_block_by_number( + &mut self, + number: u64, + ) -> Result { for attempt in 1..=L2_BLOCK_VISIBILITY_RETRY_ATTEMPTS { Metrics::l2_chain_requests(L2_BLOCK_REF_BY_NUMBER_METHOD).increment(1); @@ -265,7 +287,6 @@ impl BatchValidationProvider for AlloyL2ChainProvider { ); self.verify_block_hash(&block.header, block_hash)?; self.block_by_hash_cache.put(block_hash, Arc::clone(&block)); - self.block_by_number_cache.put(number, Arc::clone(&block)); return Ok((*block).clone()); } @@ -437,6 +458,38 @@ mod tests { assert_eq!(hits.load(Ordering::SeqCst), 3); } + #[tokio::test] + async fn test_l2_block_info_by_number_bypasses_number_cache() { + let server = MockServer::start_async().await; + let block_number = 42; + let mock = server + .mock_async(move |when, then| { + when.method(POST) + .path("/") + .json_body_includes(r#"{"method":"eth_getBlockByNumber"}"#); + then.respond_with(move |req| { + HttpMockResponse::builder() + .status(200) + .header("content-type", "application/json") + .body(json_rpc_response(req, block_json(block_number))) + .build() + }); + }) + .await; + + let mut provider = l2_provider(&server); + assert!(matches!( + provider.l2_block_info_by_number(block_number).await, + Err(AlloyL2ChainProviderError::L2BlockInfoConstruction(_)) + )); + assert!(matches!( + provider.l2_block_info_by_number(block_number).await, + Err(AlloyL2ChainProviderError::L2BlockInfoConstruction(_)) + )); + + mock.assert_calls_async(2).await; + } + #[tokio::test(start_paused = true)] async fn test_block_by_number_exhausts_null_retry_budget() { let server = MockServer::start_async().await; diff --git a/crates/proof/proof/src/l2/chain_provider.rs b/crates/proof/proof/src/l2/chain_provider.rs index 081af72330..24241aed8a 100644 --- a/crates/proof/proof/src/l2/chain_provider.rs +++ b/crates/proof/proof/src/l2/chain_provider.rs @@ -112,6 +112,12 @@ impl BatchValidationProvider for OracleL2ChainProv .map_err(OracleProviderError::BlockInfo) } + async fn l2_block_info_by_hash(&mut self, hash: B256) -> Result { + let block = self.block_from_header(self.header_by_hash(hash)?, hash).await?; + L2BlockInfo::from_block_and_genesis(&block, &self.rollup_config.genesis) + .map_err(OracleProviderError::BlockInfo) + } + async fn block_by_number(&mut self, number: u64) -> Result { // Fetch the header for the given block number. let header = self.header_by_number(number).await?; From a1c5c896cfddc1e298dd1436bbe08470850e50cb Mon Sep 17 00:00:00 2001 From: Joe Parks Date: Mon, 28 Sep 2026 19:30:04 +0000 Subject: [PATCH 144/299] fix(builder): drop LocalInstance runtime clones off the async thread (#5368) --- crates/builder/core/src/test_utils/instance.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/builder/core/src/test_utils/instance.rs b/crates/builder/core/src/test_utils/instance.rs index c88785388f..6048680e0a 100644 --- a/crates/builder/core/src/test_utils/instance.rs +++ b/crates/builder/core/src/test_utils/instance.rs @@ -356,18 +356,21 @@ impl Drop for LocalInstance { // Tokio runtimes cannot perform their blocking shutdown while they are being dropped // from another runtime's async context. `LocalInstance` is commonly owned directly by // async tests, so shut down and drop its runtime on a plain thread before cleaning up - // the resources it owns. + // the resources it owns. The node and pool hold `Runtime` clones (it is an `Arc`), so + // they must be dropped on that thread too, or the last reference, and with it the + // tokio runtime, is released here in async context. Dropping them also releases their + // database handles before the backing files are removed below. + let node_handle = self.node_handle.take(); + let pool_handle = self.pool_handle.take(); let shutdown = std::thread::spawn(move || { runtime.graceful_shutdown_with_timeout(Duration::from_secs(10)); + drop(node_handle); + drop(pool_handle); drop(runtime); }); if let Err(panic) = shutdown.join() { std::panic::resume_unwind(panic); } - // Drop the node and the pool handle (both hold open database handles via the node's - // provider / the pool's transaction validator) before removing the backing files. - drop(self.node_handle.take()); - drop(self.pool_handle.take()); if let Err(e) = std::fs::remove_dir_all(self.node_config().datadir().to_string()) { eprintln!( "Warning: failed to remove temporary data directory {}: {e}", From c8ed711f31984a325cfe024edd447fdbe3b05d16 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 05:59:34 +0000 Subject: [PATCH 145/299] refactor(protocol): build the genesis L2BlockInfo from one constructor (#5296) * refactor(protocol): build the genesis L2BlockInfo from one constructor * refactor(consensus): build the genesis L2BlockInfo in the engine processor tests from its constructor * test(harness): build the genesis block info from its constructor and keep one hand-written check * docs(protocol): tighten the genesis block info docs --- actions/harness/src/harness.rs | 22 ++++++------------- actions/harness/src/providers/l2.rs | 19 ++++------------ .../harness/src/sequencer/builder_backend.rs | 22 +++---------------- actions/harness/tests/derivation/main.rs | 13 ++--------- .../consensus/engine/src/sync/forkchoice.rs | 17 +------------- crates/consensus/protocol/src/block.rs | 6 +++++ .../actors/engine/engine_request_processor.rs | 18 +++------------ 7 files changed, 26 insertions(+), 91 deletions(-) diff --git a/actions/harness/src/harness.rs b/actions/harness/src/harness.rs index fbd2a00374..d06f2aea80 100644 --- a/actions/harness/src/harness.rs +++ b/actions/harness/src/harness.rs @@ -1,6 +1,5 @@ use std::{fmt::Debug, sync::Arc}; -use alloy_eips::BlockNumHash; use alloy_genesis::ChainConfig; use alloy_signer_local::PrivateKeySigner; use base_common_consensus::{BaseBlock, BaseTxEnvelope}; @@ -93,26 +92,19 @@ impl ActionTestHarness { block_info_from(self.l1.tip()) } - /// Return the L2 genesis [`L2BlockInfo`] anchored to the L1 genesis block. - /// - /// Convenience method eliminating the repeated 10-line construction used in - /// reorg reset tests. + /// Return the L2 genesis [`L2BlockInfo`]. Its L1 origin is this harness's L1 block at the + /// rollup genesis L1 number, or the first L1 block if that number is not mined yet. pub fn l2_genesis(&self) -> L2BlockInfo { let genesis_l1_number = self.rollup_config.genesis.l1.number; let genesis_l1 = self.l1.block_by_number(genesis_l1_number).map(block_info_from).unwrap_or_else(|| { block_info_from(self.l1.chain().first().expect("genesis always present")) }); - L2BlockInfo { - block_info: BlockInfo { - hash: self.rollup_config.genesis.l2.hash, - number: self.rollup_config.genesis.l2.number, - parent_hash: Default::default(), - timestamp: self.rollup_config.genesis.l2_time, - }, - l1_origin: BlockNumHash { number: genesis_l1.number, hash: genesis_l1.hash }, - seq_num: 0, - } + L2BlockInfo::new( + BlockInfo::from_l2_genesis(&self.rollup_config.genesis), + genesis_l1.id(), + 0, + ) } /// Create a [`SupervisedP2P`] / [`TestGossipTransport`] channel pair and diff --git a/actions/harness/src/providers/l2.rs b/actions/harness/src/providers/l2.rs index 26cf05f507..4253144568 100644 --- a/actions/harness/src/providers/l2.rs +++ b/actions/harness/src/providers/l2.rs @@ -3,13 +3,12 @@ use std::{ sync::{Arc, Mutex}, }; -use alloy_eips::BlockNumHash; use alloy_primitives::{B256, map::HashMap}; use async_trait::async_trait; use base_common_consensus::BaseBlock; use base_common_genesis::{RollupConfig, SystemConfig}; use base_consensus_derive::{L2ChainProvider, PipelineError, PipelineErrorKind}; -use base_protocol::{BatchValidationProvider, BlockInfo, L2BlockInfo}; +use base_protocol::{BatchValidationProvider, L2BlockInfo}; /// Error type for [`ActionL2ChainProvider`]. #[derive(Debug, thiserror::Error)] @@ -65,19 +64,7 @@ impl ActionL2ChainProvider { pub fn from_genesis(rollup_config: &RollupConfig) -> Self { let provider = Self::default(); - let genesis_l2 = L2BlockInfo { - block_info: BlockInfo { - hash: rollup_config.genesis.l2.hash, - number: rollup_config.genesis.l2.number, - parent_hash: Default::default(), - timestamp: rollup_config.genesis.l2_time, - }, - l1_origin: BlockNumHash { - hash: rollup_config.genesis.l1.hash, - number: rollup_config.genesis.l1.number, - }, - seq_num: 0, - }; + let genesis_l2 = L2BlockInfo::from_l2_genesis(&rollup_config.genesis); // Use the rollup config's genesis system config, falling back to a harness // default with a non-zero gas_limit. `SystemConfig::default()` has gas_limit=0 @@ -190,6 +177,8 @@ impl L2ChainProvider for ActionL2ChainProvider { #[cfg(test)] mod tests { + use base_protocol::BlockInfo; + use super::*; #[tokio::test] diff --git a/actions/harness/src/sequencer/builder_backend.rs b/actions/harness/src/sequencer/builder_backend.rs index c15c330f4c..d2cb91dae7 100644 --- a/actions/harness/src/sequencer/builder_backend.rs +++ b/actions/harness/src/sequencer/builder_backend.rs @@ -321,9 +321,7 @@ impl SequencerEngineBackend for BuilderBackedEngineClient { #[cfg(test)] mod tests { use alloy_eips::BlockNumberOrTag; - use alloy_primitives::B256; use base_common_genesis::UpgradeConfig; - use base_protocol::BlockInfo; use super::*; use crate::TestRollupConfigBuilder; @@ -346,24 +344,10 @@ mod tests { ..Default::default() }; config.genesis.l2_time = 0; + config.genesis.l2.hash = ActionEngineClient::compute_l2_genesis_hash(&config); Arc::new(config) } - /// The genesis head anchored at the harness-derived genesis hash. - fn genesis_head(rollup_config: &RollupConfig) -> L2BlockInfo { - let cg = &rollup_config.genesis; - L2BlockInfo::new( - BlockInfo::new( - ActionEngineClient::compute_l2_genesis_hash(rollup_config), - cg.l2.number, - B256::ZERO, - cg.l2_time, - ), - cg.l1, - 0, - ) - } - /// The in-process builder node must initialize the exact genesis the harness derives from the /// rollup config, and produce a first block that builds on that genesis — proving hash/state /// alignment between the real builder backend and the harness verifier. @@ -374,7 +358,7 @@ mod tests { let backend = BuilderBackedEngineClient::new( Arc::clone(&rollup_config), - genesis_head(&rollup_config), + L2BlockInfo::from_l2_genesis(&rollup_config.genesis), ) .await?; let driver = backend.driver().await?; @@ -407,7 +391,7 @@ mod tests { let rollup_config = jovian_rollup_config(); let backend = BuilderBackedEngineClient::new( Arc::clone(&rollup_config), - genesis_head(&rollup_config), + L2BlockInfo::from_l2_genesis(&rollup_config.genesis), ) .await?; diff --git a/actions/harness/tests/derivation/main.rs b/actions/harness/tests/derivation/main.rs index b58a68f748..311998083d 100644 --- a/actions/harness/tests/derivation/main.rs +++ b/actions/harness/tests/derivation/main.rs @@ -18,7 +18,7 @@ use base_consensus_derive::{ EthereumDataSource, PipelineBuilder, PipelineEncodingError, PipelineError, PipelineErrorKind, StatefulAttributesBuilder, StepResult, }; -use base_protocol::{BlockInfo, DERIVATION_VERSION_0, DepositDecodeError, Deposits, L2BlockInfo}; +use base_protocol::{DERIVATION_VERSION_0, DepositDecodeError, Deposits, L2BlockInfo}; mod holocene_span_batches; mod node; @@ -1852,16 +1852,7 @@ async fn derive_chain_from_near_l1_genesis() { rollup_cfg.genesis.l2.hash = ActionEngineClient::compute_l2_genesis_hash(&rollup_cfg); // Build an L2 genesis head anchored to L1 block #5. - let genesis_head = L2BlockInfo { - block_info: BlockInfo { - hash: rollup_cfg.genesis.l2.hash, - number: rollup_cfg.genesis.l2.number, - parent_hash: Default::default(), - timestamp: rollup_cfg.genesis.l2_time, - }, - l1_origin: BlockNumHash { number: 5, hash: l1_block_5_hash }, - seq_num: 0, - }; + let genesis_head = L2BlockInfo::from_l2_genesis(&rollup_cfg.genesis); // Update the harness rollup config so create_l2_sequencer uses the correct genesis. h.rollup_config = rollup_cfg.clone(); diff --git a/crates/consensus/engine/src/sync/forkchoice.rs b/crates/consensus/engine/src/sync/forkchoice.rs index f5260527e7..796d51d937 100644 --- a/crates/consensus/engine/src/sync/forkchoice.rs +++ b/crates/consensus/engine/src/sync/forkchoice.rs @@ -3,7 +3,6 @@ use std::fmt::Display; use alloy_eips::{BlockId, BlockNumberOrTag}; -use alloy_primitives::B256; use alloy_provider::Network; use alloy_transport::TransportResult; use base_common_genesis::RollupConfig; @@ -95,7 +94,7 @@ impl L2ForkchoiceState { Err(e) => return Err(e), } } - Ok(None) => genesis_l2_block_info(cfg), + Ok(None) => L2BlockInfo::from_l2_genesis(&cfg.genesis), Err(e) => return Err(e.into()), }; let safe = match get_block_compat(engine_client, BlockNumberOrTag::Safe.into()).await { @@ -141,20 +140,6 @@ impl L2ForkchoiceState { } } -const fn genesis_l2_block_info(cfg: &RollupConfig) -> L2BlockInfo { - L2BlockInfo { - block_info: BlockInfo { - hash: cfg.genesis.l2.hash, - number: cfg.genesis.l2.number, - // Base chains start at L2 block 0, whose parent hash is zero. - parent_hash: B256::ZERO, - timestamp: cfg.genesis.l2_time, - }, - l1_origin: cfg.genesis.l1, - seq_num: 0, - } -} - async fn block_info_from_reth_or_checkpoint< CheckpointReader: ForkchoiceCheckpointReader + ?Sized, >( diff --git a/crates/consensus/protocol/src/block.rs b/crates/consensus/protocol/src/block.rs index 753f264ff9..45280a2680 100644 --- a/crates/consensus/protocol/src/block.rs +++ b/crates/consensus/protocol/src/block.rs @@ -186,6 +186,12 @@ impl L2BlockInfo { Self { block_info, l1_origin, seq_num } } + /// Returns the [`L2BlockInfo`] of the L2 genesis block described by `genesis`, with the + /// genesis L1 block as L1 origin and sequence number zero. + pub const fn from_l2_genesis(genesis: &ChainGenesis) -> Self { + Self::new(BlockInfo::from_l2_genesis(genesis), genesis.l1, 0) + } + /// Constructs an [`L2BlockInfo`] from a given Base [`Block`] and [`ChainGenesis`]. pub fn from_block_and_genesis>( block: &Block, diff --git a/crates/consensus/service/src/actors/engine/engine_request_processor.rs b/crates/consensus/service/src/actors/engine/engine_request_processor.rs index 51dd55023d..fe96eac42c 100644 --- a/crates/consensus/service/src/actors/engine/engine_request_processor.rs +++ b/crates/consensus/service/src/actors/engine/engine_request_processor.rs @@ -706,7 +706,7 @@ mod tests { use std::{sync::Arc, time::Duration}; use alloy_consensus::transaction::Recovered; - use alloy_eips::{BlockId, BlockNumHash, BlockNumberOrTag, NumHash, eip2718::Encodable2718}; + use alloy_eips::{BlockId, BlockNumHash, BlockNumberOrTag, eip2718::Encodable2718}; use alloy_primitives::{Address, B256, Bloom, Sealed, U256}; use alloy_rpc_types_engine::{ ExecutionPayloadV1, ForkchoiceUpdated, PayloadId, PayloadStatus, PayloadStatusEnum, @@ -1495,10 +1495,7 @@ mod tests { }, ..Default::default() }); - let genesis_l2_info = L2BlockInfo { - block_info: BlockInfo { hash: genesis_hash, ..Default::default() }, - ..Default::default() - }; + let genesis_l2_info = L2BlockInfo::from_l2_genesis(&cfg.genesis); let build_fcu = ForkchoiceUpdated { payload_id: Some(PayloadId::new([1; 8])), ..valid_fcu() }; let client = Arc::new( @@ -1850,16 +1847,7 @@ mod tests { ..Default::default() }); - let genesis_l2_info = L2BlockInfo { - block_info: BlockInfo { - hash: genesis_hash, - number: 0, - parent_hash: B256::ZERO, - timestamp: 0, - }, - l1_origin: NumHash { number: 0, hash: B256::ZERO }, - seq_num: 0, - }; + let genesis_l2_info = L2BlockInfo::from_l2_genesis(&cfg.genesis); // On unfixed main, engine.reset() queries: Finalized L2 block, Latest L2 block, // the L1 origin of the unsafe head (hash B256::ZERO), FCU v3, then L1 block 0 From c01c40e1a43cce375ec914c727b0bc8890ceb4a3 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 29 Sep 2026 12:25:34 +0000 Subject: [PATCH 146/299] feat(eip8130): charge value transfers and account creation, and meter payer_auth outside the sender's gas (#5265) * feat(eip8130): charge value transfers and account creation, and meter payer_auth outside the sender's gas Adds TX_VALUE_COST (6,000) per call with value > 0 and to != sender, and charges NEW_ACCOUNT_COST (25,000) at dispatch when a value call creates its recipient. tx_payload_cost is computed over the encoding with an empty payer_auth; the payer_auth bytes are billed in payer_auth_cost, which is capped at MAX_AUTHENTICATION_GAS (100,000). * perf(eip8130): price sponsored payload cost without re-encoding the body * fix(eip8130): keep unspent call gas when a new-account charge does not fit A failed NEW_ACCOUNT_COST charge was zeroing the transaction-scoped remainder, so call_gas_spent billed the entire call pool instead of the gas already consumed. * fix(txpool): reserve peak sender call value, not the sum of self-calls A call to the sender is checked against its balance but moves nothing, so summing every call.value over-reserved: two 1 ETH self-calls with a 1 ETH balance were rejected at admission. TxEip8130::sender_call_value walks the calls in order, accumulating transfers to other accounts and requiring each self-call's value on hand only at its own point, and the validator reserves that peak. * fix(txpool): report an oversized payer auth instead of a generic intrinsic-gas error * test(eip8130): include payer_auth byte cost in the policy-gated payer expectation --- .../src/transaction/eip8130/signed.rs | 117 ++++++++++ .../consensus/src/transaction/eip8130/tx.rs | 74 ++++-- crates/common/eip8130/src/intrinsic.rs | 221 ++++++++++++++---- crates/common/eip8130/src/schedule.rs | 9 + crates/common/evm/src/eip8130.rs | 155 ++++++++---- crates/execution/eip8130/README.md | 12 +- crates/execution/txpool/src/validator.rs | 41 +++- 7 files changed, 506 insertions(+), 123 deletions(-) diff --git a/crates/common/consensus/src/transaction/eip8130/signed.rs b/crates/common/consensus/src/transaction/eip8130/signed.rs index 7fcd73f2c9..3e53d67793 100644 --- a/crates/common/consensus/src/transaction/eip8130/signed.rs +++ b/crates/common/consensus/src/transaction/eip8130/signed.rs @@ -403,6 +403,123 @@ impl Eip8130Signed { self.payer_auth.encode(out); } + /// EIP-2718 encoding with `payer_auth` replaced by the empty string: the + /// bytes `tx_payload_cost` bills to the sender. + #[must_use] + pub fn encoded_2718_without_payer_auth(&self) -> Vec { + let empty = Bytes::new(); + let payload_length = + self.tx.rlp_encoded_fields_length() + self.sender_auth.length() + empty.length(); + let mut out = Vec::with_capacity(1 + length_of_length(payload_length) + payload_length); + out.put_u8(Eip8130Constants::EIP8130_TX_TYPE); + Header { list: true, payload_length }.encode(&mut out); + self.tx.rlp_encode_fields(&mut out); + self.sender_auth.encode(&mut out); + empty.encode(&mut out); + out + } + + /// Folds `byte_cost` over the sender-billed EIP-2718 encoding: this + /// transaction with `payer_auth` replaced by the empty string. + /// + /// When `encoded_2718` is this transaction's network encoding, the fold + /// rewrites only the list header and the `payer_auth` suffix. The + /// transaction body is not re-serialized. A mismatched `encoded_2718` + /// falls back to [`Self::encoded_2718_without_payer_auth`]. + pub fn fold_sender_billed_bytes( + &self, + encoded_2718: &[u8], + mut byte_cost: impl FnMut(u8) -> u64, + ) -> u64 { + let fold = |bytes: &[u8], byte_cost: &mut dyn FnMut(u8) -> u64| { + bytes.iter().fold(0u64, |acc, &byte| acc.saturating_add(byte_cost(byte))) + }; + if self.payer_auth.is_empty() { + return fold(encoded_2718, &mut byte_cost); + } + if let Some(cost) = self.try_fold_sender_billed(encoded_2718, &mut byte_cost) { + return cost; + } + fold(&self.encoded_2718_without_payer_auth(), &mut byte_cost) + } + + /// Sender-billed fold of a verified EIP-2718 encoding. `None` when + /// `encoded_2718` is not this transaction. + fn try_fold_sender_billed( + &self, + encoded_2718: &[u8], + byte_cost: &mut impl FnMut(u8) -> u64, + ) -> Option { + let (&type_byte, rest) = encoded_2718.split_first()?; + if type_byte != Eip8130Constants::EIP8130_TX_TYPE { + return None; + } + let mut payload = rest; + let header = Header::decode(&mut payload).ok()?; + if !header.list || payload.len() != header.payload_length { + return None; + } + let payer_len = self.payer_auth.length(); + if payload.len() < payer_len { + return None; + } + let (body, payer_suffix) = payload.split_at(payload.len() - payer_len); + if !Self::is_rlp_bytes(self.payer_auth.as_ref(), payer_suffix) { + return None; + } + // Empty `payer_auth` is the one-byte RLP string `0x80`. + let new_payload_len = body.len().checked_add(1)?; + let mut header_buf = [0u8; 9]; + let remaining = { + let mut cursor: &mut [u8] = &mut header_buf; + Header { list: true, payload_length: new_payload_len }.encode(&mut cursor); + cursor.len() + }; + let header_len = header_buf.len() - remaining; + + let mut total = byte_cost(type_byte); + for &byte in &header_buf[..header_len] { + total = total.saturating_add(byte_cost(byte)); + } + for &byte in body { + total = total.saturating_add(byte_cost(byte)); + } + Some(total.saturating_add(byte_cost(0x80))) + } + + /// Whether `encoded` is the canonical RLP string for `raw`. + fn is_rlp_bytes(raw: &[u8], encoded: &[u8]) -> bool { + if raw.is_empty() { + return encoded == [0x80]; + } + if raw.len() == 1 && raw[0] < 0x80 { + return encoded == raw; + } + if raw.len() < 56 { + let Ok(len) = u8::try_from(raw.len()) else { + return false; + }; + return encoded.len() == raw.len() + 1 + && encoded[0] == 0x80 + len + && &encoded[1..] == raw; + } + let len_of_len = raw.len().to_be_bytes().iter().skip_while(|&&byte| byte == 0).count(); + if encoded.len() != 1 + len_of_len + raw.len() { + return false; + } + let Ok(len_of_len_byte) = u8::try_from(len_of_len) else { + return false; + }; + if encoded[0] != 0xb7 + len_of_len_byte { + return false; + } + let mut declared = 0usize; + for &byte in &encoded[1..1 + len_of_len] { + declared = declared.saturating_mul(256).saturating_add(usize::from(byte)); + } + declared == raw.len() && &encoded[1 + len_of_len..] == raw + } + fn rlp_encoded_signed_length(&self) -> usize { let payload = self.rlp_payload_length(); length_of_length(payload) + payload diff --git a/crates/common/consensus/src/transaction/eip8130/tx.rs b/crates/common/consensus/src/transaction/eip8130/tx.rs index 660b04d25b..b396fe6bba 100644 --- a/crates/common/consensus/src/transaction/eip8130/tx.rs +++ b/crates/common/consensus/src/transaction/eip8130/tx.rs @@ -379,14 +379,27 @@ impl TxEip8130 { + self.metadata.len() } - /// Total wei dispatched across every call in every phase. + /// The most wei `sender` must hold at any point while its calls run, in + /// call order across every phase. /// - /// This is the sender-side ETH obligation the calls impose: each call moves - /// `call.value` from the sender when dispatched, so the sender must be able - /// to cover their sum (in addition to gas and any coinbase tip). The sum - /// saturates at [`U256::MAX`] rather than wrapping. - pub fn total_call_value(&self) -> U256 { - self.calls.iter().flatten().fold(U256::ZERO, |acc, call| acc.saturating_add(call.value)) + /// A call to another account moves `call.value` out of the sender, so those + /// values accumulate. A call to `sender` itself is only checked against the + /// current balance and moves nothing, so it needs its value on hand at that + /// point but does not add to later calls. Two 1 ETH self-calls therefore + /// need 1 ETH, not 2. ETH a callee sends back is not known statically and + /// is not credited. Saturates at [`U256::MAX`] rather than wrapping. + pub fn sender_call_value(&self, sender: Address) -> U256 { + let mut spent = U256::ZERO; + let mut peak = U256::ZERO; + for call in self.calls.iter().flatten() { + if call.to == sender { + peak = peak.max(spent.saturating_add(call.value)); + } else { + spent = spent.saturating_add(call.value); + peak = peak.max(spent); + } + } + peak } } @@ -882,25 +895,50 @@ mod tests { } #[test] - fn total_call_value_sums_every_phase() { + fn sender_call_value_sums_transfers_to_other_accounts() { + let sender = Address::repeat_byte(0x11); + let other = Address::repeat_byte(0x22); + let call = |to, value: u64| Call { to, value: U256::from(value), data: Bytes::new() }; let tx = TxEip8130 { - chain_id: 1, - calls: vec![ - vec![ - Call { to: Address::ZERO, value: U256::from(3u64), data: Bytes::new() }, - Call { to: Address::ZERO, value: U256::from(4u64), data: Bytes::new() }, - ], - vec![Call { to: Address::ZERO, value: U256::from(5u64), data: Bytes::new() }], - ], + calls: vec![vec![call(other, 3), call(other, 4)], vec![call(other, 5)]], ..Default::default() }; - assert_eq!(tx.total_call_value(), U256::from(12u64)); + assert_eq!(tx.sender_call_value(sender), U256::from(12u64)); assert_eq!( - TxEip8130 { calls: vec![], ..Default::default() }.total_call_value(), + TxEip8130 { calls: vec![], ..Default::default() }.sender_call_value(sender), U256::ZERO ); } + #[test] + fn sender_call_value_does_not_sum_self_calls() { + let sender = Address::repeat_byte(0x11); + let other = Address::repeat_byte(0x22); + let call = |to, value: u64| Call { to, value: U256::from(value), data: Bytes::new() }; + + // Two self-calls of 10 need 10 on hand, not 20. + let self_only = TxEip8130 { + calls: vec![vec![call(sender, 10), call(sender, 10)]], + ..Default::default() + }; + assert_eq!(self_only.sender_call_value(sender), U256::from(10u64)); + + // A self-call after 4 has left needs 4 + 10 on hand at that point. + let after_spend = TxEip8130 { + calls: vec![vec![call(other, 4)], vec![call(sender, 10)]], + ..Default::default() + }; + assert_eq!(after_spend.sender_call_value(sender), U256::from(14u64)); + + // A self-call before the spend only needs its own value then; the later + // transfers accumulate to 12, which is the peak. + let before_spend = TxEip8130 { + calls: vec![vec![call(sender, 10), call(other, 5), call(other, 7)]], + ..Default::default() + }; + assert_eq!(before_spend.sender_call_value(sender), U256::from(12u64)); + } + #[test] fn account_change_roundtrip_in_tx() { let tx = TxEip8130 { diff --git a/crates/common/eip8130/src/intrinsic.rs b/crates/common/eip8130/src/intrinsic.rs index 1ee325e896..ab92410935 100644 --- a/crates/common/eip8130/src/intrinsic.rs +++ b/crates/common/eip8130/src/intrinsic.rs @@ -21,6 +21,9 @@ pub enum IntrinsicGasError { /// delegate authenticator is not a priced *leaf*. #[error("no gas-schedule entry for authenticator {0}")] UnscheduledAuthenticator(Address), + /// `payer_auth_cost` exceeds [`Eip8130GasSchedule::MAX_AUTHENTICATION_GAS`]. + #[error("payer authentication gas {0} exceeds MAX_AUTHENTICATION_GAS")] + PayerAuthGasExceeded(u64), } /// Wire encoding of an authentication blob, selecting how it is parsed and @@ -59,6 +62,9 @@ impl AuthWireForm { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub struct IntrinsicGasInput { + /// The resolved sender. Calls back to the sender carry no + /// `value_transfer_cost`. + pub sender: Address, /// Whether this transaction's sequence nonce channel is being used for the /// first time (its current nonce is zero) — selects the SSTORE *set* cost /// over the *reset* cost. Ignored for nonce-free (`NONCE_KEY_MAX`) @@ -90,8 +96,9 @@ pub struct IntrinsicGasInput { impl IntrinsicGasInput { /// Creates the intrinsic-gas state hints. #[must_use] - pub const fn new(nonce_key_first_use: bool) -> Self { + pub const fn new(sender: Address, nonce_key_first_use: bool) -> Self { Self { + sender, nonce_key_first_use, sender_policy_gated: false, payer_policy_gated: false, @@ -135,8 +142,8 @@ impl IntrinsicGasInput { /// drifting apart: both must feed [`IntrinsicGas::compute`] the *same* pinned /// input for the `estimate == admission >= execution` guarantee to hold. #[must_use] - pub const fn worst_case(nonce_key_first_use: bool, has_payer: bool) -> Self { - Self::new(nonce_key_first_use) + pub const fn worst_case(sender: Address, nonce_key_first_use: bool, has_payer: bool) -> Self { + Self::new(sender, nonce_key_first_use) .with_policy_gates(true, has_payer) .with_revoke_discount_slots(0) } @@ -148,10 +155,14 @@ impl IntrinsicGasInput { pub struct IntrinsicGas { /// `AA_BASE_COST`. pub base: u64, - /// `tx_payload_cost` — EIP-2028 data-availability cost. + /// `tx_payload_cost` — EIP-2028 data-availability cost over the encoding + /// with an empty `payer_auth`. pub payload: u64, /// `nonce_key_cost`. pub nonce_key: u64, + /// `value_transfer_cost` — [`Eip8130GasSchedule::TX_VALUE_COST`] per call + /// with `value > 0` and `to != sender`. + pub value_transfer: u64, /// `bytecode_cost` — account creation. pub bytecode: u64, /// `account_changes_cost` — config-change and delegation entries. @@ -160,7 +171,7 @@ pub struct IntrinsicGas { /// SLOAD(s) (see `auth_sloads`). pub sender_auth: u64, /// `payer_auth_cost` — payer authenticator execution + its `authorize` - /// SLOAD(s), or `0` for self-pay. + /// SLOAD(s) + the data cost of the `payer_auth` bytes, or `0` for self-pay. pub payer_auth: u64, } @@ -171,6 +182,7 @@ impl IntrinsicGas { self.base .saturating_add(self.payload) .saturating_add(self.nonce_key) + .saturating_add(self.value_transfer) .saturating_add(self.bytecode) .saturating_add(self.account_changes) .saturating_add(self.sender_auth) @@ -201,10 +213,15 @@ impl IntrinsicGas { /// parameter, rather than re-serialized here, because `compute` runs for /// every transaction on both the mempool-admission and block-building paths, /// where the caller already holds the serialized form; it feeds only the - /// EIP-2028 `payload` cost. + /// EIP-2028 `payload` cost. A sponsored transaction is priced as if + /// `payer_auth` were empty, since the payer's bytes are billed in + /// `payer_auth` rather than against the sender's `gas_limit`. That cost is + /// folded from `encoded` rather than by re-serializing the body. /// /// Returns [`IntrinsicGasError::UnscheduledAuthenticator`] if any sender, - /// payer, or config-change authenticator lacks a gas-schedule entry. + /// payer, or config-change authenticator lacks a gas-schedule entry, and + /// [`IntrinsicGasError::PayerAuthGasExceeded`] if `payer_auth_cost` exceeds + /// [`Eip8130GasSchedule::MAX_AUTHENTICATION_GAS`]. #[must_use = "discarding the result silently skips the entire intrinsic-gas computation"] pub fn compute( signed: &Eip8130Signed, @@ -331,20 +348,30 @@ impl IntrinsicGas { AuthWireForm::for_sender(tx.sender), input.sender_policy_gated, )?; - let payer_auth = if tx.payer.is_some() { - Self::auth_cost( - signed.payer_auth().as_ref(), - AuthWireForm::Prefixed, - input.payer_policy_gated, - )? - } else { - 0 - }; + let payer_auth = Self::payer_auth_cost(signed, input.payer_policy_gated)?; + + let payload = signed.fold_sender_billed_bytes(encoded, |byte| { + if byte == 0 { + Eip8130GasSchedule::TX_DATA_ZERO_BYTE + } else { + Eip8130GasSchedule::TX_DATA_NONZERO_BYTE + } + }); + + let value_calls = tx + .calls + .iter() + .flatten() + .filter(|call| !call.value.is_zero() && call.to != input.sender) + .count(); + let value_transfer = Eip8130GasSchedule::TX_VALUE_COST + .saturating_mul(u64::try_from(value_calls).unwrap_or(u64::MAX)); Ok(Self { base: Eip8130GasSchedule::AA_BASE_COST, - payload: Self::payload_cost(encoded), + payload, nonce_key, + value_transfer, bytecode, account_changes, sender_auth, @@ -368,35 +395,45 @@ impl IntrinsicGas { /// `gas_limit` alone could let true consumption push cumulative gas over the /// block limit. /// - /// It is a deliberate *ceiling*, not the exact charge: the auth-blob shape - /// gives the authenticator execution gas plus its cold `actor_config` SLOAD, - /// and on top of that we pin the payer's **policy gate worst-case** — one - /// extra cold `policy_manager` SLOAD ([`Eip8130GasSchedule::COLD_SLOAD`]) that - /// a policy-gated payer's `authorize` step reads. The pre-execution reservation - /// cannot resolve the payer's on-chain scope (the payer blob is not - /// authenticable before execution), so pinning the gate keeps the reservation - /// a safe upper bound regardless of whether the payer turns out to be gated. - /// Over-reserving can only reject a too-tight block, never admit an over-limit - /// one, and building and validation share this bound so they stay consistent. + /// It is a deliberate *ceiling*: the reservation cannot authenticate the payer + /// to learn whether it is policy-gated, so it pins the gate's extra cold + /// `policy_manager` SLOAD. Over-reserving can only reject a too-tight block, + /// never admit an over-limit one, and building and validation share this + /// bound so they stay consistent. #[must_use = "discarding the result skips the payer-authentication reservation"] pub fn max_payer_auth_cost(signed: &Eip8130Signed) -> Result { - if signed.tx().payer.is_some() { - // Price the blob without the policy gate (`policy_gated = false`), then - // pin the payer's policy-gate worst-case explicitly by adding one cold - // `policy_manager` SLOAD unconditionally — the reservation cannot - // authenticate the payer to resolve whether it is actually gated. - let auth = - Self::auth_cost(signed.payer_auth().as_ref(), AuthWireForm::Prefixed, false)?; - Ok(auth.saturating_add(Eip8130GasSchedule::COLD_SLOAD)) - } else { - Ok(0) + if signed.tx().payer.is_none() { + return Ok(0); + } + Ok(Self::payer_auth_cost(signed, false)?.saturating_add(Eip8130GasSchedule::COLD_SLOAD)) + } + + /// Payer-authentication gas billed on top of `gas_limit`: authenticator + /// execution gas, its `authorize` SLOADs (plus the policy-gate read when + /// `policy_gated`), and the data cost of the `payer_auth` bytes. `0` for + /// self-pay. + /// + /// Returns [`IntrinsicGasError::PayerAuthGasExceeded`] above + /// [`Eip8130GasSchedule::MAX_AUTHENTICATION_GAS`]. + fn payer_auth_cost( + signed: &Eip8130Signed, + policy_gated: bool, + ) -> Result { + if signed.tx().payer.is_none() { + return Ok(0); } + let payer_auth = signed.payer_auth().as_ref(); + let cost = Self::auth_cost(payer_auth, AuthWireForm::Prefixed, policy_gated)? + .saturating_add(Self::data_cost(payer_auth)); + if cost > Eip8130GasSchedule::MAX_AUTHENTICATION_GAS { + return Err(IntrinsicGasError::PayerAuthGasExceeded(cost)); + } + Ok(cost) } - /// EIP-2028 data-availability cost over the caller-supplied EIP-2718 - /// serialization (`type_byte || rlp([..fields.., sender_auth, payer_auth])`). - fn payload_cost(encoded: &[u8]) -> u64 { - encoded.iter().fold(0u64, |acc, &byte| { + /// EIP-2028 data cost of `bytes`. + fn data_cost(bytes: &[u8]) -> u64 { + bytes.iter().fold(0u64, |acc, &byte| { let cost = if byte == 0 { Eip8130GasSchedule::TX_DATA_ZERO_BYTE } else { @@ -570,8 +607,8 @@ mod tests { use alloy_primitives::{Address, B256, Bytes, U256, address}; use alloy_sol_types::SolValue; use base_common_consensus::{ - AccountChange, AccountChangeChannel, ChangeType, CreateEntry, Delegation, InitialActor, - SignedAccountChanges, SignedChange, TxEip8130, + AccountChange, AccountChangeChannel, Call, ChangeType, CreateEntry, Delegation, + InitialActor, SignedAccountChanges, SignedChange, TxEip8130, }; use super::*; @@ -601,7 +638,7 @@ mod tests { const ACCOUNT: Address = address!("0x1111111111111111111111111111111111111111"); const K1: Address = Eip8130Constants::K1_AUTHENTICATOR; - const EXISTING_KEY: IntrinsicGasInput = IntrinsicGasInput::new(false); + const EXISTING_KEY: IntrinsicGasInput = IntrinsicGasInput::new(ACCOUNT, false); fn signed(tx: TxEip8130, sender_auth: Vec, payer_auth: Vec) -> Eip8130Signed { Eip8130Signed::new(tx, Bytes::from(sender_auth), Bytes::from(payer_auth)) @@ -704,7 +741,8 @@ mod tests { assert_eq!(free.nonce_key, Eip8130GasSchedule::NONCE_FREE_COST); tx.nonce_key = U256::from(7u64); - let first = intrinsic(&signed(tx, vec![0; 65], vec![]), &IntrinsicGasInput::new(true)); + let first = + intrinsic(&signed(tx, vec![0; 65], vec![]), &IntrinsicGasInput::new(ACCOUNT, true)); assert_eq!(first.nonce_key, Eip8130GasSchedule::NONCE_KEY_FIRST_USE_COST); } @@ -1273,13 +1311,13 @@ mod tests { payer: Some(address!("0x2222222222222222222222222222222222222222")), ..Default::default() }; - let gas = intrinsic( - &signed(tx, configured_auth(K1), configured_auth(Eip8130Contracts::P256_AUTHENTICATOR)), - &EXISTING_KEY, - ); + let payer_auth = configured_auth(Eip8130Contracts::P256_AUTHENTICATOR); + let gas = intrinsic(&signed(tx, configured_auth(K1), payer_auth.clone()), &EXISTING_KEY); assert_eq!( gas.payer_auth, - Eip8130GasSchedule::AUTH_EXEC_P256 + Eip8130GasSchedule::COLD_SLOAD + Eip8130GasSchedule::AUTH_EXEC_P256 + + Eip8130GasSchedule::COLD_SLOAD + + IntrinsicGas::data_cost(&payer_auth) ); // payer_auth is metered on top of gas_limit, so it is excluded here. assert_eq!(gas.sender_intrinsic(), gas.total() - gas.payer_auth); @@ -1299,10 +1337,91 @@ mod tests { ); assert_eq!( policy_gated.payer_auth, - Eip8130GasSchedule::AUTH_EXEC_P256 + Eip8130GasSchedule::COLD_SLOAD * 2 + Eip8130GasSchedule::AUTH_EXEC_P256 + + Eip8130GasSchedule::COLD_SLOAD * 2 + + IntrinsicGas::data_cost(&payer_auth) ); } + #[test] + fn payer_auth_bytes_do_not_change_sender_payload() { + let tx = TxEip8130 { + sender: Some(ACCOUNT), + payer: Some(address!("0x2222222222222222222222222222222222222222")), + ..Default::default() + }; + let short = + intrinsic(&signed(tx.clone(), configured_auth(K1), configured_auth(K1)), &EXISTING_KEY); + let mut long_auth = configured_auth(K1); + long_auth.extend_from_slice(&[0xff; 200]); + let long = intrinsic(&signed(tx, configured_auth(K1), long_auth), &EXISTING_KEY); + + assert_eq!(short.payload, long.payload); + assert_eq!(short.sender_intrinsic(), long.sender_intrinsic()); + assert_eq!( + long.payer_auth - short.payer_auth, + 200 * Eip8130GasSchedule::TX_DATA_NONZERO_BYTE + ); + } + + #[test] + fn sender_payload_cost_matches_empty_payer_auth_encoding() { + let tx = TxEip8130 { + sender: Some(ACCOUNT), + payer: Some(address!("0x2222222222222222222222222222222222222222")), + calls: vec![vec![Call { + to: ACCOUNT, + value: U256::from(1u64), + data: Bytes::from(vec![0u8; 64]), + }]], + ..Default::default() + }; + // A long, mostly-zero `payer_auth` changes the list header's + // length-of-length and the zero/nonzero mix of the suffix. The + // sender-billed payload must still match a true empty-`payer_auth` + // re-encoding. + let mut payer_auth = configured_auth(K1); + payer_auth.extend_from_slice(&[0x00; 4_000]); + let signed_tx = signed(tx, configured_auth(K1), payer_auth); + let gas = intrinsic(&signed_tx, &EXISTING_KEY); + assert_eq!( + gas.payload, + IntrinsicGas::data_cost(&signed_tx.encoded_2718_without_payer_auth()) + ); + } + + #[test] + fn payer_auth_over_max_authentication_gas_is_an_error() { + let tx = TxEip8130 { + sender: Some(ACCOUNT), + payer: Some(address!("0x2222222222222222222222222222222222222222")), + ..Default::default() + }; + let mut payer_auth = configured_auth(K1); + payer_auth.extend_from_slice(&[0xff; 7_000]); + let s = signed(tx, configured_auth(K1), payer_auth); + assert!(matches!( + IntrinsicGas::compute(&s, &encode(&s), &EXISTING_KEY), + Err(IntrinsicGasError::PayerAuthGasExceeded(cost)) + if cost > Eip8130GasSchedule::MAX_AUTHENTICATION_GAS + )); + } + + #[test] + fn value_calls_to_others_charge_tx_value_cost() { + let other = address!("0x3333333333333333333333333333333333333333"); + let call = |to, value: u64| Call { to, value: U256::from(value), data: Bytes::new() }; + let tx = TxEip8130 { + calls: vec![ + vec![call(other, 1), call(ACCOUNT, 1), call(other, 0)], + vec![call(other, 5)], + ], + ..Default::default() + }; + let gas = intrinsic(&signed(tx, vec![0; 65], vec![]), &EXISTING_KEY); + assert_eq!(gas.value_transfer, 2 * Eip8130GasSchedule::TX_VALUE_COST); + } + #[test] fn execution_gas_available_subtracts_sender_intrinsic() { let tx = TxEip8130::default(); diff --git a/crates/common/eip8130/src/schedule.rs b/crates/common/eip8130/src/schedule.rs index 32fadc1d82..c821d3de71 100644 --- a/crates/common/eip8130/src/schedule.rs +++ b/crates/common/eip8130/src/schedule.rs @@ -76,6 +76,15 @@ impl Eip8130GasSchedule { /// indicator, charged per delegation entry. pub const DELEGATION_DEPOSIT_COST: u64 = Self::CODE_DEPOSIT_PER_BYTE * Eip8130Constants::DELEGATION_INDICATOR_SIZE as u64; + /// `TX_VALUE_COST`: per call with `value > 0` and `to != sender`, covering + /// the recipient balance write and the transfer log. Charged statically + /// because `to` and `value` are signed fields. + pub const TX_VALUE_COST: u64 = 6_000; + /// Account-creation charge for a value-bearing call to an account that does + /// not exist, charged at dispatch because existence is only known then. + pub const NEW_ACCOUNT_COST: u64 = 25_000; + /// Upper bound on `payer_auth_cost`, which is metered outside `gas_limit`. + pub const MAX_AUTHENTICATION_GAS: u64 = 100_000; // ── Config-change actor slot writes ────────────────────────────────────── // diff --git a/crates/common/evm/src/eip8130.rs b/crates/common/evm/src/eip8130.rs index feb0f903b1..ae555b581f 100644 --- a/crates/common/evm/src/eip8130.rs +++ b/crates/common/evm/src/eip8130.rs @@ -50,8 +50,9 @@ use alloy_primitives::{Address, B256, Bytes, U256}; use base_common_consensus::{AccountChange, Delegation, Eip8130Constants, Predeploys}; use base_common_precompiles::{NonceManagerStorage, TxContextStorage}; use base_execution_eip8130::{ - AccountChangeApplier, AccountConfigurationStorage, ApplyError, DelegationEffect, FeeCheck, - IntrinsicGas, IntrinsicGasInput, NonceMode, NonceValidator, TransactionAuthorizer, + AccountChangeApplier, AccountConfigurationStorage, ApplyError, DelegationEffect, + Eip8130GasSchedule, FeeCheck, IntrinsicGas, IntrinsicGasInput, NonceMode, NonceValidator, + TransactionAuthorizer, }; use base_precompile_storage::{JournalStorageProvider, StorageCtx}; use revm::{ @@ -860,7 +861,7 @@ impl Eip8130Executor { Self::resolve_execution_gas( signed, encoded, - &IntrinsicGasInput::worst_case(nonce_key_first_use, tx.payer.is_some()), + &IntrinsicGasInput::worst_case(sender, nonce_key_first_use, tx.payer.is_some()), gas_limit, )?; @@ -1038,7 +1039,7 @@ impl Eip8130Executor { Self::resolve_execution_gas( signed, encoded, - &IntrinsicGasInput::new(nonce_key_first_use) + &IntrinsicGasInput::new(sender, nonce_key_first_use) .with_policy_gates(sender_actor.is_policy_gated(), payer_policy_gated) .with_revoke_discount_slots(applied_tx.revoke_discount_slots), gas_limit, @@ -1183,6 +1184,19 @@ impl Eip8130Executor { break; } + if Self::creates_account(evm, outcome.sender, call.to, call.value)? { + let Some(left) = remaining.checked_sub(Eip8130GasSchedule::NEW_ACCOUNT_COST) + else { + // The charge does not fit. Leave `remaining` at the + // unspent balance: `call_gas_spent` is + // `pool - remaining` for the whole transaction, so + // zeroing it would bill the entire call pool, including + // gas this phase never consumed. + phase_reverted = true; + break; + }; + remaining = left; + } let frame = Self::run_call( evm, outcome.sender, @@ -1331,6 +1345,36 @@ impl Eip8130Executor { inspector.call_end(ctx, &inputs, &mut outcome); } + /// Whether a call moving `value` from `sender` to `to` creates `to`, so it + /// owes [`Eip8130GasSchedule::NEW_ACCOUNT_COST`]: `value` is non-zero, + /// `to` is empty (EIP-161), and `sender` can cover `value` (an unaffordable + /// call fails its balance check before the charge applies). + fn creates_account( + evm: &mut BaseEvm, + sender: Address, + to: Address, + value: U256, + ) -> Result> + where + DB: AlloyDatabase, + BaseContext: ContextTr, + { + if value.is_zero() || to == sender { + return Ok(false); + } + let journal = evm.ctx_mut().journal_mut(); + // `load_account` warms `to` and `sender`. The following `run_call` frame + // therefore sees both as warm (100 gas, not 2600, on a later access). + // Protocol calls are priced by `TX_VALUE_COST` / `NEW_ACCOUNT_COST` + // rather than the CALL opcode, so the warming does not undercharge; it + // is a side effect of the emptiness and balance checks. + if !journal.load_account(to).map_err(EVMError::Database)?.data.is_empty() { + return Ok(false); + } + let balance = journal.load_account(sender).map_err(EVMError::Database)?.data.info.balance; + Ok(balance >= value) + } + /// Dispatches a single protocol call (`from = sender`, transferring `value` /// wei to `to`) as a top-level EVM call frame with `gas_limit` and runs it to /// completion, returning the [`FrameResult`]. Reuses the Base handler's frame @@ -1389,12 +1433,11 @@ impl Eip8130Executor { // reserved for `DELEGATECALL`): revm's frame init moves `value` from // `caller` to `to`, reverting the frame with `InsufficientBalance` // when the caller's spendable balance cannot cover it, and `msg.value` - // reads as `value` inside the callee. The value-transfer stipend and - // new-account (25000) gas that a `CALL` opcode would levy are not - // charged here: this directly-built top-level frame bypasses the - // opcode gas site, exactly as the zero-value path already did, so the - // sender's dispatched calls stay metered by the 8130 call-gas pool - // rather than the opcode gas model. + // reads as `value` inside the callee. This directly-built top-level + // frame bypasses the `CALL` opcode gas site, so no value surcharge or + // stipend applies: the value transfer is priced by the intrinsic + // `TX_VALUE_COST`, and account creation by `NEW_ACCOUNT_COST` in + // `execute_calls`. value: CallValue::Transfer(value), scheme: CallScheme::Call, is_static: false, @@ -1940,53 +1983,75 @@ mod tests { assert_eq!(recipient_acc.info.balance, transfer, "recipient credited the call value"); } - /// A value transfer to an empty account does not pay the 25,000 - /// new-account gas. The protocol frame sets `charged_new_account_state_gas` - /// to false and bypasses the CALL opcode gas site, so revm neither charges - /// nor refunds that cost. Gas matches the same transfer to an existing - /// account. + /// A value-bearing call to an account that does not exist pays + /// `NEW_ACCOUNT_COST` on top of the same call to an existing account. #[test] - fn value_transfer_to_empty_account_matches_existing_account_gas() { + fn value_call_to_new_account_charges_account_creation() { let key = signing_key(0x53); let sender = eoa_address(&key); let recipient = address!("0x00000000000000000000000000000000000000e3"); - let transfer = U256::from(1_000_000u64); - + let initial_balance = U256::from(10u64).pow(U256::from(18u64)); let mut tx = base_tx(); - tx.calls = vec![vec![Call { to: recipient, value: transfer, data: Bytes::new() }]]; + tx.calls = vec![vec![Call { to: recipient, value: U256::from(1u64), data: Bytes::new() }]]; let signed = eoa_signed(tx, &key); - let initial_balance = U256::from(10u64).pow(U256::from(18u64)); - let mut empty = evm_with(initial_balance, sender); - let empty_outcome = - empty.transact_raw(into_base_tx(&signed)).expect("value transfer to an empty account"); - assert!( - empty_outcome.result.is_success(), - "expected success, got {:?}", - empty_outcome.result - ); - let created = empty_outcome.state.get(&recipient).expect("recipient in state"); - assert_eq!(created.info.balance, transfer, "empty account credited the call value"); + let gas_used = |accounts: &[(Address, Bytes)]| { + let mut evm = evm_with_accounts(initial_balance, sender, accounts); + let outcome = evm.transact_raw(into_base_tx(&signed)).expect("value call should run"); + assert!(outcome.result.is_success(), "expected success, got {:?}", outcome.result); + outcome.result.tx_gas_used() + }; - let mut existing = evm_with(initial_balance, sender); - existing.ctx_mut().journal_mut().db_mut().insert_account_info( - recipient, - AccountInfo { balance: U256::from(1u64), ..Default::default() }, - ); - let existing_outcome = existing - .transact_raw(into_base_tx(&signed)) - .expect("value transfer to an existing account"); - assert!( - existing_outcome.result.is_success(), - "expected success, got {:?}", - existing_outcome.result + assert_eq!( + gas_used(&[]) - gas_used(&[(recipient, bytes!("00"))]), + Eip8130GasSchedule::NEW_ACCOUNT_COST ); + } + + /// A new-account charge that does not fit in the remaining call pool reverts + /// the phase and bills only the gas already consumed. Zeroing the shared + /// remainder would report the whole pool as spent. + #[test] + fn unaffordable_new_account_charge_does_not_burn_the_unspent_pool() { + let key = signing_key(0x54); + let sender = eoa_address(&key); + let recipient = address!("0x00000000000000000000000000000000000000e4"); + let mut tx = base_tx(); + tx.calls = vec![vec![Call { to: recipient, value: U256::from(1u64), data: Bytes::new() }]]; + + let shortfall = Eip8130GasSchedule::NEW_ACCOUNT_COST - 1; + let mut gas_limit = shortfall; + let mut signed = eoa_signed(tx.clone(), &key); + let mut sender_intrinsic = 0; + for _ in 0..8 { + let mut trial = tx.clone(); + trial.gas_limit = gas_limit; + signed = eoa_signed(trial, &key); + let envelope = BaseTxEnvelope::Eip8130(signed.clone()); + let encoded: Bytes = alloy_eips::eip2718::Encodable2718::encoded_2718(&envelope).into(); + let intrinsic = + IntrinsicGas::compute(&signed, &encoded, &IntrinsicGasInput::new(sender, true)) + .expect("intrinsic gas"); + sender_intrinsic = intrinsic.sender_intrinsic(); + let next = sender_intrinsic.saturating_add(shortfall); + if next == gas_limit { + break; + } + gas_limit = next; + } + assert_eq!(gas_limit, sender_intrinsic + shortfall, "gas limit did not converge"); + + let initial_balance = U256::from(10u64).pow(U256::from(18u64)); + let mut evm = evm_with(initial_balance, sender); + let outcome = evm.transact_raw(into_base_tx(&signed)).expect("tx should be included"); + assert!(!outcome.result.is_success(), "phase should revert when creation gas does not fit"); assert_eq!( - empty_outcome.result.gas().tx_gas_used(), - existing_outcome.result.gas().tx_gas_used(), - "value transfer to an empty account must not pay the 25,000 new-account gas" + outcome.result.tx_gas_used(), + sender_intrinsic, + "unspent call gas must not be billed" ); + assert!(outcome.result.tx_gas_used() < signed.tx().gas_limit); } /// A call whose `value` exceeds the sender's spendable balance (after the diff --git a/crates/execution/eip8130/README.md b/crates/execution/eip8130/README.md index c68086f88e..87e98ee9a1 100644 --- a/crates/execution/eip8130/README.md +++ b/crates/execution/eip8130/README.md @@ -67,18 +67,22 @@ The crate keeps the protocol stages explicit while avoiding crate sprawl: formula: ```text -intrinsic_gas = AA_BASE_COST + tx_payload_cost + nonce_key_cost + bytecode_cost - + account_changes_cost + sender_auth_cost + payer_auth_cost +intrinsic_gas = AA_BASE_COST + tx_payload_cost + nonce_key_cost + value_transfer_cost + + bytecode_cost + account_changes_cost + sender_auth_cost + payer_auth_cost ``` | Component | Source | |---|---| | `base` | `AA_BASE_COST` (15,000) | -| `payload` | EIP-2028 data-availability cost (16/non-zero, 4/zero byte) over the caller-supplied EIP-2718 serialization of the signed transaction | +| `payload` | EIP-2028 data-availability cost (16/non-zero, 4/zero byte) over the EIP-2718 serialization of the signed transaction with an empty `payer_auth` | | `nonce_key` | nonce-free `13,000`; otherwise first-use `22,100` / existing `5,000` (a cold SLOAD plus an SSTORE set or reset) | +| `value_transfer` | `6,000` per call with `value > 0` and `to != sender` | | `bytecode` | per create entry: `32,000 + 200 · code_len` | | `account_changes` | per create entry: one fresh packed `account_state` write plus one fresh `actor_config` slot write per initial actor; policy actors set `policy_commitment`/`policy_manager`, while ungated actors pay the cold zero-to-zero touches that preserve access warming; per config-change entry: a packed `account_state` write covering the sequence advance and lock read — the first access to that slot in the transaction (create bootstrap or first config change) is a cold zero-to-nonzero write, later same-account bumps are only a warm SLOAD + dirty SSTORE (`200`, the slot was already modified earlier in the transaction) — its `auth` cost, and each mutated actor/policy slot; revokes conservatively price all three actor/policy resets, except each revoke slot execution resolves to be an empty zero-to-zero touch is discounted by the reset-vs-cold-noop delta (an inline secp256k1 self revoke discounts its always-empty `actor_config` slot plus each policy slot — `manager`/`commitment` — whose stored value is zero, so three empty slots when ungated and one to three when policy-gated, since the EIP permits a gated actor to carry a zero manager and/or commitment); a self-actor change adds no separate bump — its inline-self write is already covered by the config-change `account_state` cost and its `actor_config(self)` home by the per-change slot cost; per delegation entry: the `4,600` indicator deposit | -| `sender_auth` / `payer_auth` | authenticator execution gas + one cold config/state SLOAD, plus one cold `policy_manager` SLOAD when the resolved actor has `SCOPE_POLICY`; `payer_auth` is `0` for self-pay | +| `sender_auth` / `payer_auth` | authenticator execution gas + one cold config/state SLOAD, plus one cold `policy_manager` SLOAD when the resolved actor has `SCOPE_POLICY`; `payer_auth` adds the data cost of the `payer_auth` bytes, is `0` for self-pay, and must not exceed `MAX_AUTHENTICATION_GAS` (100,000) | + +A value-bearing call to an account that does not exist also pays `25,000` for +account creation when it runs, since existence is only known at dispatch. `sender_intrinsic` excludes `payer_auth` (payer authentication is metered on top of `gas_limit`), so `execution_gas_available(gas_limit) = gas_limit - diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index 97e7a92e07..91cac561f2 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -22,8 +22,8 @@ use base_common_genesis::DaFootprintGasScalarUpdate; use base_common_precompiles::NonceManagerStorage; use base_execution_eip8130::{ AccountConfigurationStorage, AccountState, ApplyError, AuthorizeError, FeeCheck, IntrinsicGas, - IntrinsicGasInput, LockStatus, NonceError, NonceMode, NonceValidator, TransactionAuthorizer, - TxAuthError, + IntrinsicGasError, IntrinsicGasInput, LockStatus, NonceError, NonceMode, NonceValidator, + TransactionAuthorizer, TxAuthError, }; use base_precompile_storage::{ BasePrecompileError, PrecompileStorageProvider, StorageCtx, validate_loaded_code_presence, @@ -1093,9 +1093,20 @@ where let intrinsic = IntrinsicGas::compute( signed, encoded.as_ref(), - &IntrinsicGasInput::worst_case(nonce_key_first_use, signed.tx().payer.is_some()), + &IntrinsicGasInput::worst_case( + sender, + nonce_key_first_use, + signed.tx().payer.is_some(), + ), ) - .map_err(|_| Self::eip8130_error("intrinsic gas computation failed"))?; + .map_err(|err| { + Self::eip8130_error(match err { + IntrinsicGasError::PayerAuthGasExceeded(_) => { + "payer authentication gas exceeds MAX_AUTHENTICATION_GAS" + } + _ => "intrinsic gas computation failed", + }) + })?; if intrinsic.execution_gas_available(signed.tx().gas_limit).is_none() { return Err(InvalidTransactionError::GasTooLow.into()); } @@ -1238,7 +1249,7 @@ where }; // Calls move `call.value` out of the sender, not the payer. A self-paying // sender reserves it alongside gas; a sponsored sender must hold it alone. - let call_value = signed.tx().total_call_value(); + let call_value = signed.tx().sender_call_value(sender); let payer_max_cost = gas_charge .saturating_add(additional_fee) .saturating_add(if payer == sender { call_value } else { U256::ZERO }); @@ -3623,6 +3634,26 @@ mod tests { ), "expected InsufficientFunds, got {err:?}" ); + + // Self-calls move nothing, so three of them whose sum exceeds the + // balance reserve only one call's value. + let self_call = Call { to: sender, value: U256::from(BALANCE / 2), data: Bytes::new() }; + let tx = TxEip8130 { + gas_limit: 100_000, + calls: vec![vec![self_call.clone(), self_call.clone(), self_call]], + ..minimal_valid_eoa_tx() + }; + let signature = signer.sign_hash_sync(&tx.sender_signature_hash()).unwrap(); + let self_calls = + Eip8130Signed::new(tx, Bytes::from(signature.as_bytes().to_vec()), Bytes::new()); + let state = validator + .validate_eip8130_full(&self_calls) + .expect("repeated self-calls only need one call's value on hand"); + assert_eq!( + state.payer_max_cost - without_value.payer_max_cost, + U256::from(BALANCE / 2), + "self-calls reserve their peak, not their sum" + ); } #[test] From 56044f38b6f8d8880801fc5a133b4c0565cb1c5a Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 12:51:50 +0000 Subject: [PATCH 147/299] test(batcher): consolidate the core driver test doubles (#5297) * test(batcher): consolidate the core driver test doubles * test(batcher): enable the source test utils behind the feature and tidy the doubles * test(batcher): document the pipeline call log and turn the double helpers into methods * test(batcher): name the recorded step call after its method * test(batcher): assert exact values now that the call log records arguments * test(batcher): check the whole call log where the tests promise it * chore(batcher): sort the core test-utils feature list as zepter formats it * test(batcher): keep TrackingSource parked and tighten the test double docs * test(batcher): give each submission the test pipeline hands out a fresh id --- crates/batcher/core/src/driver.rs | 649 +++++++----------- crates/batcher/core/src/test_utils/builder.rs | 42 +- crates/batcher/core/src/test_utils/mod.rs | 9 +- .../batcher/core/src/test_utils/pipeline.rs | 368 +++++----- crates/batcher/core/src/test_utils/source.rs | 36 +- .../batcher/core/src/test_utils/tx_manager.rs | 206 +++--- crates/batcher/core/tests/heads.rs | 70 +- crates/batcher/core/tests/lifecycle.rs | 58 +- crates/batcher/core/tests/reorg.rs | 38 +- crates/batcher/core/tests/stop_start.rs | 78 +-- crates/batcher/core/tests/throttle.rs | 96 +-- 11 files changed, 721 insertions(+), 929 deletions(-) diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 76f7776144..f76eb6ab37 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -421,87 +421,48 @@ where #[cfg(test)] mod tests { - use std::{ - collections::VecDeque, - sync::{ - Arc, Mutex, - atomic::{AtomicU64, Ordering}, - }, - time::Duration, - }; + use std::{sync::Arc, time::Duration}; - use alloy_consensus::{Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom}; - use alloy_primitives::{Address, B256, Bloom, Bytes}; - use alloy_rpc_types_eth::TransactionReceipt; - use base_batcher_encoder::{ - BatchSubmission, BlobPayload, FrameEncoder, SubmissionId, SubmissionPayload, + use alloy_primitives::B256; + use base_batcher_encoder::{BlobPayload, FrameEncoder, SubmissionId, SubmissionPayload}; + use base_batcher_source::{ + L2BlockEvent, + test_utils::{ChannelBlockSource, ChannelL1HeadSource}, }; - use base_batcher_source::{L1HeadSource, L2BlockEvent, UnsafeBlockSource}; use base_blobs::{BlobDecoder, BlobEncoder}; use base_protocol::{BlockInfo, Frame}; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, }; - use base_tx_manager::{SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError}; - use tokio::sync::{mpsc, oneshot}; use super::STEP_BUDGET; use crate::{ - AdminCommand, BatchDriver, BatchDriverConfig, BatchDriverError, BatchDriverInputs, - DaThrottle, DerivationStatus, NoopThrottleClient, ThrottleController, + BatchDriverError, DerivationStatus, test_utils::{ - BlockStub, DriverFixture, ImmediateConfirmTxManager, ImmediateFailTxManager, - NeverConfirmTxManager, PipelineCall, Recorded, SubmissionStub, TrackingPipeline, + BlockStub, DriverFixture, PipelineCall, ScriptedTxManager, SendOutcome, SubmissionStub, + TrackingPipeline, }, }; - #[derive(Debug)] - struct QueuedSource { - events: VecDeque, - } - - impl QueuedSource { - fn new(events: impl IntoIterator) -> Self { - Self { events: events.into_iter().collect() } - } - } - - #[async_trait::async_trait] - impl UnsafeBlockSource for QueuedSource { - async fn next(&mut self) -> L2BlockEvent { - match self.events.pop_front() { - Some(event) => event, - None => std::future::pending().await, - } - } - - fn reset_catchup(&mut self, _: BlockInfo) {} - } - - #[derive(Debug)] - struct QueuedL1HeadSource { - heads: VecDeque, + fn safe_head(number: u64) -> BlockInfo { + BlockInfo { hash: B256::with_last_byte(number as u8), number, ..Default::default() } } - impl QueuedL1HeadSource { - fn new(heads: impl IntoIterator) -> Self { - Self { heads: heads.into_iter().collect() } + /// Sources that deliver the given blocks and L1 heads, then park. + fn queued_sources( + blocks: impl IntoIterator, + l1_heads: impl IntoIterator, + ) -> (ChannelBlockSource, ChannelL1HeadSource) { + let (source, source_tx) = ChannelBlockSource::new(); + for number in blocks { + source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(number)))).unwrap(); } - } - - #[async_trait::async_trait] - impl L1HeadSource for QueuedL1HeadSource { - async fn next(&mut self) -> u64 { - match self.heads.pop_front() { - Some(head) => head, - None => std::future::pending().await, - } + let (l1_head_source, l1_head_tx) = ChannelL1HeadSource::new(); + for head in l1_heads { + l1_head_tx.send(head).unwrap(); } - } - - fn safe_head(number: u64) -> BlockInfo { - BlockInfo { hash: B256::with_last_byte(number as u8), number, ..Default::default() } + (source, l1_head_source) } /// The pipeline starts from the live L1 head, so channel duration is not measured from @@ -509,48 +470,29 @@ mod tests { #[test] fn new_driver_seeds_pipeline_from_live_l1_head() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let (_admin_tx, admin_rx) = mpsc::channel(1); - let (_status_tx, status_rx) = mpsc::channel(1); - - let _driver = BatchDriver::new( - ctx, - TrackingPipeline::new(Arc::clone(&recorded)), - NeverConfirmTxManager, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - stopped: false, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - BatchDriverInputs { - source: QueuedSource::new([]), - l1_head_source: QueuedL1HeadSource::new([]), - initial_l1_head: 50, - initial_safe_head: safe_head(10), - derivation_status_rx: status_rx, - admin_rx, - }, - ); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); - assert_eq!(recorded.lock().unwrap().l1_heads, vec![50]); + let (_driver, _handles) = DriverFixture::new(ctx, pipeline, ScriptedTxManager::new([])) + .initial_l1_head(50) + .safe_head(safe_head(10)) + .build(); + + assert_eq!(recorded.lock().unwrap().l1_heads(), [50]); }); } - /// Build a [`BatchSubmission`] whose single frame exactly fills one blob payload, + /// Build a [`SubmissionPayload`] whose single frame exactly fills one blob payload, /// leaving no room for any additional frame alongside it. /// /// `payload = 1 (DERIVATION_VERSION_0) + FRAME_OVERHEAD + data.len() = BLOB_MAX_DATA_SIZE` - fn blob_filling_submission(id: u64) -> BatchSubmission { - blob_filling_submission_with_frames(id, 1) + fn blob_filling_payload() -> SubmissionPayload { + blob_filling_payload_with_frames(1) } - fn blob_filling_submission_with_frames(id: u64, frame_count: usize) -> BatchSubmission { + fn blob_filling_payload_with_frames(frame_count: usize) -> SubmissionPayload { let data_len = BlobEncoder::BLOB_MAX_DATA_SIZE - 1 - BlobEncoder::FRAME_OVERHEAD; - BatchSubmission::blobs( - SubmissionId(id), + SubmissionPayload::Blobs( (0..frame_count) .map(|number| { BlobPayload::new(vec![Arc::new(Frame { @@ -563,200 +505,62 @@ mod tests { ) } - const fn stub_receipt(block_number: u64) -> TransactionReceipt { - let inner = ReceiptEnvelope::Legacy(ReceiptWithBloom { - receipt: Receipt { - status: Eip658Value::Eip658(true), - cumulative_gas_used: 21_000, - logs: vec![], - }, - logs_bloom: Bloom::ZERO, - }); - TransactionReceipt { - inner, - transaction_hash: B256::ZERO, - transaction_index: Some(0), - block_hash: Some(B256::ZERO), - block_number: Some(block_number), - gas_used: 21_000, - effective_gas_price: 1_000_000_000, - blob_gas_used: None, - blob_gas_price: None, - from: Address::ZERO, - to: Some(Address::ZERO), - contract_address: None, - } - } - - /// A driver whose source and L1 head source deliver the given events then park, with - /// the handles the test keeps to feed it and observe it. - struct QueuedDriver { - driver: BatchDriver< - R, - TrackingPipeline, - QueuedSource, - TM, - Arc, - QueuedL1HeadSource, - >, - recorded: Arc>, - admin_tx: mpsc::Sender, - status_tx: mpsc::Sender, - } - - fn queued_driver( - runtime: R, - blocks: impl IntoIterator, - l1_heads: impl IntoIterator, - tx_manager: TM, - ) -> QueuedDriver { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let (admin_tx, admin_rx) = mpsc::channel(1); - let (status_tx, status_rx) = mpsc::channel(1); - let driver = - BatchDriver::new( - runtime, - TrackingPipeline::new(Arc::clone(&recorded)), - tx_manager, - BatchDriverConfig { - inbox: Address::ZERO, - max_pending_transactions: 1, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, - stopped: false, - }, - DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), - BatchDriverInputs { - source: QueuedSource::new(blocks.into_iter().map(|number| { - L2BlockEvent::Block(Box::new(BlockStub::with_number(number))) - })), - l1_head_source: QueuedL1HeadSource::new(l1_heads), - initial_l1_head: 0, - initial_safe_head: safe_head(0), - derivation_status_rx: status_rx, - admin_rx, - }, - ); - QueuedDriver { driver, recorded, admin_tx, status_tx } - } - - #[derive(Debug, Default)] - struct TxpoolBlockedState { - sends: AtomicU64, - cancellations: AtomicU64, - } - - #[derive(Debug, Clone)] - struct TxpoolBlockedOnceTxManager { - state: Arc, - } - - impl TxManager for TxpoolBlockedOnceTxManager { - async fn send(&self, _: TxCandidate) -> SendResponse { - Err(TxManagerError::AlreadyReserved) - } - - fn send_async( - &self, - _: TxCandidate, - ) -> impl std::future::Future + Send { - self.state.sends.fetch_add(1, Ordering::SeqCst); - let (tx, rx) = oneshot::channel(); - let _ = tx.send(Err(TxManagerError::AlreadyReserved)); - std::future::ready(SendHandle::new(rx)) - } - - fn cancel_tx( - &self, - ) -> impl std::future::Future> + Send - { - let state = Arc::clone(&self.state); - async move { - state.cancellations.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - } - - fn sender_address(&self) -> Address { - Address::ZERO - } - } - - #[derive(Debug)] - struct RecordedCandidate { - tx_data: Bytes, - decoded_blob_payloads: Vec, - } - - #[derive(Debug, Clone)] - struct RecordingConfirmTxManager { - l1_block: u64, - candidates: Arc>>, - } - - impl TxManager for RecordingConfirmTxManager { - async fn send(&self, _: TxCandidate) -> SendResponse { - unreachable!() - } - - fn send_async( - &self, - candidate: TxCandidate, - ) -> impl std::future::Future + Send { - let decoded_blob_payloads = candidate - .blobs - .iter() - .map(|blob| BlobDecoder::decode(blob).expect("blob payload should decode")) - .collect(); - self.candidates - .lock() - .unwrap() - .push(RecordedCandidate { tx_data: candidate.tx_data, decoded_blob_payloads }); - let l1_block = self.l1_block; - let (tx, rx) = oneshot::channel(); - let _ = tx.send(Ok(stub_receipt(l1_block))); - std::future::ready(SendHandle::new(rx)) - } - - fn sender_address(&self) -> Address { - Address::ZERO - } - } - - // The loop polls its arms in priority order; each test below makes several arms ready at - // once and checks which one the driver serves first. + // The loop polls its arms in priority order. Each test below makes several arms ready at + // once and checks which one the driver serves first. Each test checks only the start of the + // call log, because the shutdown flush always ends it. #[test] fn run_prioritizes_cancellation_over_ready_admin() { Runner::start(Config::seeded(0), |ctx| async move { - let queued = - queued_driver(ctx.clone(), [1], [9], ImmediateConfirmTxManager { l1_block: 1 }); - let (reply, reply_rx) = oneshot::channel(); - queued.admin_tx.send(AdminCommand::Flush { reply }).await.unwrap(); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + let (source, l1_head_source) = queued_sources([1], [9]); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) + .l1_head_source(l1_head_source) + .build(); + + // Queue a flush, then cancel before the driver runs. + let admin = handles.admin.clone(); + let flush = ctx.spawn(async move { admin.flush().await }); + ctx.sleep(Duration::from_millis(1)).await; ctx.cancel(); - assert!(queued.driver.run().await.is_ok()); - assert!(reply_rx.await.is_err(), "a cancelled driver must not serve the flush"); - assert!(!queued.recorded.lock().unwrap().calls.contains(&PipelineCall::AddBlock)); + assert!(driver.run().await.is_ok()); + assert!(flush.await.unwrap().is_err(), "a cancelled driver must not serve the flush"); + // Only the shutdown flush is recorded, so neither the admin flush, the block nor the + // head was served. + assert_eq!(recorded.lock().unwrap().calls, [PipelineCall::Flush]); }); } #[test] fn run_prioritizes_admin_before_source() { Runner::start(Config::seeded(0), |ctx| async move { - let queued = - queued_driver(ctx.clone(), [1], [], ImmediateConfirmTxManager { l1_block: 1 }); - let (reply, _reply_rx) = oneshot::channel(); - queued.admin_tx.send(AdminCommand::Flush { reply }).await.unwrap(); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + let (source, l1_head_source) = queued_sources([1], []); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) + .l1_head_source(l1_head_source) + .build(); + + // Queue a flush before the driver runs. + let admin = handles.admin.clone(); + let flush = ctx.spawn(async move { admin.flush().await }); + ctx.sleep(Duration::from_millis(1)).await; - let handle = ctx.spawn(queued.driver.run()); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); + assert!(flush.await.unwrap().is_ok()); - let recorded = queued.recorded.lock().unwrap(); + let recorded = recorded.lock().unwrap(); assert!( - recorded.calls.starts_with(&[PipelineCall::Flush, PipelineCall::AddBlock]), + recorded.calls.starts_with(&[PipelineCall::Flush, PipelineCall::AddBlock(1)]), "{:?}", recorded.calls ); @@ -766,79 +570,105 @@ mod tests { #[test] fn run_prioritizes_source_before_receipts_and_heads() { Runner::start(Config::seeded(0), |ctx| async move { - let mut queued = - queued_driver(ctx.clone(), [1], [9], ImmediateConfirmTxManager { l1_block: 1 }); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); // Submitted by the first CPU phase, so its receipt is ready at the first wait. - queued.driver.pipeline.submissions.push_back(SubmissionStub::stub()); + pipeline.submissions.push_back(SubmissionStub::stub()); + let (source, l1_head_source) = queued_sources([1], [9]); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) + .l1_head_source(l1_head_source) + .build(); - let handle = ctx.spawn(queued.driver.run()); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let recorded = queued.recorded.lock().unwrap(); - assert_eq!( - recorded.calls.first(), - Some(&PipelineCall::AddBlock), + // The receipt confirms at L1 block 1 before the L1 head source's head 9 arrives. The + // other way round, head 1 would not advance past 9. + let recorded = recorded.lock().unwrap(); + assert!( + recorded.calls.starts_with(&[ + PipelineCall::Dequeue(SubmissionId(0)), + PipelineCall::AddBlock(1), + PipelineCall::Confirm(SubmissionId(0), 1), + PipelineCall::AdvanceL1Head(1), + PipelineCall::AdvanceL1Head(9), + ]), "{:?}", recorded.calls ); - // The receipt confirms at L1 block 1 before the source's head 9 arrives; the other - // way round, head 1 would not advance past 9. - assert_eq!(recorded.l1_heads, [1, 9]); }); } #[test] fn run_prioritizes_derivation_status_before_source_and_receipts() { Runner::start(Config::seeded(0), |ctx| async move { - let mut queued = - queued_driver(ctx.clone(), [6], [], ImmediateConfirmTxManager { l1_block: 42 }); - queued.driver.pipeline.submissions.push_back(SubmissionStub::stub()); - queued - .status_tx + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + pipeline.submissions.push_back(SubmissionStub::stub()); + let (source, l1_head_source) = queued_sources([6], []); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(42)) + .source(source) + .l1_head_source(l1_head_source) + .build(); + handles + .derivation_status_tx .send(DerivationStatus { safe_l2: safe_head(5), current_l1: safe_head(1) }) .await .unwrap(); - let handle = ctx.spawn(queued.driver.run()); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let recorded = queued.recorded.lock().unwrap(); + let recorded = recorded.lock().unwrap(); assert!( - recorded - .calls - .starts_with(&[PipelineCall::ReconcileDerivation, PipelineCall::AddBlock]), + recorded.calls.starts_with(&[ + PipelineCall::Dequeue(SubmissionId(0)), + PipelineCall::ReconcileDerivation { safe_l2: 5, current_l1: 1 }, + PipelineCall::AddBlock(6), + PipelineCall::Confirm(SubmissionId(0), 42), + PipelineCall::AdvanceL1Head(42), + ]), "{:?}", recorded.calls ); - assert!(recorded.calls.contains(&PipelineCall::Confirm)); }); } #[test] fn run_prioritizes_derivation_status_before_l1_head() { Runner::start(Config::seeded(0), |ctx| async move { - let queued = - queued_driver(ctx.clone(), [], [9], ImmediateConfirmTxManager { l1_block: 1 }); - queued - .status_tx + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + let (source, l1_head_source) = queued_sources([], [9]); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) + .l1_head_source(l1_head_source) + .build(); + handles + .derivation_status_tx .send(DerivationStatus { safe_l2: safe_head(5), current_l1: safe_head(1) }) .await .unwrap(); - let handle = ctx.spawn(queued.driver.run()); + let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let recorded = queued.recorded.lock().unwrap(); + let recorded = recorded.lock().unwrap(); assert!( - recorded - .calls - .starts_with(&[PipelineCall::ReconcileDerivation, PipelineCall::AdvanceL1Head]), + recorded.calls.starts_with(&[ + PipelineCall::ReconcileDerivation { safe_l2: 5, current_l1: 1 }, + PipelineCall::AdvanceL1Head(9), + ]), "{:?}", recorded.calls ); @@ -850,17 +680,17 @@ mod tests { fn run_finishes_a_backlog_larger_than_one_slice_without_events() { Runner::start(Config::seeded(0), |ctx| async move { let blocks = 2 * STEP_BUDGET + 5; - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_encoding_steps(blocks); + let pipeline = TrackingPipeline::new().with_encoding_steps(blocks); + let recorded = pipeline.recorded(); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::new([])).build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - assert_eq!(recorded.lock().unwrap().encoded_steps, blocks); + assert_eq!(recorded.lock().unwrap().encoded_steps(), blocks); }); } @@ -869,11 +699,10 @@ mod tests { #[test] fn run_serves_admin_between_encoding_slices() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)) - .with_encoding_steps(2 * STEP_BUDGET + 5); + let pipeline = TrackingPipeline::new().with_encoding_steps(2 * STEP_BUDGET + 5); + let recorded = pipeline.recorded(); let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::new([])).build(); let handle = ctx.spawn(driver.run()); // Stop resets the pipeline, which drops whatever was still to encode. @@ -881,7 +710,7 @@ mod tests { ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - assert_eq!(recorded.lock().unwrap().encoded_steps, STEP_BUDGET); + assert_eq!(recorded.lock().unwrap().encoded_steps(), STEP_BUDGET); }); } @@ -891,23 +720,22 @@ mod tests { fn run_yields_to_other_tasks_between_encoding_slices() { let config = Config { cycle_limit: Some(1_000_000), ..Config::seeded(0) }; Runner::start(config, |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = - TrackingPipeline::new(Arc::clone(&recorded)).with_encoding_steps(3 * STEP_BUDGET); + let pipeline = TrackingPipeline::new().with_encoding_steps(3 * STEP_BUDGET); + let recorded = pipeline.recorded(); let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::new([])).build(); let handle = ctx.spawn(driver.run()); // Send the stop once the first slice is done: the driver must yield for this task // to observe that. - while recorded.lock().unwrap().encoded_steps < STEP_BUDGET { + while recorded.lock().unwrap().encoded_steps() < STEP_BUDGET { tokio::task::yield_now().await; } handles.admin.stop().await.unwrap(); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let encoded = recorded.lock().unwrap().encoded_steps; + let encoded = recorded.lock().unwrap().encoded_steps(); assert!(encoded < 3 * STEP_BUDGET, "the stop must not wait for the whole backlog"); }); } @@ -917,16 +745,13 @@ mod tests { #[test] fn test_advance_l1_head_called_on_confirmation() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); pipeline.submissions.push_back(SubmissionStub::stub()); - let (driver, _handles) = DriverFixture::new( - ctx.clone(), - pipeline, - ImmediateConfirmTxManager { l1_block: 42 }, - ) - .build(); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(42)) + .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -934,8 +759,8 @@ mod tests { assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); assert_eq!( - recorded.lock().unwrap().l1_heads, - vec![42], + recorded.lock().unwrap().l1_heads(), + [42], "advance_l1_head must be called with the confirmed L1 block" ); }); @@ -946,12 +771,16 @@ mod tests { #[test] fn test_advance_l1_head_not_called_on_failure() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); pipeline.submissions.push_back(SubmissionStub::stub()); - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateFailTxManager).build(); + let (driver, _handles) = DriverFixture::new( + ctx.clone(), + pipeline, + ScriptedTxManager::new([SendOutcome::Failed]), + ) + .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -959,7 +788,7 @@ mod tests { assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); assert!( - recorded.lock().unwrap().l1_heads.is_empty(), + recorded.lock().unwrap().l1_heads().is_empty(), "advance_l1_head must NOT be called on submission failure" ); }); @@ -970,22 +799,18 @@ mod tests { #[test] fn test_blob_encoding_failure_is_fatal() { Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(Arc::default()); + let mut pipeline = TrackingPipeline::new(); // A frame as large as a whole blob no longer fits once framed. - pipeline.submissions.push_back(BatchSubmission::blobs( - SubmissionId(0), - vec![BlobPayload::new(vec![Arc::new(Frame { + pipeline.submissions.push_back(SubmissionPayload::Blobs(vec![BlobPayload::new(vec![ + Arc::new(Frame { data: vec![0u8; BlobEncoder::BLOB_MAX_DATA_SIZE], ..Frame::default() - })])], - )); + }), + ])])); - let (driver, _handles) = DriverFixture::new( - ctx.clone(), - pipeline, - ImmediateConfirmTxManager { l1_block: 1 }, - ) - .build(); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .build(); let result = ctx.spawn(driver.run()).await.unwrap(); assert!(matches!(result, Err(BatchDriverError::Blob(_))), "got {result:?}"); @@ -997,19 +822,15 @@ mod tests { #[test] fn test_submission_loop_submits_each_pipeline_submission_as_one_tx() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let candidates = Arc::new(Mutex::new(Vec::new())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - pipeline.submissions.push_back(SubmissionStub::with_id(0)); - pipeline.submissions.push_back(SubmissionStub::with_id(1)); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + pipeline.submissions.push_back(SubmissionStub::stub()); + pipeline.submissions.push_back(SubmissionStub::stub()); + let tx_manager = ScriptedTxManager::confirming_at(10); - let (driver, _handles) = DriverFixture::new( - ctx.clone(), - pipeline, - RecordingConfirmTxManager { l1_block: 10, candidates: Arc::clone(&candidates) }, - ) - .max_pending(2) - .build(); + let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()) + .max_pending(2) + .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -1017,14 +838,18 @@ mod tests { assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.dequeued.len(), 2, "both submissions must be dequeued"); assert_eq!( - recorded.confirmed, - vec![SubmissionId(0), SubmissionId(1)], + recorded.dequeued(), + [SubmissionId(0), SubmissionId(1)], + "both submissions must be dequeued" + ); + assert_eq!( + recorded.confirmed(), + [SubmissionId(0), SubmissionId(1)], "each pipeline submission should produce its own confirmation" ); assert_eq!( - candidates.lock().unwrap().len(), + tx_manager.candidates().len(), 2, "separate pipeline submissions must not be coalesced into one L1 tx" ); @@ -1037,25 +862,21 @@ mod tests { #[test] fn test_multi_frame_blob_submission_maps_frames_to_blobs() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let candidates = Arc::new(Mutex::new(Vec::new())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - let submission = blob_filling_submission_with_frames(0, 3); - let SubmissionPayload::Blobs(payloads) = submission.payload() else { + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + let payload = blob_filling_payload_with_frames(3); + let SubmissionPayload::Blobs(payloads) = &payload else { panic!("helper must create blob payloads"); }; let expected_blob_payloads: Vec<_> = payloads .iter() .map(|payload| FrameEncoder::to_calldata(&payload.frames()[0])) .collect(); - pipeline.submissions.push_back(submission); + pipeline.submissions.push_back(payload); + let tx_manager = ScriptedTxManager::confirming_at(10); - let (driver, _handles) = DriverFixture::new( - ctx.clone(), - pipeline, - RecordingConfirmTxManager { l1_block: 10, candidates: Arc::clone(&candidates) }, - ) - .build(); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -1063,24 +884,29 @@ mod tests { assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.dequeued, vec![SubmissionId(0)], "submission must be dequeued"); + assert_eq!(recorded.dequeued(), [SubmissionId(0)], "submission must be dequeued"); assert!( - recorded.requeued.is_empty(), + recorded.requeued().is_empty(), "multi-frame blob submission must not be requeued by blob encoding" ); assert_eq!( - recorded.confirmed, - vec![SubmissionId(0)], + recorded.confirmed(), + [SubmissionId(0)], "multi-frame blob submission should confirm once" ); - let candidates = candidates.lock().unwrap(); + let candidates = tx_manager.candidates(); assert_eq!(candidates.len(), 1, "multi-frame submission should use one L1 tx"); assert!( candidates[0].tx_data.is_empty(), "blob transactions must not also carry calldata" ); + let decoded_blob_payloads: Vec<_> = candidates[0] + .blobs + .iter() + .map(|blob| BlobDecoder::decode(blob).expect("blob payload should decode")) + .collect(); assert_eq!( - candidates[0].decoded_blob_payloads, expected_blob_payloads, + decoded_blob_payloads, expected_blob_payloads, "each frame in the submission must become its own blob payload" ); }); @@ -1091,13 +917,13 @@ mod tests { #[test] fn test_in_flight_limit_holds_back_further_submissions() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - pipeline.submissions.push_back(blob_filling_submission(0)); - pipeline.submissions.push_back(blob_filling_submission(1)); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + pipeline.submissions.push_back(blob_filling_payload()); + pipeline.submissions.push_back(blob_filling_payload()); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::new([])) .max_pending(1) .build(); let handle = ctx.spawn(driver.run()); @@ -1107,9 +933,9 @@ mod tests { assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.dequeued, vec![SubmissionId(0)], "only one tx may be in flight"); - assert!(recorded.requeued.is_empty(), "a held-back submission is not requeued"); - assert!(recorded.l1_heads.is_empty(), "the tx in flight never confirms"); + assert_eq!(recorded.dequeued(), [SubmissionId(0)], "only one tx may be in flight"); + assert!(recorded.requeued().is_empty(), "a held-back submission is not requeued"); + assert!(recorded.l1_heads().is_empty(), "the tx in flight never confirms"); }); } @@ -1118,19 +944,16 @@ mod tests { #[test] fn test_next_blob_tx_submitted_once_the_previous_settles() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - pipeline.submissions.push_back(blob_filling_submission(0)); - pipeline.submissions.push_back(blob_filling_submission(1)); - pipeline.submissions.push_back(blob_filling_submission(2)); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + pipeline.submissions.push_back(blob_filling_payload()); + pipeline.submissions.push_back(blob_filling_payload()); + pipeline.submissions.push_back(blob_filling_payload()); - let (driver, _handles) = DriverFixture::new( - ctx.clone(), - pipeline, - ImmediateConfirmTxManager { l1_block: 7 }, - ) - .max_pending(1) - .build(); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(7)) + .max_pending(1) + .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -1138,8 +961,8 @@ mod tests { assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); assert_eq!( - recorded.lock().unwrap().confirmed, - vec![SubmissionId(0), SubmissionId(1), SubmissionId(2)], + recorded.lock().unwrap().confirmed(), + [SubmissionId(0), SubmissionId(1), SubmissionId(2)], "each queued submission must confirm as the tx before it settles" ); }); @@ -1147,18 +970,18 @@ mod tests { /// `AlreadyReserved` means another transaction owns the sender nonce slot. /// The driver must requeue the submission, mark the txpool blocked, and - /// call `cancel_tx` before accepting more submissions. + /// call `cancel_tx` before accepting more submissions. Once the cancel + /// succeeds, the requeued submission is sent again. #[test] fn test_txpool_blocked_requeues_and_attempts_recovery() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); pipeline.submissions.push_back(SubmissionStub::stub()); + let tx_manager = ScriptedTxManager::new([SendOutcome::TxpoolBlocked]); - let state = Arc::new(TxpoolBlockedState::default()); - let tx_manager = TxpoolBlockedOnceTxManager { state: Arc::clone(&state) }; - - let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, tx_manager).build(); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; @@ -1166,19 +989,19 @@ mod tests { assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); assert_eq!( - recorded.lock().unwrap().requeued, - vec![SubmissionId(0)], + recorded.lock().unwrap().requeued(), + [SubmissionId(0)], "txpool-blocked submissions must be requeued" ); assert_eq!( - state.sends.load(Ordering::SeqCst), + tx_manager.cancellations(), 1, - "driver must stop submitting while txpool is blocked" + "driver must attempt txpool recovery with cancel_tx" ); assert_eq!( - state.cancellations.load(Ordering::SeqCst), - 1, - "driver must attempt txpool recovery with cancel_tx" + tx_manager.candidates().len(), + 2, + "the requeued submission must be sent again once the txpool is unblocked" ); }); } diff --git a/crates/batcher/core/src/test_utils/builder.rs b/crates/batcher/core/src/test_utils/builder.rs index 5c4b92dd99..ede19447a0 100644 --- a/crates/batcher/core/src/test_utils/builder.rs +++ b/crates/batcher/core/src/test_utils/builder.rs @@ -1,10 +1,10 @@ -//! Builder for test [`BatchDriver`] instances, and block and [`BatchSubmission`] stubs. +//! Builder for test [`BatchDriver`] instances, and block and [`SubmissionPayload`] stubs. use std::{sync::Arc, time::Duration}; use alloy_consensus::Header; use alloy_primitives::Address; -use base_batcher_encoder::{BatchPipeline, BatchSubmission, BlobPayload, SubmissionId}; +use base_batcher_encoder::{BatchPipeline, BlobPayload, SubmissionPayload}; use base_batcher_source::{L1HeadSource, UnsafeBlockSource}; use base_common_consensus::BaseBlock; use base_protocol::{BlockInfo, Frame}; @@ -30,31 +30,22 @@ impl BlockStub { } } -/// Factory methods for [`BatchSubmission`] stubs used in driver tests. +/// Factory methods for [`SubmissionPayload`] stubs used in driver tests. #[derive(Debug)] pub struct SubmissionStub; impl SubmissionStub { - /// Returns a stub submission with id `0`. - pub fn stub() -> BatchSubmission { - Self::with_id(0) - } - - /// Returns a stub submission with the given id. - pub fn with_id(id: u64) -> BatchSubmission { - BatchSubmission::blobs( - SubmissionId(id), - vec![BlobPayload::new(vec![Arc::new(Frame::default())])], - ) + /// Returns a one-blob payload holding an empty frame. + pub fn stub() -> SubmissionPayload { + SubmissionPayload::Blobs(vec![BlobPayload::new(vec![Arc::new(Frame::default())])]) } } -/// Builds a [`BatchDriver`] for tests, with a parked source, a parked L1 head source, a -/// disabled throttle and at most one in-flight transaction unless told otherwise. +/// Builds a [`BatchDriver`] for tests. /// -/// The driver starts from L1 head 0 and, unless [`safe_head`](Self::safe_head) says -/// otherwise, from the L2 genesis (block 0) as safe head, so it drops blocks numbered 0 as -/// already safe. +/// 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. /// /// [`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: @@ -78,6 +69,7 @@ pub struct DriverFixture< l1_head_source: L, throttle: DaThrottle, max_pending: usize, + initial_l1_head: u64, safe_head: BlockInfo, } @@ -101,6 +93,7 @@ impl DriverFixture { l1_head_source: PendingL1HeadSource, throttle: DaThrottle::new(ThrottleController::disabled(), Arc::new(NoopThrottleClient)), max_pending: 1, + initial_l1_head: 0, safe_head: BlockInfo::default(), } } @@ -125,6 +118,7 @@ where l1_head_source: self.l1_head_source, throttle: self.throttle, max_pending: self.max_pending, + initial_l1_head: self.initial_l1_head, safe_head: self.safe_head, } } @@ -142,6 +136,7 @@ where l1_head_source, throttle: self.throttle, max_pending: self.max_pending, + initial_l1_head: self.initial_l1_head, safe_head: self.safe_head, } } @@ -159,6 +154,7 @@ where l1_head_source: self.l1_head_source, throttle, max_pending: self.max_pending, + initial_l1_head: self.initial_l1_head, safe_head: self.safe_head, } } @@ -169,6 +165,12 @@ where self } + /// Set the L1 head the driver starts from. + pub const fn initial_l1_head(mut self, l1_head: u64) -> Self { + self.initial_l1_head = l1_head; + 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; @@ -194,7 +196,7 @@ where BatchDriverInputs { source: self.source, l1_head_source: self.l1_head_source, - initial_l1_head: 0, + initial_l1_head: self.initial_l1_head, initial_safe_head: self.safe_head, derivation_status_rx, admin_rx, diff --git a/crates/batcher/core/src/test_utils/mod.rs b/crates/batcher/core/src/test_utils/mod.rs index 631aadb23c..ca56de3acd 100644 --- a/crates/batcher/core/src/test_utils/mod.rs +++ b/crates/batcher/core/src/test_utils/mod.rs @@ -4,16 +4,13 @@ mod throttle; pub use throttle::{ThrottleCallLog, TrackingThrottleClient}; mod pipeline; -pub use pipeline::{PipelineCall, Recorded, ReorgPipeline, TrackingPipeline}; +pub use pipeline::{PipelineCall, Recorded, TrackingPipeline}; mod source; -pub use source::{OneBlockSource, PendingL1HeadSource, PendingSource, TrackingSource}; +pub use source::{PendingL1HeadSource, PendingSource, TrackingSource}; mod builder; pub use builder::{BlockStub, DriverFixture, DriverHandles, SubmissionStub}; mod tx_manager; -pub use tx_manager::{ - ImmediateConfirmTxManager, ImmediateFailTxManager, ManualConfirmTxManager, - NeverConfirmTxManager, -}; +pub use tx_manager::{Script, ScriptedTxManager, SendOutcome}; diff --git a/crates/batcher/core/src/test_utils/pipeline.rs b/crates/batcher/core/src/test_utils/pipeline.rs index 7584a37054..f53be589eb 100644 --- a/crates/batcher/core/src/test_utils/pipeline.rs +++ b/crates/batcher/core/src/test_utils/pipeline.rs @@ -1,95 +1,194 @@ -//! Test [`BatchPipeline`] implementations for action-testing the batch driver. +//! Test [`BatchPipeline`] implementation for the driver tests. //! -//! Hand-rolled rather than mocked: the driver ordering tests need one call log ordered across -//! several trait methods, and the pipelines hold state (queued submissions, steps to encode) -//! that the driver consumes while it runs. +//! Hand-rolled rather than mocked because the driver tests need one call log ordered across +//! several trait methods, and the pipeline holds state (queued submissions, blocks left to +//! encode) that the driver consumes while it runs. -use std::sync::{Arc, Mutex}; +use std::{ + collections::VecDeque, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; use alloy_primitives::B256; use base_batcher_encoder::{ BatchPipeline, BatchSubmission, DerivationReconciliation, ReorgError, StepError, StepResult, - SubmissionId, + SubmissionId, SubmissionPayload, }; use base_common_consensus::BaseBlock; use base_protocol::BlockInfo; -/// A [`BatchPipeline`] call recorded by [`TrackingPipeline`] for the ordering tests. +/// A [`BatchPipeline`] call recorded by [`TrackingPipeline`], with the arguments the tests +/// look at. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PipelineCall { - /// `add_block`. - AddBlock, - /// `confirm`. - Confirm, + /// `add_block`, with the block number. + AddBlock(u64), + /// `step`, recorded only when it encodes a block. + Step, + /// `next_submission` handed out this submission. + Dequeue(SubmissionId), + /// `confirm`, with the submission id and its L1 inclusion block. + Confirm(SubmissionId, u64), + /// `requeue`. + Requeue(SubmissionId), /// `flush`. Flush, /// `advance_l1_head`, recorded only when the head advances. - AdvanceL1Head, - /// `reconcile_derivation`. - ReconcileDerivation, + AdvanceL1Head(u64), + /// `reconcile_derivation`, with the safe L2 block number and the L1 block derivation is + /// processing. + ReconcileDerivation { + /// The safe L2 block number. + safe_l2: u64, + /// The L1 block derivation is processing. + current_l1: u64, + }, + /// `reset`. + Reset, } -/// Shared recording state populated by the test pipeline implementations. +/// The calls a [`TrackingPipeline`] received, in order. #[derive(Debug, Default)] pub struct Recorded { - /// L1 heads the pipeline advanced to, in order. Like [`BatchEncoder`], the pipeline - /// ignores a head that does not advance. - /// - /// [`BatchEncoder`]: base_batcher_encoder::BatchEncoder - pub l1_heads: Vec, - /// Submission IDs passed to `confirm` in order. - pub confirmed: Vec, - /// Submission IDs passed to `requeue` in order. - pub requeued: Vec, - /// Submission IDs dequeued via `next_submission` in order. - pub dequeued: Vec, - /// Number of times `reset()` was called. - pub resets: usize, - /// Safe L2 block numbers passed to `reconcile_derivation` in order. - pub safe_numbers: Vec, - /// Number of times `flush()` was called. - pub flush_count: usize, - /// The calls the ordering tests look at, in call order. + /// Every recorded call, in call order. pub calls: Vec, - /// Number of `step()` calls that encoded a block. - pub encoded_steps: usize, } -/// [`BatchPipeline`] that records every significant method call into a shared [`Recorded`]. +impl Recorded { + /// The block numbers passed to `add_block`, in order. + pub fn added_blocks(&self) -> Vec { + self.pick(|call| match call { + PipelineCall::AddBlock(number) => Some(*number), + _ => None, + }) + } + + /// The submission ids handed out by `next_submission`, in order. + pub fn dequeued(&self) -> Vec { + self.pick(|call| match call { + PipelineCall::Dequeue(id) => Some(*id), + _ => None, + }) + } + + /// The submission ids passed to `confirm`, in order. + pub fn confirmed(&self) -> Vec { + self.pick(|call| match call { + PipelineCall::Confirm(id, _) => Some(*id), + _ => None, + }) + } + + /// The submission ids passed to `requeue`, in order. + pub fn requeued(&self) -> Vec { + self.pick(|call| match call { + PipelineCall::Requeue(id) => Some(*id), + _ => None, + }) + } + + /// The L1 heads the pipeline advanced to, in order. + pub fn l1_heads(&self) -> Vec { + self.pick(|call| match call { + PipelineCall::AdvanceL1Head(l1_block) => Some(*l1_block), + _ => None, + }) + } + + /// The safe L2 block numbers passed to `reconcile_derivation`, in order. + pub fn reconciled(&self) -> Vec { + self.pick(|call| match call { + PipelineCall::ReconcileDerivation { safe_l2, .. } => Some(*safe_l2), + _ => None, + }) + } + + /// The number of `reset` calls. + pub fn resets(&self) -> usize { + self.count(PipelineCall::Reset) + } + + /// The number of `flush` calls. + pub fn flushes(&self) -> usize { + self.count(PipelineCall::Flush) + } + + /// The number of `step` calls that encoded a block. + pub fn encoded_steps(&self) -> usize { + self.count(PipelineCall::Step) + } + + fn pick(&self, pick: impl Fn(&PipelineCall) -> Option) -> Vec { + self.calls.iter().filter_map(pick).collect() + } + + fn count(&self, call: PipelineCall) -> usize { + self.calls.iter().filter(|&&recorded| recorded == call).count() + } +} + +/// [`BatchPipeline`] that records its calls into a shared [`Recorded`] and hands out the +/// payloads queued in [`submissions`](Self::submissions). /// -/// Submissions are pre-loaded by pushing into [`TrackingPipeline::submissions`] before -/// handing the pipeline to the driver. +/// Like the real pipeline, it gives each submission it hands out a fresh id, never reused even +/// across a reset, returns a requeued payload before the queued ones, and ignores the +/// confirmation or requeue of a submission dequeued before a reset. #[derive(Debug)] pub struct TrackingPipeline { - /// Shared recording state. - pub recorded: Arc>, - /// Queue of submissions returned by `next_submission` in FIFO order. - pub submissions: std::collections::VecDeque, - /// Value returned by `da_backlog_bytes`. Default: 0. - da_backlog_bytes_value: u64, - /// Whether derivation reconciliation reports a safe-head mismatch. - safe_head_matches: bool, - /// Whether derivation reconciliation reports a stalled channel. - derivation_stalled: bool, + /// The call log, shared with the test through [`recorded`](Self::recorded). + recorded: Arc>, + /// Payloads returned by `next_submission`, in FIFO order. + pub submissions: VecDeque, + /// Value returned by `da_backlog_bytes`, shared so a test can change it while the driver + /// runs. + pub da_backlog_bytes: Arc, + /// Submissions handed out and neither confirmed nor requeued since. + in_flight: Vec<(SubmissionId, SubmissionPayload)>, + /// The id of the next submission handed out. + next_id: u64, + /// The L1 head the pipeline is at. Like the real encoder, a head that does not advance is + /// ignored. + l1_head: u64, + /// What `reconcile_derivation` answers. + reconciliation: DerivationReconciliation, + /// Whether `add_block` reports a parent mismatch. + add_block_reorgs: bool, /// When set, `flush` records the call then returns this error. flush_error: Option, - /// Blocks left to encode: `step` reports one encoded block per call while above zero. + /// Blocks left to encode. `step` reports one encoded block per call until it reaches zero. encoding_steps: usize, } -impl TrackingPipeline { - /// Create a new pipeline that records into `recorded`. - pub fn new(recorded: Arc>) -> Self { +impl Default for TrackingPipeline { + fn default() -> Self { Self { - recorded, - submissions: Default::default(), - da_backlog_bytes_value: 0, - safe_head_matches: true, - derivation_stalled: false, + recorded: Arc::default(), + submissions: VecDeque::new(), + da_backlog_bytes: Arc::default(), + in_flight: Vec::new(), + next_id: 0, + l1_head: 0, + reconciliation: DerivationReconciliation::Consistent, + add_block_reorgs: false, flush_error: None, encoding_steps: 0, } } +} + +impl TrackingPipeline { + /// Create a pipeline that answers every call as if it were consistent and idle. + pub fn new() -> Self { + Self::default() + } + + /// The shared call log. Take it before handing the pipeline to the driver. + pub fn recorded(&self) -> Arc> { + Arc::clone(&self.recorded) + } /// Make `step` report `steps` encoded blocks before reporting idle. pub const fn with_encoding_steps(mut self, steps: usize) -> Self { @@ -98,20 +197,20 @@ impl TrackingPipeline { } /// Set the value returned by `da_backlog_bytes`. - pub const fn with_da_backlog(mut self, value: u64) -> Self { - self.da_backlog_bytes_value = value; + pub fn with_da_backlog(self, bytes: u64) -> Self { + self.da_backlog_bytes.store(bytes, Ordering::SeqCst); self } - /// Set whether safe-head validation succeeds. - pub const fn with_safe_head_match(mut self, matches: bool) -> Self { - self.safe_head_matches = matches; + /// Set what `reconcile_derivation` answers. + pub const fn with_reconciliation(mut self, reconciliation: DerivationReconciliation) -> Self { + self.reconciliation = reconciliation; self } - /// Set whether derivation-stall detection requests a replay. - pub const fn with_derivation_stalled(mut self, stalled: bool) -> Self { - self.derivation_stalled = stalled; + /// Make `add_block` report a parent mismatch. + pub const fn with_add_block_reorg(mut self) -> Self { + self.add_block_reorgs = true; self } @@ -120,11 +219,29 @@ impl TrackingPipeline { self.flush_error = Some(error); self } + + fn record(&self, call: PipelineCall) { + self.recorded.lock().unwrap().calls.push(call); + } + + /// A submission carrying a copy of `payload`, which is not `Clone`. The copy shares its + /// frames, held behind `Arc`s. + fn submission(id: SubmissionId, payload: &SubmissionPayload) -> BatchSubmission { + match payload { + SubmissionPayload::Blobs(payloads) => BatchSubmission::blobs(id, payloads.clone()), + SubmissionPayload::Calldata(frame) => BatchSubmission::calldata(id, Arc::clone(frame)), + } + } } impl BatchPipeline for TrackingPipeline { - fn add_block(&mut self, _: BaseBlock) -> Result<(), (ReorgError, Box)> { - self.recorded.lock().unwrap().calls.push(PipelineCall::AddBlock); + fn add_block(&mut self, block: BaseBlock) -> Result<(), (ReorgError, Box)> { + self.record(PipelineCall::AddBlock(block.header.number)); + if self.add_block_reorgs { + let error = + ReorgError::ParentMismatch { expected: B256::ZERO, got: B256::with_last_byte(1) }; + return Err((error, Box::new(block))); + } Ok(()) } @@ -133,118 +250,61 @@ impl BatchPipeline for TrackingPipeline { return Ok(StepResult::Idle); } self.encoding_steps -= 1; - self.recorded.lock().unwrap().encoded_steps += 1; + self.record(PipelineCall::Step); Ok(StepResult::BlockEncoded) } fn next_submission(&mut self) -> Option { - let sub = self.submissions.pop_front()?; - self.recorded.lock().unwrap().dequeued.push(sub.id); - Some(sub) + let payload = self.submissions.pop_front()?; + let id = SubmissionId(self.next_id); + self.next_id += 1; + self.record(PipelineCall::Dequeue(id)); + let submission = Self::submission(id, &payload); + self.in_flight.push((id, payload)); + Some(submission) } - fn confirm(&mut self, id: SubmissionId, _: u64) { - let mut recorded = self.recorded.lock().unwrap(); - recorded.confirmed.push(id); - recorded.calls.push(PipelineCall::Confirm); + fn confirm(&mut self, id: SubmissionId, l1_block: u64) { + self.record(PipelineCall::Confirm(id, l1_block)); + self.in_flight.retain(|(in_flight, _)| *in_flight != id); } fn requeue(&mut self, id: SubmissionId) { - self.recorded.lock().unwrap().requeued.push(id); + self.record(PipelineCall::Requeue(id)); + if let Some(index) = self.in_flight.iter().position(|(in_flight, _)| *in_flight == id) { + self.submissions.push_front(self.in_flight.remove(index).1); + } } fn flush(&mut self) -> Result<(), StepError> { - let mut recorded = self.recorded.lock().unwrap(); - recorded.flush_count += 1; - recorded.calls.push(PipelineCall::Flush); - drop(recorded); - if let Some(error) = self.flush_error.take() { - return Err(error); - } - Ok(()) + self.record(PipelineCall::Flush); + self.flush_error.take().map_or(Ok(()), Err) } fn advance_l1_head(&mut self, l1_block: u64) { - let mut recorded = self.recorded.lock().unwrap(); - if l1_block > recorded.l1_heads.last().copied().unwrap_or_default() { - recorded.l1_heads.push(l1_block); - recorded.calls.push(PipelineCall::AdvanceL1Head); + if l1_block > self.l1_head { + self.l1_head = l1_block; + self.record(PipelineCall::AdvanceL1Head(l1_block)); } } - fn reconcile_derivation(&mut self, safe_l2: BlockInfo, _: u64) -> DerivationReconciliation { - let mut recorded = self.recorded.lock().unwrap(); - recorded.safe_numbers.push(safe_l2.number); - recorded.calls.push(PipelineCall::ReconcileDerivation); - drop(recorded); - if !self.safe_head_matches { - return DerivationReconciliation::SafeHeadMismatch; - } - if self.derivation_stalled { - return DerivationReconciliation::StalledChannel; - } - DerivationReconciliation::Consistent + fn reconcile_derivation( + &mut self, + safe_l2: BlockInfo, + current_l1: u64, + ) -> DerivationReconciliation { + self.record(PipelineCall::ReconcileDerivation { safe_l2: safe_l2.number, current_l1 }); + self.reconciliation } fn reset(&mut self) { + self.record(PipelineCall::Reset); self.submissions.clear(); + self.in_flight.clear(); self.encoding_steps = 0; - self.recorded.lock().unwrap().resets += 1; - } - - fn da_backlog_bytes(&self) -> u64 { - self.da_backlog_bytes_value - } -} - -/// [`BatchPipeline`] that always returns [`ReorgError`] from `add_block`. -/// -/// Used to verify the driver's reorg-on-add path: it must reset the pipeline -/// rather than propagating a fatal error. -#[derive(Debug)] -pub struct ReorgPipeline { - /// Shared recording state (only `resets` is incremented). - pub recorded: Arc>, -} - -impl ReorgPipeline { - /// Create a new pipeline that records resets into `recorded`. - pub const fn new(recorded: Arc>) -> Self { - Self { recorded } - } -} - -impl BatchPipeline for ReorgPipeline { - fn add_block(&mut self, block: BaseBlock) -> Result<(), (ReorgError, Box)> { - Err(( - ReorgError::ParentMismatch { expected: B256::ZERO, got: B256::with_last_byte(1) }, - Box::new(block), - )) - } - - fn step(&mut self) -> Result { - Ok(StepResult::Idle) - } - - fn next_submission(&mut self) -> Option { - None - } - - fn confirm(&mut self, _: SubmissionId, _: u64) {} - fn requeue(&mut self, _: SubmissionId) {} - fn flush(&mut self) -> Result<(), StepError> { - Ok(()) - } - fn advance_l1_head(&mut self, _: u64) {} - fn reconcile_derivation(&mut self, _: BlockInfo, _: u64) -> DerivationReconciliation { - DerivationReconciliation::Consistent - } - - fn reset(&mut self) { - self.recorded.lock().unwrap().resets += 1; } fn da_backlog_bytes(&self) -> u64 { - 0 + self.da_backlog_bytes.load(Ordering::SeqCst) } } diff --git a/crates/batcher/core/src/test_utils/source.rs b/crates/batcher/core/src/test_utils/source.rs index 1efc2bf564..da00e9fad1 100644 --- a/crates/batcher/core/src/test_utils/source.rs +++ b/crates/batcher/core/src/test_utils/source.rs @@ -1,4 +1,7 @@ //! Test [`UnsafeBlockSource`] and [`L1HeadSource`] implementations. +//! +//! Hand-rolled rather than mocked because `next` parks forever, which `mockall` expectations +//! cannot express. use std::sync::{Arc, Mutex}; @@ -6,8 +9,6 @@ use async_trait::async_trait; use base_batcher_source::{L1HeadSource, L2BlockEvent, UnsafeBlockSource}; use base_protocol::BlockInfo; -use crate::test_utils::BlockStub; - /// [`UnsafeBlockSource`] that parks the select arm forever. /// /// Use this in tests that do not exercise the block-delivery path, so that @@ -48,37 +49,6 @@ impl UnsafeBlockSource for TrackingSource { } } -/// [`UnsafeBlockSource`] that delivers exactly one block, numbered 1, then parks forever. -#[derive(Debug)] -pub struct OneBlockSource { - delivered: bool, -} - -impl OneBlockSource { - /// Create a new source that has not yet delivered its block. - pub const fn new() -> Self { - Self { delivered: false } - } -} - -impl Default for OneBlockSource { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl UnsafeBlockSource for OneBlockSource { - async fn next(&mut self) -> L2BlockEvent { - if !self.delivered { - self.delivered = true; - L2BlockEvent::Block(Box::new(BlockStub::with_number(1))) - } else { - std::future::pending().await - } - } -} - /// [`L1HeadSource`] that parks the select arm forever. /// /// Use this as the default L1 head source in driver tests that do not exercise diff --git a/crates/batcher/core/src/test_utils/tx_manager.rs b/crates/batcher/core/src/test_utils/tx_manager.rs index 0e9823e78f..505733bf26 100644 --- a/crates/batcher/core/src/test_utils/tx_manager.rs +++ b/crates/batcher/core/src/test_utils/tx_manager.rs @@ -1,4 +1,7 @@ -//! Test [`TxManager`] implementations for controlling submission outcomes in driver tests. +//! Test [`TxManager`] implementation for the driver tests. +//! +//! Hand-rolled rather than mocked because `send_async` returns a [`SendHandle`] that a test +//! settles while the driver runs, which `mockall` expectations cannot express. use std::{ collections::VecDeque, @@ -8,130 +11,139 @@ use std::{ use alloy_consensus::{Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom}; use alloy_primitives::{Address, B256, Bloom}; use alloy_rpc_types_eth::TransactionReceipt; -use base_tx_manager::{SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError}; +use base_tx_manager::{ + SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError, TxManagerResult, +}; use tokio::sync::oneshot; -const fn stub_receipt(block_number: u64) -> TransactionReceipt { - let inner = ReceiptEnvelope::Legacy(ReceiptWithBloom { - receipt: Receipt { - status: Eip658Value::Eip658(true), - cumulative_gas_used: 21_000, - logs: vec![], - }, - logs_bloom: Bloom::ZERO, - }); - TransactionReceipt { - inner, - transaction_hash: B256::ZERO, - transaction_index: Some(0), - block_hash: Some(B256::ZERO), - block_number: Some(block_number), - gas_used: 21_000, - effective_gas_price: 1_000_000_000, - blob_gas_used: None, - blob_gas_price: None, - from: Address::ZERO, - to: Some(Address::ZERO), - contract_address: None, - } +/// What [`ScriptedTxManager`] does with a sent transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SendOutcome { + /// The transaction is confirmed right away, at this L1 block. + Confirmed(u64), + /// The send fails right away. + Failed, + /// The send fails right away because another transaction holds the nonce slot. + TxpoolBlocked, + /// The transaction stays in flight until [`ScriptedTxManager::confirm_next`] settles it. + #[default] + Pending, } -/// [`TxManager`] that immediately confirms every submission at a fixed L1 block number. -#[derive(Debug)] -pub struct ImmediateConfirmTxManager { - /// L1 block number reported in every confirmed receipt. - pub l1_block: u64, +/// The shared state of a [`ScriptedTxManager`]. +#[derive(Debug, Default)] +pub struct Script { + /// Outcomes of the next sends, in order. + outcomes: VecDeque, + /// Outcome of every send once `outcomes` is used up. + fallback: SendOutcome, + /// The transactions left pending, oldest first. + pending: VecDeque>, + /// Every candidate sent, in order. + candidates: Vec, + /// Number of `cancel_tx` calls. + cancellations: usize, } -impl TxManager for ImmediateConfirmTxManager { - async fn send(&self, _: TxCandidate) -> SendResponse { - unreachable!() - } - - fn send_async(&self, _: TxCandidate) -> impl std::future::Future + Send { - let (tx, rx) = oneshot::channel(); - let _ = tx.send(Ok(stub_receipt(self.l1_block))); - std::future::ready(SendHandle::new(rx)) - } - - fn sender_address(&self) -> Address { - Address::ZERO - } +/// [`TxManager`] that applies a scripted [`SendOutcome`] to each send, and records every +/// candidate. +/// +/// Clones share their state, so a test keeps one and hands the other to the driver. +#[derive(Debug, Clone)] +pub struct ScriptedTxManager { + script: Arc>, } -/// [`TxManager`] that immediately fails every submission with [`TxManagerError::ChannelClosed`]. -#[derive(Debug)] -pub struct ImmediateFailTxManager; - -impl TxManager for ImmediateFailTxManager { - async fn send(&self, _: TxCandidate) -> SendResponse { - unreachable!() +impl ScriptedTxManager { + /// Apply `outcomes` to the first sends, in order, then leave every later send pending. + pub fn new(outcomes: impl IntoIterator) -> Self { + let script = Script { outcomes: outcomes.into_iter().collect(), ..Default::default() }; + Self { script: Arc::new(Mutex::new(script)) } } - fn send_async(&self, _: TxCandidate) -> impl std::future::Future + Send { - let (tx, rx) = oneshot::channel(); - let _ = tx.send(Err(TxManagerError::ChannelClosed)); - std::future::ready(SendHandle::new(rx)) + /// Confirm every send right away, at `l1_block`. + pub fn confirming_at(l1_block: u64) -> Self { + let script = Script { fallback: SendOutcome::Confirmed(l1_block), ..Default::default() }; + Self { script: Arc::new(Mutex::new(script)) } } - fn sender_address(&self) -> Address { - Address::ZERO + /// Confirm the oldest pending transaction at `l1_block`. + pub fn confirm_next(&self, l1_block: u64) { + let tx = self.script.lock().unwrap().pending.pop_front().expect("a transaction is pending"); + let _ = tx.send(Ok(Self::stub_receipt(l1_block))); } -} -/// [`TxManager`] that never confirms any submission — the in-flight future parks forever. -/// -/// Used to test the in-flight limit: a transaction sent never settles, so once the limit -/// is reached no further submissions are dequeued. -#[derive(Debug)] -pub struct NeverConfirmTxManager; - -impl TxManager for NeverConfirmTxManager { - async fn send(&self, _: TxCandidate) -> SendResponse { - unreachable!() + /// The candidates sent so far, in order. + pub fn candidates(&self) -> Vec { + self.script.lock().unwrap().candidates.clone() } - fn send_async(&self, _: TxCandidate) -> impl std::future::Future + Send { - let (tx, rx) = oneshot::channel(); - // Keep tx alive by forgetting it — rx parks forever without a result. - std::mem::forget(tx); - std::future::ready(SendHandle::new(rx)) + /// The number of `cancel_tx` calls so far. + pub fn cancellations(&self) -> usize { + self.script.lock().unwrap().cancellations } - fn sender_address(&self) -> Address { - Address::ZERO + /// A successful receipt for a transaction included in `block_number`. + const fn stub_receipt(block_number: u64) -> TransactionReceipt { + let inner = ReceiptEnvelope::Legacy(ReceiptWithBloom { + receipt: Receipt { + status: Eip658Value::Eip658(true), + cumulative_gas_used: 21_000, + logs: vec![], + }, + logs_bloom: Bloom::ZERO, + }); + TransactionReceipt { + inner, + transaction_hash: B256::ZERO, + transaction_index: Some(0), + block_hash: Some(B256::ZERO), + block_number: Some(block_number), + gas_used: 21_000, + effective_gas_price: 1_000_000_000, + blob_gas_used: None, + blob_gas_price: None, + from: Address::ZERO, + to: Some(Address::ZERO), + contract_address: None, + } } } -/// [`TxManager`] whose submissions stay in flight until the test confirms them. -/// -/// Hand-rolled because tests settle submissions while they are in flight, which `mockall` -/// expectations cannot express. Clones share the same in-flight queue: keep one in the test and -/// hand the other to the driver. -#[derive(Debug, Clone, Default)] -pub struct ManualConfirmTxManager { - in_flight: Arc>>>, -} - -impl ManualConfirmTxManager { - /// Confirm the oldest in-flight submission at `l1_block`. - pub fn confirm_next(&self, l1_block: u64) { - let tx = self.in_flight.lock().unwrap().pop_front().expect("a submission is in flight"); - let _ = tx.send(Ok(stub_receipt(l1_block))); +impl TxManager for ScriptedTxManager { + async fn send(&self, candidate: TxCandidate) -> SendResponse { + self.send_async(candidate).await.await } -} -impl TxManager for ManualConfirmTxManager { - async fn send(&self, _: TxCandidate) -> SendResponse { - unreachable!() - } + fn send_async( + &self, + candidate: TxCandidate, + ) -> impl std::future::Future + Send { + let mut script = self.script.lock().unwrap(); + script.candidates.push(candidate); + let outcome = script.outcomes.pop_front().unwrap_or(script.fallback); - fn send_async(&self, _: TxCandidate) -> impl std::future::Future + Send { let (tx, rx) = oneshot::channel(); - self.in_flight.lock().unwrap().push_back(tx); + match outcome { + SendOutcome::Confirmed(l1_block) => { + let _ = tx.send(Ok(Self::stub_receipt(l1_block))); + } + SendOutcome::Failed => { + let _ = tx.send(Err(TxManagerError::ChannelClosed)); + } + SendOutcome::TxpoolBlocked => { + let _ = tx.send(Err(TxManagerError::AlreadyReserved)); + } + SendOutcome::Pending => script.pending.push_back(tx), + } std::future::ready(SendHandle::new(rx)) } + fn cancel_tx(&self) -> impl std::future::Future> + Send { + self.script.lock().unwrap().cancellations += 1; + std::future::ready(Ok(())) + } + fn sender_address(&self) -> Address { Address::ZERO } diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index 1d8f0b6ac2..a5ecb2e9d2 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -1,17 +1,15 @@ //! Integration tests for L1 and safe L2 head handling in [`BatchDriver`]. -use std::{ - sync::{Arc, Mutex}, - time::Duration, -}; +use std::time::Duration; use alloy_primitives::B256; use base_batcher_core::{ BatchDriverError, DerivationStatus, test_utils::{ - DriverFixture, ImmediateConfirmTxManager, Recorded, TrackingPipeline, TrackingSource, + DriverFixture, PipelineCall, ScriptedTxManager, TrackingPipeline, TrackingSource, }, }; +use base_batcher_encoder::DerivationReconciliation; use base_batcher_source::test_utils::ChannelL1HeadSource; use base_protocol::BlockInfo; use base_runtime::{ @@ -28,13 +26,13 @@ fn safe_head(number: u64) -> BlockInfo { #[test] fn test_l1_head_source_advances_pipeline() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); let (l1_source, l1_tx) = ChannelL1HeadSource::new(); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .l1_head_source(l1_source) .build(); let handle = ctx.spawn(driver.run()); @@ -45,24 +43,20 @@ fn test_l1_head_source_advances_pipeline() { ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let r = recorded.lock().unwrap(); - assert!( - r.l1_heads.contains(&42), - "advance_l1_head must be called with the source value, got {:?}", - r.l1_heads - ); + assert_eq!(recorded.lock().unwrap().l1_heads(), [42]); }); } #[test] fn test_safe_head_conflicts_reset_pipeline_and_source() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_safe_head_match(false); + let pipeline = + TrackingPipeline::new().with_reconciliation(DerivationReconciliation::SafeHeadMismatch); + let recorded = pipeline.recorded(); let (source, catchup_heads) = TrackingSource::new(); let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) .safe_head(safe_head(10)) .build(); @@ -80,22 +74,23 @@ fn test_safe_head_conflicts_reset_pipeline_and_source() { assert!(handle.await.unwrap().is_ok()); let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.resets, 3); - assert_eq!(recorded.safe_numbers, vec![10]); - assert_eq!(*catchup_heads.lock().unwrap(), vec![regressed, replacement, safe_head(10)]); + assert_eq!(recorded.resets(), 3); + assert_eq!(recorded.reconciled(), [10]); + assert_eq!(*catchup_heads.lock().unwrap(), [regressed, replacement, safe_head(10)]); }); } #[test] fn test_derivation_cursor_advance_replays_stalled_channel() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_derivation_stalled(true); + let pipeline = + TrackingPipeline::new().with_reconciliation(DerivationReconciliation::StalledChannel); + let recorded = pipeline.recorded(); let (source, catchup_heads) = TrackingSource::new(); let safe_l2 = safe_head(10); let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) .safe_head(safe_l2) .build(); @@ -107,20 +102,25 @@ fn test_derivation_cursor_advance_replays_stalled_channel() { ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.safe_numbers, vec![safe_l2.number]); - assert_eq!(recorded.resets, 1); - assert_eq!(*catchup_heads.lock().unwrap(), vec![safe_l2]); + // Reconciliation runs once, the reset follows, and the shutdown flush ends the log. + assert_eq!( + recorded.lock().unwrap().calls, + [ + PipelineCall::ReconcileDerivation { safe_l2: safe_l2.number, current_l1: 50 }, + PipelineCall::Reset, + PipelineCall::Flush, + ] + ); + assert_eq!(*catchup_heads.lock().unwrap(), [safe_l2]); }); } #[test] fn test_derivation_status_sender_drop_is_fatal() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); let (driver, handles) = - DriverFixture::new(ctx, pipeline, ImmediateConfirmTxManager { l1_block: 1 }).build(); + DriverFixture::new(ctx, TrackingPipeline::new(), ScriptedTxManager::confirming_at(1)) + .build(); drop(handles); assert!(matches!(driver.run().await, Err(BatchDriverError::DerivationStatusSourceClosed))); @@ -130,10 +130,12 @@ fn test_derivation_status_sender_drop_is_fatal() { #[test] fn test_derivation_status_sender_drop_during_shutdown_is_clean() { Runner::start(Config::seeded(0), |ctx| async move { - let pipeline = TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))); - let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .build(); + let (driver, handles) = DriverFixture::new( + ctx.clone(), + TrackingPipeline::new(), + ScriptedTxManager::confirming_at(1), + ) + .build(); ctx.cancel(); drop(handles); diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index 7463485478..053de5972c 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -9,10 +9,7 @@ use std::{ use async_trait::async_trait; use base_batcher_core::{ BatchDriverError, - test_utils::{ - DriverFixture, ManualConfirmTxManager, NeverConfirmTxManager, Recorded, SubmissionStub, - TrackingPipeline, - }, + test_utils::{DriverFixture, Recorded, ScriptedTxManager, SubmissionStub, TrackingPipeline}, }; use base_batcher_encoder::{ChannelLimit, StepError, SubmissionId}; use base_batcher_source::{L2BlockEvent, UnsafeBlockSource}; @@ -21,18 +18,17 @@ use base_runtime::{ deterministic::{Config, Runner}, }; -/// When cancellation fires while a submission is in-flight with a -/// `NeverConfirmTxManager`, the drain timeout must fire and the driver must -/// exit cleanly. +/// When cancellation fires while a submission is in flight and never settles, the drain +/// timeout must fire and the driver must exit cleanly. #[test] fn test_drain_timeout_exits_with_in_flight_submissions() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); pipeline.submissions.push_back(SubmissionStub::stub()); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::new([])).build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(20)).await; @@ -43,9 +39,9 @@ fn test_drain_timeout_exits_with_in_flight_submissions() { result.is_ok(), "driver must exit after drain timeout even with in-flight submissions" ); - let r = recorded.lock().unwrap(); - assert_eq!(r.dequeued, vec![SubmissionId(0)], "submission must have been dequeued"); - assert_eq!(r.flush_count, 1, "flush must be called on shutdown"); + let recorded = recorded.lock().unwrap(); + assert_eq!(recorded.dequeued(), [SubmissionId(0)], "submission must have been dequeued"); + assert_eq!(recorded.flushes(), 1, "flush must be called on shutdown"); }); } @@ -53,17 +49,16 @@ fn test_drain_timeout_exits_with_in_flight_submissions() { #[test] fn test_shutdown_drains_in_flight_before_returning_flush_error() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_flush_error( - StepError::BlockExceedsChannelLimit { + let mut pipeline = + TrackingPipeline::new().with_flush_error(StepError::BlockExceedsChannelLimit { cursor: 0, limit: ChannelLimit::RlpBytes { required: 1, maximum: 0 }, - }, - ); + }); + let recorded = pipeline.recorded(); pipeline.submissions.push_back(SubmissionStub::stub()); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, NeverConfirmTxManager).build(); + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::new([])).build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(20)).await; @@ -79,9 +74,9 @@ fn test_shutdown_drains_in_flight_before_returning_flush_error() { ctx.now().saturating_sub(cancelled_at) >= Duration::from_millis(10), "in-flight receipts must be drained before the flush error is returned" ); - let r = recorded.lock().unwrap(); - assert_eq!(r.dequeued, vec![SubmissionId(0)]); - assert_eq!(r.flush_count, 1); + let recorded = recorded.lock().unwrap(); + assert_eq!(recorded.dequeued(), [SubmissionId(0)]); + assert_eq!(recorded.flushes(), 1); }); } @@ -98,7 +93,7 @@ struct PollRecorder { #[async_trait] impl UnsafeBlockSource for PollRecorder { async fn next(&mut self) -> L2BlockEvent { - let dequeued = self.recorded.lock().unwrap().dequeued.len(); + let dequeued = self.recorded.lock().unwrap().dequeued().len(); self.dequeued_at_poll.lock().unwrap().push(dequeued); std::future::pending().await } @@ -110,16 +105,13 @@ impl UnsafeBlockSource for PollRecorder { #[test] fn test_driver_finishes_pending_work_before_waiting_for_events() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); - pipeline.submissions.push_back(SubmissionStub::with_id(0)); - pipeline.submissions.push_back(SubmissionStub::with_id(1)); - let tx_manager = ManualConfirmTxManager::default(); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + pipeline.submissions.push_back(SubmissionStub::stub()); + pipeline.submissions.push_back(SubmissionStub::stub()); + let tx_manager = ScriptedTxManager::new([]); let dequeued_at_poll = Arc::new(Mutex::new(Vec::new())); - let source = PollRecorder { - recorded: Arc::clone(&recorded), - dequeued_at_poll: Arc::clone(&dequeued_at_poll), - }; + let source = PollRecorder { recorded, dequeued_at_poll: Arc::clone(&dequeued_at_poll) }; // One tx in flight at most: the second submission can only leave the pipeline once the // receipt of the first one has been processed. @@ -137,7 +129,7 @@ fn test_driver_finishes_pending_work_before_waiting_for_events() { assert!(handle.await.unwrap().is_ok()); assert_eq!( *dequeued_at_poll.lock().unwrap(), - vec![1, 2], + [1, 2], "the submission released by the receipt must be sent before the driver waits again" ); }); diff --git a/crates/batcher/core/tests/reorg.rs b/crates/batcher/core/tests/reorg.rs index 8b058ada35..183febe246 100644 --- a/crates/batcher/core/tests/reorg.rs +++ b/crates/batcher/core/tests/reorg.rs @@ -1,13 +1,9 @@ //! Integration tests for reorg handling in [`BatchDriver`]. -use std::{ - sync::{Arc, Mutex}, - time::Duration, -}; +use std::time::Duration; use base_batcher_core::test_utils::{ - DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, OneBlockSource, Recorded, - ReorgPipeline, SubmissionStub, TrackingPipeline, + BlockStub, DriverFixture, ScriptedTxManager, SubmissionStub, TrackingPipeline, }; use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; use base_runtime::{ @@ -21,22 +17,24 @@ use base_runtime::{ #[test] fn test_add_block_reorg_resets_pipeline_instead_of_fatal_error() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = ReorgPipeline::new(Arc::clone(&recorded)); + let pipeline = TrackingPipeline::new().with_add_block_reorg(); + let recorded = pipeline.recorded(); + let (source, source_tx) = ChannelBlockSource::new(); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .source(OneBlockSource::new()) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) .build(); let handle = ctx.spawn(driver.run()); + source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); let result = handle.await.unwrap(); assert!(result.is_ok(), "driver must not return a fatal error on add_block reorg"); assert_eq!( - recorded.lock().unwrap().resets, + recorded.lock().unwrap().resets(), 1, "pipeline.reset() must be called when add_block returns ReorgError" ); @@ -49,12 +47,12 @@ fn test_add_block_reorg_resets_pipeline_instead_of_fatal_error() { #[test] fn test_l2_reorg_event_resets_pipeline() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); let (source, source_tx) = ChannelBlockSource::new(); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) .build(); let handle = ctx.spawn(driver.run()); @@ -65,7 +63,7 @@ fn test_l2_reorg_event_resets_pipeline() { assert!(handle.await.unwrap().is_ok()); assert_eq!( - recorded.lock().unwrap().resets, + recorded.lock().unwrap().resets(), 1, "pipeline must be reset when source delivers a Reorg event" ); @@ -77,10 +75,10 @@ fn test_l2_reorg_event_resets_pipeline() { #[test] fn test_reorg_keeps_tracking_in_flight_submissions() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let mut pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); pipeline.submissions.push_back(SubmissionStub::stub()); - let tx_manager = ManualConfirmTxManager::default(); + let tx_manager = ScriptedTxManager::new([]); let (source, source_tx) = ChannelBlockSource::new(); let (driver, handles) = DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).source(source).build(); @@ -91,7 +89,7 @@ fn test_reorg_keeps_tracking_in_flight_submissions() { source_tx.send(L2BlockEvent::Reorg).unwrap(); ctx.sleep(Duration::from_millis(10)).await; - assert_eq!(recorded.lock().unwrap().resets, 1); + assert_eq!(recorded.lock().unwrap().resets(), 1); assert_eq!( handles.admin.get_status().await.unwrap().in_flight, 1, @@ -106,7 +104,7 @@ fn test_reorg_keeps_tracking_in_flight_submissions() { 0, "the receipt must settle the submission after the reset" ); - assert_eq!(recorded.lock().unwrap().l1_heads, vec![7]); + assert_eq!(recorded.lock().unwrap().l1_heads(), [7]); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); diff --git a/crates/batcher/core/tests/stop_start.rs b/crates/batcher/core/tests/stop_start.rs index 11c388fed8..e0a755415f 100644 --- a/crates/batcher/core/tests/stop_start.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -1,15 +1,12 @@ //! Integration tests for the stop, start and flush admin commands in [`BatchDriver`]. -use std::{ - sync::{Arc, Mutex}, - time::Duration, -}; +use std::time::Duration; use base_batcher_core::{ AdminError, test_utils::{ - BlockStub, DriverFixture, ImmediateConfirmTxManager, ManualConfirmTxManager, PipelineCall, - Recorded, SubmissionStub, TrackingPipeline, TrackingSource, + BlockStub, DriverFixture, ScriptedTxManager, SubmissionStub, TrackingPipeline, + TrackingSource, }, }; use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; @@ -24,11 +21,10 @@ use base_runtime::{ #[test] fn test_stop_resets_pipeline() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .build(); + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)).build(); let handle = ctx.spawn(driver.run()); handles.admin.stop().await.unwrap(); @@ -37,7 +33,7 @@ fn test_stop_resets_pipeline() { assert!(handle.await.unwrap().is_ok()); assert_eq!( - recorded.lock().unwrap().resets, + recorded.lock().unwrap().resets(), 1, "pipeline must be reset exactly once when stopped" ); @@ -51,13 +47,13 @@ fn test_stop_resets_pipeline() { #[test] fn test_start_triggers_catchup_from_safe_head() { Runner::start(Config::seeded(0), |ctx| async move { - let (source, catchup_args) = TrackingSource::new(); + let (source, catchup_heads) = TrackingSource::new(); let safe_head = BlockInfo { number: 42, ..Default::default() }; let (driver, handles) = DriverFixture::new( ctx.clone(), - TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))), - ImmediateConfirmTxManager { l1_block: 1 }, + TrackingPipeline::new(), + ScriptedTxManager::confirming_at(1), ) .source(source) .safe_head(safe_head) @@ -72,8 +68,8 @@ fn test_start_triggers_catchup_from_safe_head() { assert!(handle.await.unwrap().is_ok()); assert_eq!( - *catchup_args.lock().unwrap(), - vec![safe_head], + *catchup_heads.lock().unwrap(), + [safe_head], "source must be reanchored at the safe head on start" ); }); @@ -84,31 +80,33 @@ fn test_start_triggers_catchup_from_safe_head() { #[test] fn test_stopped_leaves_the_source_unread() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); let (source, source_tx) = ChannelBlockSource::new(); - let (driver, handles) = DriverFixture::new( - ctx.clone(), - TrackingPipeline::new(Arc::clone(&recorded)), - ImmediateConfirmTxManager { l1_block: 1 }, - ) - .source(source) - .build(); + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) + .build(); let handle = ctx.spawn(driver.run()); - let blocks_added = || { - recorded.lock().unwrap().calls.iter().filter(|&&c| c == PipelineCall::AddBlock).count() - }; // Stop, then send a block: it stays in the source. handles.admin.stop().await.unwrap(); source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); ctx.sleep(Duration::from_millis(10)).await; - assert_eq!(blocks_added(), 0, "a stopped batcher must not ingest blocks"); + assert!( + recorded.lock().unwrap().added_blocks().is_empty(), + "a stopped batcher must not ingest blocks" + ); // Start: the queued block and the next one reach the pipeline. handles.admin.start().await.unwrap(); source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(2)))).unwrap(); ctx.sleep(Duration::from_millis(10)).await; - assert_eq!(blocks_added(), 2, "a started batcher must ingest the queued blocks"); + assert_eq!( + recorded.lock().unwrap().added_blocks(), + [1, 2], + "a started batcher must ingest the queued blocks" + ); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); @@ -120,9 +118,9 @@ fn test_stopped_leaves_the_source_unread() { #[test] fn test_stop_leaves_in_flight_submissions_to_settle() { Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(Arc::new(Mutex::new(Recorded::default()))); + let mut pipeline = TrackingPipeline::new(); pipeline.submissions.push_back(SubmissionStub::stub()); - let tx_manager = ManualConfirmTxManager::default(); + let tx_manager = ScriptedTxManager::new([]); let (driver, handles) = DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).build(); let handle = ctx.spawn(driver.run()); @@ -147,16 +145,15 @@ fn test_stop_leaves_in_flight_submissions_to_settle() { #[test] fn test_flush_closes_the_channel_on_a_running_batcher() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .build(); + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)).build(); let handle = ctx.spawn(driver.run()); handles.admin.flush().await.unwrap(); - assert_eq!(recorded.lock().unwrap().flush_count, 1); + assert_eq!(recorded.lock().unwrap().flushes(), 1); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); @@ -167,18 +164,17 @@ fn test_flush_closes_the_channel_on_a_running_batcher() { #[test] fn test_flush_is_rejected_while_stopped() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) - .build(); + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)).build(); let handle = ctx.spawn(driver.run()); handles.admin.stop().await.unwrap(); let result = handles.admin.flush().await; assert!(matches!(result, Err(AdminError::Stopped))); - assert_eq!(recorded.lock().unwrap().flush_count, 0); + assert_eq!(recorded.lock().unwrap().flushes(), 0); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); diff --git a/crates/batcher/core/tests/throttle.rs b/crates/batcher/core/tests/throttle.rs index 8a879b4a74..67a796ae49 100644 --- a/crates/batcher/core/tests/throttle.rs +++ b/crates/batcher/core/tests/throttle.rs @@ -1,24 +1,17 @@ //! Integration tests for DA throttle behaviour in [`BatchDriver`]. use std::{ - sync::{Arc, Mutex}, + sync::{Arc, atomic::Ordering}, time::Duration, }; use base_batcher_core::{ DaThrottle, ThrottleConfig, ThrottleController, ThrottleStrategy, test_utils::{ - BlockStub, DriverFixture, ImmediateConfirmTxManager, Recorded, TrackingPipeline, - TrackingThrottleClient, + BlockStub, DriverFixture, ScriptedTxManager, TrackingPipeline, TrackingThrottleClient, }, }; -use base_batcher_encoder::{ - BatchPipeline, BatchSubmission, DerivationReconciliation, ReorgError, StepError, StepResult, - SubmissionId, -}; use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; -use base_common_consensus::BaseBlock; -use base_protocol::BlockInfo; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, @@ -29,15 +22,14 @@ use base_runtime::{ #[test] fn test_throttle_client_called_on_high_backlog() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); // 2 MB backlog — above the default 1 MB threshold. - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_da_backlog(2_000_000); + let pipeline = TrackingPipeline::new().with_da_backlog(2_000_000); let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) .build(); let handle = ctx.spawn(driver.run()); @@ -65,14 +57,13 @@ fn test_throttle_client_called_on_high_backlog() { #[test] fn test_throttle_client_called_with_upper_limits_on_zero_backlog() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_da_backlog(0); + let pipeline = TrackingPipeline::new().with_da_backlog(0); let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) .build(); let handle = ctx.spawn(driver.run()); @@ -100,14 +91,13 @@ fn test_throttle_client_called_with_upper_limits_on_zero_backlog() { #[test] fn test_throttle_not_called_redundantly() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_da_backlog(0); + let pipeline = TrackingPipeline::new().with_da_backlog(0); let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) .build(); let handle = ctx.spawn(driver.run()); @@ -132,9 +122,8 @@ fn test_throttle_not_called_redundantly() { #[test] fn test_step_strategy_full_intensity_applies_lower_limits() { Runner::start(Config::seeded(0), |ctx| async move { - let recorded = Arc::new(Mutex::new(Recorded::default())); // Backlog of 100 — above threshold of 1. - let pipeline = TrackingPipeline::new(Arc::clone(&recorded)).with_da_backlog(100); + let pipeline = TrackingPipeline::new().with_da_backlog(100); let config = ThrottleConfig { threshold_bytes: 1, max_intensity: 1.0, ..Default::default() }; @@ -142,7 +131,7 @@ fn test_step_strategy_full_intensity_applies_lower_limits() { let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) .build(); let handle = ctx.spawn(driver.run()); @@ -167,55 +156,21 @@ fn test_step_strategy_full_intensity_applies_lower_limits() { /// Verifies that when the DA backlog transitions from above the threshold /// (throttle active) to zero (throttle inactive), the driver makes exactly -/// two RPC calls: one with reduced limits and one resetting to upper limits. +/// two RPC calls: one with the lower limits and one resetting to the upper limits. #[test] fn test_throttle_transitions_from_active_to_inactive() { - // Pipeline whose DA backlog is controlled from the test via a shared lock. - struct DynamicPipeline { - backlog: Arc>, - } - - impl BatchPipeline for DynamicPipeline { - fn add_block(&mut self, _: BaseBlock) -> Result<(), (ReorgError, Box)> { - Ok(()) - } - - fn step(&mut self) -> Result { - Ok(StepResult::Idle) - } - - fn next_submission(&mut self) -> Option { - None - } - - fn confirm(&mut self, _: SubmissionId, _: u64) {} - fn requeue(&mut self, _: SubmissionId) {} - fn flush(&mut self) -> Result<(), StepError> { - Ok(()) - } - fn advance_l1_head(&mut self, _: u64) {} - fn reconcile_derivation(&mut self, _: BlockInfo, _: u64) -> DerivationReconciliation { - DerivationReconciliation::Consistent - } - fn reset(&mut self) {} - - fn da_backlog_bytes(&self) -> u64 { - *self.backlog.lock().unwrap() - } - } - Runner::start(Config::seeded(0), |ctx| async move { let (source, source_tx) = ChannelBlockSource::new(); // Start with 2 MB backlog — above the default 1 MB threshold. - let backlog = Arc::new(Mutex::new(2_000_000u64)); - let pipeline = DynamicPipeline { backlog: Arc::clone(&backlog) }; + let pipeline = TrackingPipeline::new().with_da_backlog(2_000_000); + let backlog = Arc::clone(&pipeline.da_backlog_bytes); let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ImmediateConfirmTxManager { l1_block: 1 }) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) .build(); @@ -226,7 +181,7 @@ fn test_throttle_transitions_from_active_to_inactive() { // Drop the backlog to zero, then wake the driver by delivering a dummy // block so the select! arm fires and the loop re-runs the throttle check. - *backlog.lock().unwrap() = 0; + backlog.store(0, Ordering::SeqCst); source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); ctx.sleep(Duration::from_millis(30)).await; @@ -234,23 +189,8 @@ fn test_throttle_transitions_from_active_to_inactive() { assert!(handle.await.unwrap().is_ok()); let calls = throttle_recorded.lock().unwrap(); - assert!( - calls.len() >= 2, - "expected at least 2 throttle calls (activate + deactivate), got {}", - calls.len() - ); - - // First call must have reduced limits (throttle active, backlog was high). - let (first_tx, first_block) = calls[0]; - assert!( - first_block < 130_000, - "first call should apply throttled block limit, got {first_block}" - ); - assert!(first_tx < 20_000, "first call should apply throttled tx limit, got {first_tx}"); - - // Last call must reset to upper limits (throttle deactivated). - let (last_tx, last_block) = *calls.last().unwrap(); - assert_eq!(last_block, 130_000, "last call should reset block limit to upper bound"); - assert_eq!(last_tx, 20_000, "last call should reset tx limit to upper bound"); + // Twice the threshold is full intensity, so the lower limits first, then the upper + // limits once the backlog is gone. + assert_eq!(*calls, [(150, 2_000), (20_000, 130_000)]); }); } From 429722a25efb1adb2f640417432246b219b69870 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 13:13:23 +0000 Subject: [PATCH 148/299] refactor(batcher): drop the txpool recovery path, unreachable in production (#5302) * refactor(batcher): drop the txpool recovery path, unreachable in production * refactor(batcher): finish dropping the requeued outcome and its trace in the tx-manager doc * docs(batcher): tighten the failed-submission docs --- actions/harness/src/batcher/actor.rs | 30 ---- actions/harness/src/batcher/tx_manager.rs | 92 +----------- actions/harness/tests/batcher/main.rs | 1 - .../harness/tests/batcher/txpool_blocked.rs | 132 ------------------ crates/batcher/core/README.md | 19 ++- crates/batcher/core/src/driver.rs | 70 +++------- crates/batcher/core/src/outcome.rs | 4 - crates/batcher/core/src/submissions.rs | 50 +------ .../batcher/core/src/test_utils/tx_manager.rs | 21 +-- crates/batcher/encoder/src/metrics.rs | 7 +- crates/utilities/tx-manager/src/traits.rs | 3 - .../devnet/grafana/dashboards/devnet.json | 4 +- .../grafana/dashboards/shadow-batcher.json | 2 +- 13 files changed, 37 insertions(+), 398 deletions(-) delete mode 100644 actions/harness/tests/batcher/txpool_blocked.rs diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 4bc509b81c..beccbf784c 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -322,36 +322,6 @@ impl Batcher { self.tx_manager.fail_next_n(n); } - /// Schedule the next `n` frame submissions to be rejected as if the txpool - /// nonce slot is held by a stuck transaction. - /// - /// Each of the next `n` calls the background [`BatchDriver`] makes to - /// [`TxManager::send_async`] resolves with [`TxManagerError::AlreadyReserved`]. - /// The driver classifies this as [`TxOutcome::TxpoolBlocked`]: it requeues - /// the frames, stops submitting, and calls [`TxManager::cancel_tx`] on its - /// next loop iteration to clear the slot before resubmitting. Once - /// [`encode_only`] returns, the recovery has run: use [`cancellation_count`] - /// to assert it did. - /// - /// [`BatchDriver`]: base_batcher_core::BatchDriver - /// [`TxManager::send_async`]: base_tx_manager::TxManager::send_async - /// [`TxManager::cancel_tx`]: base_tx_manager::TxManager::cancel_tx - /// [`TxManagerError::AlreadyReserved`]: base_tx_manager::TxManagerError::AlreadyReserved - /// [`TxOutcome::TxpoolBlocked`]: base_batcher_core::TxOutcome::TxpoolBlocked - /// [`encode_only`]: Batcher::encode_only - /// [`cancellation_count`]: Batcher::cancellation_count - pub fn block_next_n_submissions(&self, n: usize) { - self.tx_manager.block_next_n(n); - } - - /// Returns how many times the driver has called [`TxManager::cancel_tx`] to - /// recover from a txpool blockage. - /// - /// [`TxManager::cancel_tx`]: base_tx_manager::TxManager::cancel_tx - pub fn cancellation_count(&self) -> usize { - self.tx_manager.cancellation_count() - } - /// Mine all pending frame submissions in one L1 block. /// /// Stages every pending frame, mines one L1 block, fires all receipts and waits until diff --git a/actions/harness/src/batcher/tx_manager.rs b/actions/harness/src/batcher/tx_manager.rs index 8926f8d294..81a29cd885 100644 --- a/actions/harness/src/batcher/tx_manager.rs +++ b/actions/harness/src/batcher/tx_manager.rs @@ -11,7 +11,6 @@ use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; use base_tx_manager::{ BlobTxBuilder, SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError, - TxManagerResult, }; use tokio::sync::oneshot; use tracing::info; @@ -59,21 +58,6 @@ pub struct Inner { /// Number of upcoming `send_async` calls to immediately fail with /// [`TxManagerError::Rpc`] before falling through to normal queuing. fail_remaining: usize, - /// Number of upcoming `send_async` calls to immediately reject with - /// [`TxManagerError::AlreadyReserved`], modelling a txpool nonce slot held - /// by a stuck transaction. The [`BatchDriver`] classifies this as - /// [`TxOutcome::TxpoolBlocked`] and must clear it via [`cancel_tx`]. - /// - /// [`BatchDriver`]: base_batcher_core::BatchDriver - /// [`TxOutcome::TxpoolBlocked`]: base_batcher_core::TxOutcome::TxpoolBlocked - /// [`cancel_tx`]: base_tx_manager::TxManager::cancel_tx - blocked_remaining: usize, - /// Number of times [`cancel_tx`] has been invoked by the driver's txpool - /// recovery path. Tests assert on this to prove the blockage was cleared - /// through the production recovery flow rather than by chance. - /// - /// [`cancel_tx`]: base_tx_manager::TxManager::cancel_tx - cancellations: usize, } /// Adapts [`L1Miner`] to the [`TxManager`] trait for action tests. @@ -132,32 +116,6 @@ impl L1MinerTxManager { self.inner.lock().unwrap().fail_remaining += n; } - /// Schedule the next `n` [`send_async`] calls to immediately reject with - /// [`TxManagerError::AlreadyReserved`], modelling a txpool nonce slot held - /// by a stuck transaction. - /// - /// The [`BatchDriver`] classifies this as - /// [`TxOutcome::TxpoolBlocked`]: it requeues the frames, stops submitting - /// new ones, and calls [`cancel_tx`] on the next loop iteration to clear the - /// slot before resubmitting. Rejections are consumed one-per-call, so - /// `n = 2` blocks the next two separate `send_async` calls. - /// - /// [`send_async`]: L1MinerTxManager::send_async - /// [`BatchDriver`]: base_batcher_core::BatchDriver - /// [`TxOutcome::TxpoolBlocked`]: base_batcher_core::TxOutcome::TxpoolBlocked - /// [`cancel_tx`]: base_tx_manager::TxManager::cancel_tx - pub fn block_next_n(&self, n: usize) { - self.inner.lock().unwrap().blocked_remaining += n; - } - - /// Returns how many times the driver has called [`cancel_tx`] to recover - /// from a txpool blockage. - /// - /// [`cancel_tx`]: base_tx_manager::TxManager::cancel_tx - pub fn cancellation_count(&self) -> usize { - self.inner.lock().unwrap().cancellations - } - /// Drop the first `n` pending submissions without staging them to L1. /// /// Returns the actual number dropped (≤ `n`). Use this to skip specific @@ -327,12 +285,6 @@ impl TxManager for L1MinerTxManager { tx.send(Err(TxManagerError::Rpc("simulated submission failure".to_string()))); return SendHandle::new(rx); } - if inner.blocked_remaining > 0 { - inner.blocked_remaining -= 1; - let (tx, rx) = oneshot::channel::(); - let _ = tx.send(Err(TxManagerError::AlreadyReserved)); - return SendHandle::new(rx); - } } let nonce = { @@ -359,24 +311,13 @@ impl TxManager for L1MinerTxManager { fn sender_address(&self) -> Address { self.signer.address() } - - async fn cancel_tx(&self) -> TxManagerResult<()> { - // Production `cancel_tx` sends a self-transfer at the stuck nonce with a - // higher gas price to evict the transaction and free the slot. The - // harness blockage is injected synthetically (no real transaction ever - // occupied the slot), so clearing it is simply acknowledging the - // cancellation. Record the invocation so tests can prove the driver - // drove the production txpool-recovery path. - self.inner.lock().unwrap().cancellations += 1; - Ok(()) - } } #[cfg(test)] mod tests { use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_signer_local::PrivateKeySigner; - use base_tx_manager::{TxCandidate, TxManager, TxManagerError}; + use base_tx_manager::{TxCandidate, TxManager}; use super::L1MinerTxManager; use crate::L1Miner; @@ -424,35 +365,4 @@ mod tests { assert_eq!(receipt.transaction_index, Some(0)); assert_eq!(receipt.to, Some(inbox)); } - - #[tokio::test] - async fn block_next_n_rejects_with_already_reserved_then_succeeds() { - let inbox = Address::repeat_byte(0x42); - let manager = L1MinerTxManager::new(TxManagerFixture::signer(), inbox, 1); - - // The next two submissions are rejected as if the nonce slot is held by - // a stuck transaction; the third queues normally. - manager.block_next_n(2); - - let first = manager.send_async(TxManagerFixture::candidate(inbox)).await; - assert!(matches!(first.await, Err(TxManagerError::AlreadyReserved))); - assert_eq!(manager.pending_count(), 0, "rejected submission must not queue"); - - let second = manager.send_async(TxManagerFixture::candidate(inbox)).await; - assert!(matches!(second.await, Err(TxManagerError::AlreadyReserved))); - - let _third = manager.send_async(TxManagerFixture::candidate(inbox)).await; - assert_eq!(manager.pending_count(), 1, "third submission must queue normally"); - } - - #[tokio::test] - async fn cancel_tx_records_invocations() { - let inbox = Address::repeat_byte(0x42); - let manager = L1MinerTxManager::new(TxManagerFixture::signer(), inbox, 1); - - assert_eq!(manager.cancellation_count(), 0); - manager.cancel_tx().await.expect("cancel_tx never fails in the harness"); - manager.cancel_tx().await.expect("cancel_tx never fails in the harness"); - assert_eq!(manager.cancellation_count(), 2); - } } diff --git a/actions/harness/tests/batcher/main.rs b/actions/harness/tests/batcher/main.rs index cfce8d368a..b834d0f6d8 100644 --- a/actions/harness/tests/batcher/main.rs +++ b/actions/harness/tests/batcher/main.rs @@ -7,5 +7,4 @@ mod production_da; mod sequencer_drift; mod submission; mod submission_failure; -mod txpool_blocked; mod upgrade_transitions; diff --git a/actions/harness/tests/batcher/txpool_blocked.rs b/actions/harness/tests/batcher/txpool_blocked.rs deleted file mode 100644 index 5e782c568a..0000000000 --- a/actions/harness/tests/batcher/txpool_blocked.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! Action tests for batcher recovery when the L1 txpool nonce slot is blocked. -//! -//! These tests verify the end-to-end production recovery path: -//! sequencer → batcher (txpool blocked) → requeue + `cancel_tx` → derivation -//! -//! When [`TxManager::send_async`] rejects a submission with -//! [`TxManagerError::AlreadyReserved`], the [`BatchDriver`] classifies the -//! outcome as [`TxOutcome::TxpoolBlocked`]: it requeues the frames, stops -//! submitting new ones, and calls [`TxManager::cancel_tx`] on its next loop -//! iteration to clear the stuck slot before resubmitting. This is a distinct -//! driver code path from a plain submission failure (see `submission_failure.rs`), -//! and it exercises the `cancel_tx` hook that the harness previously left as the -//! trait's default no-op. -//! -//! [`TxManager::send_async`]: base_tx_manager::TxManager::send_async -//! [`TxManager::cancel_tx`]: base_tx_manager::TxManager::cancel_tx -//! [`TxManagerError::AlreadyReserved`]: base_tx_manager::TxManagerError::AlreadyReserved -//! [`BatchDriver`]: base_batcher_core::BatchDriver -//! [`TxOutcome::TxpoolBlocked`]: base_batcher_core::TxOutcome::TxpoolBlocked - -use base_action_harness::{ - ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, - TestRollupConfigBuilder, -}; -use base_batcher_encoder::{DaType, EncoderConfig}; - -fn calldata_batcher_config() -> BatcherConfig { - BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - } -} - -/// When the first submission hits a reserved nonce slot, the [`BatchDriver`] -/// requeues the frame, clears the blockage via [`TxManager::cancel_tx`], and the -/// derivation node successfully derives the L2 block after recovery. -/// -/// [`BatchDriver`]: base_batcher_core::BatchDriver -/// [`TxManager::cancel_tx`]: base_tx_manager::TxManager::cancel_tx -#[tokio::test] -async fn txpool_blocked_recovers_via_cancel_tx_and_derives() { - let batcher_cfg = calldata_batcher_config(); - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - let mut source = ActionL2Source::new(); - source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); - - // Block the first submission so the driver must recover the txpool slot - // before it can resubmit the requeued frame. - batcher.block_next_n_submissions(1); - batcher.encode_only().await; - - // Driver path: TxpoolBlocked receipt → requeue → recover_txpool → cancel_tx - // → resubmit. The frame returns to pending only after the blockage clears. - assert_eq!(batcher.pending_count(), 1, "the blocked frame must be resubmitted"); - - assert_eq!( - batcher.cancellation_count(), - 1, - "driver must clear the txpool blockage via exactly one cancel_tx" - ); - - // Mine the successfully resubmitted frame into an L1 block. - let block_num = batcher.mine_pending(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - node.initialize().await; - let derived = node.run_until_idle().await; - - assert_eq!(derived, 1, "frame must derive one L2 block after txpool recovery"); - assert_eq!(node.l2_safe_number(), 1, "safe head must reach 1 after recovery"); - assert!(block_num >= 1, "resubmitted frame was mined into an L1 block"); -} - -/// With two consecutive txpool blockages, the driver recovers twice via -/// [`TxManager::cancel_tx`] before the third submission succeeds. No data is -/// lost: the derivation node still sees the correct L2 block. -/// -/// [`TxManager::cancel_tx`]: base_tx_manager::TxManager::cancel_tx -#[tokio::test] -async fn consecutive_txpool_blocks_recover_and_derive() { - let batcher_cfg = calldata_batcher_config(); - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - let mut source = ActionL2Source::new(); - source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); - - // Block the next two submissions; the driver must recover after each before - // the third submission queues successfully. - batcher.block_next_n_submissions(2); - batcher.encode_only().await; - - assert_eq!(batcher.pending_count(), 1, "the blocked frame must be resubmitted"); - - assert_eq!( - batcher.cancellation_count(), - 2, - "driver must clear two consecutive txpool blockages via cancel_tx" - ); - - let block_num = batcher.mine_pending(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - node.initialize().await; - let derived = node.run_until_idle().await; - - assert_eq!(derived, 1, "frame must survive two blockages and derive one L2 block"); - assert_eq!(node.l2_safe_number(), 1, "safe head must reach 1 after two recoveries"); - assert!(block_num >= 1, "frame was eventually mined into an L1 block"); -} diff --git a/crates/batcher/core/README.md b/crates/batcher/core/README.md index 1103d5437c..853ffc93df 100644 --- a/crates/batcher/core/README.md +++ b/crates/batcher/core/README.md @@ -15,22 +15,19 @@ Each arm advances the pipeline or adjusts submission pressure without blocking t `BatchDriverConfig` carries the L1 inbox address, in-flight transaction limit, shutdown drain timeout, DA-throttle submission policy, and whether block ingestion starts stopped. -`SubmissionQueue` owns the entire L1 submission lifecycle. It holds the `TxManager`, a -`FuturesUnordered` set of in-flight receipt futures, and a txpool-blocked flag. When the driver -calls `submit_pending`, the queue sends one L1 transaction per ready submission, as blobs or -calldata depending on its `DaType`, until `max_pending_transactions` are in flight. Each -transaction becomes a receipt future that resolves to a `(SubmissionId, TxOutcome)` pair when -it settles. Confirmed receipts call `pipeline.confirm` and `pipeline.advance_l1_head`. Failed -submissions are requeued. A `TxpoolBlocked` outcome sets a sticky flag that prevents further -submissions until `recover_txpool` successfully cancels the stuck transaction. A blob +`SubmissionQueue` owns the entire L1 submission lifecycle. It holds the `TxManager` and a +`FuturesUnordered` set of in-flight receipt futures. When the driver calls `submit_pending`, +the queue sends one L1 transaction per ready submission, as blobs or calldata depending on its +`DaType`, until `max_pending_transactions` are in flight. Each transaction becomes a receipt +future that resolves to a `(SubmissionId, TxOutcome)` pair when it settles. Confirmed receipts +call `pipeline.confirm` and `pipeline.advance_l1_head`. Failed submissions are requeued. A blob submission that cannot be built into a transaction is fatal: the encoder packs blobs within protocol limits, so a retry would fail the same way. In-flight transactions survive a pipeline reset and keep counting against the limit until they settle; the reset pipeline ignores the stale ids they report. -`TxOutcome` represents the three terminal states of an L1 submission: `Confirmed { l1_block }`, -`Failed`, and `TxpoolBlocked`. Failed frames are requeued for retry; txpool-blocked frames are -also requeued but submission is suspended until the nonce slot is freed. +`TxOutcome` represents the two terminal states of an L1 submission: `Confirmed { l1_block }` +and `Failed`. The throttle subsystem controls how much DA data the sequencer may include per block and per transaction based on the L1 DA backlog. `ThrottleController` takes a `ThrottleConfig` and a diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index f76eb6ab37..30fd06fcc7 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -60,7 +60,7 @@ where pipeline: P, /// The L2 block source. source: S, - /// Submission lifecycle manager (tx manager, in-flight tracking, txpool state). + /// Submission lifecycle manager (tx manager, in-flight tracking). submissions: SubmissionQueue, /// DA backlog throttle (controller, client, dedup cache). throttle: DaThrottle, @@ -129,8 +129,8 @@ where /// Run the batch driver loop. /// /// Each iteration has two phases: - /// 1. **CPU phase** (`work`): drain encoding, apply throttle, recover txpool, submit - /// ready submissions up to the in-flight limit. + /// 1. **CPU phase** (`work`): drain encoding, apply throttle, submit ready submissions up + /// to the in-flight limit. /// 2. **I/O phase**: block on a biased `tokio::select!` until an event fires, and apply it. /// /// Every event is therefore followed by a CPU phase before the driver waits again, so the @@ -200,8 +200,8 @@ where self.shutdown().await } - /// The CPU phase: encode what is buffered, apply the DA throttle, recover the txpool and - /// submit ready submissions up to the in-flight limit. + /// The CPU phase: encode what is buffered, apply the DA throttle and submit ready + /// submissions up to the in-flight limit. /// /// Returns `true` when the encoding step budget ran out, so encoding must continue. Fails /// on a fatal encoding error or a blob submission that cannot be built. @@ -213,7 +213,6 @@ where self.pipeline.set_blob_override(is_throttling); } - self.submissions.recover_txpool().await; self.submissions.submit_pending(&mut self.pipeline).await?; Ok(encoding_left) } @@ -766,31 +765,32 @@ mod tests { }); } - /// `advance_l1_head` must NOT be called when a submission fails — we have no - /// confirmed L1 block to report. + /// A failed submission is requeued and resent, and `advance_l1_head` is not called + /// because there is no confirmed L1 block to report. #[test] - fn test_advance_l1_head_not_called_on_failure() { + fn test_failed_submission_is_resent_without_advancing_l1_head() { Runner::start(Config::seeded(0), |ctx| async move { let mut pipeline = TrackingPipeline::new(); let recorded = pipeline.recorded(); pipeline.submissions.push_back(SubmissionStub::stub()); - let (driver, _handles) = DriverFixture::new( - ctx.clone(), - pipeline, - ScriptedTxManager::new([SendOutcome::Failed]), - ) - .build(); + let tx_manager = ScriptedTxManager::new([SendOutcome::Failed]); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); + let recorded = recorded.lock().unwrap(); assert!( - recorded.lock().unwrap().l1_heads().is_empty(), + recorded.l1_heads().is_empty(), "advance_l1_head must NOT be called on submission failure" ); + // The failure requeues the submission and the next CPU phase resends it. + assert_eq!(recorded.requeued(), [SubmissionId(0)]); + assert_eq!(tx_manager.candidates().len(), 2, "the failed submission must be resent"); }); } @@ -967,42 +967,4 @@ mod tests { ); }); } - - /// `AlreadyReserved` means another transaction owns the sender nonce slot. - /// The driver must requeue the submission, mark the txpool blocked, and - /// call `cancel_tx` before accepting more submissions. Once the cancel - /// succeeds, the requeued submission is sent again. - #[test] - fn test_txpool_blocked_requeues_and_attempts_recovery() { - Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - pipeline.submissions.push_back(SubmissionStub::stub()); - let tx_manager = ScriptedTxManager::new([SendOutcome::TxpoolBlocked]); - - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).build(); - let handle = ctx.spawn(driver.run()); - - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - assert_eq!( - recorded.lock().unwrap().requeued(), - [SubmissionId(0)], - "txpool-blocked submissions must be requeued" - ); - assert_eq!( - tx_manager.cancellations(), - 1, - "driver must attempt txpool recovery with cancel_tx" - ); - assert_eq!( - tx_manager.candidates().len(), - 2, - "the requeued submission must be sent again once the txpool is unblocked" - ); - }); - } } diff --git a/crates/batcher/core/src/outcome.rs b/crates/batcher/core/src/outcome.rs index 0313d3b961..480a744270 100644 --- a/crates/batcher/core/src/outcome.rs +++ b/crates/batcher/core/src/outcome.rs @@ -10,8 +10,4 @@ pub enum TxOutcome { }, /// Transaction failed or timed out; frames should be requeued. Failed, - /// The txpool rejected the transaction because the nonce slot is already - /// reserved by a stuck transaction. Frames are requeued and no new - /// submissions are attempted until the blockage is cleared. - TxpoolBlocked, } diff --git a/crates/batcher/core/src/submissions.rs b/crates/batcher/core/src/submissions.rs index 9c1ba42db7..340d23afff 100644 --- a/crates/batcher/core/src/submissions.rs +++ b/crates/batcher/core/src/submissions.rs @@ -9,7 +9,7 @@ use base_batcher_encoder::{ }; use base_blobs::{BlobEncodeError, BlobEncoder}; use base_protocol::Frame; -use base_tx_manager::{TxCandidate, TxManager, TxManagerError}; +use base_tx_manager::{TxCandidate, TxManager}; use futures::stream::{FuturesUnordered, StreamExt}; use tracing::{info, warn}; @@ -92,39 +92,30 @@ impl BatchTxCandidateBuilder { /// Sends ready submissions to L1, one transaction each, and tracks those transactions until /// they settle. /// -/// At most `max_pending` transactions are in flight at once. A -/// [`TxOutcome::TxpoolBlocked`] outcome suspends sending until -/// [`recover_txpool`](Self::recover_txpool) cancels the transaction holding the nonce slot. +/// At most `max_pending` transactions are in flight at once. #[derive(Debug)] pub struct SubmissionQueue { tx_manager: TM, in_flight: InFlight, max_pending: usize, inbox: Address, - txpool_blocked: bool, } impl SubmissionQueue { /// Create a new [`SubmissionQueue`]. pub fn new(tx_manager: TM, inbox: Address, max_pending: usize) -> Self { - Self { - tx_manager, - in_flight: FuturesUnordered::new(), - max_pending, - inbox, - txpool_blocked: false, - } + Self { tx_manager, in_flight: FuturesUnordered::new(), max_pending, inbox } } /// Send ready submissions, one L1 transaction each, until `max_pending` transactions are - /// in flight, the pipeline has nothing ready, or the txpool is blocked. + /// in flight or the pipeline has nothing ready. /// /// Fails when a blob submission cannot be built into a transaction. pub async fn submit_pending( &mut self, pipeline: &mut P, ) -> Result<(), BatchTxCandidateError> { - while !self.txpool_blocked && self.in_flight.len() < self.max_pending { + while self.in_flight.len() < self.max_pending { let Some(sub) = pipeline.next_submission() else { return Ok(()); }; @@ -177,10 +168,6 @@ impl SubmissionQueue { } TxOutcome::Confirmed { l1_block } } - Err(TxManagerError::AlreadyReserved) => { - warn!(id = ?id, "txpool nonce slot already reserved"); - TxOutcome::TxpoolBlocked - } Err(e) => { warn!(id = ?id, error = %e, "submission failed"); TxOutcome::Failed @@ -192,30 +179,10 @@ impl SubmissionQueue { Ok(()) } - /// Attempt to clear a txpool blockage by cancelling the stuck transaction. - /// - /// No-op if the txpool is not currently blocked. On success, clears the - /// blocked flag so submission can resume. - pub async fn recover_txpool(&mut self) { - if !self.txpool_blocked { - return; - } - match self.tx_manager.cancel_tx().await { - Ok(()) => { - self.txpool_blocked = false; - info!("txpool unblocked after cancellation tx"); - } - Err(e) => { - warn!(error = %e, "cancel_tx failed, txpool remains blocked"); - } - } - } - /// Report a settled transaction to the pipeline. /// /// A confirmation confirms the submission and advances the pipeline's L1 head to the - /// inclusion block. A failure requeues the submission. A txpool blockage requeues it too, - /// and suspends sending until [`recover_txpool`](Self::recover_txpool) succeeds. + /// inclusion block. A failure requeues the submission. pub fn handle_outcome( &mut self, pipeline: &mut P, @@ -234,11 +201,6 @@ impl SubmissionQueue { pipeline.requeue(id); BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_FAILED).increment(1); } - TxOutcome::TxpoolBlocked => { - pipeline.requeue(id); - self.txpool_blocked = true; - BatcherMetrics::submission_total(BatcherMetrics::OUTCOME_REQUEUED).increment(1); - } } } diff --git a/crates/batcher/core/src/test_utils/tx_manager.rs b/crates/batcher/core/src/test_utils/tx_manager.rs index 505733bf26..8512ec7bab 100644 --- a/crates/batcher/core/src/test_utils/tx_manager.rs +++ b/crates/batcher/core/src/test_utils/tx_manager.rs @@ -11,9 +11,7 @@ use std::{ use alloy_consensus::{Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom}; use alloy_primitives::{Address, B256, Bloom}; use alloy_rpc_types_eth::TransactionReceipt; -use base_tx_manager::{ - SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError, TxManagerResult, -}; +use base_tx_manager::{SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError}; use tokio::sync::oneshot; /// What [`ScriptedTxManager`] does with a sent transaction. @@ -23,8 +21,6 @@ pub enum SendOutcome { Confirmed(u64), /// The send fails right away. Failed, - /// The send fails right away because another transaction holds the nonce slot. - TxpoolBlocked, /// The transaction stays in flight until [`ScriptedTxManager::confirm_next`] settles it. #[default] Pending, @@ -41,8 +37,6 @@ pub struct Script { pending: VecDeque>, /// Every candidate sent, in order. candidates: Vec, - /// Number of `cancel_tx` calls. - cancellations: usize, } /// [`TxManager`] that applies a scripted [`SendOutcome`] to each send, and records every @@ -78,11 +72,6 @@ impl ScriptedTxManager { self.script.lock().unwrap().candidates.clone() } - /// The number of `cancel_tx` calls so far. - pub fn cancellations(&self) -> usize { - self.script.lock().unwrap().cancellations - } - /// A successful receipt for a transaction included in `block_number`. const fn stub_receipt(block_number: u64) -> TransactionReceipt { let inner = ReceiptEnvelope::Legacy(ReceiptWithBloom { @@ -131,19 +120,11 @@ impl TxManager for ScriptedTxManager { SendOutcome::Failed => { let _ = tx.send(Err(TxManagerError::ChannelClosed)); } - SendOutcome::TxpoolBlocked => { - let _ = tx.send(Err(TxManagerError::AlreadyReserved)); - } SendOutcome::Pending => script.pending.push_back(tx), } std::future::ready(SendHandle::new(rx)) } - fn cancel_tx(&self) -> impl std::future::Future> + Send { - self.script.lock().unwrap().cancellations += 1; - std::future::ready(Ok(())) - } - fn sender_address(&self) -> Address { Address::ZERO } diff --git a/crates/batcher/encoder/src/metrics.rs b/crates/batcher/encoder/src/metrics.rs index 9a37802a1b..e06e23a876 100644 --- a/crates/batcher/encoder/src/metrics.rs +++ b/crates/batcher/encoder/src/metrics.rs @@ -31,7 +31,7 @@ base_metrics::define_metrics! { )] pipeline_reset_total: counter, #[describe("Total number of L1 batch submissions")] - #[label(name = "outcome", default = ["submitted", "confirmed", "failed", "requeued"])] + #[label(name = "outcome", default = ["submitted", "confirmed", "failed"])] submission_total: counter, #[describe("Total bytes of frame payload submitted to the DA layer")] #[label(name = "da_type", default = ["blob", "calldata"])] @@ -107,12 +107,9 @@ impl BatcherMetrics { /// Submission confirmed on L1. pub const OUTCOME_CONFIRMED: &'static str = "confirmed"; - /// Submission failed (tx reverted or timed out) and was requeued. + /// Submission failed for any reason and was requeued. pub const OUTCOME_FAILED: &'static str = "failed"; - /// Submission requeued due to txpool blockage. - pub const OUTCOME_REQUEUED: &'static str = "requeued"; - /// Blob DA: frames encoded into EIP-4844 blobs. pub const DA_TYPE_BLOB: &'static str = "blob"; diff --git a/crates/utilities/tx-manager/src/traits.rs b/crates/utilities/tx-manager/src/traits.rs index e1f47075fa..57a3e9fd3d 100644 --- a/crates/utilities/tx-manager/src/traits.rs +++ b/crates/utilities/tx-manager/src/traits.rs @@ -64,9 +64,6 @@ pub trait TxManager: Send + Sync + Debug { /// Attempt to cancel a stuck txpool transaction by sending a self-transfer /// with a higher gas price at the same nonce, freeing the slot. /// - /// Called by the batch driver when it receives [`TxOutcome::TxpoolBlocked`] - /// to clear the blocked slot so submission can resume. - /// /// The default implementation is a no-op that immediately returns `Ok(())`, /// suitable for test managers and environments where txpool management is /// not needed. diff --git a/etc/scripts/devnet/grafana/dashboards/devnet.json b/etc/scripts/devnet/grafana/dashboards/devnet.json index 8b4be7f084..87a766ae48 100644 --- a/etc/scripts/devnet/grafana/dashboards/devnet.json +++ b/etc/scripts/devnet/grafana/dashboards/devnet.json @@ -2701,7 +2701,7 @@ "type": "prometheus", "uid": "PBFA97CFB590B2093" }, - "description": "L1 submission outcomes per second, shown separately for op-batcher stages and base-batcher outcomes. Submitted and successful or confirmed transactions should track; failed or requeued outcomes identify publication instability.", + "description": "L1 submission outcomes per second, shown separately for op-batcher stages and base-batcher outcomes. Submitted and successful or confirmed transactions should track; failed outcomes identify publication instability.", "fieldConfig": { "defaults": { "color": { @@ -2741,7 +2741,7 @@ { "matcher": { "id": "byRegexp", - "options": "failed|requeued" + "options": "failed" }, "properties": [ { diff --git a/etc/scripts/devnet/grafana/dashboards/shadow-batcher.json b/etc/scripts/devnet/grafana/dashboards/shadow-batcher.json index bceca34113..dd0489f9ae 100644 --- a/etc/scripts/devnet/grafana/dashboards/shadow-batcher.json +++ b/etc/scripts/devnet/grafana/dashboards/shadow-batcher.json @@ -825,7 +825,7 @@ "type": "prometheus", "uid": "PBFA97CFB590B2093" }, - "description": "L1 batch submission attempts by outcome (submitted / confirmed / requeued / failed). Requeued and failed indicate trouble getting batches on-chain.", + "description": "L1 batch submission attempts by outcome (submitted / confirmed / failed). Failed indicates trouble getting batches on-chain.", "fieldConfig": { "defaults": { "color": { From 56f3a607e1d4f6508688899eaafb09c93e6b048e Mon Sep 17 00:00:00 2001 From: Francis Li Date: Tue, 29 Sep 2026 14:10:49 +0000 Subject: [PATCH 149/299] fix(consensus): pin Denim L1 origins within each second (#5331) Amp-Thread-ID: https://ampcode.com/threads/T-01a0d9d8-7342-73db-a429-8aea73fb4e63 Co-authored-by: Amp --- .../derive/src/stages/batch/batch_provider.rs | 135 +++++++++++++- .../derive/src/stages/batch/batch_queue.rs | 8 +- .../src/stages/batch/batch_validator.rs | 8 +- crates/consensus/protocol/README.md | 12 ++ crates/consensus/protocol/src/batch/single.rs | 172 +++++++++++++++++- .../consensus/protocol/src/batch/validity.rs | 5 + .../actors/sequencer/l1_origin/selector.rs | 93 +++++++++- 7 files changed, 409 insertions(+), 24 deletions(-) diff --git a/crates/consensus/derive/src/stages/batch/batch_provider.rs b/crates/consensus/derive/src/stages/batch/batch_provider.rs index 45b3208a82..b454164cfc 100644 --- a/crates/consensus/derive/src/stages/batch/batch_provider.rs +++ b/crates/consensus/derive/src/stages/batch/batch_provider.rs @@ -211,8 +211,10 @@ mod tests { use alloy_eips::BlockNumHash; use alloy_primitives::B256; - use base_common_genesis::{BaseUpgradeConfig, RollupConfig, SystemConfig, UpgradeConfig}; - use base_protocol::{Batch, BlockInfo, L2BlockInfo, SingleBatch}; + use base_common_genesis::{ + BaseUpgradeConfig, ChainGenesis, RollupConfig, SystemConfig, UpgradeConfig, + }; + use base_protocol::{Batch, BatchValidity, BlockInfo, L2BlockInfo, SingleBatch}; use super::BatchProvider; use crate::{ @@ -221,6 +223,120 @@ mod tests { traits::OriginProvider, }; + #[tokio::test] + async fn test_denim_same_second_origin_forced_empty_batches() { + for holocene in [false, true] { + let cfg = Arc::new(RollupConfig { + block_time: 2, + max_sequencer_drift: 1800, + seq_window_size: 2, + genesis: ChainGenesis { l2_time: 1898, ..Default::default() }, + upgrades: UpgradeConfig { + holocene_time: holocene.then_some(0), + base: BaseUpgradeConfig { denim: Some(1902), ..Default::default() }, + ..Default::default() + }, + ..Default::default() + }); + let current = BlockInfo { + number: 10, + hash: B256::repeat_byte(10), + timestamp: 88, + ..Default::default() + }; + let next = BlockInfo { + number: 11, + hash: B256::repeat_byte(11), + parent_hash: current.hash, + timestamp: 100, + }; + let later = BlockInfo { + number: 12, + hash: B256::repeat_byte(12), + parent_hash: next.hash, + timestamp: 112, + }; + let inclusion = later; + let mut prev = TestNextBatchProvider::new(vec![]); + prev.origin = Some(inclusion); + let mut provider = + BatchProvider::new(Arc::clone(&cfg), prev, TestL2ChainProvider::default()); + provider.attempt_update().unwrap(); + // Both batches and L1 data have been drained through the sequencing window. + if let Some(stage) = provider.batch_validator.as_mut() { + stage.origin = Some(inclusion); + stage.l1_blocks = vec![current, next, later]; + } else { + let stage = provider.batch_queue.as_mut().unwrap(); + stage.origin = Some(inclusion); + stage.l1_blocks = vec![current, next, later]; + } + let mut parent = L2BlockInfo { + block_info: BlockInfo { + number: 4, + timestamp: 1902, + hash: B256::repeat_byte(42), + ..Default::default() + }, + l1_origin: current.id(), + ..Default::default() + }; + // Complete .600 and .800 on the parent's origin, even past maximum drift. + for number in [5, 6] { + let batch = provider.next_batch(parent).await.unwrap(); + assert_eq!(batch.epoch(), current.id()); + assert_eq!(batch.timestamp, 1902); + assert!(batch.transactions.is_empty()); + assert_eq!( + batch.check_batch(&cfg, &[current, next], parent, &inclusion), + BatchValidity::Accept, + ); + parent.block_info.number = number; + parent.block_info.hash = B256::repeat_byte(number as u8); + if number == 5 { + // Reset at .600, then replay L1 traversal before deriving .800. + if let Some(stage) = provider.batch_validator.as_mut() { + stage.prev.origin = Some(current); + } else { + provider.batch_queue.as_mut().unwrap().prev.origin = Some(current); + } + provider.reset(current.id(), SystemConfig::default()).await.unwrap(); + if let Some(stage) = provider.batch_validator.as_mut() { + stage.prev.origin = Some(next); + } else { + provider.batch_queue.as_mut().unwrap().prev.origin = Some(next); + } + assert_eq!( + provider.next_batch(parent).await.unwrap_err(), + PipelineError::Eof.temp(), + ); + if let Some(stage) = provider.batch_validator.as_mut() { + stage.prev.origin = Some(inclusion); + } else { + provider.batch_queue.as_mut().unwrap().prev.origin = Some(inclusion); + } + } + } + // Advancing the internal epoch takes one EOF/retry without advancing the L2 head. + assert_eq!(provider.next_batch(parent).await.unwrap_err(), PipelineError::Eof.temp()); + // Expire the next epoch's sequencing window as well. + let inclusion = BlockInfo { number: 13, timestamp: 124, ..inclusion }; + if let Some(stage) = provider.batch_validator.as_mut() { + stage.prev.origin = Some(inclusion); + } else { + provider.batch_queue.as_mut().unwrap().prev.origin = Some(inclusion); + } + let batch = provider.next_batch(parent).await.unwrap(); + assert_eq!(batch.epoch(), next.id()); + assert_eq!(batch.timestamp, 1903); + assert!(batch.transactions.is_empty()); + assert_eq!( + batch.check_batch(&cfg, &[current, next], parent, &inclusion), + BatchValidity::Accept, + ); + } + } + #[test] fn test_batch_provider_validator_active() { let provider = TestNextBatchProvider::new(vec![]); @@ -381,8 +497,15 @@ mod tests { #[tokio::test] async fn test_denim_validator_skips_same_second_stale_batch() { let origin = BlockInfo { number: 1, hash: B256::repeat_byte(0x11), ..Default::default() }; + let inclusion = BlockInfo { + number: 2, + hash: B256::repeat_byte(0x22), + parent_hash: origin.hash, + timestamp: 12, + }; let cfg = Arc::new(RollupConfig { block_time: 2, + seq_window_size: 2, upgrades: UpgradeConfig { holocene_time: Some(0), base: BaseUpgradeConfig { denim: Some(46), ..Default::default() }, @@ -397,7 +520,7 @@ mod tests { timestamp: cfg.l2_block_timestamp(300), ..Default::default() }, - l1_origin: BlockNumHash { number: 0, ..Default::default() }, + l1_origin: origin.id(), ..Default::default() }; assert_eq!(cfg.denim_activation_block_number(), Some(23)); @@ -418,7 +541,7 @@ mod tests { Ok(Batch::Single(stale_299)), Ok(Batch::Single(stale_298)), ]); - prev.origin = Some(origin); + prev.origin = Some(inclusion); let l2_provider = TestL2ChainProvider { blocks: (295..parent.block_info.number) .map(|number| L2BlockInfo { @@ -435,8 +558,8 @@ mod tests { let mut batch_provider = BatchProvider::new(cfg, prev, l2_provider); batch_provider.attempt_update().unwrap(); let validator = batch_provider.batch_validator.as_mut().unwrap(); - validator.origin = Some(origin); - validator.l1_blocks = vec![origin, origin]; + validator.origin = Some(inclusion); + validator.l1_blocks = vec![origin, inclusion]; assert_eq!( batch_provider.next_batch(parent).await.unwrap_err(), diff --git a/crates/consensus/derive/src/stages/batch/batch_queue.rs b/crates/consensus/derive/src/stages/batch/batch_queue.rs index 420b634ae0..f66c88cf0d 100644 --- a/crates/consensus/derive/src/stages/batch/batch_queue.rs +++ b/crates/consensus/derive/src/stages/batch/batch_queue.rs @@ -204,6 +204,8 @@ where let force_empty_batches = (expiry_epoch == origin.number && empty) || expiry_epoch < origin.number; let first_of_epoch = epoch.number == parent.l1_origin.number + 1; + let same_second = self.cfg.is_denim_active(next_timestamp) + && next_timestamp == parent.block_info.timestamp; // If the sequencer window did not expire, // there is still room to receive batches for the current epoch. @@ -227,9 +229,9 @@ where let next_epoch = self.l1_blocks[1]; // Fill with empty L2 blocks of the same epoch until we meet the time of the next L1 origin, - // to preserve that L2 time >= L1 time. If this is the first block of the epoch, always - // generate a batch to ensure that we at least have one batch per epoch. - if next_timestamp < next_epoch.timestamp || first_of_epoch { + // to preserve that L2 time >= L1 time. Finish a Denim whole second on its current origin. + // If this is the first block of the epoch, always generate at least one batch per epoch. + if next_timestamp < next_epoch.timestamp || first_of_epoch || same_second { info!(target: "batch_queue", epoch_number = epoch.number, "Generating empty batch for epoch"); return Ok(Batch::Single(SingleBatch { parent_hash: parent.block_info.hash, diff --git a/crates/consensus/derive/src/stages/batch/batch_validator.rs b/crates/consensus/derive/src/stages/batch/batch_validator.rs index eb67e0cfaf..97cbce1696 100644 --- a/crates/consensus/derive/src/stages/batch/batch_validator.rs +++ b/crates/consensus/derive/src/stages/batch/batch_validator.rs @@ -151,6 +151,8 @@ where let force_empty_batches = expiry_epoch <= stage_origin.number; let first_of_epoch = epoch.number == parent.l1_origin.number + 1; let next_timestamp = self.cfg.l2_block_timestamp(parent.block_info.number + 1); + let same_second = self.cfg.is_denim_active(next_timestamp) + && next_timestamp == parent.block_info.timestamp; // If the sequencer window did not expire, // there is still room to receive batches for the current epoch. @@ -167,9 +169,9 @@ where let next_epoch = self.l1_blocks[1]; // Fill with empty L2 blocks of the same epoch until we meet the time of the next L1 origin, - // to preserve that L2 time >= L1 time. If this is the first block of the epoch, always - // generate a batch to ensure that we at least have one batch per epoch. - if next_timestamp < next_epoch.timestamp || first_of_epoch { + // to preserve that L2 time >= L1 time. Finish a Denim whole second on its current origin. + // If this is the first block of the epoch, always generate at least one batch per epoch. + if next_timestamp < next_epoch.timestamp || first_of_epoch || same_second { info!(target: "batch_validator", epoch_number = epoch.number, "Generating empty batch for epoch"); return Ok(SingleBatch { parent_hash: parent.block_info.hash, diff --git a/crates/consensus/protocol/README.md b/crates/consensus/protocol/README.md index 60d2e1cbf4..616759cd12 100644 --- a/crates/consensus/protocol/README.md +++ b/crates/consensus/protocol/README.md @@ -12,6 +12,18 @@ L1 block info structs for each upgrade (Bedrock through Jovian). These types form the shared vocabulary between the derivation pipeline, consensus node, and proof system. +## Denim L1 origin rule + +After Denim activation, consecutive L2 blocks with the same whole-second header timestamp must +reference the same L1 origin (both number and hash). An origin change is allowed only when the +whole-second timestamp advances, subject to the existing origin and timestamp checks. This keeps +each second's EIP-4788 beacon-root value stable across its 200ms blocks. + +The rule applies to sequenced blocks, submitted single batches, and forced-empty derivation. Empty +blocks retain the current origin until the next second even when maximum sequencer drift is +exceeded; sequenced user transactions remain forbidden past drift. At the next second, the existing +origin-advancement rules resume. Pre-Denim behavior is unchanged. + ## Usage Add the dependency to your `Cargo.toml`: diff --git a/crates/consensus/protocol/src/batch/single.rs b/crates/consensus/protocol/src/batch/single.rs index b191aeb668..e12e3cb209 100644 --- a/crates/consensus/protocol/src/batch/single.rs +++ b/crates/consensus/protocol/src/batch/single.rs @@ -96,6 +96,14 @@ impl SingleBatch { return BatchValidity::Drop(BatchDropReason::ParentHashMismatch); } + // Denim permits only one L1 origin per whole second. Compare against the exact parent + // origin because the derivation pipeline's current epoch may already be one block ahead. + let same_second = cfg.is_denim_active(self.timestamp) + && self.timestamp == l2_safe_head.block_info.timestamp; + if same_second && self.epoch() != l2_safe_head.l1_origin { + return BatchValidity::Drop(BatchDropReason::SameSecondOriginChange); + } + // Filter out batches that were included too late. if self.epoch_num + cfg.seq_window_size < inclusion_block.number { return BatchValidity::Drop(BatchDropReason::IncludedTooLate); @@ -148,9 +156,9 @@ impl SingleBatch { if self.timestamp > max && no_txs { // If the sequencer is co-operating by producing an empty batch, // allow the batch if it was the right thing to do to maintain the L2 time >= L1 time - // invariant. Only check batches that do not advance the epoch, to ensure - // epoch advancement regardless of time drift is allowed. - if epoch.number == batch_origin.number { + // invariant. Only check batches that do not advance the epoch and are not pinned by + // Denim's one-origin-per-second rule, to ensure permitted epoch advancement is allowed. + if epoch.number == batch_origin.number && !same_second { if l1_blocks.len() < 2 { return BatchValidity::Undecided; } @@ -212,6 +220,7 @@ mod tests { use alloy_rlp::{Decodable, Encodable}; use base_common_consensus::{BaseTxEnvelope, TxDeposit}; use base_common_genesis::{BaseUpgradeConfig, ChainGenesis, UpgradeConfig}; + use rstest::rstest; use tracing::Level; use super::*; @@ -375,6 +384,159 @@ mod tests { ); } + #[rstest] + #[case::pre_denim(0, 98, 100, true, true)] + #[case::activation(1, 100, 102, true, true)] + #[case::retain_origin(2, 102, 102, false, true)] + #[case::at_200ms(2, 102, 102, true, false)] + #[case::at_400ms(3, 102, 102, true, false)] + #[case::at_600ms(4, 102, 102, true, false)] + #[case::at_800ms(5, 102, 102, true, false)] + #[case::next_second(6, 102, 103, true, true)] + fn test_denim_same_second_origin_validation( + #[case] parent_number: u64, + #[case] parent_timestamp: u64, + #[case] timestamp: u64, + #[case] advance_origin: bool, + #[case] accepted: bool, + ) { + let cfg = RollupConfig { + block_time: 2, + max_sequencer_drift: 600, + seq_window_size: 10, + genesis: ChainGenesis { l2_time: 98, ..Default::default() }, + upgrades: UpgradeConfig { + base: BaseUpgradeConfig { denim: Some(102), ..Default::default() }, + ..Default::default() + }, + ..Default::default() + }; + let current = BlockInfo { + number: 10, + hash: BlockHash::repeat_byte(10), + timestamp: 88, + ..Default::default() + }; + let next = BlockInfo { + number: 11, + hash: BlockHash::repeat_byte(11), + parent_hash: current.hash, + timestamp: 100, + }; + let parent = L2BlockInfo { + block_info: BlockInfo { + number: parent_number, + timestamp: parent_timestamp, + hash: BlockHash::repeat_byte(42), + ..Default::default() + }, + l1_origin: current.id(), + ..Default::default() + }; + let origin = if advance_origin { next } else { current }; + let batch = SingleBatch { + parent_hash: parent.block_info.hash, + epoch_num: origin.number, + epoch_hash: origin.hash, + timestamp, + transactions: vec![Bytes::from_static(&[0x02, 0x01])], + }; + let validity = batch.check_batch(&cfg, &[current, next], parent, &next); + if accepted { + assert_eq!(validity, BatchValidity::Accept); + if !advance_origin { + // Matching origin numbers do not permit a different hash within the same second. + let mut conflicting_parent = parent; + conflicting_parent.l1_origin.hash = BlockHash::repeat_byte(12); + assert_eq!( + batch.check_batch(&cfg, &[current, next], conflicting_parent, &next), + BatchValidity::Drop(BatchDropReason::SameSecondOriginChange), + ); + } + } else { + let expected = BatchValidity::Drop(BatchDropReason::SameSecondOriginChange); + assert_eq!(validity, expected); + // The pipeline may already have advanced its internal epoch ahead of the parent. + assert_eq!(batch.check_batch(&cfg, &[next], parent, &next), expected); + } + } + + #[rstest] + #[case::empty_past_drift_same_second(2, 102, 102, false, BatchValidity::Accept)] + #[case::nonempty_past_drift_same_second( + 2, + 102, + 102, + true, + BatchValidity::Drop(BatchDropReason::SequencerDriftExceeded) + )] + #[case::empty_past_drift_next_second( + 6, + 102, + 103, + false, + BatchValidity::Drop(BatchDropReason::SequencerDriftNotAdoptedNextOrigin) + )] + #[case::empty_past_drift_pre_denim( + 0, + 98, + 100, + false, + BatchValidity::Drop(BatchDropReason::SequencerDriftNotAdoptedNextOrigin) + )] + fn test_denim_same_second_origin_drift( + #[case] parent_number: u64, + #[case] parent_timestamp: u64, + #[case] timestamp: u64, + #[case] nonempty: bool, + #[case] expected: BatchValidity, + ) { + let cfg = RollupConfig { + block_time: 2, + max_sequencer_drift: 10, + seq_window_size: 10, + genesis: ChainGenesis { l2_time: 98, ..Default::default() }, + upgrades: UpgradeConfig { + base: BaseUpgradeConfig { denim: Some(102), ..Default::default() }, + ..Default::default() + }, + ..Default::default() + }; + let current = BlockInfo { + number: 10, + hash: BlockHash::repeat_byte(10), + timestamp: 88, + ..Default::default() + }; + let next = BlockInfo { + number: 11, + hash: BlockHash::repeat_byte(11), + parent_hash: current.hash, + timestamp: 100, + }; + let parent = L2BlockInfo { + block_info: BlockInfo { + number: parent_number, + timestamp: parent_timestamp, + ..Default::default() + }, + l1_origin: current.id(), + ..Default::default() + }; + let batch = SingleBatch { + epoch_num: current.number, + epoch_hash: current.hash, + timestamp, + transactions: if nonempty { vec![Bytes::from_static(&[0x02, 0x01])] } else { vec![] }, + ..Default::default() + }; + assert_eq!(batch.check_batch(&cfg, &[current, next], parent, &next), expected); + if expected == BatchValidity::Accept { + // A pinned origin does not require knowing its successor, even past drift. + assert_eq!(batch.check_batch(&cfg, &[current], parent, &next), expected); + } + } + #[test] fn test_check_batch_timestamp_pre_denim_unchanged() { let cfg = RollupConfig { @@ -619,7 +781,7 @@ mod tests { let single_batch = SingleBatch { parent_hash: BlockHash::ZERO, - epoch_num: 1, + epoch_num: 0, epoch_hash: BlockHash::ZERO, timestamp: 0, transactions, @@ -654,7 +816,7 @@ mod tests { let single_batch = SingleBatch { parent_hash: BlockHash::ZERO, - epoch_num: 1, + epoch_num: 0, epoch_hash: BlockHash::ZERO, timestamp: 0, transactions, diff --git a/crates/consensus/protocol/src/batch/validity.rs b/crates/consensus/protocol/src/batch/validity.rs index 6c8e6eb9fd..ac25e4caf2 100644 --- a/crates/consensus/protocol/src/batch/validity.rs +++ b/crates/consensus/protocol/src/batch/validity.rs @@ -24,6 +24,8 @@ pub enum BatchDropReason { EpochTooFarInFuture, /// Batch epoch hash does not match the L1 origin. EpochHashMismatch, + /// Batch changes L1 origin within a whole second after Denim activation. + SameSecondOriginChange, // === Timestamp/origin relationship drops === /// Batch timestamp is before the L1 origin timestamp. @@ -86,6 +88,9 @@ impl core::fmt::Display for BatchDropReason { Self::EpochTooOld => write!(f, "batch epoch is too old"), Self::EpochTooFarInFuture => write!(f, "batch epoch is too far in the future"), Self::EpochHashMismatch => write!(f, "batch epoch hash does not match L1 origin"), + Self::SameSecondOriginChange => { + write!(f, "batch changes L1 origin within the same whole second") + } Self::TimestampBeforeL1Origin => { write!(f, "batch timestamp is before L1 origin timestamp") } diff --git a/crates/consensus/service/src/actors/sequencer/l1_origin/selector.rs b/crates/consensus/service/src/actors/sequencer/l1_origin/selector.rs index ab742f5fdc..54fe365df0 100644 --- a/crates/consensus/service/src/actors/sequencer/l1_origin/selector.rs +++ b/crates/consensus/service/src/actors/sequencer/l1_origin/selector.rs @@ -60,11 +60,15 @@ pub trait OriginSelector: Debug + Send + Sync { /// The [`L1OriginSelector`] is responsible for selecting the L1 origin block based on the /// current L2 unsafe head's sequence epoch. /// +/// Once Denim is active, every L2 block within the same whole second uses the unsafe head's L1 +/// origin, including when sequencer drift has been exceeded. +/// /// Next-origin lookups run in a self-aborting background task and are adopted only if they still /// extend the current origin under the same observed L1 chain view. While sequencer drift permits, -/// an unfinished lookup does not prevent building on the current origin. Once drift is exceeded, -/// selection returns [`L1OriginSelectorError::NotEnoughData`] until the lookup is ready rather than -/// awaiting speculative work on the build path. Before the first observed L1 head, the selector may +/// an unfinished lookup does not prevent building on the current origin. Once drift is exceeded +/// and the origin is not pinned within a Denim second, selection returns +/// [`L1OriginSelectorError::NotEnoughData`] until the lookup is ready rather than awaiting +/// speculative work on the build path. Before the first observed L1 head, the selector may /// reuse the exact-hash current origin accepted by the engine reset, but it cannot prepare or advance /// to a successor. An L1 reorg that orphans the current origin is therefore detected when the /// background successor lookup completes its parent check, after which the sequencer requests an @@ -88,10 +92,8 @@ impl OriginSelector for L1OriginSelec /// Determines what the next L1 origin block should be, based off of the [`L2BlockInfo`] unsafe /// head. /// - /// The L1 origin is selected based off of the sequencing epoch, determined by the next L2 - /// block's timestamp in relation to the current L1 origin's timestamp. If the next L2 - /// block's timestamp is greater than the L2 unsafe head's L1 origin timestamp, the L1 - /// origin is the block following the current L1 origin. + /// A ready successor may be adopted once the next L2 timestamp reaches its L1 timestamp. + /// After Denim, blocks sharing their parent's whole-second timestamp retain its origin. async fn next_l1_origin( &mut self, unsafe_head: L2BlockInfo, @@ -158,6 +160,15 @@ impl L1OriginSelector

{ let Some(current) = self.current.as_ref() else { return Err(L1OriginSelectorError::OriginNotFound(unsafe_head.l1_origin.hash)); }; + + // Denim permits only one L1 origin per whole second. Keep the parent's origin until the + // next L2 timestamp advances, even when a successor is ready or sequencer drift expired. + if self.cfg.is_denim_active(next_l2_timestamp) + && next_l2_timestamp == unsafe_head.block_info.timestamp + { + return Ok(current.clone()); + } + let next = self.next_ready(); // Start building on the next L1 origin block if the next L2 block's timestamp is @@ -405,6 +416,8 @@ mod tests { use std::{collections::HashSet, sync::Mutex, time::Duration}; use alloy_eips::NumHash; + use base_common_genesis::{BaseUpgradeConfig, ChainGenesis, UpgradeConfig}; + use base_protocol::{BatchValidity, SingleBatch}; #[cfg(feature = "metrics")] use metrics_util::debugging::{DebugValue, DebuggingRecorder}; use rstest::rstest; @@ -551,6 +564,72 @@ mod tests { } } + #[tokio::test] + #[rstest] + #[case::within_drift(600)] + #[case::at_drift_limit(14)] + #[case::past_drift(10)] + async fn test_denim_same_second_origin_selection(#[case] max_sequencer_drift: u64) { + let cfg = Arc::new(RollupConfig { + block_time: 2, + max_sequencer_drift, + seq_window_size: 10, + genesis: ChainGenesis { l2_time: 98, ..Default::default() }, + upgrades: UpgradeConfig { + base: BaseUpgradeConfig { denim: Some(102), ..Default::default() }, + ..Default::default() + }, + ..Default::default() + }); + let current = BlockInfo { + number: 10, + hash: B256::repeat_byte(10), + timestamp: 88, + ..Default::default() + }; + let next = BlockInfo { + number: 11, + hash: B256::repeat_byte(11), + parent_hash: current.hash, + timestamp: 100, + }; + let provider = MockOriginSelectorProvider::default(); + provider.with_block(current); + let mut selector = L1OriginSelector::new(Arc::clone(&cfg), provider.clone()); + let mut parent = L2BlockInfo { + block_info: BlockInfo { number: 2, timestamp: 102, ..Default::default() }, + l1_origin: current.id(), + ..Default::default() + }; + + // The successor is initially unknown, then becomes ready during this header second. + assert_eq!(selector.next_l1_origin(parent).await.unwrap(), current); + selector.await_inflight().await; + provider.with_block(next); + assert_eq!(selector.next_l1_origin(parent).await.unwrap(), current); + selector.await_inflight().await; + assert_eq!(selector.next(), Some(next)); + + for number in 2..=6 { + parent.block_info.number = number; + let origin = selector.next_l1_origin(parent).await.unwrap(); + let (expected_origin, timestamp) = + if number < 6 { (current, 102) } else { (next, 103) }; + assert_eq!(origin, expected_origin); + let batch = SingleBatch { + parent_hash: parent.block_info.hash, + epoch_num: origin.number, + epoch_hash: origin.hash, + timestamp, + transactions: Vec::new(), + }; + assert_eq!( + batch.check_batch(&cfg, &[current, next], parent, &next), + BatchValidity::Accept, + ); + } + } + #[tokio::test] #[rstest] #[case::single_epoch(1)] From c14a50785e849e66d61f64ae2dc3969f7e1564bb Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 14:14:58 +0000 Subject: [PATCH 150/299] fix(batcher): reject throttle configs that can send a zero or out-of-range DA limit (#5315) * fix(batcher): reject throttle configs that can send a zero or out-of-range DA limit * test(batcher): fail instead of hanging when an invalid throttle config reaches the driver * refactor(batcher): use jsonrpsee's InvalidParams code for invalid throttle configs * docs(batcher): tighten the throttle validation docs and pin the setup error * test(batcher): assert the exact throttle config errors --- crates/batcher/admin/README.md | 4 +- crates/batcher/admin/src/api.rs | 13 +++- crates/batcher/core/src/admin.rs | 32 ++++++-- crates/batcher/core/src/lib.rs | 3 +- crates/batcher/core/src/throttle.rs | 108 ++++++++++++++++++++++++++ crates/batcher/service/src/service.rs | 17 ++++ 6 files changed, 166 insertions(+), 11 deletions(-) diff --git a/crates/batcher/admin/README.md b/crates/batcher/admin/README.md index 14656049eb..6582b03169 100644 --- a/crates/batcher/admin/README.md +++ b/crates/batcher/admin/README.md @@ -9,10 +9,10 @@ command is queued. | `admin_startBatcher` | Starts ingestion again from the safe L2 head. Does nothing if the batcher is already running. | | `admin_flushBatcher` | Closes the current channel so its frames become eligible for submission; it does not wait for L1 inclusion. Fails if the batcher is stopped. | | `admin_getBatcherStatus` | Returns `stopped`, `in_flight` and `da_backlog_bytes`. | -| `admin_setThrottleController` | Replaces the DA throttle strategy and its full configuration. The new limits are pushed to the block builder right after. | +| `admin_setThrottleController` | Replaces the DA throttle strategy and its full configuration. The new limits are pushed to the block builder right after. Fails with `-32602` if the configuration is invalid. | | `admin_resetThrottleController` | Pushes the current DA limits to the block builder again, even if they have not changed. | | `admin_getThrottleController` | Returns the throttle strategy, its threshold and maximum intensity, and the current intensity and limits. | | `admin_setLogLevel` | Not supported yet; always fails with `-32601`. | Error codes: `-32001` the driver has shut down, `-32002` the batcher is in the wrong state for the -request, `-32601` the method is not supported. +request, `-32601` the method is not supported, `-32602` the parameters are invalid. diff --git a/crates/batcher/admin/src/api.rs b/crates/batcher/admin/src/api.rs index d37bce9ec7..6a58f04eee 100644 --- a/crates/batcher/admin/src/api.rs +++ b/crates/batcher/admin/src/api.rs @@ -6,7 +6,7 @@ use base_batcher_core::{ use jsonrpsee::{ core::{RpcResult, async_trait}, proc_macros::rpc, - types::ErrorObjectOwned, + types::{ErrorCode, ErrorObjectOwned}, }; use tracing::warn; @@ -32,7 +32,8 @@ pub trait BatcherAdminApi { /// Replace the throttle strategy and configuration. /// - /// `config` sets the full throttle configuration; all fields are required. + /// `config` sets the full throttle configuration, and all fields are required. Fails if + /// `config` does not pass [`ThrottleConfig::validate`]. #[method(name = "setThrottleController")] async fn set_throttle_controller( &self, @@ -71,6 +72,7 @@ impl BatcherAdminApiServerImpl { AdminError::NotSupported(_) => -32601, AdminError::ChannelClosed => -32001, AdminError::Stopped => -32002, + AdminError::InvalidThrottleConfig(_) => ErrorCode::InvalidParams.code(), }; ErrorObjectOwned::owned(code, e.to_string(), None::<()>) } @@ -138,4 +140,11 @@ mod tests { let err = BatcherAdminApiServerImpl::admin_error(AdminError::Stopped); assert_eq!(err.code(), -32002); } + + #[test] + fn admin_error_invalid_throttle_config_uses_invalid_params_code() { + let config = ThrottleConfig { max_intensity: 2.0, ..ThrottleConfig::default() }; + let err = BatcherAdminApiServerImpl::admin_error(config.validate().unwrap_err().into()); + assert_eq!(err.code(), ErrorCode::InvalidParams.code()); + } } diff --git a/crates/batcher/core/src/admin.rs b/crates/batcher/core/src/admin.rs index 61d679b76a..e5556e5534 100644 --- a/crates/batcher/core/src/admin.rs +++ b/crates/batcher/core/src/admin.rs @@ -2,7 +2,7 @@ use tokio::sync::{mpsc, oneshot}; -use crate::{ThrottleConfig, ThrottleInfo, ThrottleStrategy}; +use crate::{ThrottleConfig, ThrottleConfigError, ThrottleInfo, ThrottleStrategy}; /// Capacity of the admin command channel. /// @@ -35,6 +35,9 @@ pub enum AdminError { /// The operation needs a running batcher, but it is stopped. #[error("batcher is stopped")] Stopped, + /// The requested throttle configuration is invalid. + #[error("invalid throttle config: {0}")] + InvalidThrottleConfig(#[from] ThrottleConfigError), } /// Result type alias for admin operations. @@ -135,16 +138,15 @@ impl AdminHandle { /// Replace the throttle strategy and configuration. /// - /// The full [`ThrottleConfig`] is required — partial updates are not - /// supported. Callers that want to change only one field should call - /// [`get_throttle_info`](Self::get_throttle_info) first to read the - /// current config, adjust the desired field, and pass the result here. - /// The new limits are pushed to the block builder right after. + /// The full [`ThrottleConfig`] is required because partial updates are not supported. The + /// new limits are pushed to the block builder right after. An invalid `config` is rejected + /// with [`AdminError::InvalidThrottleConfig`] before reaching the driver. pub async fn set_throttle( &self, strategy: ThrottleStrategy, config: ThrottleConfig, ) -> AdminResult<()> { + config.validate()?; self.request(|reply| AdminCommand::SetThrottle { strategy, config, reply }).await } @@ -187,6 +189,8 @@ impl AdminHandle { #[cfg(test)] mod tests { + use futures::FutureExt; + use super::*; #[tokio::test] @@ -205,6 +209,22 @@ mod tests { assert!(matches!(err, AdminError::ChannelClosed)); } + #[tokio::test] + async fn set_throttle_rejects_an_invalid_config_before_the_driver() { + let (handle, mut rx) = AdminHandle::channel(); + let config = ThrottleConfig { max_intensity: 2.0, ..ThrottleConfig::default() }; + + // Rejected before any wait on the driver, which never answers here. + let err = handle + .set_throttle(ThrottleStrategy::Linear, config) + .now_or_never() + .expect("an invalid config must be rejected without waiting on the driver") + .unwrap_err(); + + assert!(matches!(err, AdminError::InvalidThrottleConfig(_))); + assert!(rx.try_recv().is_err(), "the driver must not receive the command"); + } + #[test] fn set_log_level_returns_not_supported() { let (handle, _rx) = AdminHandle::channel(); diff --git a/crates/batcher/core/src/lib.rs b/crates/batcher/core/src/lib.rs index bfd49e53a2..28dd024e44 100644 --- a/crates/batcher/core/src/lib.rs +++ b/crates/batcher/core/src/lib.rs @@ -15,7 +15,8 @@ pub use outcome::TxOutcome; mod throttle; pub use throttle::{ - DaThrottle, ThrottleConfig, ThrottleController, ThrottleInfo, ThrottleParams, ThrottleStrategy, + DaThrottle, ThrottleConfig, ThrottleConfigError, ThrottleController, ThrottleInfo, + ThrottleParams, ThrottleStrategy, }; mod throttle_client; diff --git a/crates/batcher/core/src/throttle.rs b/crates/batcher/core/src/throttle.rs index e1fd367d8b..82d4ffd5a9 100644 --- a/crates/batcher/core/src/throttle.rs +++ b/crates/batcher/core/src/throttle.rs @@ -1,6 +1,9 @@ //! Throttle controller for DA backlog management. /// Configuration for the throttle controller. +/// +/// Must pass [`validate`](Self::validate) before use because the block builder reads a DA +/// limit of 0 as no limit at all. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ThrottleConfig { /// Backlog threshold in bytes at which throttling activates. @@ -36,6 +39,67 @@ impl Default for ThrottleConfig { } } +impl ThrottleConfig { + /// Checks that every limit the throttle can produce stays within its `[lower, upper]` + /// range and above zero. + pub fn validate(&self) -> Result<(), ThrottleConfigError> { + if !(0.0..=1.0).contains(&self.max_intensity) { + return Err(ThrottleConfigError::MaxIntensityOutOfRange { + max_intensity: self.max_intensity, + }); + } + Self::validate_limits( + "block_size", + self.block_size_lower_limit, + self.block_size_upper_limit, + )?; + Self::validate_limits("tx_size", self.tx_size_lower_limit, self.tx_size_upper_limit) + } + + /// Checks one lower and upper limit pair. Full intensity sends the lower limit. + const fn validate_limits( + name: &'static str, + lower: u64, + upper: u64, + ) -> Result<(), ThrottleConfigError> { + if lower == 0 { + return Err(ThrottleConfigError::ZeroLowerLimit { name }); + } + if lower > upper { + return Err(ThrottleConfigError::LowerAboveUpper { name, lower, upper }); + } + Ok(()) + } +} + +/// Errors returned when validating [`ThrottleConfig`]. +#[derive(Debug, thiserror::Error)] +pub enum ThrottleConfigError { + /// `max_intensity` is outside `[0, 1]`, or `NaN`, so throttling would push a limit outside + /// its `[lower, upper]` range. + #[error("max_intensity ({max_intensity}) must be within [0, 1]")] + MaxIntensityOutOfRange { + /// The configured maximum intensity. + max_intensity: f64, + }, + /// A lower limit is 0, which full intensity sends as is. + #[error("{name}_lower_limit must be greater than zero")] + ZeroLowerLimit { + /// The limit pair, `block_size` or `tx_size`. + name: &'static str, + }, + /// A lower limit is above its upper limit, so throttling would raise the limit. + #[error("{name}_lower_limit ({lower}) must not exceed {name}_upper_limit ({upper})")] + LowerAboveUpper { + /// The limit pair, `block_size` or `tx_size`. + name: &'static str, + /// The configured lower limit. + lower: u64, + /// The configured upper limit. + upper: u64, + }, +} + /// Parameters to apply when throttling is active. #[derive(Debug, Clone, Copy)] pub struct ThrottleParams { @@ -324,6 +388,50 @@ mod tests { } } + #[rstest] + #[case::default(ThrottleConfig::default(), Ok(()))] + #[case::equal_limits( + ThrottleConfig { block_size_lower_limit: 130_000, ..Default::default() }, + Ok(()) + )] + #[case::intensity_above_one( + ThrottleConfig { max_intensity: 1.5, ..Default::default() }, + Err("max_intensity (1.5) must be within [0, 1]") + )] + #[case::negative_intensity( + ThrottleConfig { max_intensity: -0.1, ..Default::default() }, + Err("max_intensity (-0.1) must be within [0, 1]") + )] + #[case::nan_intensity( + ThrottleConfig { max_intensity: f64::NAN, ..Default::default() }, + Err("max_intensity (NaN) must be within [0, 1]") + )] + #[case::zero_block_lower( + ThrottleConfig { block_size_lower_limit: 0, ..Default::default() }, + Err("block_size_lower_limit must be greater than zero") + )] + #[case::zero_tx_lower( + ThrottleConfig { tx_size_lower_limit: 0, ..Default::default() }, + 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)") + )] + #[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)") + )] + fn validate_accepts_only_limits_within_range( + #[case] config: ThrottleConfig, + #[case] expected: Result<(), &str>, + ) { + assert_eq!( + config.validate().map_err(|error| error.to_string()), + expected.map_err(String::from) + ); + } + #[test] fn strategy_display_parse_roundtrip() { for (input, expected) in [ diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index d02a3cb19b..65535611df 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -371,6 +371,9 @@ impl BatcherService { let cancellation = runtime.token().clone(); let mut background_tasks = Vec::new(); self.config.encoder_config.validate()?; + if let Some(throttle) = &self.config.throttle { + throttle.validate()?; + } if self.config.poll_interval.is_zero() { eyre::bail!("poll_interval must be greater than zero"); @@ -722,6 +725,7 @@ mod tests { use alloy_node_bindings::Anvil; use alloy_primitives::Address; + use base_batcher_core::ThrottleConfig; use rstest::rstest; use super::*; @@ -807,6 +811,19 @@ mod tests { assert_eq!(error.to_string(), expected); } + #[tokio::test] + async fn setup_rejects_invalid_throttle_config() { + let throttle = ThrottleConfig { block_size_lower_limit: 0, ..ThrottleConfig::default() }; + let config = BatcherConfig { throttle: Some(throttle), ..BatcherConfig::default() }; + + let error = BatcherService::new(config) + .setup(TokioRuntime::new()) + .await + .expect_err("a throttle that can send a zero limit must not start"); + + assert_eq!(error.to_string(), "block_size_lower_limit must be greater than zero"); + } + #[tokio::test] async fn l1_head_stream_outlives_its_builder() { let anvil = Anvil::new().spawn(); From fd8f66c283662dccc9769f80218ab938103b1683 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 14:35:51 +0000 Subject: [PATCH 151/299] fix(batcher): refuse to start when the signer is not the SystemConfig batcher (#5316) * fix(batcher): refuse to start when the signer is not the SystemConfig batcher * docs(batcher): name what the shadow-mode setup test fails on and tighten the CLI README * test(batcher): pin the signer check error and name the read setup stops after --- Cargo.lock | 3 + crates/batcher/cli/README.md | 4 + crates/batcher/service/Cargo.toml | 3 + crates/batcher/service/src/lib.rs | 3 + crates/batcher/service/src/service.rs | 137 +++++++++++++++++++- crates/batcher/service/src/system_config.rs | 38 ++++++ 6 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 crates/batcher/service/src/system_config.rs diff --git a/Cargo.lock b/Cargo.lock index 8a6e009490..cc78f1795f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3110,6 +3110,7 @@ dependencies = [ "alloy-primitives", "alloy-provider", "alloy-rpc-types-eth 2.4.1", + "alloy-sol-types", "async-trait", "backon", "base-balance-monitor", @@ -3118,6 +3119,7 @@ dependencies = [ "base-batcher-encoder", "base-batcher-source", "base-common-consensus", + "base-common-genesis", "base-common-network", "base-consensus-rpc", "base-metrics", @@ -3132,6 +3134,7 @@ dependencies = [ "jsonrpsee", "metrics", "rstest", + "serde_json", "tokio", "tokio-util", "tracing", diff --git a/crates/batcher/cli/README.md b/crates/batcher/cli/README.md index 64b1dacae9..ed32c4d201 100644 --- a/crates/batcher/cli/README.md +++ b/crates/batcher/cli/README.md @@ -29,6 +29,10 @@ The corresponding environment variables use the `BASE_BATCHER_` prefix. `optimism_rollupConfig` response and submits DA transactions to that canonical inbox. +Outside shadow mode, `base batcher` refuses to start unless its signer is the +current `SystemConfig` batcher address, since derivation would ignore every +batch it posts. + Shadow deployments may set `--shadow-mode` together with `--dangerously-override-batch-inbox-address` to submit to a non-canonical inbox. The flags must be set together so production deployments cannot redirect DA by diff --git a/crates/batcher/service/Cargo.toml b/crates/batcher/service/Cargo.toml index b1e507f5a3..d3c709baa4 100644 --- a/crates/batcher/service/Cargo.toml +++ b/crates/batcher/service/Cargo.toml @@ -21,6 +21,7 @@ async-trait.workspace = true base-retry.workspace = true base-metrics.workspace = true base-tx-manager.workspace = true +alloy-sol-types.workspace = true base-batcher-core.workspace = true base-batcher-admin.workspace = true base-balance-monitor.workspace = true @@ -43,8 +44,10 @@ jsonrpsee = { workspace = true, features = ["http-client", "macros"] } [dev-dependencies] rstest.workspace = true httpmock.workspace = true +serde_json.workspace = true alloy-node-bindings.workspace = true base-runtime = { workspace = true, features = ["test-utils"] } +base-common-genesis = { workspace = true, features = ["serde"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [features] diff --git a/crates/batcher/service/src/lib.rs b/crates/batcher/service/src/lib.rs index 5b8415e3d8..7e5affe1eb 100644 --- a/crates/batcher/service/src/lib.rs +++ b/crates/batcher/service/src/lib.rs @@ -16,6 +16,9 @@ pub use metrics::L2BlockParityMetrics; mod recent_txs; pub use recent_txs::{MAX_CHECK_RECENT_TXS_DEPTH, RecentTxSyncTarget}; +mod system_config; +pub use system_config::{ISystemConfig, SystemConfigBatcher}; + mod source; pub use source::RpcPollingSource; diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index 65535611df..f968548647 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -38,6 +38,7 @@ use crate::{ BatcherConfig, DerivationStatusPoller, DerivationStatusProvider, L2BlockParityMonitor, L2BlockParityMonitorConfig, MAX_CHECK_RECENT_TXS_DEPTH, RecentTxSyncTarget, RpcL1HeadPollingSource, RpcL2BlockProvider, RpcPollingSource, RpcThrottleClient, + SystemConfigBatcher, }; const WEI_PER_ETHER: f64 = 1_000_000_000_000_000_000.0; @@ -358,12 +359,12 @@ impl BatcherService { /// Initialise all batcher components and return a [`ReadyBatcher`]. /// - /// Connects to the L2 and L1 RPC endpoints, fetches the rollup config, - /// validates the private key, 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 — the caller sees the failure immediately, before any - /// background work is spawned. + /// Requires a signer, connects to the L2 RPC, fetches the rollup config and checks that its + /// node derives the inbox the batcher posts to, connects to 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, + /// before any background work is spawned. /// /// The runtime's cancellation token is forwarded to the derivation-status poller /// spawned here so it stops cleanly when the batcher shuts down. @@ -512,6 +513,22 @@ impl BatcherService { }) .await?; + // 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. + if self.config.batch_inbox_override.is_none() { + let system_config = rollup_config.l1_system_config_address; + let authorized = Self::rpc_retry("system-config-batcher", retry, rpc_timeout, || { + SystemConfigBatcher::fetch(&l1_provider, system_config) + }) + .await?; + if authorized != signer_address { + eyre::bail!( + "signer {signer_address} is not the batcher {authorized} authorized by the L1 \ + SystemConfig at {system_config}" + ); + } + } + // Recent transactions only select an L1 synchronization target. // They never advance the L2 backfill cursor. if self.config.wait_node_sync { @@ -726,14 +743,68 @@ mod tests { use alloy_node_bindings::Anvil; use alloy_primitives::Address; use base_batcher_core::ThrottleConfig; + use base_common_genesis::RollupConfig; + use base_tx_manager::SignerConfig; + use httpmock::{Mock, prelude::*}; use rstest::rstest; use super::*; + /// The `SystemConfig` address of the mocked rollup config. + const SYSTEM_CONFIG: Address = Address::repeat_byte(0x5c); + + /// The batch inbox of the mocked rollup config, which a shadow batcher following that rollup + /// node declares as its override. + const BATCH_INBOX: Address = Address::repeat_byte(0x1b); + fn test_retry() -> RetryConfig { RetryConfig::unbounded(Duration::from_millis(1), Duration::from_millis(1)) } + /// Answers every JSON-RPC request whose body includes `request` with `result`, under id 0, + /// the id of each client's first request. + async fn mock_rpc<'a>(server: &'a MockServer, request: &str, result: String) -> Mock<'a> { + let response = format!(r#"{{"jsonrpc":"2.0","id":0,"result":{result}}}"#); + server + .mock_async(|when, then| { + when.method(POST).path("/").json_body_includes(request); + then.status(200).header("content-type", "application/json").body(response); + }) + .await + } + + /// A batcher whose L1, L2 and rollup endpoints are all `server`, signing as `signer`. + fn mocked_config(server: &MockServer, signer: Address) -> BatcherConfig { + let url: Url = server.url("/").parse().unwrap(); + BatcherConfig { + l1_rpc_url: vec![url.clone()], + l2_rpc_url: vec![url.clone()], + rollup_rpc_url: vec![url.clone()], + signer: Some(SignerConfig::Remote { endpoint: url, address: signer }), + poll_interval: Duration::from_millis(10), + wait_node_sync_timeout: Duration::from_millis(200), + ..BatcherConfig::default() + } + } + + /// Serves a rollup config whose `SystemConfig` authorizes `authorized`, and returns the + /// mock of the `batcherHash()` call. + async fn mock_system_config(server: &MockServer, authorized: Address) -> Mock<'_> { + let rollup_config = RollupConfig { + batch_inbox_address: BATCH_INBOX, + l1_system_config_address: SYSTEM_CONFIG, + ..RollupConfig::default() + }; + mock_rpc( + server, + r#"{"method":"optimism_rollupConfig"}"#, + serde_json::to_string(&rollup_config).unwrap(), + ) + .await; + mock_rpc(server, r#"{"method":"eth_call"}"#, format!(r#""{}""#, authorized.into_word())) + .await + } + #[tokio::test] async fn rpc_retry_succeeds_after_transient_failure() { let attempts = AtomicU8::new(0); @@ -824,6 +895,60 @@ mod tests { assert_eq!(error.to_string(), "block_size_lower_limit must be greater than zero"); } + #[tokio::test] + async fn setup_rejects_a_signer_the_system_config_does_not_authorize() { + let server = MockServer::start_async().await; + let (signer, authorized) = (Address::repeat_byte(0x51), Address::repeat_byte(0xba)); + mock_system_config(&server, authorized).await; + + let error = BatcherService::new(mocked_config(&server, signer)) + .setup(TokioRuntime::new()) + .await + .expect_err("a batcher whose batches derivation ignores must not start"); + + assert_eq!( + error.to_string(), + format!( + "signer {signer} is not the batcher {authorized} authorized by the L1 \ + SystemConfig at {SYSTEM_CONFIG}" + ) + ); + } + + #[tokio::test] + async fn setup_accepts_the_signer_the_system_config_authorizes() { + let server = MockServer::start_async().await; + let signer = Address::repeat_byte(0x51); + mock_system_config(&server, signer).await; + // Setup reads the L1 head right after the check, so a served read means the check passed. + let l1_head = mock_rpc(&server, r#"{"method":"eth_blockNumber"}"#, r#""0x1""#.into()).await; + + // Setup fails later, on the reads this test does not mock. + let _ = + BatcherService::new(mocked_config(&server, signer)).setup(TokioRuntime::new()).await; + + assert!(l1_head.calls_async().await > 0, "setup must go past the check"); + } + + #[tokio::test] + async fn setup_skips_the_batcher_check_in_shadow_mode() { + let server = MockServer::start_async().await; + let batcher_hash = mock_system_config(&server, Address::repeat_byte(0xba)).await; + // Setup reads the L1 head right after the check, so a served read means setup got that far. + let l1_head = mock_rpc(&server, r#"{"method":"eth_blockNumber"}"#, r#""0x1""#.into()).await; + let config = BatcherConfig { + batch_inbox_override: Some(BATCH_INBOX), + parity_validator_l2_rpc_url: Some(server.url("/").parse().unwrap()), + ..mocked_config(&server, Address::repeat_byte(0x51)) + }; + + // Setup fails later, on the reads this test does not mock. + let _ = BatcherService::new(config).setup(TokioRuntime::new()).await; + + assert!(l1_head.calls_async().await > 0, "setup must reach the L1 head read"); + batcher_hash.assert_calls_async(0).await; + } + #[tokio::test] async fn l1_head_stream_outlives_its_builder() { let anvil = Anvil::new().spawn(); diff --git a/crates/batcher/service/src/system_config.rs b/crates/batcher/service/src/system_config.rs new file mode 100644 index 0000000000..5b28e8ef89 --- /dev/null +++ b/crates/batcher/service/src/system_config.rs @@ -0,0 +1,38 @@ +//! Startup read of the batcher address authorized by the L1 `SystemConfig` contract. + +use alloy_primitives::Address; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_types_eth::TransactionRequest; +use alloy_sol_types::{SolCall, sol}; + +sol! { + /// ABI of the L1 `SystemConfig` contract, reduced to what the batcher reads. + interface ISystemConfig { + /// The authorized batcher address, left-padded to 32 bytes. + function batcherHash() external view returns (bytes32); + } +} + +/// Reads the batcher address 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

{ + let call = TransactionRequest::default() + .to(system_config) + .input(ISystemConfig::batcherHashCall {}.abi_encode().into()); + let output = l1_provider + .call(call) + .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)) + } +} From 147a14976ab1e8ee61939565ddfce51e179f917c Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 29 Sep 2026 14:36:09 +0000 Subject: [PATCH 152/299] feat(eip8130): add open payer mode (#5267) * feat(eip8130): add open payer mode payer = 0x00 on the wire lets any secp256k1 key sign the payer hash and pay for the transaction. The payer is recovered from a raw 65-byte payer_auth bound to the resolved sender. A 20-byte zero payer address is rejected so open mode has a single encoding. * fix(eip8130): return open-payer recovery errors from resolved_payer * fix(eip8130): share checked k1 recovery and reject a zero payer directly Open-payer resolution and RecoveredActorId::recover_k1 now both use Eip8130Signed::recover_raw_k1. Payer decode rejects the 20-byte zero address by comparing the bytes to zero. * docs(eip8130): scope the payer_auth layouts to open and named payer modes * perf(payload): reuse the admission-resolved open payer for tip affordability CoinbaseTipAffordability recovered an open payer's secp256k1 signature on every build attempt. The pool already resolved the payer at admission and stored it in the transaction's LimitClass, so affordability reads it from there and only recovers when no classification is attached. resolved_payer now recovers directly instead of round-tripping through recover_open_payer's Option, which had no other caller and is removed. * fix(eip8130): never substitute the sender for an open payer An open payer is the signer its payer_auth recovers to, so a transaction whose payer_auth does not recover has no payer and is invalid. Estimation now rejects it instead of pricing it against the sender, the RPC requires payer_auth for an open payer instead of synthesizing a stub that can never recover, and a receipt omits the payer rather than reporting the sender if recovery ever fails. * fix(eip8130): estimate open payer transactions before the payer signs An absent open-payer payer_auth is priced as a 65-byte signature that never recovers, and simulation publishes the UNSIGNED_OPEN_PAYER placeholder to TxContext for a payer that has not signed, never the sender. Signatures are never verified during estimation, so a sender can estimate before a payer exists. --- .../src/transaction/eip8130/constants.rs | 6 ++ .../src/transaction/eip8130/signed.rs | 81 +++++++++++++---- .../consensus/src/transaction/eip8130/tx.rs | 81 +++++++++++++++-- crates/common/eip8130/src/intrinsic.rs | 20 ++++- crates/common/evm/src/eip8130.rs | 66 ++++++++++++-- crates/common/rpc-types/src/eip8130.rs | 14 +++ crates/common/rpc-types/src/reth.rs | 25 ++++++ .../rpc-types/src/transaction/request.rs | 25 ++++-- .../protocol/src/batch/transactions.rs | 18 +++- .../protocol/src/batch/tx_data/eip8130.rs | 13 ++- .../protocol/src/batch/tx_data/wrapper.rs | 2 +- crates/execution/eip8130/src/recovered.rs | 28 ++---- crates/execution/eip8130/src/tx_error.rs | 5 ++ crates/execution/eip8130/src/verify.rs | 87 +++++++++++++++++++ crates/execution/payload/src/affordability.rs | 75 +++++++++++++++- crates/execution/rpc/src/eth/receipt.rs | 22 +++-- crates/execution/txpool/src/validator.rs | 13 ++- 17 files changed, 501 insertions(+), 80 deletions(-) diff --git a/crates/common/consensus/src/transaction/eip8130/constants.rs b/crates/common/consensus/src/transaction/eip8130/constants.rs index 924cdcc2c8..d59a697bcc 100644 --- a/crates/common/consensus/src/transaction/eip8130/constants.rs +++ b/crates/common/consensus/src/transaction/eip8130/constants.rs @@ -180,6 +180,12 @@ impl Eip8130Constants { /// is never a valid authenticator selector; addresses below this are reserved. pub const K1_AUTHENTICATOR: Address = address!("0x0000000000000000000000000000000000000001"); + /// In-memory `payer` value for open payer mode, encoded on the wire as the + /// single byte `0x00`. The payer is recovered from a raw 65-byte + /// `payer_auth` over the payer signature hash. A 20-byte zero address is + /// not a valid wire `payer`. + pub const OPEN_PAYER: Address = Address::ZERO; + /// `AccountState.flags` bit that disables the implicit default-EOA path. /// /// The implicit default EOA is a [`Self::K1_AUTHENTICATOR`] signature whose diff --git a/crates/common/consensus/src/transaction/eip8130/signed.rs b/crates/common/consensus/src/transaction/eip8130/signed.rs index 3e53d67793..2db2079c24 100644 --- a/crates/common/consensus/src/transaction/eip8130/signed.rs +++ b/crates/common/consensus/src/transaction/eip8130/signed.rs @@ -46,9 +46,14 @@ pub struct Eip8130Signed { sender_auth: Bytes, /// Payer authentication payload, or empty for self-pay. /// - /// When `tx.payer.is_some()` this carries the payer's authorization, - /// formatted as `authenticator(20) || authenticator_data` and validated against - /// [`TxEip8130::payer_signature_hash`] (with the resolved sender substituted). + /// When `tx.payer.is_some()` this carries the payer's authorization over + /// [`TxEip8130::payer_signature_hash`] (with the resolved sender + /// substituted): + /// + /// - Open payer mode: a raw 65-byte `r || s || v` signature, from which the + /// payer is recovered. + /// - Named payer: `authenticator(20) || authenticator_data`. + /// /// When `tx.payer.is_none()` this is empty. payer_auth: Bytes, /// Cached EIP-2718 transaction hash (`keccak256(encode_2718(self))`). @@ -355,6 +360,59 @@ impl Eip8130Signed { self.recover_eoa_sender_unchecked()?.ok_or_else(alloy_consensus::crypto::RecoveryError::new) } + /// Recovers a signer from a raw 65-byte `r || s || v` blob over `hash`. + /// + /// Requires `v in {27, 28}` and EIP-2 low-`s`. This is the single checked + /// recovery for a raw k1 blob: open-payer resolution and + /// `RecoveredActorId::recover_k1` both go through it. + #[cfg(feature = "k256")] + pub fn recover_raw_k1( + hash: B256, + raw: &[u8], + ) -> Result { + let signature = Self::parse_raw_k1_signature(raw)?; + alloy_consensus::crypto::secp256k1::recover_signer(&signature, hash) + } + + /// The account that pays gas: `resolved_sender` for self-pay, the named + /// payer for sponsored pay, and the recovered signer in open payer mode. + /// + /// Returns [`RecoveryError`] when an open-mode `payer_auth` does not + /// recover. Included transactions have already passed payer verification, + /// so callers must handle that error instead of guessing what a missing + /// payer means. + /// + /// [`RecoveryError`]: alloy_consensus::crypto::RecoveryError + #[cfg(feature = "k256")] + pub fn resolved_payer( + &self, + resolved_sender: Address, + ) -> Result { + match self.tx.payer { + None => Ok(resolved_sender), + Some(_) if self.tx.is_open_payer() => Self::recover_raw_k1( + self.tx.payer_signature_hash(resolved_sender), + self.payer_auth.as_ref(), + ), + Some(payer) => Ok(payer), + } + } + + /// Parses a raw `r || s || v` secp256k1 signature, requiring exactly 65 + /// bytes and `v in {27, 28}`. The auth blob is not covered by the hash it + /// signs, so accepting the alternative `v in {0, 1}` encoding would let a + /// relayer mint a second transaction hash for the same signer. + #[cfg(feature = "k256")] + fn parse_raw_k1_signature( + raw: &[u8], + ) -> Result { + if raw.len() != 65 || !matches!(raw[64], 27 | 28) { + return Err(alloy_consensus::crypto::RecoveryError::new()); + } + alloy_primitives::Signature::try_from(raw) + .map_err(|_| alloy_consensus::crypto::RecoveryError::new()) + } + #[cfg(feature = "k256")] fn recover_eoa_sender_with( &self, @@ -366,22 +424,9 @@ impl Eip8130Signed { if self.tx.sender.is_some() { return Ok(None); } - // Canonical-encoding guard: the EOA `sender_auth` must be exactly a - // 65-byte `r || s || v` blob with `v` in 'Electrum' notation - // (`v in {27, 28}`). `sender_auth` cannot be covered by - // `sender_signature_hash` (a signature cannot sign over itself), so if we - // accepted the alternative `v in {0, 1}` encoding any relayer could flip - // that byte to mint a second, equally valid transaction hash for the same - // signer without the key (txid malleability). `alloy`'s parser normalizes - // `{0, 1, 27, 28}` all to the same parity, so we reject the non-canonical - // forms here before parsing, matching the k1 authenticator's strict + // Canonical-encoding guard, matching the k1 authenticator's strict // `v in {27, 28}` check in `RecoveredActorId::recover_k1`. - let raw = self.sender_auth.as_ref(); - if raw.len() != 65 || !matches!(raw[64], 27 | 28) { - return Err(alloy_consensus::crypto::RecoveryError::new()); - } - let signature = alloy_primitives::Signature::try_from(raw) - .map_err(|_| alloy_consensus::crypto::RecoveryError::new())?; + let signature = Self::parse_raw_k1_signature(self.sender_auth.as_ref())?; let hash = self.tx.sender_signature_hash(); recover(&signature, hash).map(Some) } diff --git a/crates/common/consensus/src/transaction/eip8130/tx.rs b/crates/common/consensus/src/transaction/eip8130/tx.rs index b396fe6bba..35bdbfff42 100644 --- a/crates/common/consensus/src/transaction/eip8130/tx.rs +++ b/crates/common/consensus/src/transaction/eip8130/tx.rs @@ -36,7 +36,8 @@ use crate::transaction::eip8130::{ /// `sender_auth` as a 65-byte ECDSA signature); `Some` selects the /// configured-actor path with an explicit account address. /// - [`Self::payer`]: `None` selects self-pay (the resolved sender pays); -/// `Some` selects sponsored pay (the payer address pays). +/// [`Eip8130Constants::OPEN_PAYER`] selects open payer mode (the payer is +/// recovered from `payer_auth`); any other address selects that payer. /// /// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] @@ -84,7 +85,9 @@ pub struct TxEip8130 { /// wire body between `calls` and `payer` and committed to by both the /// sender and payer signatures, but otherwise uninterpreted by the protocol. pub metadata: Bytes, - /// Optional explicit payer; `None` means the resolved sender pays gas. + /// Optional payer; `None` means the resolved sender pays gas, and + /// [`Eip8130Constants::OPEN_PAYER`] means the payer is recovered from + /// `payer_auth`. pub payer: Option
, } @@ -116,6 +119,45 @@ impl TxEip8130 { } } + /// Encodes `payer`: empty for self-pay, the single byte `0x00` for open + /// payer mode ([`Eip8130Constants::OPEN_PAYER`]), otherwise the 20-byte + /// address. + fn encode_payer(payer: Option
, out: &mut dyn BufMut) { + match payer { + Some(Eip8130Constants::OPEN_PAYER) => [0u8].as_slice().encode(out), + payer => Self::encode_address_opt(&payer, out), + } + } + + /// Length contribution of `payer` under [`Self::encode_payer`]. + const fn payer_encoded_length(payer: Option
) -> usize { + match payer { + Some(Eip8130Constants::OPEN_PAYER) => 1, + payer => Self::address_opt_encoded_length(&payer), + } + } + + /// Decodes the [`Self::encode_payer`] wire format. A 20-byte zero address + /// is rejected so open payer mode has a single encoding. + fn decode_payer(buf: &mut &[u8]) -> alloy_rlp::Result> { + let raw = Bytes::decode(buf)?; + match raw.as_ref() { + [] => Ok(None), + [0] => Ok(Some(Eip8130Constants::OPEN_PAYER)), + bytes if bytes.len() == 20 && bytes != [0u8; 20] => { + Ok(Some(Address::from_slice(bytes))) + } + _ => Err(alloy_rlp::Error::Custom("invalid EIP-8130 payer encoding")), + } + } + + /// Whether the transaction uses open payer mode: the sender does not name a + /// payer, and any key willing to pay signs `payer_auth`. + #[must_use] + pub fn is_open_payer(&self) -> bool { + self.payer == Some(Eip8130Constants::OPEN_PAYER) + } + /// Encodes the inner phase list of `calls` as `rlp([rlp([Call, ...]), ...])`. fn encode_calls(calls: &[Vec], out: &mut dyn BufMut) { let mut payload_len = 0usize; @@ -173,7 +215,7 @@ impl TxEip8130 { + self.account_changes.length() + Self::calls_encoded_length(&self.calls) + self.metadata.length() - + Self::address_opt_encoded_length(&self.payer) + + Self::payer_encoded_length(self.payer) } /// Encodes the RLP fields (no list header) in canonical order, encoding @@ -191,7 +233,7 @@ impl TxEip8130 { self.account_changes.encode(out); Self::encode_calls(&self.calls, out); self.metadata.encode(out); - Self::encode_address_opt(&self.payer, out); + Self::encode_payer(self.payer, out); } /// Length of all RLP fields (no list header). @@ -219,7 +261,7 @@ impl TxEip8130 { account_changes: Decodable::decode(buf)?, calls: Self::decode_calls(buf)?, metadata: Decodable::decode(buf)?, - payer: Self::decode_address_opt(buf)?, + payer: Self::decode_payer(buf)?, }) } @@ -324,7 +366,7 @@ impl TxEip8130 { + self.account_changes.length() + Self::calls_encoded_length(&self.calls) + self.metadata.length() - + Self::address_opt_encoded_length(&self.payer); + + Self::payer_encoded_length(self.payer); let mut buf = Vec::with_capacity( Eip8130Constants::REPLAY_ID_TYPE.len() + length_of_length(payload_length) @@ -339,7 +381,7 @@ impl TxEip8130 { self.account_changes.encode(&mut buf); Self::encode_calls(&self.calls, &mut buf); self.metadata.encode(&mut buf); - Self::encode_address_opt(&self.payer, &mut buf); + Self::encode_payer(self.payer, &mut buf); keccak256(&buf) } @@ -790,6 +832,31 @@ mod tests { assert!(res.is_err()); } + #[test] + fn open_payer_roundtrips_as_single_zero_byte() { + let tx = TxEip8130 { payer: Some(Eip8130Constants::OPEN_PAYER), ..sample_tx() }; + assert!(tx.is_open_payer()); + + let mut payer = Vec::new(); + TxEip8130::encode_payer(tx.payer, &mut payer); + assert_eq!(payer, vec![0x00]); + assert_eq!(TxEip8130::payer_encoded_length(tx.payer), 1); + + let mut buf = Vec::new(); + tx.encode(&mut buf); + assert_eq!(buf.len(), tx.length()); + assert_eq!(TxEip8130::decode(&mut buf.as_slice()).unwrap(), tx); + } + + #[test] + fn payer_rejects_zero_address_and_other_lengths() { + for raw in [vec![0u8; 20], vec![1u8], vec![0u8; 19]] { + let mut buf = Vec::new(); + Bytes::from(raw).encode(&mut buf); + assert!(TxEip8130::decode_payer(&mut buf.as_slice()).is_err()); + } + } + #[test] fn signing_hashes_are_distinct() { let tx = sample_tx(); diff --git a/crates/common/eip8130/src/intrinsic.rs b/crates/common/eip8130/src/intrinsic.rs index ab92410935..73e23e2fd1 100644 --- a/crates/common/eip8130/src/intrinsic.rs +++ b/crates/common/eip8130/src/intrinsic.rs @@ -423,7 +423,12 @@ impl IntrinsicGas { return Ok(0); } let payer_auth = signed.payer_auth().as_ref(); - let cost = Self::auth_cost(payer_auth, AuthWireForm::Prefixed, policy_gated)? + let form = if signed.tx().is_open_payer() { + AuthWireForm::BareSignature + } else { + AuthWireForm::Prefixed + }; + let cost = Self::auth_cost(payer_auth, form, policy_gated)? .saturating_add(Self::data_cost(payer_auth)); if cost > Eip8130GasSchedule::MAX_AUTHENTICATION_GAS { return Err(IntrinsicGasError::PayerAuthGasExceeded(cost)); @@ -1343,6 +1348,19 @@ mod tests { ); } + #[test] + fn open_payer_auth_is_priced_as_a_bare_k1_signature() { + let tx = TxEip8130 { payer: Some(Eip8130Constants::OPEN_PAYER), ..Default::default() }; + let payer_auth = vec![0xab; 65]; + let gas = intrinsic(&signed(tx, vec![0; 65], payer_auth.clone()), &EXISTING_KEY); + assert_eq!( + gas.payer_auth, + Eip8130GasSchedule::AUTH_EXEC_K1 + + Eip8130GasSchedule::COLD_SLOAD + + IntrinsicGas::data_cost(&payer_auth) + ); + } + #[test] fn payer_auth_bytes_do_not_change_sender_payload() { let tx = TxEip8130 { diff --git a/crates/common/evm/src/eip8130.rs b/crates/common/evm/src/eip8130.rs index ae555b581f..78a430daa3 100644 --- a/crates/common/evm/src/eip8130.rs +++ b/crates/common/evm/src/eip8130.rs @@ -46,7 +46,7 @@ use alloc::{boxed::Box, rc::Rc, vec::Vec}; use alloy_evm::{Database as AlloyDatabase, EvmInternals}; -use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_primitives::{Address, B256, Bytes, U256, address}; use base_common_consensus::{AccountChange, Delegation, Eip8130Constants, Predeploys}; use base_common_precompiles::{NonceManagerStorage, TxContextStorage}; use base_execution_eip8130::{ @@ -164,6 +164,12 @@ struct CallsResult { pub struct Eip8130Executor; impl Eip8130Executor { + /// Payer published to the `TxContext` precompile when simulating an open + /// payer transaction before any payer has signed (its `payer_auth` does not + /// recover). The real payer is unknown, and the context cannot carry the + /// zero address, which would read back as `tx.origin` (the sender). + pub const UNSIGNED_OPEN_PAYER: Address = address!("0x0000000000000000000000000000000000008130"); + /// Executes the EIP-8130 transaction currently set on `evm`, mutating the /// journal in place and returning the [`ExecutionResult`]. A success result /// is returned for an included transaction whether or not its `calls` @@ -777,11 +783,14 @@ impl Eip8130Executor { let gas_limit = tx.gas_limit; let max_fee = tx.max_fee_per_gas; let max_priority = tx.max_priority_fee_per_gas; - // Use the declared payer (sponsor) so the published `TxContext` matches a - // real execution: a call that reads the payer from the `TxContext` - // precompile must see the same address it would on-chain, or it could take - // a different path and skew the estimate. No signature is verified here. - let payer = tx.payer.unwrap_or(sender); + // Use the declared payer (sponsor) so the payer published to the + // `TxContext` precompile matches a real execution: a call that reads it + // must see the same address it would on-chain, or it could take a + // different path and skew the estimate. A named payer's signature is not + // verified here. An open payer is the signer its `payer_auth` recovers + // to; before a payer has signed (a stub that does not recover) the payer + // is unknown and published as `UNSIGNED_OPEN_PAYER`, never the sender. + let payer = signed.resolved_payer(sender).unwrap_or(Self::UNSIGNED_OPEN_PAYER); let internals = EvmInternals::from_context(ctx); let mut provider = JournalStorageProvider::new(internals, Address::ZERO); @@ -2707,6 +2716,51 @@ mod tests { } } + /// In open payer mode the account recovered from `payer_auth` pays gas, so + /// an unfunded sender's transaction executes against the payer's balance. + #[test] + fn open_payer_is_charged_instead_of_the_sender() { + let key = signing_key(0x34); + let sender = eoa_address(&key); + let payer_key = signing_key(0x35); + let payer = eoa_address(&payer_key); + + let tx = TxEip8130 { payer: Some(Eip8130Constants::OPEN_PAYER), ..base_tx() }; + let sender_auth = eoa_sig(&key, tx.sender_signature_hash()); + let payer_auth = eoa_sig(&payer_key, tx.payer_signature_hash(sender)); + let signed = Eip8130Signed::new(tx, sender_auth, payer_auth); + + let initial = U256::from(10u64).pow(U256::from(18u64)); + let mut evm = evm_with(initial, payer); + let outcome = evm.transact_raw(into_base_tx(&signed)).expect("open-payer tx executes"); + + assert!(outcome.result.is_success(), "expected success, got {:?}", outcome.result); + assert!(outcome.state[&payer].info.balance < initial, "payer must be charged"); + assert!(outcome.state[&sender].info.balance.is_zero(), "sender must not be charged"); + } + + /// The sender estimates an open payer transaction before any payer has + /// signed: a `payer_auth` that does not recover still simulates, with the + /// payer unknown rather than the sender. + #[test] + fn simulate_prices_an_open_payer_that_has_not_signed() { + let key = signing_key(0x36); + let sender = eoa_address(&key); + let tx = TxEip8130 { payer: Some(Eip8130Constants::OPEN_PAYER), ..base_tx() }; + let sender_auth = eoa_sig(&key, tx.sender_signature_hash()); + // `v = 0xff` is never a valid recovery byte. + let signed = Eip8130Signed::new(tx, sender_auth, Bytes::from(vec![0xffu8; 65])); + let mut evm = evm_with(U256::from(10u64).pow(U256::from(18u64)), sender); + evm.ctx_mut().tx = into_base_tx(&signed); + evm.ctx_mut().tx.base.caller = sender; + if let Some(parts) = evm.ctx_mut().tx.eip8130.as_mut() { + parts.mode = Eip8130ExecutionMode::Simulate; + } + + let result = Eip8130Executor::simulate(&mut evm).expect("an unsigned open payer simulates"); + assert!(result.is_success(), "expected success, got {result:?}"); + } + #[test] fn underfunded_payer_is_rejected() { let key = signing_key(0x33); diff --git a/crates/common/rpc-types/src/eip8130.rs b/crates/common/rpc-types/src/eip8130.rs index b847cc8a38..1e08fe25b3 100644 --- a/crates/common/rpc-types/src/eip8130.rs +++ b/crates/common/rpc-types/src/eip8130.rs @@ -109,6 +109,20 @@ impl BaseTransactionRequest { let (payer, payer_auth) = match aa.payer { None => (None, Bytes::new()), + Some(Eip8130Constants::OPEN_PAYER) => { + // The sender estimates before any payer has signed, so an absent + // `payer_auth` is priced as a 65-byte signature. The stub never + // recovers, leaving the payer unknown rather than a random + // address or the sender. + let blob = match &aa.payer_auth { + Some(blob) => { + Self::check_auth_len(blob, false)?; + blob.clone() + } + None => Self::default_bare_auth(), + }; + (Some(Eip8130Constants::OPEN_PAYER), blob) + } Some(payer) => { let blob = match &aa.payer_auth { Some(blob) => { diff --git a/crates/common/rpc-types/src/reth.rs b/crates/common/rpc-types/src/reth.rs index 01a5f61277..83067e7422 100644 --- a/crates/common/rpc-types/src/reth.rs +++ b/crates/common/rpc-types/src/reth.rs @@ -436,6 +436,31 @@ mod tests { assert_eq!(auth.len(), 20 + 65); } + /// An open payer request is estimated before any payer has signed, so an + /// absent `payer_auth` is priced as a 65-byte signature that never recovers + /// (the payer stays unknown, never the sender); a supplied one is used + /// verbatim. + #[test] + fn open_payer_without_payer_auth_prices_a_signature() { + let tx = sim_tx(json!({ + "sender": SENDER, + "calls": [], + "payer": Eip8130Constants::OPEN_PAYER, + })); + let s = signed(&tx); + assert_eq!(s.tx().payer, Some(Eip8130Constants::OPEN_PAYER)); + assert_eq!(s.payer_auth().len(), 65, "priced as a raw secp256k1 signature"); + assert!(s.resolved_payer(SENDER).is_err(), "the stub never recovers"); + + let tx = sim_tx(json!({ + "sender": SENDER, + "calls": [], + "payer": Eip8130Constants::OPEN_PAYER, + "payerAuth": blob(None, 65), + })); + assert_eq!(signed(&tx).payer_auth().len(), 65, "the supplied signature is used verbatim"); + } + #[test] fn prefixed_p256_payer_auth_is_rejected() { let payer = address!("0x00000000000000000000000000000000000000b2"); diff --git a/crates/common/rpc-types/src/transaction/request.rs b/crates/common/rpc-types/src/transaction/request.rs index b75e4d9932..524ddfc867 100644 --- a/crates/common/rpc-types/src/transaction/request.rs +++ b/crates/common/rpc-types/src/transaction/request.rs @@ -146,16 +146,25 @@ pub struct Eip8130RequestFields { /// filler-byte stub of the right length); you need not sign first. #[serde(default, skip_serializing_if = "Option::is_none")] pub sender_auth: Option, - /// Sponsoring payer account. When set, the estimate includes payer - /// authentication gas (metered on top of the gas limit, as in execution). + /// Sponsoring payer account, or the zero address for open payer mode. When + /// set, the estimate includes payer authentication gas (metered on top of + /// the gas limit, as in execution). #[serde(default, skip_serializing_if = "Option::is_none")] pub payer: Option
, - /// Raw payer authentication blob (`authenticator(20) || data`) whose shape - /// is priced when a `payer` is declared. Absent defaults to a representative - /// secp256k1 payer authorization. Unlike `sender_auth`, a supplied blob is - /// always the prefixed form and its leading 20 bytes must be the native k1 - /// authenticator. Any other selector, including P256, `WebAuthn`, and the - /// delegate authenticator, is rejected as `INVALID_PARAMS` rather than priced. + /// Raw payer authentication blob whose shape is priced when a `payer` is + /// declared. + /// + /// For a named payer this is `authenticator(20) || data`, and absent + /// defaults to a representative secp256k1 payer authorization. A supplied + /// blob's leading 20 bytes must be the native k1 authenticator. Any other + /// selector, including P256, `WebAuthn`, and the delegate authenticator, is + /// rejected as `INVALID_PARAMS` rather than priced. + /// + /// In open payer mode this is the payer's raw 65-byte signature, from which + /// the payer is recovered. It is optional: before any payer has signed, an + /// absent (or unrecoverable) signature is priced as 65 bytes and the payer + /// is simulated as the placeholder address + /// `0x0000000000000000000000000000000000008130`, never as the sender. #[serde(default, skip_serializing_if = "Option::is_none")] pub payer_auth: Option, /// Optional acting-actor hint for simulation. Estimation never recovers a diff --git a/crates/consensus/protocol/src/batch/transactions.rs b/crates/consensus/protocol/src/batch/transactions.rs index 965ce59d23..2e54b2460f 100644 --- a/crates/consensus/protocol/src/batch/transactions.rs +++ b/crates/consensus/protocol/src/batch/transactions.rs @@ -426,7 +426,7 @@ impl SpanBatchTransactions { .1; let payer_proof = SpanBatchEip8130TransactionData::split_auth( signed.payer_auth(), - inner.payer.is_some(), + SpanBatchEip8130TransactionData::named_payer(inner.payer), )? .1; self.eip8130_auth_data.push((sender_proof, payer_proof)); @@ -474,7 +474,8 @@ mod tests { use alloy_primitives::{B256, Signature, TxKind, address}; use base_common_consensus::{ AccountChange, AccountChangeChannel, Call, ChangeType, CreateEntry, Delegation, - Eip8130Signed, InitialActor, SignedAccountChanges, SignedChange, TxEip8130, + Eip8130Constants, Eip8130Signed, InitialActor, SignedAccountChanges, SignedChange, + TxEip8130, }; use super::*; @@ -725,7 +726,18 @@ mod tests { // Minimal body: empty auth, no account changes or calls. let minimal = eip8130_raw(eip8130_body(), Bytes::new(), Bytes::new()); - assert_span_batch_roundtrip(vec![eoa, configured, rich, minimal], EIP8130_CHAIN_ID); + // Open payer: `payer` is the single byte 0x00 and `payer_auth` is a raw + // signature with no authenticator prefix. + let open_payer = { + let mut tx = eip8130_body(); + tx.payer = Some(Eip8130Constants::OPEN_PAYER); + eip8130_raw(tx, Bytes::from_static(&[0xab; 65]), Bytes::from_static(&[0xcd; 65])) + }; + + assert_span_batch_roundtrip( + vec![eoa, configured, rich, minimal, open_payer], + EIP8130_CHAIN_ID, + ); } #[test] diff --git a/crates/consensus/protocol/src/batch/tx_data/eip8130.rs b/crates/consensus/protocol/src/batch/tx_data/eip8130.rs index d40add4846..8deab54d32 100644 --- a/crates/consensus/protocol/src/batch/tx_data/eip8130.rs +++ b/crates/consensus/protocol/src/batch/tx_data/eip8130.rs @@ -4,7 +4,7 @@ use alloc::vec::Vec; use alloy_primitives::{Address, Bytes, U256}; use alloy_rlp::{BufMut, Decodable, Encodable, Header}; -use base_common_consensus::{AccountChange, Call, Eip8130Signed, TxEip8130}; +use base_common_consensus::{AccountChange, Call, Eip8130Constants, Eip8130Signed, TxEip8130}; use crate::{Channel, SpanBatchError, SpanDecodingError}; @@ -114,6 +114,13 @@ impl SpanBatchEip8130TransactionData { Ok((blob.slice(..20), blob.slice(20..))) } + /// Whether `payer` names an account, so its `payer_auth` carries an + /// authenticator prefix. Self-pay has no `payer_auth`, and open payer mode + /// carries a raw signature. + pub fn named_payer(payer: Option
) -> bool { + payer.is_some_and(|payer| payer != Eip8130Constants::OPEN_PAYER) + } + /// Reassembles an authentication blob from its authenticator and proof parts. /// /// Inverse of `split_auth`. @@ -160,7 +167,7 @@ impl SpanBatchEip8130TransactionData { let sender_auth = Self::join_auth(&self.sender_authenticator, &sender_proof, self.sender.is_some()); let payer_auth = - Self::join_auth(&self.payer_authenticator, &payer_proof, self.payer.is_some()); + Self::join_auth(&self.payer_authenticator, &payer_proof, Self::named_payer(self.payer)); let tx = TxEip8130 { chain_id, sender: self.sender, @@ -220,7 +227,7 @@ impl Decodable for SpanBatchEip8130TransactionData { payer_authenticator: Decodable::decode(buf)?, }; Self::validate_authenticator(&this.sender_authenticator, this.sender.is_some())?; - Self::validate_authenticator(&this.payer_authenticator, this.payer.is_some())?; + Self::validate_authenticator(&this.payer_authenticator, Self::named_payer(this.payer))?; let consumed = started - buf.len(); if consumed != header.payload_length { return Err(alloy_rlp::Error::ListLengthMismatch { diff --git a/crates/consensus/protocol/src/batch/tx_data/wrapper.rs b/crates/consensus/protocol/src/batch/tx_data/wrapper.rs index b0f722fb04..5a826cbcad 100644 --- a/crates/consensus/protocol/src/batch/tx_data/wrapper.rs +++ b/crates/consensus/protocol/src/batch/tx_data/wrapper.rs @@ -126,7 +126,7 @@ impl TryFrom<&BaseTxEnvelope> for SpanBatchTransactionData { .0, payer_authenticator: SpanBatchEip8130TransactionData::split_auth( signed.payer_auth(), - tx.payer.is_some(), + SpanBatchEip8130TransactionData::named_payer(tx.payer), )? .0, })) diff --git a/crates/execution/eip8130/src/recovered.rs b/crates/execution/eip8130/src/recovered.rs index 63ec786b70..a1d518322d 100644 --- a/crates/execution/eip8130/src/recovered.rs +++ b/crates/execution/eip8130/src/recovered.rs @@ -1,9 +1,8 @@ //! Proof-of-recovery actor id: a recovered secp256k1 signer that can only be //! produced by a verified signature recovery. -use alloy_primitives::{Address, B256, keccak256}; +use alloy_primitives::{Address, B256}; use base_common_consensus::Eip8130Signed; -use k256::ecdsa::{RecoveryId, Signature as K256Signature, VerifyingKey as K256VerifyingKey}; use crate::AuthError; @@ -29,33 +28,18 @@ impl RecoveredActorId { /// Recovers the signer of `hash` from a 65-byte `r || s || v` secp256k1 /// signature — the native `K1_AUTHENTICATOR` wire form — requiring /// `v in {27, 28}` and enforcing **EIP-2 low-`s`** (malleable upper-half-`s` - /// signatures are rejected, not canonicalized). This is the recovery the - /// enshrined k1 authenticator performs; [`AuthenticatorDispatch`] routes its - /// k1 path through here, so the two stay byte-parity with the deployed - /// `AccountConfiguration` reference by construction. + /// signatures are rejected, not canonicalized). Recovery goes through + /// [`Eip8130Signed::recover_raw_k1`], the same checked path open-payer + /// resolution uses. [`AuthenticatorDispatch`] routes its k1 path through + /// here. /// /// [`AuthenticatorDispatch`]: crate::AuthenticatorDispatch pub fn recover_k1(hash: B256, signature: &[u8]) -> Result { if signature.len() != 65 { return Err(AuthError::MalformedAuth); } - let recovery = match signature[64] { - 27 | 28 => signature[64] - 27, - _ => return Err(AuthError::InvalidSignature), - }; - let sig = - K256Signature::from_slice(&signature[..64]).map_err(|_| AuthError::InvalidSignature)?; - // `normalize_s` returns `Some` only when `s` is in the upper half, i.e. a - // malleable high-`s` signature: reject it rather than canonicalizing. - if sig.normalize_s().is_some() { - return Err(AuthError::InvalidSignature); - } - let recovery_id = RecoveryId::from_byte(recovery).ok_or(AuthError::InvalidSignature)?; - let key = K256VerifyingKey::recover_from_prehash(hash.as_slice(), &sig, recovery_id) + let address = Eip8130Signed::recover_raw_k1(hash, signature) .map_err(|_| AuthError::InvalidSignature)?; - let encoded = key.to_encoded_point(false); - // encoded = 0x04 || x(32) || y(32); address = keccak256(x || y)[12..]. - let address = Address::from_slice(&keccak256(&encoded.as_bytes()[1..])[12..]); Ok(Self { address }) } diff --git a/crates/execution/eip8130/src/tx_error.rs b/crates/execution/eip8130/src/tx_error.rs index 094959f3f9..f8d91cbe3c 100644 --- a/crates/execution/eip8130/src/tx_error.rs +++ b/crates/execution/eip8130/src/tx_error.rs @@ -24,6 +24,11 @@ pub enum TxAuthError { #[error("EOA sender signature could not be recovered")] SenderRecovery, + /// The open-payer `payer_auth` was malformed or did not recover. Applies + /// only in open payer mode. + #[error("open payer signature could not be recovered")] + PayerRecovery, + /// The resolved actor is valid but its scope does not grant the operation it /// was authorized for. Mirrors the contract's scope requirement. #[error("{operation:?} actor scope {scope:#04x} does not grant the required context")] diff --git a/crates/execution/eip8130/src/verify.rs b/crates/execution/eip8130/src/verify.rs index 38d1f3a268..bb4cf01365 100644 --- a/crates/execution/eip8130/src/verify.rs +++ b/crates/execution/eip8130/src/verify.rs @@ -80,6 +80,25 @@ impl ActorTxVerifier { } None } + Some(_) if tx.is_open_payer() => { + // Open mode: whoever signs the payer hash pays. The hash binds to + // the resolved sender, so the signature cannot be reused for + // another sender's identical body. + let hash = tx.payer_signature_hash(sender.account); + let recovered = RecoveredActorId::recover_k1(hash, signed.payer_auth()) + .map_err(|_| TxAuthError::PayerRecovery)?; + let account = recovered.address(); + let resolved = ActorAuthorizer::authorize_k1(storage, account, recovered, now)?; + let operation = if account == sender.account { + Operation::SelfPayer + } else { + Operation::SponsorPayer + }; + if !operation.is_granted(&resolved) { + return Err(TxAuthError::Scope { operation, scope: resolved.scope }); + } + Some(AuthorizedActor { account, resolved }) + } Some(account) => { // The payer digest binds to the resolved sender account. let hash = tx.payer_signature_hash(sender.account); @@ -426,6 +445,74 @@ mod tests { }); } + #[test] + fn open_payer_resolves_to_the_recovered_signer() { + let sk = key(0x46); + let sender_account = addr(&sk); + let pk = key(0x57); + let payer_account = addr(&pk); + + let tx = base_tx(None, Some(Eip8130Constants::OPEN_PAYER)); + let sender_hash = tx.sender_signature_hash(); + let payer_hash = tx.payer_signature_hash(sender_account); + let signed = Eip8130Signed::new( + tx, + Bytes::from(sig(&sk, sender_hash)), + Bytes::from(sig(&pk, payer_hash)), + ); + with_storage(|acc| { + let actors = ActorTxVerifier::verify(&signed, acc, NOW).unwrap(); + assert_eq!(actors.sender.account, sender_account); + let payer = actors.payer.expect("open payer resolves"); + assert_eq!(payer.account, payer_account); + assert!(payer.resolved.is_admin()); + }); + } + + #[test] + fn open_payer_signature_bound_to_another_sender_resolves_elsewhere() { + // The open-mode payer hash binds to the resolved sender, so a signature + // issued for another sender recovers to an unrelated address. + let sk = key(0x47); + let pk = key(0x58); + let payer_account = addr(&pk); + + let tx = base_tx(None, Some(Eip8130Constants::OPEN_PAYER)); + let sender_hash = tx.sender_signature_hash(); + let foreign_hash = tx.payer_signature_hash(Address::repeat_byte(0x99)); + let signed = Eip8130Signed::new( + tx, + Bytes::from(sig(&sk, sender_hash)), + Bytes::from(sig(&pk, foreign_hash)), + ); + with_storage(|acc| { + let actors = ActorTxVerifier::verify(&signed, acc, NOW).unwrap(); + assert_ne!(actors.payer.expect("recovers some signer").account, payer_account); + }); + } + + #[test] + fn open_payer_with_prefixed_auth_is_rejected() { + let sk = key(0x48); + let sender_account = addr(&sk); + let pk = key(0x59); + + let tx = base_tx(None, Some(Eip8130Constants::OPEN_PAYER)); + let sender_hash = tx.sender_signature_hash(); + let payer_hash = tx.payer_signature_hash(sender_account); + let signed = Eip8130Signed::new( + tx, + Bytes::from(sig(&sk, sender_hash)), + auth_blob(K1, &sig(&pk, payer_hash)), + ); + with_storage(|acc| { + assert_eq!( + ActorTxVerifier::verify(&signed, acc, NOW).unwrap_err(), + TxAuthError::PayerRecovery + ); + }); + } + #[test] fn payer_signature_bound_to_wrong_sender_is_rejected() { // Same as above, but the payer signs over a digest bound to a *different* diff --git a/crates/execution/payload/src/affordability.rs b/crates/execution/payload/src/affordability.rs index 10096c27a1..faffafdac1 100644 --- a/crates/execution/payload/src/affordability.rs +++ b/crates/execution/payload/src/affordability.rs @@ -65,7 +65,19 @@ impl CoinbaseTipAffordability { return false; }; let sender = tx.sender(); - let payer = signed.tx().payer.unwrap_or(sender); + // Admission already resolved the payer (recovering it in open payer + // mode), so reuse it rather than recovering the signature again for + // every build attempt. Without a classification, an open payer whose + // signature does not recover cannot be priced and is skipped. + let payer = match tx.limit_class() { + Some(class) => class.payer, + None => { + let Ok(payer) = signed.resolved_payer(sender) else { + return true; + }; + payer + } + }; Self::unaffordable_tip( sender, payer, @@ -80,7 +92,13 @@ impl CoinbaseTipAffordability { #[cfg(test)] mod tests { - use alloy_primitives::{Address, B256, U256}; + use alloy_consensus::transaction::Recovered; + use alloy_eips::eip2718::Encodable2718; + use alloy_primitives::{Address, B256, Bytes, U256}; + use base_common_consensus::{ + BaseTxEnvelope, Call, Eip8130Constants, Eip8130Signed, Predeploys, TxEip8130, + }; + use base_execution_txpool::{BasePooledTransaction, BasePooledTx, LimitClass}; use revm::{ Database, database::InMemoryDB, @@ -177,6 +195,59 @@ mod tests { )); } + /// An open-payer transaction tipping the fee vault, whose `payer_auth` is + /// 65 zero bytes (`v = 0`), which never recovers. + fn unrecoverable_open_payer_tip() -> BasePooledTransaction { + let tx = TxEip8130 { + gas_limit: 21_000, + max_fee_per_gas: 2, + payer: Some(Eip8130Constants::OPEN_PAYER), + calls: vec![vec![Call { + to: Predeploys::SEQUENCER_FEE_VAULT, + value: TIP, + data: Bytes::new(), + }]], + ..Default::default() + }; + let envelope = BaseTxEnvelope::Eip8130(Eip8130Signed::new( + tx, + Bytes::from(vec![0u8; 65]), + Bytes::from(vec![0u8; 65]), + )); + let encoded_len = envelope.encode_2718_len(); + BasePooledTransaction::new(Recovered::new_unchecked(envelope, SENDER), encoded_len) + } + + /// Block building prices an open payer from the payer admission resolved, + /// without recovering `payer_auth` again. Without a classification the + /// payer must be recovered, and an unrecoverable one is skipped. + #[test] + fn open_payer_uses_the_admission_resolved_payer() { + let mut db = InMemoryDB::default(); + fund(&mut db, PAYER, 42_000); + fund(&mut db, SENDER, 1_000); + let tx = unrecoverable_open_payer_tip(); + assert!( + CoinbaseTipAffordability::unaffordable(&tx, 0, &mut db), + "an unclassified open payer that does not recover is unaffordable" + ); + + tx.set_limit_class(LimitClass { + sender: SENDER, + payer: PAYER, + classification_generation: 0, + sender_locked: false, + payer_locked: false, + payer_trusted: false, + payer_balance: U256::from(42_000u64), + max_cost: U256::from(42_000u64), + }); + assert!( + !CoinbaseTipAffordability::unaffordable(&tx, 0, &mut db), + "the classified payer covers gas and the sender covers the tip" + ); + } + #[test] fn database_read_errors_fail_open() { assert!(!CoinbaseTipAffordability::unaffordable_tip( diff --git a/crates/execution/rpc/src/eth/receipt.rs b/crates/execution/rpc/src/eth/receipt.rs index fe0630528f..a8e3acad3a 100644 --- a/crates/execution/rpc/src/eth/receipt.rs +++ b/crates/execution/rpc/src/eth/receipt.rs @@ -334,11 +334,23 @@ impl BaseReceiptBuilder { let tx_signed = *input.tx.inner(); // EIP-8130 RPC-only fields, derived before `input` is consumed below. The payer is - // the sender for self-pay or the explicit payer for sponsored transactions; the - // per-phase statuses are persisted on the receipt and surfaced only at RPC. - let payer = tx_signed - .as_eip8130() - .map(|signed| signed.tx().payer.unwrap_or_else(|| input.tx.signer())); + // the sender for self-pay, the named payer for sponsored transactions, or the + // recovered signer in open payer mode; the per-phase statuses are persisted on the + // receipt and surfaced only at RPC. + // Recovery of an included open-payer transaction succeeds, since + // inclusion required it. If it somehow does not, omit the payer rather + // than report an address that did not pay. + let payer = tx_signed.as_eip8130().and_then(|signed| { + signed + .resolved_payer(input.tx.signer()) + .inspect_err(|error| { + tracing::error!( + error = %error, + "open payer recovery failed while building an EIP-8130 receipt; omitting the payer" + ); + }) + .ok() + }); // Omit empty metadata rather than serializing it as `"0x"`. Empty // `phase_statuses` is still serialized as `[]` on EIP-8130 receipts; // only metadata is skipped when empty. diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index 91cac561f2..2d4c331165 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -1536,6 +1536,7 @@ where "delegate nested actor lacks SIGNATURE scope" } TxAuthError::SenderRecovery => "EOA sender recovery failed", + TxAuthError::PayerRecovery => "open payer recovery failed", TxAuthError::Scope { .. } => "actor scope insufficient", TxAuthError::AccountIsLocked => "account is locked", TxAuthError::DelegationUnauthorized => "delegation requires admin actor", @@ -1761,9 +1762,9 @@ where Ok(()) } - /// Ensures `payer_auth` is present iff a `payer` is set, and that its - /// authenticator prefix sits in the live policy range (above the reserved - /// floor, below the revoked sentinel). + /// Ensures `payer_auth` is present iff a `payer` is set. Open payer mode + /// carries a raw 65-byte signature; a named payer carries an allowed + /// `authenticator || data` blob. fn validate_payer_auth(signed: &Eip8130Signed) -> Result<(), InvalidPoolTransactionError> { let payer_present = signed.tx().payer.is_some(); let auth = signed.payer_auth(); @@ -1771,7 +1772,11 @@ where if payer_present == auth.is_empty() { return Err(InvalidTransactionError::TxTypeNotSupported.into()); } - if payer_present { + if signed.tx().is_open_payer() { + if auth.len() != 65 { + return Err(InvalidTransactionError::TxTypeNotSupported.into()); + } + } else if payer_present { if auth.len() < 20 { return Err(InvalidTransactionError::TxTypeNotSupported.into()); } From 5bd5147631280ae0c54fbbbf592a1b8f4b06b0e1 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 15:17:55 +0000 Subject: [PATCH 153/299] fix(batcher): serve receipts before L2 blocks so a failed submission is resent first (#5317) * fix(batcher): serve receipts before L2 blocks so a failed submission is resent first * docs(batcher): state the receipts-first rule once --- crates/batcher/core/src/driver.rs | 62 +++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 30fd06fcc7..566b7837a1 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -143,11 +143,12 @@ where /// would waste an RPC. /// /// The I/O phase polls its arms in priority order: cancellation, admin commands, - /// derivation status, L2 blocks, receipts, L1 heads. Admin commands come before the + /// derivation status, receipts, L2 blocks, L1 heads. Admin commands come before the /// source so control-plane operations (stop, start, flush) are never starved by sustained - /// block throughput; derivation-status changes come before unsafe blocks so pruning and - /// recovery cannot be starved by sequential catchup. A stopped batcher does not poll its - /// source at all. + /// block throughput. Derivation-status changes come before unsafe blocks so pruning and + /// recovery cannot be starved by sequential catchup. Receipts come before unsafe blocks so + /// a failed submission is resent before anything a block ready at the same wait releases. A + /// stopped batcher does not poll its source at all. /// /// Cancellation ends the loop with a bounded drain of the in-flight submissions; see /// `shutdown`. @@ -174,6 +175,10 @@ where None => return Err(BatchDriverError::DerivationStatusSourceClosed), }, + Some((id, outcome)) = self.submissions.next_settled() => { + self.submissions.handle_outcome(&mut self.pipeline, id, outcome); + } + event = self.source.next(), if !self.stopped && !encoding_left => match event { L2BlockEvent::Block(block) => self.on_block(block), L2BlockEvent::Reorg => { @@ -182,10 +187,6 @@ where } }, - Some((id, outcome)) = self.submissions.next_settled() => { - self.submissions.handle_outcome(&mut self.pipeline, id, outcome); - } - head = self.l1_head_source.next(), if !encoding_left => { self.pipeline.advance_l1_head(head); debug!(l1_head = %head, "L1 head advanced via source"); @@ -567,7 +568,7 @@ mod tests { } #[test] - fn run_prioritizes_source_before_receipts_and_heads() { + fn run_prioritizes_receipts_before_source_and_heads() { Runner::start(Config::seeded(0), |ctx| async move { let mut pipeline = TrackingPipeline::new(); let recorded = pipeline.recorded(); @@ -591,9 +592,9 @@ mod tests { assert!( recorded.calls.starts_with(&[ PipelineCall::Dequeue(SubmissionId(0)), - PipelineCall::AddBlock(1), PipelineCall::Confirm(SubmissionId(0), 1), PipelineCall::AdvanceL1Head(1), + PipelineCall::AddBlock(1), PipelineCall::AdvanceL1Head(9), ]), "{:?}", @@ -630,9 +631,9 @@ mod tests { recorded.calls.starts_with(&[ PipelineCall::Dequeue(SubmissionId(0)), PipelineCall::ReconcileDerivation { safe_l2: 5, current_l1: 1 }, - PipelineCall::AddBlock(6), PipelineCall::Confirm(SubmissionId(0), 42), PipelineCall::AdvanceL1Head(42), + PipelineCall::AddBlock(6), ]), "{:?}", recorded.calls @@ -856,6 +857,45 @@ mod tests { }); } + /// A failed submission is resent before anything a block releases, even when both are + /// ready at the same wait. + #[test] + fn run_resends_a_failed_submission_before_a_block_releases_newer_ones() { + Runner::start(Config::seeded(0), |ctx| async move { + let mut pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + // Submitted by the first CPU phase, so its failure is ready at the first wait, + // along with the block. + pipeline.submissions.push_back(SubmissionStub::stub()); + let (source, l1_head_source) = queued_sources([1], []); + let (driver, _handles) = DriverFixture::new( + ctx.clone(), + pipeline, + ScriptedTxManager::new([SendOutcome::Failed]), + ) + .source(source) + .l1_head_source(l1_head_source) + .build(); + + let handle = ctx.spawn(driver.run()); + ctx.sleep(Duration::from_millis(10)).await; + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + + let recorded = recorded.lock().unwrap(); + assert!( + recorded.calls.starts_with(&[ + PipelineCall::Dequeue(SubmissionId(0)), + PipelineCall::Requeue(SubmissionId(0)), + PipelineCall::Dequeue(SubmissionId(1)), + PipelineCall::AddBlock(1), + ]), + "{:?}", + recorded.calls + ); + }); + } + /// A single submission may contain multiple blob-filling frames when /// `max_blobs_per_tx > 1`. Each frame becomes its own blob in the same L1 /// transaction. From 6861b7dfb3cf9f964a5aadb2a23dcf117c7cd1bb Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 29 Sep 2026 15:31:33 +0000 Subject: [PATCH 154/299] feat(txpool): require both fee fields to rise when replacing an EIP-8130 transaction (#5271) * feat(eip8130): add open payer mode payer = 0x00 on the wire lets any secp256k1 key sign the payer hash and pay for the transaction. The payer is recovered from a raw 65-byte payer_auth bound to the resolved sender. A 20-byte zero payer address is rejected so open mode has a single encoding. * fix(eip8130): return open-payer recovery errors from resolved_payer * fix(eip8130): share checked k1 recovery and reject a zero payer directly Open-payer resolution and RecoveredActorId::recover_k1 now both use Eip8130Signed::recover_raw_k1. Payer decode rejects the 20-byte zero address by comparing the bytes to zero. * docs(eip8130): scope the payer_auth layouts to open and named payer modes * perf(payload): reuse the admission-resolved open payer for tip affordability CoinbaseTipAffordability recovered an open payer's secp256k1 signature on every build attempt. The pool already resolved the payer at admission and stored it in the transaction's LimitClass, so affordability reads it from there and only recovers when no classification is attached. resolved_payer now recovers directly instead of round-tripping through recover_open_payer's Option, which had no other caller and is removed. * fix(eip8130): never substitute the sender for an open payer An open payer is the signer its payer_auth recovers to, so a transaction whose payer_auth does not recover has no payer and is invalid. Estimation now rejects it instead of pricing it against the sender, the RPC requires payer_auth for an open payer instead of synthesizing a stub that can never recover, and a receipt omits the payer rather than reporting the sender if recovery ever fails. * fix(eip8130): estimate open payer transactions before the payer signs An absent open-payer payer_auth is priced as a 65-byte signature that never recovers, and simulation publishes the UNSIGNED_OPEN_PAYER placeholder to TxContext for a payer that has not signed, never the sender. Signatures are never verified during estimation, so a sender can estimate before a payer exists. * feat(txpool): require both fee fields to rise when replacing an EIP-8130 transaction The default price-bump check exempts a replacement with a zero priority fee from the priority bump. EIP-8130 replacements now need both max_fee_per_gas and max_priority_fee_per_gas to rise by the configured bump. Replacements where both transactions carry validity predicates keep their relaxed rule. * test(txpool): cover EIP-8130 replacement when both transactions carry validity predicates * docs(txpool): note which type's price bump applies to cross-type EIP-8130 replacement --- crates/execution/txpool/src/transaction.rs | 127 +++++++++++++++------ 1 file changed, 90 insertions(+), 37 deletions(-) diff --git a/crates/execution/txpool/src/transaction.rs b/crates/execution/txpool/src/transaction.rs index 1c4873e8af..e5d219f83c 100644 --- a/crates/execution/txpool/src/transaction.rs +++ b/crates/execution/txpool/src/transaction.rs @@ -15,7 +15,9 @@ use alloy_eips::{ }; use alloy_primitives::{Address, B256, Bytes, TxHash, TxKind, U256}; use base_bundles::MeterBundleResponse; -use base_common_consensus::{BaseTransactionSigned, Eip8130Constants, Eip8130Signed}; +use base_common_consensus::{ + BaseTransactionSigned, EIP8130_TX_TYPE_ID, Eip8130Constants, Eip8130Signed, +}; use c_kzg::KzgSettings; use reth_primitives_traits::{InMemorySize, SignedTransaction}; use reth_transaction_pool::{ @@ -180,11 +182,22 @@ where replacement: &Self, price_bumps: &PriceBumpConfig, ) -> bool { - if self.validity_predicates().is_empty() || replacement.validity_predicates().is_empty() { - return price_bumps.is_replacement_underpriced(self, replacement); + if !self.validity_predicates().is_empty() && !replacement.validity_predicates().is_empty() { + return replacement.max_fee_per_gas() <= self.max_fee_per_gas(); } - - replacement.max_fee_per_gas() <= self.max_fee_per_gas() + if self.ty() == EIP8130_TX_TYPE_ID || replacement.ty() == EIP8130_TX_TYPE_ID { + // EIP-8130 requires both fee fields to rise by the bump, including + // when the replacement's priority fee is zero (which the default + // check exempts). The bump is keyed on the existing transaction's + // type, as in the default check, so a non-8130 transaction replaced + // by an 8130 one uses the non-8130 bump. + let bump = price_bumps.price_bump(self.ty()); + let bumped = |fee: u128| fee.saturating_mul(100 + bump).div_ceil(100); + return replacement.max_fee_per_gas() < bumped(self.max_fee_per_gas()) + || replacement.max_priority_fee_per_gas().unwrap_or_default() + < bumped(self.max_priority_fee_per_gas().unwrap_or_default()); + } + price_bumps.is_replacement_underpriced(self, replacement) } fn clone_into_consensus(&self) -> Recovered { @@ -541,7 +554,7 @@ where mod tests { use std::sync::Arc; - use alloy_consensus::transaction::Recovered; + use alloy_consensus::{SignableTransaction, TxEip1559, transaction::Recovered}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{Address, Bytes, TxKind, U256}; use alloy_signer::SignerSync; @@ -592,6 +605,24 @@ mod tests { PrivateKeySigner::random() } + fn eip1559_pooled_with_fees( + max_priority_fee_per_gas: u128, + max_fee_per_gas: u128, + ) -> BasePooledTransaction { + let signer = signer(); + let tx = TxEip1559 { + chain_id: ChainConfig::mainnet().chain_id, + max_priority_fee_per_gas, + max_fee_per_gas, + gas_limit: 21_000, + to: TxKind::Call(Address::ZERO), + ..Default::default() + }; + let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap(); + let pooled = ConsensusPooledTransaction::Eip1559(tx.into_signed(signature)); + BasePooledTransaction::from_pooled(Recovered::new_unchecked(pooled, signer.address())) + } + fn eip8130_pooled(nonce_key: U256) -> BasePooledTransaction { eip8130_pooled_with_fees(nonce_key, 0, 1) } @@ -669,22 +700,25 @@ mod tests { assert!(!eip8130_pooled(Eip8130Constants::NONCE_KEY_MAX).requires_nonce_check()); } - #[test] - fn validity_replacement_only_requires_a_higher_max_fee() { - let predicate = ValidityPredicate::Balance { + fn balance_predicate() -> ValidityPredicate { + ValidityPredicate::Balance { address: Address::ZERO, op: ValidityOperator::Equal, value: U256::ZERO, - }; - let existing = eip8130_pooled_with_fees(U256::ZERO, 10, 100) - .with_validity_predicates(vec![predicate.clone()]); - let replacement = eip8130_pooled_with_fees(U256::ZERO, 0, 101) - .with_validity_predicates(vec![predicate.clone()]); + } + } + + #[test] + fn validity_replacement_only_requires_a_higher_max_fee() { + let existing = + eip1559_pooled_with_fees(10, 100).with_validity_predicates(vec![balance_predicate()]); + let replacement = + eip1559_pooled_with_fees(0, 101).with_validity_predicates(vec![balance_predicate()]); assert!(!existing.is_replacement_underpriced(&replacement, &PriceBumpConfig::default())); - let unchanged_max_fee = eip8130_pooled_with_fees(U256::ZERO, 100, 100) - .with_validity_predicates(vec![predicate]); + let unchanged_max_fee = + eip1559_pooled_with_fees(100, 100).with_validity_predicates(vec![balance_predicate()]); assert!( existing.is_replacement_underpriced(&unchanged_max_fee, &PriceBumpConfig::default()) ); @@ -692,37 +726,56 @@ mod tests { #[test] fn validity_replacement_of_non_validity_transaction_uses_configured_price_bumps() { - let existing = eip8130_pooled_with_fees(U256::ZERO, 10, 100); - let predicate = ValidityPredicate::Balance { - address: Address::ZERO, - op: ValidityOperator::Equal, - value: U256::ZERO, - }; - let underpriced = eip8130_pooled_with_fees(U256::ZERO, 0, 109) - .with_validity_predicates(vec![predicate.clone()]); - + let existing = eip1559_pooled_with_fees(10, 100); + let underpriced = + eip1559_pooled_with_fees(0, 109).with_validity_predicates(vec![balance_predicate()]); assert!(existing.is_replacement_underpriced(&underpriced, &PriceBumpConfig::default())); let replacement = - eip8130_pooled_with_fees(U256::ZERO, 0, 110).with_validity_predicates(vec![predicate]); + eip1559_pooled_with_fees(0, 110).with_validity_predicates(vec![balance_predicate()]); assert!(!existing.is_replacement_underpriced(&replacement, &PriceBumpConfig::default())); } #[test] - fn non_validity_replacement_uses_configured_price_bumps() { - let predicate = ValidityPredicate::Balance { - address: Address::ZERO, - op: ValidityOperator::Equal, - value: U256::ZERO, - }; - let existing = - eip8130_pooled_with_fees(U256::ZERO, 10, 100).with_validity_predicates(vec![predicate]); - let underpriced = eip8130_pooled_with_fees(U256::ZERO, 10, 110); + fn eip8130_replacement_requires_both_fees_to_rise() { + let existing = eip8130_pooled_with_fees(U256::ZERO, 10, 100); + let bumps = PriceBumpConfig::default(); - assert!(existing.is_replacement_underpriced(&underpriced, &PriceBumpConfig::default())); + // A zero priority fee does not exempt the replacement from the bump. + assert!( + existing + .is_replacement_underpriced(&eip8130_pooled_with_fees(U256::ZERO, 0, 200), &bumps) + ); + assert!( + existing + .is_replacement_underpriced(&eip8130_pooled_with_fees(U256::ZERO, 10, 110), &bumps) + ); + assert!( + existing + .is_replacement_underpriced(&eip8130_pooled_with_fees(U256::ZERO, 11, 109), &bumps) + ); + assert!( + !existing + .is_replacement_underpriced(&eip8130_pooled_with_fees(U256::ZERO, 11, 110), &bumps) + ); + } - let replacement = eip8130_pooled_with_fees(U256::ZERO, 11, 110); + #[test] + fn eip8130_with_validity_predicates_uses_relaxed_replacement_rule() { + let existing = eip8130_pooled_with_fees(U256::ZERO, 10, 100) + .with_validity_predicates(vec![balance_predicate()]); + // Zero tip and a higher max fee is accepted: both transactions carry + // validity predicates, so the relaxed rule runs before the EIP-8130 + // bump that requires both fee fields to rise. + let replacement = eip8130_pooled_with_fees(U256::ZERO, 0, 101) + .with_validity_predicates(vec![balance_predicate()]); assert!(!existing.is_replacement_underpriced(&replacement, &PriceBumpConfig::default())); + + let unchanged_max_fee = eip8130_pooled_with_fees(U256::ZERO, 100, 100) + .with_validity_predicates(vec![balance_predicate()]); + assert!( + existing.is_replacement_underpriced(&unchanged_max_fee, &PriceBumpConfig::default()) + ); } #[test] From af5b6771cd09bd855da412fb7df012f5b666412c Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 15:38:44 +0000 Subject: [PATCH 155/299] refactor(tx-manager): drop cancel_tx and the AlreadyReserved error, which nothing produces or calls (#5338) * refactor(tx-manager): drop cancel_tx and the AlreadyReserved error, which nothing produces or calls * test(tx-manager): drop the classifier case that only guarded AlreadyReserved * docs(tx-manager): drop the cancellation leftovers --- actions/harness/README.md | 4 +- crates/utilities/tx-manager/README.md | 3 +- crates/utilities/tx-manager/src/error.rs | 9 +--- crates/utilities/tx-manager/src/send_state.rs | 53 +++---------------- crates/utilities/tx-manager/src/traits.rs | 10 ---- 5 files changed, 10 insertions(+), 69 deletions(-) diff --git a/actions/harness/README.md b/actions/harness/README.md index a5ee23a5f4..d6ba845b6c 100644 --- a/actions/harness/README.md +++ b/actions/harness/README.md @@ -161,8 +161,8 @@ Current behavior: Remaining gaps: -- There is no real RPC tx manager, mempool, replacement, fee bumping, - cancellation, or timeout policy. +- There is no real RPC tx manager, mempool, replacement, fee bumping, or + timeout policy. - Receipt polling is driven by explicit test calls instead of a background RPC polling task. diff --git a/crates/utilities/tx-manager/README.md b/crates/utilities/tx-manager/README.md index b9538bb3c6..16aa536261 100644 --- a/crates/utilities/tx-manager/README.md +++ b/crates/utilities/tx-manager/README.md @@ -13,8 +13,7 @@ confirmation. ### Core types -- **`TxManager`** — trait defining the public API (`send`, `send_async`, `cancel_tx`, - `sender_address`). +- **`TxManager`** — trait defining the public API (`send`, `send_async`, `sender_address`). - **`SimpleTxManager`** — default `TxManager` implementation. Handles gas estimation, nonce management, signing, submission with fee bumps, and receipt polling. - **`TxCandidate`** — input to the send pipeline (calldata, recipient, gas limit, value, diff --git a/crates/utilities/tx-manager/src/error.rs b/crates/utilities/tx-manager/src/error.rs index c2774862bf..bf3c49bff0 100644 --- a/crates/utilities/tx-manager/src/error.rs +++ b/crates/utilities/tx-manager/src/error.rs @@ -81,10 +81,6 @@ pub enum TxManagerError { #[error("runtime mempool deadline requires critical_error_at")] RuntimeMempoolDeadlineMissingTimestamp, - /// Nonce slot was already reserved. - #[error("nonce already reserved")] - AlreadyReserved, - /// Nonce arithmetic overflowed `u64::MAX`. #[error("nonce overflow")] NonceOverflow, @@ -197,7 +193,7 @@ impl TxManagerError { /// /// Fee/replacement errors and infrastructure errors are retryable. /// Critical errors (nonce conflicts, insufficient funds, reverts, - /// deadline expiry, reservation conflicts) are not. + /// deadline expiry) are not. /// /// # Caller requirements /// @@ -411,7 +407,6 @@ mod tests { #[case::preserves_casing("Some Unknown ERROR", TxManagerError::Rpc("Some Unknown ERROR".to_string()))] #[case::empty_string("", TxManagerError::Rpc(String::new()))] #[case::mempool_deadline_not_classified("mempool deadline expired", TxManagerError::Rpc("mempool deadline expired".to_string()))] - #[case::already_reserved_not_classified("nonce already reserved", TxManagerError::Rpc("nonce already reserved".to_string()))] fn classify_rpc_error(#[case] input: &str, #[case] expected: TxManagerError) { let transport_err = error_resp(input); assert_eq!(RpcErrorClassifier::classify_rpc_error(&transport_err), expected); @@ -447,7 +442,6 @@ mod tests { TxManagerError::RuntimeMempoolDeadlineMissingTimestamp, false )] - #[case::already_reserved(TxManagerError::AlreadyReserved, false)] #[case::channel_closed(TxManagerError::ChannelClosed, false)] #[case::fee_limit_exceeded(TxManagerError::FeeLimitExceeded { fee: 0, ceiling: 0 }, false)] #[case::invalid_safe_abort(TxManagerError::InvalidSafeAbortNonceTooLowCount, false)] @@ -506,7 +500,6 @@ mod tests { TxManagerError::RuntimeMempoolDeadlineMissingTimestamp, "runtime mempool deadline requires critical_error_at" )] - #[case::already_reserved(TxManagerError::AlreadyReserved, "nonce already reserved")] #[case::underpriced(TxManagerError::Underpriced, "transaction underpriced")] #[case::replacement_underpriced( TxManagerError::ReplacementUnderpriced, diff --git a/crates/utilities/tx-manager/src/send_state.rs b/crates/utilities/tx-manager/src/send_state.rs index 35a868c93b..e46cf0e0fb 100644 --- a/crates/utilities/tx-manager/src/send_state.rs +++ b/crates/utilities/tx-manager/src/send_state.rs @@ -30,7 +30,6 @@ struct SendStateInner { mined_txs: Vec, has_published: bool, nonce_too_low_count: u64, - already_reserved: bool, bump_fees: bool, bump_count: u64, mempool_deadline: Option, @@ -61,7 +60,6 @@ impl SendState { mined_txs: Vec::new(), has_published: false, nonce_too_low_count: 0, - already_reserved: false, bump_fees: false, bump_count: 0, mempool_deadline: None, @@ -75,7 +73,6 @@ impl SendState { /// /// - [`TxManagerError::NonceTooLow`] increments the nonce-too-low counter. /// - [`TxManagerError::NonceTooHigh`] is transient and leaves no state. - /// - [`TxManagerError::AlreadyReserved`] sets the already-reserved flag. /// - Any other [retryable](TxManagerError::is_retryable) error sets the /// bump-fees flag for the next send attempt. /// - Other critical errors are no-ops (handled at a higher level). @@ -92,9 +89,6 @@ impl SendState { // publication has propagated. Retrying the same signed // transaction requires no persistent state change. TxManagerError::NonceTooHigh => {} - TxManagerError::AlreadyReserved => { - inner.already_reserved = true; - } e if e.is_retryable() => { inner.bump_fees = true; } @@ -133,20 +127,18 @@ impl SendState { /// /// Conditions are checked in priority order: /// 1. If any transaction is mined, returns `None` (wait for confirmation). - /// 2. If the nonce slot was already reserved, returns - /// [`TxManagerError::AlreadyReserved`]. - /// 3. If no successful publish has occurred and a nonce-too-low error was + /// 2. If no successful publish has occurred and a nonce-too-low error was /// seen, returns [`TxManagerError::NonceTooLow`] (immediate abort). - /// 4. If nonce-too-low errors have reached the threshold, returns + /// 3. If nonce-too-low errors have reached the threshold, returns /// [`TxManagerError::NonceTooLow`]. - /// 5. If the mempool deadline has expired *and* no transaction has been + /// 4. If the mempool deadline has expired *and* no transaction has been /// successfully published, returns /// [`TxManagerError::MempoolDeadlineExpired`]. Once a publish has /// succeeded the tx is in the mempool and the deadline's purpose is /// served — further waiting is governed by `tx_send_timeout` and the /// bump cycle, not by aborting (which would leak a pre-reserved nonce /// and stall publication on lower nonces still live on L1). - /// 6. Otherwise, returns `None`. + /// 5. Otherwise, returns `None`. #[must_use] pub fn critical_error(&self) -> Option { self.critical_error_with(None) @@ -164,9 +156,6 @@ impl SendState { if !inner.mined_txs.is_empty() { return None; } - if inner.already_reserved { - return Some(TxManagerError::AlreadyReserved); - } if !inner.has_published && inner.nonce_too_low_count > 0 { return Some(TxManagerError::NonceTooLow); } @@ -365,16 +354,6 @@ mod tests { assert!(state.critical_error().is_none()); } - #[test] - fn mined_tx_suppresses_already_reserved_abort() { - let state = SendState::new(3).unwrap(); - state.process_send_error(&TxManagerError::AlreadyReserved); - assert_eq!(state.critical_error(), Some(TxManagerError::AlreadyReserved)); - - state.tx_mined(B256::with_last_byte(1)); - assert!(state.critical_error().is_none()); - } - #[test] fn mined_tx_suppresses_mempool_deadline_abort() { let state = SendState::new(3).unwrap(); @@ -577,28 +556,8 @@ mod tests { assert!(!state.take_bump_fees()); } - // ── AlreadyReserved ───────────────────────────────────────────────── - - #[test] - fn already_reserved_triggers_abort() { - let state = SendState::new(3).unwrap(); - state.process_send_error(&TxManagerError::AlreadyReserved); - assert_eq!(state.critical_error(), Some(TxManagerError::AlreadyReserved)); - } - // ── Priority ordering ──────────────────────────────────────────────── - #[test] - fn already_reserved_takes_priority_over_expired_deadline() { - let state = SendState::new(3).unwrap(); - state.process_send_error(&TxManagerError::AlreadyReserved); - state.set_wall_clock_mempool_deadline(Instant::now() - Duration::from_secs(1)); - - // AlreadyReserved (priority 2) wins over MempoolDeadlineExpired - // (priority 6). - assert_eq!(state.critical_error(), Some(TxManagerError::AlreadyReserved)); - } - #[test] fn pre_publish_nonce_too_low_takes_priority_over_expired_deadline() { let state = SendState::new(3).unwrap(); @@ -606,8 +565,8 @@ mod tests { state.process_send_error(&TxManagerError::NonceTooLow); state.set_wall_clock_mempool_deadline(Instant::now() - Duration::from_secs(1)); - // Pre-publish NonceTooLow (priority 4) wins over - // MempoolDeadlineExpired (priority 6). + // Pre-publish NonceTooLow (priority 2) wins over + // MempoolDeadlineExpired (priority 4). assert_eq!(state.critical_error(), Some(TxManagerError::NonceTooLow)); } diff --git a/crates/utilities/tx-manager/src/traits.rs b/crates/utilities/tx-manager/src/traits.rs index 57a3e9fd3d..e4e954687a 100644 --- a/crates/utilities/tx-manager/src/traits.rs +++ b/crates/utilities/tx-manager/src/traits.rs @@ -60,16 +60,6 @@ pub trait TxManager: Send + Sync + Debug { /// Returns the address transactions are sent from. fn sender_address(&self) -> Address; - - /// Attempt to cancel a stuck txpool transaction by sending a self-transfer - /// with a higher gas price at the same nonce, freeing the slot. - /// - /// The default implementation is a no-op that immediately returns `Ok(())`, - /// suitable for test managers and environments where txpool management is - /// not needed. - fn cancel_tx(&self) -> impl Future> + Send { - std::future::ready(Ok(())) - } } #[cfg(test)] From 859b77d5c9786840ef666268ee2ebc7f3d074027 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 29 Sep 2026 15:48:45 +0000 Subject: [PATCH 156/299] feat(eip8130): normalize seconds/milliseconds validity bounds (#5040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(eip8130): add open payer mode payer = 0x00 on the wire lets any secp256k1 key sign the payer hash and pay for the transaction. The payer is recovered from a raw 65-byte payer_auth bound to the resolved sender. A 20-byte zero payer address is rejected so open mode has a single encoding. * fix(eip8130): return open-payer recovery errors from resolved_payer * fix(eip8130): share checked k1 recovery and reject a zero payer directly Open-payer resolution and RecoveredActorId::recover_k1 now both use Eip8130Signed::recover_raw_k1. Payer decode rejects the 20-byte zero address by comparing the bytes to zero. * docs(eip8130): scope the payer_auth layouts to open and named payer modes * perf(payload): reuse the admission-resolved open payer for tip affordability CoinbaseTipAffordability recovered an open payer's secp256k1 signature on every build attempt. The pool already resolved the payer at admission and stored it in the transaction's LimitClass, so affordability reads it from there and only recovers when no classification is attached. resolved_payer now recovers directly instead of round-tripping through recover_open_payer's Option, which had no other caller and is removed. * fix(eip8130): never substitute the sender for an open payer An open payer is the signer its payer_auth recovers to, so a transaction whose payer_auth does not recover has no payer and is invalid. Estimation now rejects it instead of pricing it against the sender, the RPC requires payer_auth for an open payer instead of synthesizing a stub that can never recover, and a receipt omits the payer rather than reporting the sender if recovery ever fails. * fix(eip8130): estimate open payer transactions before the payer signs An absent open-payer payer_auth is priced as a 65-byte signature that never recovers, and simulation publishes the UNSIGNED_OPEN_PAYER placeholder to TxContext for a payer that has not signed, never the sender. Signatures are never verified during estimation, so a sender can estimate before a payer exists. * feat(txpool): require both fee fields to rise when replacing an EIP-8130 transaction The default price-bump check exempts a replacement with a zero priority fee from the priority bump. EIP-8130 replacements now need both max_fee_per_gas and max_priority_fee_per_gas to rise by the configured bump. Replacements where both transactions carry validity predicates keep their relaxed rule. * test(txpool): cover EIP-8130 replacement when both transactions carry validity predicates * docs(txpool): note which type's price bump applies to cross-type EIP-8130 replacement * feat(eip8130): normalize seconds/milliseconds validity bounds EIP-8130's `valid_after`/`valid_before` may be supplied in either Unix seconds or Unix milliseconds; the denomination is auto-detected per value (Timestamp Normalization). Previously the pool and the consensus inclusion window both assumed milliseconds, so a seconds-denominated bound (~`1.7e9`) compared as far below `block.timestamp * 1000` (~`1.7e12`) and was silently treated as already-expired — a footgun for clients that pin bounds in seconds. Since EIP-8130 is enshrined at Cobalt and Cobalt is not yet active on any network, there are no live 8130 transactions or blocks to stay compatible with: this defines the correct launch behavior rather than migrating a shipped consensus rule, so no separate fork gate is required. Normalize both bounds to milliseconds at every validity comparison: - Add `Eip8130Constants::TIMESTAMP_MS_THRESHOLD` (`10^11`) and `normalize_timestamp_ms`: a non-zero bound below the threshold is seconds (scaled by 1000, saturating); `0` (disabled) and values at/above the threshold pass through unchanged. The split sits far from every realistic timestamp for millennia. - Add `TxEip8130::valid_after_ms`/`valid_before_ms` as the single normalized view. The raw fields remain what the wire encoding, signature hashes, and the nonce-free replay id commit to; only window and expiry comparisons use the normalized view. - Use the normalized accessors in `Eip8130Signed::validate_timestamp` (pool admission), the consensus inclusion window and nonce-free replay-ring record in `Eip8130Executor::authorize_and_apply`, and the pool expiry sites (`TwoDNoncePool::remove_expired_nonce_free`, `validator::tx_valid_before_secs`). Tests: unit coverage for `normalize_timestamp_ms` and the accessors; seconds-denominated bounds accepted/rejected identically to their ms equivalents in both the pool (`validate_timestamp`) and the consensus execution window (`Eip8130Executor::execute`, EOA path). Toy-timestamp fixtures were migrated to a realistic millisecond clock (the integration pool is seeded via a new `BaseL1BlockInfo::set_timestamp` seam) so normalization is a no-op where those tests exercise the raw comparisons. * fix(txpool): address timestamp-normalization review nits Follow-up to the seconds/milliseconds validity-bound normalization: - map_timestamp_error logged the raw tx.valid_after / tx.valid_before beside a millisecond `now`, so a seconds-denominated bound read as decades of skew (e.g. a 1s-stale tx logged now=1700000100000 valid_before=1700000099). Log the normalized valid_after_ms() / valid_before_ms() that validate_timestamp actually compares, keeping the raw values under *_raw names for debugging the auto-detection itself. - Remove the unconditional pub BaseL1BlockInfo::set_timestamp seam whose only caller was the integration-pool test harness. The harness now seeds its clock through update_l1_block_info(_, None) — the same store the live head uses, which leaves l1_block_info untouched — so runtime code no longer exposes a second, unsynchronized writer to the admission clock. - Migrate the three timestamp tests that were left on a zero clock (and thus silently rescaled x1000 by normalization) to the same realistic millisecond clock as their neighbours. rejects_..._expiry_too_far_in_future again exercises the true edge+1 (now_ms + NONCE_FREE_MAX_EXPIRY_WINDOW + 1) it was written for, mirroring accepts_..._at_expiry_window_edge. * fix(eip8130): set call value in seconds-normalization tests These tests predate the Call.value field and constructed Call without it, so they failed to compile once value-in-calls landed below this PR in the stack. Set an explicit zero value. * docs(eip8130): RPC validity bounds accept seconds or milliseconds --- .../src/transaction/eip8130/constants.rs | 52 +++++++++++ .../src/transaction/eip8130/signed.rs | 90 ++++++++++++++++--- .../consensus/src/transaction/eip8130/tx.rs | 61 +++++++++++-- crates/common/evm/src/eip8130.rs | 55 ++++++++++-- .../rpc-types/src/transaction/request.rs | 12 +-- crates/execution/txpool/src/pool.rs | 61 +++++++++++-- .../execution/txpool/src/two_d_nonce_pool.rs | 19 ++-- crates/execution/txpool/src/validator.rs | 81 ++++++++++++----- 8 files changed, 361 insertions(+), 70 deletions(-) diff --git a/crates/common/consensus/src/transaction/eip8130/constants.rs b/crates/common/consensus/src/transaction/eip8130/constants.rs index d59a697bcc..a2377249a1 100644 --- a/crates/common/consensus/src/transaction/eip8130/constants.rs +++ b/crates/common/consensus/src/transaction/eip8130/constants.rs @@ -277,6 +277,38 @@ impl Eip8130Constants { /// invariant test in `base-common-precompiles`). pub const NONCE_FREE_MAX_EXPIRY_WINDOW: u64 = 20_000; + /// Seconds/milliseconds split for validity-bound normalization, from + /// EIP-8130's Timestamp Normalization section (`10^11`). + /// + /// `valid_after`/`valid_before` may be supplied in either Unix **seconds** + /// or Unix **milliseconds**; the denomination is auto-detected per value. + /// A non-zero bound *below* this threshold is interpreted as seconds and + /// scaled to milliseconds; a bound at or above it is already milliseconds. + /// The split sits far from every realistic timestamp for the next several + /// millennia (`10^11` seconds is year ~5138; `10^11` ms is March 1973), so a + /// seconds value (~`1.7e9`) is always below it and a milliseconds value + /// (~`1.7e12`) always at or above it. + pub const TIMESTAMP_MS_THRESHOLD: u64 = 100_000_000_000; + + /// Normalizes a validity bound (`valid_after`/`valid_before`) to Unix + /// **milliseconds**, per EIP-8130 Timestamp Normalization. + /// + /// `0` is the "disabled" sentinel and passes through unchanged; a non-zero + /// value below [`Self::TIMESTAMP_MS_THRESHOLD`] is treated as seconds and + /// scaled by `1000` (saturating); a value at or above the threshold is + /// already milliseconds and passes through unchanged. This is the single + /// definition of the seconds↔ms rule shared by the mempool admission window + /// ([`crate::Eip8130Signed::validate_timestamp`]) and the consensus + /// inclusion window and nonce-free replay ring. + #[must_use] + pub const fn normalize_timestamp_ms(value: u64) -> u64 { + if value != 0 && value < Self::TIMESTAMP_MS_THRESHOLD { + value.saturating_mul(1_000) + } else { + value + } + } + /// Maximum number of actor entries the mempool accepts in a single /// `Create.initial_actors` slice. Bounds per-transaction memory and CPU /// spent on duplicate-actor_id detection at admission time. @@ -359,6 +391,26 @@ mod tests { assert!(!Eip8130Constants::sender_is_policy_gated(0)); } + #[test] + fn normalize_timestamp_ms_detects_seconds_and_milliseconds() { + // Zero is the disabled sentinel: never scaled. + assert_eq!(Eip8130Constants::normalize_timestamp_ms(0), 0); + // Realistic seconds (~1.7e9) are below the threshold and scale to ms. + assert_eq!(Eip8130Constants::normalize_timestamp_ms(1_700_000_000), 1_700_000_000_000); + // Realistic milliseconds (~1.7e12) are at/above the threshold: unchanged. + assert_eq!(Eip8130Constants::normalize_timestamp_ms(1_700_000_000_000), 1_700_000_000_000); + // Threshold boundary: the value just below is seconds; the threshold + // itself is already milliseconds. + assert_eq!( + Eip8130Constants::normalize_timestamp_ms(Eip8130Constants::TIMESTAMP_MS_THRESHOLD - 1), + (Eip8130Constants::TIMESTAMP_MS_THRESHOLD - 1) * 1_000 + ); + assert_eq!( + Eip8130Constants::normalize_timestamp_ms(Eip8130Constants::TIMESTAMP_MS_THRESHOLD), + Eip8130Constants::TIMESTAMP_MS_THRESHOLD + ); + } + #[test] fn delegation_indicator_size_matches_prefix_plus_address() { assert_eq!( diff --git a/crates/common/consensus/src/transaction/eip8130/signed.rs b/crates/common/consensus/src/transaction/eip8130/signed.rs index 2db2079c24..f93118d75c 100644 --- a/crates/common/consensus/src/transaction/eip8130/signed.rs +++ b/crates/common/consensus/src/transaction/eip8130/signed.rs @@ -241,20 +241,29 @@ impl Eip8130Signed { /// Validates the validity-window admission rules for nonce-bearing and /// nonce-free transactions against a single caller-supplied `now` value. /// - /// `now`, `valid_after`, and `valid_before` are all Unix timestamps in - /// **milliseconds**; callers pass `block.timestamp * 1000` (or an equivalent - /// millisecond wall clock) so the comparison matches the EIP's on-chain - /// `block.timestamp * 1000` evaluation. Txpool passes in one head-block - /// snapshot so both branches see the same value even if the tip updates - /// concurrently. + /// `now` is a Unix timestamp in **milliseconds**; callers pass + /// `block.timestamp * 1000` (or an equivalent millisecond wall clock) so the + /// comparison matches the EIP's on-chain `block.timestamp * 1000` evaluation. + /// Txpool passes in one head-block snapshot so both branches see the same + /// value even if the tip updates concurrently. + /// + /// The transaction's `valid_after`/`valid_before` may be supplied in seconds + /// or milliseconds; both are normalized to milliseconds here (via + /// [`TxEip8130::valid_after_ms`]/[`TxEip8130::valid_before_ms`]) before being + /// compared against `now`, matching the consensus inclusion window. pub fn validate_timestamp(&self, now: u64) -> Result<(), Eip8130TimestampError> { let tx = self.tx(); + // Normalized to milliseconds (seconds bounds are scaled by 1000); `0` + // stays `0` (disabled), so the "is this bound set?" checks below are + // unaffected by normalization. + let valid_after = tx.valid_after_ms(); + let valid_before = tx.valid_before_ms(); if tx.nonce_key == Eip8130Constants::NONCE_KEY_MAX { // Structural precondition first (independent of `now`). - if tx.nonce_sequence != 0 || tx.valid_before == 0 { + if tx.nonce_sequence != 0 || valid_before == 0 { return Err(Eip8130TimestampError::NonceFreeMalformed); } - if tx.valid_after != 0 && now < tx.valid_after { + if valid_after != 0 && now < valid_after { return Err(Eip8130TimestampError::NotYetValid); } // Exclusive on purpose: nonce-free replay protection is the @@ -265,21 +274,20 @@ impl Eip8130Signed { // boundary would only fail later in the ring. This is stricter than // the inclusive validity-window upper bound applied to nonce-bearing // transactions below. - if tx.valid_before <= now { + if valid_before <= now { return Err(Eip8130TimestampError::NonceFreeExpired); } - if tx.valid_before > now.saturating_add(Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW) - { + if valid_before > now.saturating_add(Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW) { return Err(Eip8130TimestampError::NonceFreeExpiryTooFar); } } else { - if tx.valid_after != 0 && now < tx.valid_after { + if valid_after != 0 && now < valid_after { return Err(Eip8130TimestampError::NotYetValid); } // Inclusive upper bound (matches the execution-path check and the EIP): // a transaction at exactly `valid_before` is still valid, so reject // only once `now` is strictly past it. - if tx.valid_before != 0 && now > tx.valid_before { + if valid_before != 0 && now > valid_before { return Err(Eip8130TimestampError::Expired); } } @@ -946,7 +954,12 @@ mod tests { #[test] fn timestamp_validation_covers_channel_and_nonce_free_rules() { - let now = 1_000; + // Millisecond-scale reference clock: all bounds here are already + // milliseconds (>= TIMESTAMP_MS_THRESHOLD), so normalization is a no-op + // and these assertions exercise the raw window comparisons. Seconds + // normalization is covered separately by + // `seconds_denominated_bounds_are_normalized_to_milliseconds`. + let now = 1_700_000_000_000; let mut tx = sample_signed(false).into_tx(); // Nonce-bearing upper bound is inclusive: valid at `now == valid_before`, // expired only once `now` is strictly past it. @@ -990,6 +1003,55 @@ mod tests { ); } + #[test] + fn seconds_denominated_bounds_are_normalized_to_milliseconds() { + // `now` is milliseconds (block.timestamp * 1000). The transaction bounds + // are supplied in *seconds* and must be interpreted identically to their + // millisecond equivalents (EIP-8130 Timestamp Normalization). + let now_secs = 1_700_000_000u64; + let now_ms = now_secs * 1_000; + let mut tx = sample_signed(false).into_tx(); + + // Nonce-bearing: a seconds `valid_before` one second in the future is + // still valid; one second in the past is expired. + tx.valid_after = 0; + tx.valid_before = now_secs + 1; + assert_eq!( + Eip8130Signed::new(tx.clone(), Bytes::new(), Bytes::new()).validate_timestamp(now_ms), + Ok(()) + ); + tx.valid_before = now_secs - 1; + assert_eq!( + Eip8130Signed::new(tx.clone(), Bytes::new(), Bytes::new()).validate_timestamp(now_ms), + Err(Eip8130TimestampError::Expired) + ); + + // A seconds `valid_after` in the future gates activation (not treated as + // already-active, which was the pre-normalization footgun). + tx.valid_before = 0; + tx.valid_after = now_secs + 1; + assert_eq!( + Eip8130Signed::new(tx.clone(), Bytes::new(), Bytes::new()).validate_timestamp(now_ms), + Err(Eip8130TimestampError::NotYetValid) + ); + + // Nonce-free: a seconds `valid_before` inside the expiry window (20 s) + // is admitted; the normalized ms value is compared against the ms window. + tx.valid_after = 0; + tx.nonce_key = Eip8130Constants::NONCE_KEY_MAX; + tx.nonce_sequence = 0; + tx.valid_before = now_secs + 10; // +10 s == +10_000 ms, within the 20_000 ms window + assert_eq!( + Eip8130Signed::new(tx.clone(), Bytes::new(), Bytes::new()).validate_timestamp(now_ms), + Ok(()) + ); + tx.valid_before = now_secs - 1; // already elapsed + assert_eq!( + Eip8130Signed::new(tx, Bytes::new(), Bytes::new()).validate_timestamp(now_ms), + Err(Eip8130TimestampError::NonceFreeExpired) + ); + } + #[cfg(feature = "k256")] #[test] fn recover_eoa_sender_returns_none_for_configured_actor() { diff --git a/crates/common/consensus/src/transaction/eip8130/tx.rs b/crates/common/consensus/src/transaction/eip8130/tx.rs index 35bdbfff42..06ad6cda87 100644 --- a/crates/common/consensus/src/transaction/eip8130/tx.rs +++ b/crates/common/consensus/src/transaction/eip8130/tx.rs @@ -54,14 +54,16 @@ pub struct TxEip8130 { pub nonce_key: U256, /// Sequence number within the nonce key. pub nonce_sequence: u64, - /// Lower bound of the validity window: a Unix timestamp in **milliseconds**. - /// The transaction is invalid when `block.timestamp * 1000 < valid_after`; - /// `0` means no lower bound. + /// Lower bound of the validity window: a Unix timestamp in **seconds or + /// milliseconds** (auto-detected per EIP-8130 Timestamp Normalization; see + /// [`Self::valid_after_ms`]). After normalization the transaction is invalid + /// when `block.timestamp * 1000 < valid_after_ms`; `0` means no lower bound. pub valid_after: u64, - /// Upper bound of the validity window: a Unix timestamp in **milliseconds**. - /// The bound is inclusive — the transaction is still valid at - /// `block.timestamp * 1000 == valid_before` and invalid only once - /// `block.timestamp * 1000 > valid_before`; `0` means no expiry. It MUST be + /// Upper bound of the validity window: a Unix timestamp in **seconds or + /// milliseconds** (auto-detected; see [`Self::valid_before_ms`]). After + /// normalization the bound is inclusive — the transaction is still valid at + /// `block.timestamp * 1000 == valid_before_ms` and invalid only once + /// `block.timestamp * 1000 > valid_before_ms`; `0` means no expiry. It MUST be /// non-zero for nonce-free (`nonce_key == NONCE_KEY_MAX`) transactions, which /// additionally require `valid_before` to be strictly in the future when the /// nonce is recorded: the nonce-manager replay ring's admission window is @@ -92,6 +94,31 @@ pub struct TxEip8130 { } impl TxEip8130 { + /// The lower validity bound normalized to Unix **milliseconds**. + /// + /// [`Self::valid_after`] may be supplied in seconds or milliseconds; this + /// applies [`Eip8130Constants::normalize_timestamp_ms`] so callers compare + /// against `block.timestamp * 1000` (or a millisecond wall clock) uniformly. + /// `0` (no lower bound) is preserved. The raw field is still what the wire + /// encoding, signature hashes, and replay id commit to; only validity-window + /// comparisons use this normalized view. + #[must_use] + pub const fn valid_after_ms(&self) -> u64 { + Eip8130Constants::normalize_timestamp_ms(self.valid_after) + } + + /// The upper validity bound normalized to Unix **milliseconds**. + /// + /// [`Self::valid_before`] may be supplied in seconds or milliseconds; this + /// applies [`Eip8130Constants::normalize_timestamp_ms`]. `0` (no expiry) is + /// preserved. As with [`Self::valid_after_ms`], the raw field remains the + /// signed/encoded/replay-committed value; this normalized view is used only + /// for validity-window and nonce-free expiry comparisons. + #[must_use] + pub const fn valid_before_ms(&self) -> u64 { + Eip8130Constants::normalize_timestamp_ms(self.valid_before) + } + /// Encodes an `Option
` as the AA wire format: zero-length byte /// string when `None`, 20-byte string when `Some`. fn encode_address_opt(addr: &Option
, out: &mut dyn BufMut) { @@ -708,6 +735,26 @@ mod tests { encoded } + #[test] + fn validity_bounds_normalize_seconds_to_milliseconds() { + let mut tx = sample_tx(); + // Zero (disabled) is preserved on both bounds. + tx.valid_after = 0; + tx.valid_before = 0; + assert_eq!(tx.valid_after_ms(), 0); + assert_eq!(tx.valid_before_ms(), 0); + // Seconds (< threshold) are scaled by 1000. + tx.valid_after = 1_700_000_000; + tx.valid_before = 1_700_000_020; + assert_eq!(tx.valid_after_ms(), 1_700_000_000_000); + assert_eq!(tx.valid_before_ms(), 1_700_000_020_000); + // Milliseconds (>= threshold) pass through unchanged. + tx.valid_after = 1_700_000_000_000; + tx.valid_before = 1_700_000_020_000; + assert_eq!(tx.valid_after_ms(), 1_700_000_000_000); + assert_eq!(tx.valid_before_ms(), 1_700_000_020_000); + } + #[test] fn decode_calls_accepts_maximum_phase_count() { let encoded = empty_call_phases(Eip8130Constants::MAX_CALL_PHASES_PER_TX); diff --git a/crates/common/evm/src/eip8130.rs b/crates/common/evm/src/eip8130.rs index 78a430daa3..6bcddc9849 100644 --- a/crates/common/evm/src/eip8130.rs +++ b/crates/common/evm/src/eip8130.rs @@ -924,10 +924,15 @@ impl Eip8130Executor { let gas_limit = tx.gas_limit; let max_fee = tx.max_fee_per_gas; let max_priority = tx.max_priority_fee_per_gas; - // The nonce-free replay ring records the transaction's upper validity - // bound (`valid_before`, Unix milliseconds); the ring compares it against - // `block.timestamp * 1000` internally. - let valid_before = tx.valid_before; + // Validity bounds are normalized to Unix milliseconds (seconds bounds are + // scaled by 1000 per EIP-8130 Timestamp Normalization); `0` stays `0` + // (disabled). The nonce-free replay ring records this normalized upper + // bound and compares it against `block.timestamp * 1000` internally, so + // it MUST be the normalized value. The raw `valid_after`/`valid_before` + // remain the signed/encoded/replay-committed fields; only these + // comparisons use the normalized view. + let valid_after = tx.valid_after_ms(); + let valid_before = tx.valid_before_ms(); // Consensus-level validity window. The transaction is includable only // within the inclusive interval `[valid_after, valid_before]` on the @@ -941,7 +946,7 @@ impl Eip8130Executor { // its own admission window (`valid_before > now_ms`) when it records the // nonce, so a nonce-free transaction at the boundary still fails there. let now_ms = now.saturating_mul(1_000); - if tx.valid_after != 0 && now_ms < tx.valid_after { + if valid_after != 0 && now_ms < valid_after { return Err(BaseTransactionError::eip8130("transaction is not yet valid")); } if valid_before != 0 && now_ms > valid_before { @@ -3405,6 +3410,46 @@ mod tests { assert!(outcome.result.is_success(), "expected success, got {:?}", outcome.result); } + #[test] + fn execution_window_normalizes_seconds_denominated_valid_before() { + // The block timestamp is `NOW` seconds, so the consensus inclusion window + // compares against `now_ms = NOW * 1000`. A `valid_before` supplied in + // *seconds* must be normalized to milliseconds first: `NOW + 1` seconds + // normalizes to `(NOW + 1) * 1000` ms, one second in the future, so the + // transaction is includable. Without normalization the raw `NOW + 1` + // would compare as far below `now_ms` and be wrongly rejected as expired. + let key = signing_key(0x7a); + let sender = eoa_address(&key); + let target = address!("0x00000000000000000000000000000000000000d2"); + let initial = U256::from(10u64).pow(U256::from(18u64)); + + let mut tx = base_tx(); + tx.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; + tx.valid_before = NOW + 1; + let signed = eoa_signed(tx, &key); + let mut evm = evm_with_accounts(initial, sender, &[(target, bytes!("00"))]); + let outcome = evm + .transact_raw(into_base_tx(&signed)) + .expect("a seconds valid_before in the future must be admitted"); + assert!(outcome.result.is_success(), "expected success, got {:?}", outcome.result); + + // A seconds `valid_before` one second in the past normalizes to + // `(NOW - 1) * 1000` ms and is strictly past `now_ms`, so the inclusion + // window rejects it. + let mut tx = base_tx(); + tx.calls = vec![vec![Call { to: target, value: U256::ZERO, data: Bytes::new() }]]; + tx.valid_before = NOW - 1; + let signed = eoa_signed(tx, &key); + let mut evm = evm_with_accounts(initial, sender, &[(target, bytes!("00"))]); + let err = evm + .transact_raw(into_base_tx(&signed)) + .expect_err("a seconds valid_before in the past must be rejected"); + let EVMError::Transaction(BaseTransactionError::Eip8130(got)) = err else { + panic!("expected an Eip8130 validity rejection, got {err:?}"); + }; + assert!(got.contains("validity window has expired"), "unexpected reason: {got}"); + } + #[test] fn capped_refund_uses_signed_transaction_level_accounting() { // Two calls touch the same slot: call A clears it (+4800 clear refund), diff --git a/crates/common/rpc-types/src/transaction/request.rs b/crates/common/rpc-types/src/transaction/request.rs index 524ddfc867..6247a37e64 100644 --- a/crates/common/rpc-types/src/transaction/request.rs +++ b/crates/common/rpc-types/src/transaction/request.rs @@ -102,13 +102,15 @@ pub struct Eip8130RequestFields { /// The phased call batches dispatched by the sender account. #[serde(default, skip_serializing_if = "Option::is_none")] pub calls: Option>>, - /// Optional lower bound of the validity window (Unix milliseconds; `0` or - /// absent means no lower bound). Checked as `block.timestamp * 1000 >= - /// valid_after`. + /// Optional lower bound of the validity window, in Unix seconds or + /// milliseconds (the unit is detected from the magnitude; `0` or absent + /// means no lower bound). Checked as `block.timestamp * 1000 >=` the + /// bound in milliseconds. #[serde(default, skip_serializing_if = "Option::is_none")] pub valid_after: Option, - /// Optional upper bound of the validity window (Unix milliseconds; `0` or - /// absent means no expiry). Required (non-zero) for nonce-free transactions. + /// Optional upper bound of the validity window, in Unix seconds or + /// milliseconds (the unit is detected from the magnitude; `0` or absent + /// means no expiry). Required (non-zero) for nonce-free transactions. #[serde(default, skip_serializing_if = "Option::is_none")] pub valid_before: Option, /// Opaque, non-executed transaction metadata. diff --git a/crates/execution/txpool/src/pool.rs b/crates/execution/txpool/src/pool.rs index 2abfe45d5b..be0d4d3358 100644 --- a/crates/execution/txpool/src/pool.rs +++ b/crates/execution/txpool/src/pool.rs @@ -2033,6 +2033,13 @@ mod tests { BaseEvmConfig, >; + /// Realistic wall clock the integration pool starts at (Unix seconds), so + /// millisecond `valid_before` bounds are compared against a representative + /// `block.timestamp * 1000` rather than the epoch. + const INTEGRATION_POOL_NOW_SECS: u64 = 1_700_000_000; + /// [`INTEGRATION_POOL_NOW_SECS`] expressed in milliseconds. + const INTEGRATION_POOL_NOW_MS: u64 = INTEGRATION_POOL_NOW_SECS * 1_000; + fn build_integration_pool() -> (IntegrationPool, MockEthProvider>) { let mut genesis = build_test_genesis_everest(); @@ -2048,8 +2055,24 @@ mod tests { .no_cancun() .build_with_tasks(Runtime::test(), blob_store.clone()) .map(|inner| { - BaseTransactionValidator::with_block_info(inner, BaseL1BlockInfo::default()) - .require_l1_data_gas_fee(false) + // Seed a realistic wall clock so millisecond-scale nonce-free + // `valid_before` bounds evaluate against a representative `now` + // (default is timestamp 0, where no future ms bound is reachable). + // Reuse the same seam the live head uses: `update_l1_block_info` + // with `tx = None` stores only the header timestamp and leaves the + // L1 block info untouched, so the harness shares the one admission + // clock writer rather than a second, unsynchronized setter. + let validator = + BaseTransactionValidator::with_block_info(inner, BaseL1BlockInfo::default()) + .require_l1_data_gas_fee(false); + validator.update_l1_block_info::<_, TxEip1559>( + &alloy_consensus::Header { + timestamp: INTEGRATION_POOL_NOW_SECS, + ..Default::default() + }, + None, + ); + validator }); let ordering = BaseOrdering::default(); let pool = Pool::new(validator, ordering.clone(), blob_store, PoolConfig::default()); @@ -2184,24 +2207,44 @@ mod tests { fund(&client, signer.address()); let cap = u64::from(GuardLimits::default().signature_limit); + // Distinct millisecond `valid_before` bounds, each inside the nonce-free + // admission window `(now_ms, now_ms + NONCE_FREE_MAX_EXPIRY_WINDOW]`, so + // each is a separately-tracked sidecar member; `cap` stays far below the + // 20_000 ms window. let mut admitted = Vec::new(); for offset in 0..cap { - let transaction = - self_paid_eoa_8130(&signer, Eip8130Constants::NONCE_KEY_MAX, 0, offset + 1, 1_000); + let transaction = self_paid_eoa_8130( + &signer, + Eip8130Constants::NONCE_KEY_MAX, + 0, + INTEGRATION_POOL_NOW_MS + offset + 1, + 1_000, + ); admitted.push(*transaction.hash()); assert!(pool.add_transaction(TransactionOrigin::Local, transaction).await.is_ok()); } assert!(admitted.iter().all(|hash| pool.nonce_pool.read().contains(hash))); - let over = self_paid_eoa_8130(&signer, Eip8130Constants::NONCE_KEY_MAX, 0, cap + 1, 1_000); + let over = self_paid_eoa_8130( + &signer, + Eip8130Constants::NONCE_KEY_MAX, + 0, + INTEGRATION_POOL_NOW_MS + cap + 1, + 1_000, + ); let over_hash = *over.hash(); assert!(pool.add_transaction(TransactionOrigin::Local, over).await.is_err()); assert!(pool.get(&over_hash).is_none()); let removed = pool.remove_transactions(vec![admitted[0]]); assert_eq!(removed.len(), 1); - let replacement = - self_paid_eoa_8130(&signer, Eip8130Constants::NONCE_KEY_MAX, 0, cap + 2, 1_000); + let replacement = self_paid_eoa_8130( + &signer, + Eip8130Constants::NONCE_KEY_MAX, + 0, + INTEGRATION_POOL_NOW_MS + cap + 2, + 1_000, + ); assert!(pool.add_transaction(TransactionOrigin::Local, replacement).await.is_ok()); } @@ -2335,7 +2378,7 @@ mod tests { &signer, Eip8130Constants::NONCE_KEY_MAX, 0, - Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW, + INTEGRATION_POOL_NOW_MS + Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW, 1_000, ); let nonce_free_hash = *nonce_free.hash(); @@ -2382,7 +2425,7 @@ mod tests { &signer, Eip8130Constants::NONCE_KEY_MAX, 0, - Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW, + INTEGRATION_POOL_NOW_MS + Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW, 1_000, ); let hash = *transaction.hash(); diff --git a/crates/execution/txpool/src/two_d_nonce_pool.rs b/crates/execution/txpool/src/two_d_nonce_pool.rs index 1582c3e328..adfdf1b2ea 100644 --- a/crates/execution/txpool/src/two_d_nonce_pool.rs +++ b/crates/execution/txpool/src/two_d_nonce_pool.rs @@ -439,9 +439,10 @@ impl TwoDNoncePool { /// `now` (Unix **milliseconds**, i.e. `block.timestamp * 1000`). /// /// A nonce-free transaction is invalid when `valid_before <= now`, matching - /// its structural validation rule. Finite channels are unaffected; their - /// optional window is handled by normal transaction validation until the - /// state-keyed expiry index is introduced. + /// its structural validation rule. The bound is normalized to milliseconds + /// (seconds are scaled by 1000) so it lines up with the millisecond `now`. + /// Finite channels are unaffected; their optional window is handled by normal + /// transaction validation until the state-keyed expiry index is introduced. pub(crate) fn remove_expired_nonce_free( &mut self, now: u64, @@ -451,7 +452,7 @@ impl TwoDNoncePool { .values() .filter_map(|transaction| { let signed = transaction.transaction.as_eip8130()?; - (signed.tx().valid_before <= now).then_some(*transaction.hash()) + (signed.tx().valid_before_ms() <= now).then_some(*transaction.hash()) }) .collect(); self.remove_transactions(&expired) @@ -962,16 +963,20 @@ mod tests { #[test] fn nonce_free_expiry_removes_due_transactions_and_keeps_future_entries() { + // Millisecond-scale bounds (>= TIMESTAMP_MS_THRESHOLD) so normalization + // is a no-op and `valid_before` is compared directly against the + // millisecond `now`. + const NOW_MS: u64 = 1_700_000_000_000; let mut pool = TwoDNoncePool::new(PriceBumpConfig::default()); let signer = signer(); - let due = valid_pool_transaction(signed_nonce_free_tx(&signer, 10, 20, 1_000)); - let future = valid_pool_transaction(signed_nonce_free_tx(&signer, 11, 10, 1_000)); + let due = valid_pool_transaction(signed_nonce_free_tx(&signer, NOW_MS, 20, 1_000)); + let future = valid_pool_transaction(signed_nonce_free_tx(&signer, NOW_MS + 1, 10, 1_000)); let due_hash = *due.hash(); let future_hash = *future.hash(); pool.insert_validated(due, 0).unwrap(); pool.insert_validated(future, 0).unwrap(); - let removed = pool.remove_expired_nonce_free(10); + let removed = pool.remove_expired_nonce_free(NOW_MS); assert_eq!(removed.iter().map(|tx| *tx.hash()).collect::>(), vec![due_hash]); assert!(pool.get(&due_hash).is_none()); diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index 2d4c331165..51c44ef246 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -1138,7 +1138,8 @@ where .saturating_mul(U256::from(signed.tx().max_fee_per_gas)); let nonce_free = signed.tx().nonce_key == Eip8130Constants::NONCE_KEY_MAX; - let transaction_expiry = Self::tx_valid_before_secs(signed.tx().valid_before, nonce_free); + let transaction_expiry = + Self::tx_valid_before_secs(signed.tx().valid_before_ms(), nonce_free); let sender_expiry = Self::expiry_or_unbounded(sender_actor.expiry); let payer_expiry = Self::expiry_or_unbounded(payer_actor.map_or(0, |actor| actor.expiry)); let effective_expiry = @@ -1628,12 +1629,19 @@ where // The `window` bound only governs the nonce-free "too far in the future" // rejection; logging it for the other variants (e.g. a nonce-bearing // `Expired`) would wrongly imply the nonce-free window was involved. + // Log the *normalized* (millisecond) bounds that `validate_timestamp` + // actually compared against `now` (also milliseconds); the raw fields may + // be seconds-denominated, so logging them beside a millisecond `now` reads + // as decades of skew. The raw values are kept under distinct names for + // debugging the seconds/milliseconds auto-detection itself. if matches!(error, Eip8130TimestampError::NonceFreeExpiryTooFar) { tracing::debug!( reason, now, - valid_after = tx.valid_after, - valid_before = tx.valid_before, + valid_after = tx.valid_after_ms(), + valid_before = tx.valid_before_ms(), + valid_after_raw = tx.valid_after, + valid_before_raw = tx.valid_before, nonce_key = %tx.nonce_key, window = Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW, "EIP-8130 timestamp validation failed", @@ -1642,8 +1650,10 @@ where tracing::debug!( reason, now, - valid_after = tx.valid_after, - valid_before = tx.valid_before, + valid_after = tx.valid_after_ms(), + valid_before = tx.valid_before_ms(), + valid_after_raw = tx.valid_after, + valid_before_raw = tx.valid_before, nonce_key = %tx.nonce_key, "EIP-8130 timestamp validation failed", ); @@ -2672,17 +2682,18 @@ mod tests { #[test] fn rejects_eip8130_nonce_free_already_expired() { - // Advance the validator's tracked block timestamp to 100s (now_ms = - // 100_000) so that valid_before=50_000 is strictly in the past; the - // default fixture sits at timestamp 0 where there is no way to express - // "already expired". + // Advance the validator's tracked block timestamp to a realistic clock + // (now_ms = 1_700_000_100_000) so `valid_before` (a millisecond value one + // second in the past) is strictly elapsed. Both values are >= + // TIMESTAMP_MS_THRESHOLD, so normalization is a no-op and this exercises + // the raw expiry comparison. let validator = build_test_validator(); - let header = alloy_consensus::Header { timestamp: 100, ..Default::default() }; + let header = alloy_consensus::Header { timestamp: 1_700_000_100, ..Default::default() }; validator.update_l1_block_info::<_, TxEip1559>(&header, None); let tx = TxEip8130 { nonce_key: Eip8130Constants::NONCE_KEY_MAX, nonce_sequence: 0, - valid_before: 50_000, + valid_before: 1_700_000_099_000, ..minimal_valid_eoa_tx() }; let signed = sign_eoa_eip8130(tx); @@ -2694,15 +2705,19 @@ mod tests { #[test] fn rejects_eip8130_nonce_free_not_yet_valid() { - // Default fixture sits at block timestamp 0 (now_ms = 0). A future - // `valid_after` opens the window later, so the nonce-free branch must - // reject with `NotYetValid` before the (satisfiable) expiry checks. + // Realistic millisecond clock (both bounds >= TIMESTAMP_MS_THRESHOLD, so + // normalization is a no-op). A future `valid_after` opens the window + // later, so the nonce-free branch must reject with `NotYetValid` before + // the expiry checks (which `validate_timestamp` evaluates afterward). let validator = build_test_validator(); + let header = alloy_consensus::Header { timestamp: 1_700_000_000, ..Default::default() }; + validator.update_l1_block_info::<_, TxEip1559>(&header, None); + let now_ms = 1_700_000_000_000; let tx = TxEip8130 { nonce_key: Eip8130Constants::NONCE_KEY_MAX, nonce_sequence: 0, - valid_after: 50_000, - valid_before: 60_000, + valid_after: now_ms + 50_000, + valid_before: now_ms + 60_000, ..minimal_valid_eoa_tx() }; let signed = sign_eoa_eip8130(tx); @@ -2714,11 +2729,15 @@ mod tests { #[test] fn rejects_eip8130_nonce_bearing_not_yet_valid() { - // Sequenced (nonce-bearing) transaction with a future `valid_after` and - // now_ms = 0: the else-branch of `validate_timestamp` must reject with + // Sequenced (nonce-bearing) transaction on a realistic millisecond clock + // with a future `valid_after` (>= TIMESTAMP_MS_THRESHOLD, so normalization + // is a no-op): the else-branch of `validate_timestamp` must reject with // `NotYetValid`. let validator = build_test_validator(); - let tx = TxEip8130 { valid_after: 50_000, ..minimal_valid_eoa_tx() }; + let header = alloy_consensus::Header { timestamp: 1_700_000_000, ..Default::default() }; + validator.update_l1_block_info::<_, TxEip1559>(&header, None); + let now_ms = 1_700_000_000_000; + let tx = TxEip8130 { valid_after: now_ms + 50_000, ..minimal_valid_eoa_tx() }; let signed = sign_eoa_eip8130(tx); assert_structural_reason( validator.validate_eip8130_structural(&signed), @@ -2728,12 +2747,19 @@ mod tests { #[test] fn rejects_eip8130_nonce_free_expiry_too_far_in_future() { + // Seed a realistic millisecond clock and place `valid_before` exactly one + // millisecond past the admission-window edge (`now_ms + + // NONCE_FREE_MAX_EXPIRY_WINDOW`). Both bounds are >= TIMESTAMP_MS_THRESHOLD + // so normalization is a no-op; this exercises the true edge+1 rejection, + // the mirror of `accepts_eip8130_nonce_free_at_expiry_window_edge`. let validator = build_test_validator(); - // block_timestamp returns 0 by default; cap is NONCE_FREE_MAX_EXPIRY_WINDOW. + let header = alloy_consensus::Header { timestamp: 1_700_000_000, ..Default::default() }; + validator.update_l1_block_info::<_, TxEip1559>(&header, None); + let now_ms = 1_700_000_000_000; let tx = TxEip8130 { nonce_key: Eip8130Constants::NONCE_KEY_MAX, nonce_sequence: 0, - valid_before: Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW + 1, + valid_before: now_ms + Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW + 1, ..minimal_valid_eoa_tx() }; let signed = sign_eoa_eip8130(tx); @@ -2745,11 +2771,18 @@ mod tests { #[test] fn accepts_eip8130_nonce_free_at_expiry_window_edge() { + // Seed a realistic millisecond clock and place `valid_before` exactly at + // the admission-window edge (`now_ms + NONCE_FREE_MAX_EXPIRY_WINDOW`). + // Both bounds are >= TIMESTAMP_MS_THRESHOLD so normalization is a no-op; + // this checks the inclusive edge in true milliseconds. let validator = build_test_validator(); + let header = alloy_consensus::Header { timestamp: 1_700_000_000, ..Default::default() }; + validator.update_l1_block_info::<_, TxEip1559>(&header, None); + let now_ms = 1_700_000_000_000; let tx = TxEip8130 { nonce_key: Eip8130Constants::NONCE_KEY_MAX, nonce_sequence: 0, - valid_before: Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW, + valid_before: now_ms + Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW, ..minimal_valid_eoa_tx() }; let signed = sign_eoa_eip8130(tx); @@ -3665,7 +3698,9 @@ mod tests { fn nonce_free_manifest_uses_transaction_validity_window() { let chain_spec = everest_chain_spec(); let signer = PrivateKeySigner::random(); - let now = 100; + // Realistic seconds clock; `now * 1000` stays >= TIMESTAMP_MS_THRESHOLD so + // the millisecond `valid_before` below is not re-scaled by normalization. + let now = 1_700_000_000; // `valid_before` is in milliseconds; at the admission-window edge it is // `now * 1000 + NONCE_FREE_MAX_EXPIRY_WINDOW`. The on-chain bound is // exclusive, so the manifest boundary folds it onto the seconds axis as From 9029198113e3e7781a71ce6959935a2e67108d1a Mon Sep 17 00:00:00 2001 From: Francis Li Date: Tue, 29 Sep 2026 15:50:26 +0000 Subject: [PATCH 157/299] Preserve full-node history retention after Denim (#5367) * fix(consensus): fail reset when finalized history is pruned Amp-Thread-ID: https://ampcode.com/threads/T-01a0db1e-2dd6-73ab-b9fc-49f29515ed99 Co-authored-by: Amp * test(consensus): reject genesis fallback during pruned startup Amp-Thread-ID: https://ampcode.com/threads/T-01a0db1e-2dd6-73ab-b9fc-49f29515ed99 Co-authored-by: Amp * Drop pruned-history reset changes Amp-Thread-ID: https://ampcode.com/threads/T-01a0db1e-2dd6-73ab-b9fc-49f29515ed99 Co-authored-by: Amp * Preserve full-node history retention after Denim Amp-Thread-ID: https://ampcode.com/threads/T-01a0db1e-2dd6-73ab-b9fc-49f29515ed99 Co-authored-by: Amp * Document Denim retention in node environment examples Amp-Thread-ID: https://ampcode.com/threads/T-01a0db1e-2dd6-73ab-b9fc-49f29515ed99 Co-authored-by: Amp --------- Co-authored-by: Amp --- .env.mainnet | 3 ++- .env.sepolia | 3 ++- crates/utilities/reth-cli/src/snapshots.rs | 9 +++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.env.mainnet b/.env.mainnet index 0dec86c923..ffeb1762f2 100644 --- a/.env.mainnet +++ b/.env.mainnet @@ -30,5 +30,6 @@ BASE_NODE_L2_ENGINE_AUTH_RAW=688f5d737bad920bdfb2fc2f488d6b6209eebda1dae949a8de9 # PRUNING (OPTIONAL - UNCOMMENT TO ENABLE) # NOTE: Set to any number of blocks you want, but it should be >10064 # NOTE: The node type that was chosen when first running a node cannot be changed after the initial sync. Turning Archive into Pruned, or Pruned into Full is not supported [source](https://reth.rs/run/faq/pruning/). -# NOTE: The pruned snapshots provided are set with a distance of 1_339_200 (~31 days). +# NOTE: --full retains 13_392_000 blocks (~31 days at Denim's 200ms interval). +# NOTE: This does not restore history missing from a downloaded snapshot. # RETH_PRUNING_ARGS="--prune.senderrecovery.distance=50000 --prune.transactionlookup.distance=50000 --prune.receipts.distance=50000 --prune.accounthistory.distance=50000 --prune.storagehistory.distance=50000 --prune.bodies.distance=50000" diff --git a/.env.sepolia b/.env.sepolia index 79b5ad7df5..ca15883a8d 100644 --- a/.env.sepolia +++ b/.env.sepolia @@ -30,5 +30,6 @@ BASE_NODE_L2_ENGINE_AUTH_RAW=688f5d737bad920bdfb2fc2f488d6b6209eebda1dae949a8de9 # PRUNING (OPTIONAL - UNCOMMENT TO ENABLE) # NOTE: Set to any number of blocks you want, but it should be >10064 # NOTE: The node type that was chosen when first running a node cannot be changed after the initial sync. Turning Archive into Pruned, or Pruned into Full is not supported [source](https://reth.rs/run/faq/pruning/). -# NOTE: The pruned snapshots provided are set with a distance of 1_339_200 (~31 days). +# NOTE: --full retains 13_392_000 blocks (~31 days at Denim's 200ms interval). +# NOTE: This does not restore history missing from a downloaded snapshot. # RETH_PRUNING_ARGS="--prune.senderrecovery.distance=50000 --prune.transactionlookup.distance=50000 --prune.receipts.distance=50000 --prune.accounthistory.distance=50000 --prune.storagehistory.distance=50000 --prune.bodies.distance=50000" diff --git a/crates/utilities/reth-cli/src/snapshots.rs b/crates/utilities/reth-cli/src/snapshots.rs index b3d6152485..525d0bd3b6 100644 --- a/crates/utilities/reth-cli/src/snapshots.rs +++ b/crates/utilities/reth-cli/src/snapshots.rs @@ -13,7 +13,8 @@ const SNAPSHOT_SOURCE_URL: &str = "https://chain.base.org"; const MAINNET_SNAPSHOT_URL: &str = "https://mainnet-v2-snapshots.base.org"; const SEPOLIA_SNAPSHOT_URL: &str = "https://sepolia-v2-snapshots.base.org"; const ZERONET_SNAPSHOT_URL: &str = "https://zeronet-v2-snapshots.base.org"; -const FULL_HISTORY_DISTANCE: u64 = 1_339_200; +/// Thirty-one days of history at Denim's 200ms block interval. +const FULL_HISTORY_DISTANCE: u64 = 13_392_000; /// Reth snapshot and pruning-default initialization for Base execution layer binaries. #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -44,7 +45,7 @@ impl Snapshots { /// Initializes Reth's global snapshot download URLs and pruning defaults. /// /// This sets up the snapshot sources and makes the full preset retain approximately one month - /// of bodies, receipts, and account and storage history. + /// of bodies, receipts, and account and storage history at Denim's 200ms block interval. /// /// ### Panics /// @@ -117,11 +118,11 @@ mod tests { } #[test] - fn full_preset_retains_one_month_of_history() { + fn full_preset_retains_one_month_of_denim_history() { Snapshots::init_snapshots(); let defaults = DefaultPruningValues::get_global(); - let distance = Some(PruneMode::Distance(FULL_HISTORY_DISTANCE)); + let distance = Some(PruneMode::Distance(31 * 24 * 60 * 60 * 1_000 / 200)); assert_eq!(defaults.full_prune_modes.bodies_history, distance); assert_eq!(defaults.full_prune_modes.receipts, distance); assert_eq!(defaults.full_prune_modes.account_history, distance); From 23a0efd1f8371881674f89ee4ffe407235cae9f4 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Tue, 29 Sep 2026 15:50:32 +0000 Subject: [PATCH 158/299] refactor(derive): anchor Denim ancestry checks to the safe head (#5361) Amp-Thread-ID: https://ampcode.com/threads/T-01a0db07-4a44-736d-8707-20dbb9c35b55 Co-authored-by: Amp --- .../derive/src/stages/batch/batch_provider.rs | 3 +- .../src/stages/batch/batch_validator.rs | 171 ++++++++++++++++-- 2 files changed, 158 insertions(+), 16 deletions(-) diff --git a/crates/consensus/derive/src/stages/batch/batch_provider.rs b/crates/consensus/derive/src/stages/batch/batch_provider.rs index b454164cfc..96a791c411 100644 --- a/crates/consensus/derive/src/stages/batch/batch_provider.rs +++ b/crates/consensus/derive/src/stages/batch/batch_provider.rs @@ -517,8 +517,8 @@ mod tests { block_info: BlockInfo { number: 300, hash: B256::repeat_byte(0x33), + parent_hash: B256::with_last_byte(299_u64 as u8), timestamp: cfg.l2_block_timestamp(300), - ..Default::default() }, l1_origin: origin.id(), ..Default::default() @@ -548,6 +548,7 @@ mod tests { block_info: BlockInfo { number, hash: B256::with_last_byte(number as u8), + parent_hash: B256::with_last_byte(number.saturating_sub(1) as u8), ..Default::default() }, ..Default::default() diff --git a/crates/consensus/derive/src/stages/batch/batch_validator.rs b/crates/consensus/derive/src/stages/batch/batch_validator.rs index 97cbce1696..fd884ef011 100644 --- a/crates/consensus/derive/src/stages/batch/batch_validator.rs +++ b/crates/consensus/derive/src/stages/batch/batch_validator.rs @@ -256,12 +256,31 @@ where && next_batch.parent_hash != parent.block_info.hash; if needs_ancestry_check { let same_second_blocks = 1_000 / RollupConfig::NATIVE_SUBSECOND_BLOCK_INTERVAL_MILLIS; - let first = parent.block_info.number.saturating_sub(same_second_blocks); + let first = parent + .block_info + .number + .saturating_sub(same_second_blocks) + .max(self.cfg.genesis.l2.number); + // Heights bound the window; parent hashes anchor the walk to this safe head's fork. + let mut ancestor_hash = parent.block_info.parent_hash; for number in (first..parent.block_info.number).rev() { if self.cfg.l2_block_timestamp(number.saturating_add(1)) != next_batch.timestamp { break; } - let ancestor = match self.provider.l2_block_info_by_number(number).await { + if ancestor_hash == next_batch.parent_hash { + debug!( + target: "batch_validator", + batch_parent = %next_batch.parent_hash, + safe_head = %parent.block_info.hash, + batch_timestamp = next_batch.timestamp, + "Dropping same-timestamp batch built on a canonical ancestor" + ); + return Err(PipelineError::NotEnoughData.temp()); + } + if number == first || self.cfg.l2_block_timestamp(number) != next_batch.timestamp { + break; + } + let ancestor = match self.provider.l2_block_info_by_hash(ancestor_hash).await { Ok(ancestor) => ancestor, Err(error) => { let kind = @@ -274,16 +293,7 @@ where return Err(kind); } }; - if ancestor.block_info.hash == next_batch.parent_hash { - debug!( - target: "batch_validator", - batch_parent = %next_batch.parent_hash, - safe_head = %parent.block_info.hash, - batch_timestamp = next_batch.timestamp, - "Dropping same-timestamp batch built on a canonical ancestor" - ); - return Err(PipelineError::NotEnoughData.temp()); - } + ancestor_hash = ancestor.block_info.parent_hash; } } @@ -742,6 +752,134 @@ mod tests { assert!(bv.prev.flushed); } + #[tokio::test] + async fn test_denim_validator_ancestry_ignores_other_fork_at_same_height() { + let origin = BlockInfo { number: 1, hash: B256::repeat_byte(0x11), ..Default::default() }; + let cfg = Arc::new(RollupConfig { + block_time: 2, + upgrades: UpgradeConfig { + holocene_time: Some(0), + base: BaseUpgradeConfig { denim: Some(0), ..Default::default() }, + ..Default::default() + }, + ..Default::default() + }); + let parent = L2BlockInfo { + block_info: BlockInfo { + number: 3, + hash: B256::with_last_byte(0x43), + parent_hash: B256::with_last_byte(0x42), + ..Default::default() + }, + ..Default::default() + }; + let other_fork = L2BlockInfo { + block_info: BlockInfo { + number: 1, + hash: B256::with_last_byte(0xaa), + parent_hash: B256::with_last_byte(0x40), + ..Default::default() + }, + ..Default::default() + }; + let mut flushed = Vec::new(); + for different_number_view in [false, true] { + for batch_parent in + [other_fork.block_info.hash, B256::with_last_byte(0x41), B256::with_last_byte(0x40)] + { + let mut blocks: Vec<_> = (0..parent.block_info.number) + .map(|number| L2BlockInfo { + block_info: BlockInfo { + number, + hash: B256::with_last_byte(0x40 + number as u8), + parent_hash: B256::with_last_byte(0x3f + number as u8), + ..Default::default() + }, + ..Default::default() + }) + .collect(); + // By-number reads select the first entry; hash reads identify the exact fork. + if different_number_view { + blocks.insert(0, other_fork); + } + let batch = SingleBatch { + parent_hash: batch_parent, + epoch_num: origin.number, + epoch_hash: origin.hash, + timestamp: cfg.l2_block_timestamp(parent.block_info.number + 1), + ..Default::default() + }; + let mut prev = TestNextBatchProvider::new(vec![Ok(Batch::Single(batch))]); + prev.origin = Some(origin); + let provider = TestL2ChainProvider { blocks, ..Default::default() }; + let mut bv = BatchValidator::new(Arc::clone(&cfg), prev, provider); + bv.origin = Some(origin); + bv.l1_blocks = vec![origin, origin]; + + assert_eq!( + bv.next_batch(parent).await.unwrap_err(), + PipelineError::NotEnoughData.temp() + ); + flushed.push(bv.prev.flushed); + } + } + // Flush unrelated parents and skip real ancestors, even when the by-number view disagrees. + assert_eq!(flushed, vec![true, false, false, true, false, false]); + } + + #[tokio::test] + async fn test_denim_validator_ancestry_stops_at_genesis_and_second_boundary() { + let origin = BlockInfo { number: 1, hash: B256::repeat_byte(0x11), ..Default::default() }; + for genesis_number in [0, 10] { + let mut cfg = RollupConfig { + block_time: 2, + upgrades: UpgradeConfig { + holocene_time: Some(0), + base: BaseUpgradeConfig { denim: Some(0), ..Default::default() }, + ..Default::default() + }, + ..Default::default() + }; + cfg.genesis.l2.number = genesis_number; + let cfg = Arc::new(cfg); + for (offset, expected_flush) in [(0, true), (1, false), (4, true), (5, false)] { + let number = genesis_number + offset; + let parent = L2BlockInfo { + block_info: BlockInfo { + number, + hash: B256::repeat_byte(0x33), + parent_hash: B256::repeat_byte(0x22), + timestamp: cfg.l2_block_timestamp(number), + }, + ..Default::default() + }; + let batch = SingleBatch { + parent_hash: parent.block_info.parent_hash, + epoch_num: origin.number, + epoch_hash: origin.hash, + timestamp: cfg.l2_block_timestamp(number + 1), + ..Default::default() + }; + let mut prev = TestNextBatchProvider::new(vec![Ok(Batch::Single(batch))]); + prev.origin = Some(origin); + // The immediate parent link suffices; no ancestor lookup should be needed. + let mut bv = + BatchValidator::new(Arc::clone(&cfg), prev, TestL2ChainProvider::default()); + bv.origin = Some(origin); + bv.l1_blocks = vec![origin, origin]; + + assert_eq!( + bv.next_batch(parent).await.unwrap_err(), + PipelineError::NotEnoughData.temp() + ); + assert_eq!( + bv.prev.flushed, expected_flush, + "genesis={genesis_number}, offset={offset}" + ); + } + } + } + #[tokio::test] async fn test_denim_validator_retries_then_flushes_unknown_parent() { let origin = BlockInfo { number: 1, hash: B256::repeat_byte(0x11), ..Default::default() }; @@ -749,6 +887,7 @@ mod tests { block_info: BlockInfo { number: 3, hash: B256::repeat_byte(0x33), + parent_hash: B256::with_last_byte(2), ..Default::default() }, l1_origin: BlockNumHash { number: 0, ..Default::default() }, @@ -787,6 +926,7 @@ mod tests { block_info: BlockInfo { number, hash: B256::with_last_byte(number as u8), + parent_hash: B256::with_last_byte(number.saturating_sub(1) as u8), ..Default::default() }, ..Default::default() @@ -804,6 +944,7 @@ mod tests { block_info: BlockInfo { number: 3, hash: B256::repeat_byte(0x33), + parent_hash: B256::with_last_byte(2), ..Default::default() }, l1_origin: BlockNumHash { number: 0, ..Default::default() }, @@ -830,10 +971,10 @@ mod tests { bv.origin = Some(origin); bv.l1_blocks = vec![origin, origin]; - assert!(matches!( + assert_eq!( bv.next_batch(parent).await.unwrap_err(), - PipelineErrorKind::Reset(ResetError::BlockNotFound(_)) - )); + ResetError::BlockNotFound(BlockId::Hash(B256::with_last_byte(2).into())).reset() + ); assert!(bv.pending_batch.is_none()); } } From b67f665918f443d2530380e33cf3cc7e6953831f Mon Sep 17 00:00:00 2001 From: Francis Li Date: Tue, 29 Sep 2026 16:41:54 +0000 Subject: [PATCH 159/299] Share the Denim timestamp schedule between consensus and execution (#5332) * Share the Denim timestamp schedule between consensus and execution Amp-Thread-ID: https://ampcode.com/threads/T-01a0d9fd-e2b7-77f2-a493-f9ce60f14dad Co-authored-by: Amp * Name the Denim timestamp schedule explicitly Amp-Thread-ID: https://ampcode.com/threads/T-01a0d9fd-e2b7-77f2-a493-f9ce60f14dad Co-authored-by: Amp --------- Co-authored-by: Amp --- bin/base/src/commands/follow.rs | 1 + bin/base/src/commands/rpc.rs | 1 + bin/base/src/commands/sequencer.rs | 1 + crates/common/genesis/src/lib.rs | 3 + crates/common/genesis/src/rollup.rs | 60 +++++------ crates/common/genesis/src/schedule.rs | 69 ++++++++++++ crates/consensus/protocol/src/base_time.rs | 19 +++- crates/execution/chainspec/src/builder.rs | 34 +++++- crates/execution/chainspec/src/spec.rs | 119 ++++++++++++++++++++- etc/systems/src/l2/stack.rs | 7 +- 10 files changed, 268 insertions(+), 46 deletions(-) create mode 100644 crates/common/genesis/src/schedule.rs diff --git a/bin/base/src/commands/follow.rs b/bin/base/src/commands/follow.rs index 1dfd32971f..f0d06fbfdf 100644 --- a/bin/base/src/commands/follow.rs +++ b/bin/base/src/commands/follow.rs @@ -64,6 +64,7 @@ impl FollowCommand { .apply_default_from(&follow_config.l1_rpc_args.l1_eth_rpc); let follow_args = ConsensusFollowNodeArgs::new(consensus_chain, follow_config); let mut rollup_config = follow_args.load_rollup_config()?; + Arc::make_mut(&mut execution_chain).block_time = Some(rollup_config.block_time); CliRunner::try_default_runtime()?.run_command_until_exit(|ctx| async move { execution diff --git a/bin/base/src/commands/rpc.rs b/bin/base/src/commands/rpc.rs index 45b17b5829..1c13bde7b7 100644 --- a/bin/base/src/commands/rpc.rs +++ b/bin/base/src/commands/rpc.rs @@ -67,6 +67,7 @@ impl RpcCommand { consensus_config.upgrade_signal = execution.standard.rollup_args.upgrade_signal.clone(); let consensus_args = ConsensusNodeArgs::new(consensus_chain, consensus_config); let mut rollup_config = consensus_args.load_rollup_config()?; + Arc::make_mut(&mut execution_chain).block_time = Some(rollup_config.block_time); CliRunner::try_default_runtime()?.run_command_until_exit(|ctx| async move { execution diff --git a/bin/base/src/commands/sequencer.rs b/bin/base/src/commands/sequencer.rs index c25a10c24f..043fedaaca 100644 --- a/bin/base/src/commands/sequencer.rs +++ b/bin/base/src/commands/sequencer.rs @@ -65,6 +65,7 @@ impl SequencerCommand { consensus_config.upgrade_signal = builder.rollup_args.upgrade_signal.clone(); let consensus_args = ConsensusNodeArgs::new(consensus_chain, consensus_config); let mut rollup_config = consensus_args.load_rollup_config()?; + Arc::make_mut(&mut execution_chain).block_time = Some(rollup_config.block_time); let rollup_args = builder.rollup_args.clone(); let sequencer_rpc = rollup_args.sequencer.clone(); diff --git a/crates/common/genesis/src/lib.rs b/crates/common/genesis/src/lib.rs index c9d6a661be..f89d058625 100644 --- a/crates/common/genesis/src/lib.rs +++ b/crates/common/genesis/src/lib.rs @@ -39,3 +39,6 @@ pub use genesis::ChainGenesis; mod rollup; pub use rollup::RollupConfig; + +mod schedule; +pub use schedule::DenimTimestampSchedule; diff --git a/crates/common/genesis/src/rollup.rs b/crates/common/genesis/src/rollup.rs index 28623aa27c..3fbede67d4 100644 --- a/crates/common/genesis/src/rollup.rs +++ b/crates/common/genesis/src/rollup.rs @@ -1,12 +1,14 @@ //! Rollup Config Types +use core::num::NonZeroU64; + use alloy_chains::Chain; use alloy_hardforks::{EthereumHardfork, EthereumHardforks, ForkCondition}; use alloy_primitives::Address; use crate::{ - BaseUpgrade, ChainGenesis, FeeConfig, RuntimeUpgradeRegistry, UpgradeActivation, - UpgradeActivationSink, UpgradeConfig, + BaseUpgrade, ChainGenesis, DenimTimestampSchedule, FeeConfig, RuntimeUpgradeRegistry, + UpgradeActivation, UpgradeActivationSink, UpgradeConfig, }; /// The Rollup configuration. @@ -372,14 +374,20 @@ impl RollupConfig { /// /// If Denim is not configured or the block time is zero, returns [`None`]. pub fn denim_activation_block_number(&self) -> Option { - let denim_timestamp = self.upgrade_activation_timestamp(BaseUpgrade::Denim)?; - let block_time = self.block_time; - - if block_time == 0 { - return None; - } + self.denim_timestamp_schedule().map(|schedule| schedule.denim_activation_block_number()) + } - Some(denim_timestamp.saturating_sub(self.genesis.l2_time).div_ceil(block_time)) + /// Returns the legacy-to-Denim timestamp schedule. + /// + /// Returns `None` when Denim is unscheduled or the legacy block time is zero. + /// [`Self::l2_block_timestamp_millis`] uses the legacy formula in that case. + pub fn denim_timestamp_schedule(&self) -> Option { + Some(DenimTimestampSchedule { + genesis_block_number: self.genesis.l2.number, + genesis_timestamp: self.genesis.l2_time, + legacy_block_interval: NonZeroU64::new(self.block_time)?, + denim_activation_timestamp: self.upgrade_activation_timestamp(BaseUpgrade::Denim)?, + }) } /// Returns the L2 block number at which the genesis-only Zenith testing gate activates. @@ -405,32 +413,15 @@ impl RollupConfig { /// block number (`self.genesis.l2.number`), which is non-zero for chains whose L2 genesis /// was anchored at a later block. pub fn l2_block_timestamp_millis(&self, block_number: u64) -> u64 { - let blocks_since_genesis = block_number.saturating_sub(self.genesis.l2.number); - - let legacy_seconds = self - .genesis - .l2_time - .saturating_add(blocks_since_genesis.saturating_mul(self.block_time)); - let legacy_millis = legacy_seconds.saturating_mul(1_000); - - let Some(denim_activation_block) = self.denim_activation_block_number() else { - return legacy_millis; - }; - - if blocks_since_genesis < denim_activation_block { - return legacy_millis; + if let Some(schedule) = self.denim_timestamp_schedule() { + return schedule.block_timestamp_millis(block_number); } - - let denim_activation_seconds = self - .genesis + self.genesis .l2_time - .saturating_add(denim_activation_block.saturating_mul(self.block_time)); - let denim_activation_full_millis = denim_activation_seconds.saturating_mul(1_000); - denim_activation_full_millis.saturating_add( - blocks_since_genesis - .saturating_sub(denim_activation_block) - .saturating_mul(Self::NATIVE_SUBSECOND_BLOCK_INTERVAL_MILLIS), - ) + .saturating_add( + block_number.saturating_sub(self.genesis.l2.number).saturating_mul(self.block_time), + ) + .saturating_mul(1_000) } /// Returns the deterministic whole-second timestamp of an L2 block. @@ -491,7 +482,8 @@ impl RollupConfig { pub const GRANITE_CHANNEL_TIMEOUT: u64 = 50; /// The fixed cadence once subsecond blocks activates. - pub const NATIVE_SUBSECOND_BLOCK_INTERVAL_MILLIS: u64 = 200; + pub const NATIVE_SUBSECOND_BLOCK_INTERVAL_MILLIS: u64 = + DenimTimestampSchedule::DENIM_BLOCK_INTERVAL_MILLIS; /// The number of Denim blocks produced in one legacy two-second block interval. pub const DENIM_GAS_PARAMETER_SCALING_FACTOR: u32 = 10; diff --git a/crates/common/genesis/src/schedule.rs b/crates/common/genesis/src/schedule.rs new file mode 100644 index 0000000000..e027263a44 --- /dev/null +++ b/crates/common/genesis/src/schedule.rs @@ -0,0 +1,69 @@ +//! Denim L2 block timestamp scheduling. + +use core::num::NonZeroU64; + +/// The L2 timestamp schedule for the legacy-to-Denim transition. +/// +/// Before Denim, timestamps follow the legacy interval from the genesis anchor. Activation +/// rounds up to the first legacy slot at or after the configured time; subsequent blocks +/// advance every 200 milliseconds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DenimTimestampSchedule { + /// Absolute L2 genesis block number. + pub genesis_block_number: u64, + /// L2 genesis timestamp in seconds. + pub genesis_timestamp: u64, + /// Legacy block interval in seconds. + pub legacy_block_interval: NonZeroU64, + /// Configured Denim activation time in seconds. Activation uses the first legacy block + /// slot at or after this timestamp. + pub denim_activation_timestamp: u64, +} + +impl DenimTimestampSchedule { + /// Milliseconds between blocks after Denim. + pub const DENIM_BLOCK_INTERVAL_MILLIS: u64 = 200; + + /// Returns the L2 block offset from genesis at which Denim activates. + pub const fn denim_activation_block_number(&self) -> u64 { + self.denim_activation_timestamp + .saturating_sub(self.genesis_timestamp) + .div_ceil(self.legacy_block_interval.get()) + } + + /// Returns whether the absolute block number is in the Denim schedule. + pub const fn is_denim_active_at_block(&self, block_number: u64) -> bool { + block_number.saturating_sub(self.genesis_block_number) + >= self.denim_activation_block_number() + } + + /// Returns the deterministic timestamp of an L2 block in milliseconds. + pub const fn block_timestamp_millis(&self, block_number: u64) -> u64 { + let blocks_since_genesis = block_number.saturating_sub(self.genesis_block_number); + let legacy_seconds = self + .genesis_timestamp + .saturating_add(blocks_since_genesis.saturating_mul(self.legacy_block_interval.get())); + let legacy_millis = legacy_seconds.saturating_mul(1_000); + let denim_activation_block = self.denim_activation_block_number(); + + if blocks_since_genesis < denim_activation_block { + return legacy_millis; + } + + let denim_activation_millis = self + .genesis_timestamp + .saturating_add(denim_activation_block.saturating_mul(self.legacy_block_interval.get())) + .saturating_mul(1_000); + denim_activation_millis.saturating_add( + blocks_since_genesis + .saturating_sub(denim_activation_block) + .saturating_mul(Self::DENIM_BLOCK_INTERVAL_MILLIS), + ) + } + + /// Returns the deterministic timestamp split into `(seconds, millis_part)`. + pub const fn block_timestamp_parts(&self, block_number: u64) -> (u64, u16) { + let millis = self.block_timestamp_millis(block_number); + (millis.saturating_div(1_000), (millis % 1_000) as u16) + } +} diff --git a/crates/consensus/protocol/src/base_time.rs b/crates/consensus/protocol/src/base_time.rs index 0428c8879d..1d5fe59dde 100644 --- a/crates/consensus/protocol/src/base_time.rs +++ b/crates/consensus/protocol/src/base_time.rs @@ -7,7 +7,7 @@ use base_common_consensus::{ BaseTimeDepositSource, BaseTransaction, DepositSourceDomain, Predeploys, SystemAddresses, TxDeposit, }; -use base_common_genesis::RollupConfig; +use base_common_genesis::{DenimTimestampSchedule, RollupConfig}; use crate::REGOLITH_SYSTEM_TX_GAS; @@ -125,16 +125,25 @@ impl BaseTimeUpdateTx { block_number: u64, timestamp: u64, ) -> Result<(), BaseTimeScheduleError> { - let Some(denim_activation_block) = rollup_config.denim_activation_block_number() else { + let Some(schedule) = rollup_config.denim_timestamp_schedule() else { return Ok(()); }; - let blocks_since_genesis = block_number.saturating_sub(rollup_config.genesis.l2.number); - if blocks_since_genesis < denim_activation_block { + Self::validate_timestamp_schedule(&schedule, transactions, block_number, timestamp) + } + + /// Validates a Denim block's timestamp against a shared timestamp schedule. + pub fn validate_timestamp_schedule( + schedule: &DenimTimestampSchedule, + transactions: &[T], + block_number: u64, + timestamp: u64, + ) -> Result<(), BaseTimeScheduleError> { + if !schedule.is_denim_active_at_block(block_number) { return Ok(()); } let (expected_timestamp, expected_millis_part) = - rollup_config.l2_block_timestamp_parts(block_number); + schedule.block_timestamp_parts(block_number); if timestamp != expected_timestamp { return Err(BaseTimeScheduleError::InvalidTimestamp { expected: expected_timestamp, diff --git a/crates/execution/chainspec/src/builder.rs b/crates/execution/chainspec/src/builder.rs index d3655b2a49..a046a4907f 100644 --- a/crates/execution/chainspec/src/builder.rs +++ b/crates/execution/chainspec/src/builder.rs @@ -16,6 +16,8 @@ pub struct BaseChainSpecBuilder { inner: ChainSpecBuilder, /// Activation registry admin address. activation_admin_address: Option
, + /// Legacy L2 block interval in seconds. + block_time: Option, } impl BaseChainSpecBuilder { @@ -27,7 +29,11 @@ impl BaseChainSpecBuilder { .genesis(base_mainnet.genesis.clone()); let forks = base_mainnet.hardforks.clone(); inner = inner.with_forks(forks); - Self { inner, activation_admin_address: base_mainnet.activation_admin_address } + Self { + inner, + activation_admin_address: base_mainnet.activation_admin_address, + block_time: base_mainnet.block_time, + } } /// Set the chain ID. @@ -38,6 +44,13 @@ impl BaseChainSpecBuilder { /// Set the genesis block. pub fn genesis(mut self, genesis: Genesis) -> Self { + // Report malformed intervals in `try_build`, keeping this setter infallible. + self.block_time = genesis + .config + .extra_fields + .get_deserialized("blockTime") + .and_then(Result::ok) + .or(self.block_time); self.inner = self.inner.genesis(genesis); self } @@ -66,6 +79,12 @@ impl BaseChainSpecBuilder { self } + /// Set the legacy L2 block interval in seconds. + pub const fn block_time(mut self, block_time: u64) -> Self { + self.block_time = Some(block_time); + self + } + /// Remove the given fork from the spec. pub fn without_fork(mut self, fork: BaseUpgrade) -> Self { self.inner = self.inner.without_fork(fork); @@ -167,6 +186,8 @@ impl BaseChainSpecBuilder { /// [`Self::genesis`]). pub fn try_build(self) -> Result { let mut inner = self.inner.build(); + let _ = + inner.genesis.config.extra_fields.get_deserialized::("blockTime").transpose()?; BaseChainSpec::validate_beryl_activation_admin( &inner.hardforks, self.activation_admin_address, @@ -176,7 +197,11 @@ impl BaseChainSpecBuilder { &inner.genesis, &inner.hardforks, )); - Ok(BaseChainSpec { inner, activation_admin_address: self.activation_admin_address }) + Ok(BaseChainSpec { + inner, + activation_admin_address: self.activation_admin_address, + block_time: self.block_time, + }) } /// Build the resulting [`BaseChainSpec`]. @@ -184,8 +209,9 @@ impl BaseChainSpecBuilder { /// # Panics /// /// This function panics if the chain ID and genesis is not set ([`Self::chain`] and - /// [`Self::genesis`]), or if Beryl is scheduled without an activation registry admin address. + /// [`Self::genesis`]), if `config.blockTime` is malformed, or if Beryl is scheduled without + /// an activation registry admin address. pub fn build(self) -> BaseChainSpec { - self.try_build().expect("Beryl-enabled chain spec requires activation admin") + self.try_build().expect("chain spec configuration must be valid") } } diff --git a/crates/execution/chainspec/src/spec.rs b/crates/execution/chainspec/src/spec.rs index 6894a4a2f0..af2b30bbd1 100644 --- a/crates/execution/chainspec/src/spec.rs +++ b/crates/execution/chainspec/src/spec.rs @@ -1,4 +1,5 @@ use alloc::{boxed::Box, sync::Arc, vec, vec::Vec}; +use core::num::NonZeroU64; use alloy_chains::Chain; use alloy_consensus::{BlockHeader, EMPTY_ROOT_HASH, Header, proofs::storage_root_unhashed}; @@ -9,7 +10,8 @@ use alloy_primitives::{Address, B256, U256}; use base_common_chains::{BaseUpgradeExt, ChainConfig, Upgrades}; use base_common_consensus::Predeploys; use base_common_genesis::{ - BaseUpgrade, RuntimeUpgradeRegistry, UpgradeActivation, UpgradeActivationSink, + BaseUpgrade, DenimTimestampSchedule, RuntimeUpgradeRegistry, UpgradeActivation, + UpgradeActivationSink, }; use base_protocol::OutputRoot; use derive_more::{Constructor, Deref, Into}; @@ -29,6 +31,9 @@ pub enum BaseChainSpecError { /// Genesis JSON failed to deserialize. #[error("invalid genesis JSON: {0}")] GenesisJson(#[from] serde_json::Error), + /// Denim is scheduled without a usable legacy block interval. + #[error("Denim is scheduled but config.blockTime is missing or zero")] + InvalidDenimBlockTime, /// Beryl is scheduled but no activation registry admin address is configured. #[error("missing activation admin address for Beryl-enabled chain ID: {chain_id}")] MissingActivationAdminAddress { @@ -101,6 +106,9 @@ pub struct BaseChainSpec { /// Activation registry admin address. #[deref(ignore)] pub activation_admin_address: Option
, + /// Legacy L2 block interval in seconds. + #[deref(ignore)] + pub block_time: Option, } impl BaseChainSpec { @@ -135,6 +143,8 @@ impl BaseChainSpec { let base_genesis_info = GenesisInfo::extract_from(&genesis); let genesis_info = base_genesis_info.base_chain_info.genesis_info.unwrap_or_default(); let activation_admin_address = genesis_info.activation_admin_address; + // Parse this fallibly: dropping malformed timing config must not disable the schedule. + let block_time = genesis.config.extra_fields.get_deserialized("blockTime").transpose()?; // Block-based upgrades in canonical fork ID order. let block_upgrade_opts = [ @@ -224,6 +234,7 @@ impl BaseChainSpec { ..Default::default() }, activation_admin_address, + block_time, }) } @@ -237,7 +248,33 @@ impl BaseChainSpec { activation_admin_address, value.chain.id(), )?; - Ok(Self { inner: value, activation_admin_address }) + let block_time = + value.genesis.config.extra_fields.get_deserialized("blockTime").transpose()?; + Ok(Self { inner: value, activation_admin_address, block_time }) + } + + /// Returns the runtime-aware Denim timestamp schedule, or `Ok(None)` when unscheduled. + /// + /// # Errors + /// + /// Returns [`BaseChainSpecError::InvalidDenimBlockTime`] when Denim is scheduled but the + /// legacy block interval is missing or zero. + pub fn denim_timestamp_schedule( + &self, + ) -> Result, BaseChainSpecError> { + let ForkCondition::Timestamp(denim_activation_timestamp) = self.fork(BaseUpgrade::Denim) + else { + return Ok(None); + }; + let Some(legacy_block_interval) = self.block_time.and_then(NonZeroU64::new) else { + return Err(BaseChainSpecError::InvalidDenimBlockTime); + }; + Ok(Some(DenimTimestampSchedule { + genesis_block_number: self.genesis_header.number(), + genesis_timestamp: self.genesis_header.timestamp(), + legacy_block_interval, + denim_activation_timestamp, + })) } /// Validates that Beryl-enabled chains have a valid activation registry admin address: @@ -581,6 +618,7 @@ impl TryFrom<&ChainConfig> for BaseChainSpec { ..Default::default() }, activation_admin_address, + block_time: Some(cfg.block_time), }) } } @@ -1836,4 +1874,81 @@ mod tests { assert!(!content.contains(eth_hf.name())); } } + + #[test] + fn custom_genesis_timestamp_schedule_preserves_interval_and_anchor() { + let genesis: Genesis = serde_json::from_value(serde_json::json!({ + "number": "0x7", "timestamp": "0x64", + "config": { "chainId": 9100010, "blockTime": 3, "base": { "denim": 107 } } + })) + .unwrap(); + let parsed = BaseChainSpec::from_genesis(genesis.clone()); + let built = BaseChainSpecBuilder::default() + .chain(9100010.into()) + .genesis(genesis) + .with_fork(BaseUpgrade::Denim, ForkCondition::Timestamp(107)) + .build(); + let converted = BaseChainSpec::from(parsed.inner.clone()); + for spec in [parsed, built, converted] { + let schedule = spec.denim_timestamp_schedule().unwrap().unwrap(); + assert!(!schedule.is_denim_active_at_block(9)); + assert!(schedule.is_denim_active_at_block(10)); + assert_eq!(schedule.block_timestamp_parts(9), (106, 0)); + assert_eq!(schedule.block_timestamp_parts(10), (109, 0)); + assert_eq!(schedule.block_timestamp_parts(11), (109, 200)); + assert_eq!(schedule.block_timestamp_parts(15), (110, 0)); + } + } + + #[test] + fn malformed_block_time_does_not_disable_denim() { + let genesis: Genesis = serde_json::from_value(serde_json::json!({ + "config": { "blockTime": "invalid", "base": { "denim": 100 } } + })) + .unwrap(); + let chain_spec = ChainSpec { genesis: genesis.clone(), ..Default::default() }; + for result in [ + BaseChainSpecBuilder::default() + .chain(9_100_012.into()) + .block_time(2) + .genesis(genesis.clone()) + .with_fork(BaseUpgrade::Denim, ForkCondition::Timestamp(100)) + .try_build(), + BaseChainSpec::try_from_genesis(genesis), + BaseChainSpec::try_from_chainspec(chain_spec, None), + ] { + assert!(matches!(result, Err(BaseChainSpecError::GenesisJson(_)))); + } + } + + #[test] + fn runtime_denim_schedule_requires_block_time_and_observes_changes() { + let chain_id = 9_100_011; + RuntimeUpgradeRegistry::clear_chain(chain_id); + let mut spec = BaseChainSpecBuilder::default() + .chain(chain_id.into()) + .genesis(Genesis { timestamp: 100, ..Default::default() }) + .build(); + assert!(spec.denim_timestamp_schedule().unwrap().is_none()); + RuntimeUpgradeRegistry::set_activation_timestamp(chain_id, BaseUpgrade::Denim, 105); + for interval in [None, Some(0)] { + spec.block_time = interval; + assert!(matches!( + spec.denim_timestamp_schedule(), + Err(BaseChainSpecError::InvalidDenimBlockTime) + )); + } + spec.block_time = Some(2); + assert_eq!( + spec.denim_timestamp_schedule().unwrap().unwrap().block_timestamp_parts(4), + (106, 200) + ); + RuntimeUpgradeRegistry::set_activation_timestamp(chain_id, BaseUpgrade::Denim, 107); + assert_eq!( + spec.denim_timestamp_schedule().unwrap().unwrap().block_timestamp_parts(4), + (108, 0) + ); + RuntimeUpgradeRegistry::clear_chain(chain_id); + assert!(spec.denim_timestamp_schedule().unwrap().is_none()); + } } diff --git a/etc/systems/src/l2/stack.rs b/etc/systems/src/l2/stack.rs index cae211677c..0e80f958e9 100644 --- a/etc/systems/src/l2/stack.rs +++ b/etc/systems/src/l2/stack.rs @@ -193,7 +193,7 @@ impl L2Stack { /// # Errors /// /// Returns an error if any component fails to start. - pub async fn start(config: L2StackConfig) -> Result { + pub async fn start(mut config: L2StackConfig) -> Result { let container_config = config.container_config.as_ref(); let l1_rpc_url: Url = config.l1_rpc_url.parse().wrap_err("Invalid L1 RPC URL")?; @@ -204,6 +204,11 @@ impl L2Stack { if config.shadow_sequencers.as_ref().is_some_and(|shadow| shadow.start_block.is_some()) { rollup_config.block_time = 2; } + // op-deployer supplies the legacy interval in rollup.json, not the EL genesis. + let mut genesis: serde_json::Value = + serde_json::from_slice(&config.l2_genesis).wrap_err("Failed to parse L2 genesis")?; + genesis["config"]["blockTime"] = rollup_config.block_time.into(); + config.l2_genesis = serde_json::to_vec(&genesis)?; let l1_chain_config: ChainConfig = serde_json::from_slice(&config.l1_genesis) .wrap_err("Failed to parse L1 chain config")?; let builder_chain_spec = From 797d8c8cc6fcf8d294a66830f627b2d8b82806fb Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 29 Sep 2026 16:44:29 +0000 Subject: [PATCH 160/299] feat(eip8130): apply EIP-7623 calldata floor to intrinsic gas (#5073) * feat(eip8130): apply EIP-7623 calldata floor to intrinsic gas EIP-8130 priced tx_payload_cost at a flat EIP-2028 16/4 per byte with no floor, so a data-heavy 0x79 transaction could post data availability ~40% cheaper than an equivalent type-2 transaction on the same chain: standard txs get the EIP-7623 floor via revm, but the 8130 path bypasses revm through Eip8130Executor. This reopens the max-block-size hole EIP-7623 was created to close. Port EIP-7623's max(standard, floor) form over the serialized 8130 transaction (its bytes live in account_changes, sender_auth, calls, metadata, and payer_auth, so there is no single `data` field to floor): sender_floor = (sender_intrinsic - payload) + payload_floor sender_metered_gas = max(sender_intrinsic + execution_gas_used, sender_floor) payload_floor charges TX_TOTAL_COST_FLOOR_PER_TOKEN (10) per payload token instead of the standard 4, so computation-heavy transactions are unaffected and only data-heavy ones pay the floor. Enforced at mempool admission and inclusion (gas_limit >= sender_floor), and applied to the settled charge and to gas estimation. payer_auth is metered outside gas_limit and its bytes are already part of payload, so the transaction-level floor covers that padding vector too. The 10-per-token rate is pinned to revm's TOTAL_COST_FLOOR_PER_TOKEN by the gas_primitives_match_evm_reference drift tripwire. Warmth tests that carry little calldata are padded with JUMPDEST no-ops so their cold/warm SLOAD delta is not masked by the floor clamp. Mirrors ethereum/EIPs#12320. * fix(eip8130): price the calldata floor without re-encoding the body Sponsored intrinsic gas was re-RLP-encoding the transaction on every admission and execution check. Fold the standard and floor byte costs over the existing encoding instead. * fix(eip8130): reject gas limits below intrinsic gas instead of panicking resolve_execution_gas relied on a debug_assert that the calldata floor is at least sender-intrinsic gas, then expected the subtraction to succeed. A floor rate repriced below the standard data rate would have panicked in release. Both bounds are now real checks, the helper returns IntrinsicGas by name instead of a four-u64 tuple, and every site that floors sender gas goes through Eip8130Outcome::floored_sender_gas. The new-account test now expects the floored bill, since settlement on this branch applies the calldata floor. * test(eip8130): pin payload token counting to revm's calldata token count payload and payload_floor count EIP-7623 tokens by hand over bytes streamed from several pieces of the transaction. The parity test now checks both against revm's get_tokens_in_calldata_istanbul over the same sender-billed bytes, so a change to either formula fails the build. * test(eip8130): set the actor fields in the calldata-floor billing fixture * fix(eip8130): bill payer_auth bytes at the calldata floor rate payer_auth is excluded from the sender's payload and floor and is never executed, so billing it at the standard 4/16 rate let a sponsored transaction pad it up to MAX_AUTHENTICATION_GAS without meeting the EIP-7623 floor. Charge its bytes at the 10/40 floor rate. --- .../src/transaction/eip8130/signed.rs | 66 +++-- crates/common/eip8130/src/intrinsic.rs | 153 +++++++++-- crates/common/eip8130/src/schedule.rs | 12 + crates/common/evm/src/eip8130.rs | 242 +++++++++++++----- crates/execution/eip8130/README.md | 24 +- .../eip8130/tests/primitives_parity.rs | 51 +++- crates/execution/txpool/src/validator.rs | 8 +- 7 files changed, 435 insertions(+), 121 deletions(-) diff --git a/crates/common/consensus/src/transaction/eip8130/signed.rs b/crates/common/consensus/src/transaction/eip8130/signed.rs index f93118d75c..cf1f79d541 100644 --- a/crates/common/consensus/src/transaction/eip8130/signed.rs +++ b/crates/common/consensus/src/transaction/eip8130/signed.rs @@ -472,37 +472,61 @@ impl Eip8130Signed { out } - /// Folds `byte_cost` over the sender-billed EIP-2718 encoding: this + /// Folds `fold_byte` over the sender-billed EIP-2718 encoding: this /// transaction with `payer_auth` replaced by the empty string. /// /// When `encoded_2718` is this transaction's network encoding, the fold /// rewrites only the list header and the `payer_auth` suffix. The /// transaction body is not re-serialized. A mismatched `encoded_2718` /// falls back to [`Self::encoded_2718_without_payer_auth`]. - pub fn fold_sender_billed_bytes( + pub fn fold_sender_billed_bytes( &self, encoded_2718: &[u8], - mut byte_cost: impl FnMut(u8) -> u64, - ) -> u64 { - let fold = |bytes: &[u8], byte_cost: &mut dyn FnMut(u8) -> u64| { - bytes.iter().fold(0u64, |acc, &byte| acc.saturating_add(byte_cost(byte))) - }; + init: T, + mut fold_byte: impl FnMut(T, u8) -> T, + ) -> T { if self.payer_auth.is_empty() { - return fold(encoded_2718, &mut byte_cost); + return encoded_2718.iter().copied().fold(init, fold_byte); } - if let Some(cost) = self.try_fold_sender_billed(encoded_2718, &mut byte_cost) { - return cost; + match self.try_fold_sender_billed(encoded_2718, init, &mut fold_byte) { + Ok(total) => total, + Err(init) => { + self.encoded_2718_without_payer_auth().iter().copied().fold(init, fold_byte) + } } - fold(&self.encoded_2718_without_payer_auth(), &mut byte_cost) } - /// Sender-billed fold of a verified EIP-2718 encoding. `None` when - /// `encoded_2718` is not this transaction. - fn try_fold_sender_billed( + /// Sender-billed fold of a verified EIP-2718 encoding. `Err(init)` when + /// `encoded_2718` is not this transaction, so the caller can fall back + /// without dropping the accumulator. + fn try_fold_sender_billed( &self, encoded_2718: &[u8], - byte_cost: &mut impl FnMut(u8) -> u64, - ) -> Option { + init: T, + fold_byte: &mut impl FnMut(T, u8) -> T, + ) -> Result { + let Some((type_byte, header_buf, header_len, body)) = + self.sender_billed_parts(encoded_2718) + else { + return Err(init); + }; + + let mut total = fold_byte(init, type_byte); + for &byte in &header_buf[..header_len] { + total = fold_byte(total, byte); + } + for &byte in body { + total = fold_byte(total, byte); + } + Ok(fold_byte(total, 0x80)) + } + + /// Header rewrite and body slice for the sender-billed encoding. + /// `None` when `encoded_2718` is not this transaction. + fn sender_billed_parts<'a>( + &self, + encoded_2718: &'a [u8], + ) -> Option<(u8, [u8; 9], usize, &'a [u8])> { let (&type_byte, rest) = encoded_2718.split_first()?; if type_byte != Eip8130Constants::EIP8130_TX_TYPE { return None; @@ -529,15 +553,7 @@ impl Eip8130Signed { cursor.len() }; let header_len = header_buf.len() - remaining; - - let mut total = byte_cost(type_byte); - for &byte in &header_buf[..header_len] { - total = total.saturating_add(byte_cost(byte)); - } - for &byte in body { - total = total.saturating_add(byte_cost(byte)); - } - Some(total.saturating_add(byte_cost(0x80))) + Some((type_byte, header_buf, header_len, body)) } /// Whether `encoded` is the canonical RLP string for `raw`. diff --git a/crates/common/eip8130/src/intrinsic.rs b/crates/common/eip8130/src/intrinsic.rs index 73e23e2fd1..d596135360 100644 --- a/crates/common/eip8130/src/intrinsic.rs +++ b/crates/common/eip8130/src/intrinsic.rs @@ -156,8 +156,16 @@ pub struct IntrinsicGas { /// `AA_BASE_COST`. pub base: u64, /// `tx_payload_cost` — EIP-2028 data-availability cost over the encoding - /// with an empty `payer_auth`. + /// with an empty `payer_auth` (the *standard* per-token rate). Subject to + /// the EIP-7623 calldata floor; see [`Self::payload_floor`] and + /// [`Self::sender_floor`]. pub payload: u64, + /// The EIP-7623 calldata *floor* over the same bytes [`Self::payload`] + /// covers: `TX_TOTAL_COST_FLOOR_PER_TOKEN` (10) per payload token instead of + /// the standard `TX_DATA_ZERO_BYTE` (4). Always `>= payload`. Not part of + /// [`Self::total`]; it only raises the sender's metered gas via + /// [`Self::sender_floor`] when a data-heavy transaction's execution is cheap. + pub payload_floor: u64, /// `nonce_key_cost`. pub nonce_key: u64, /// `value_transfer_cost` — [`Eip8130GasSchedule::TX_VALUE_COST`] per call @@ -200,11 +208,35 @@ impl IntrinsicGas { /// Gas available to `calls` after sender-intrinsic gas, or `None` when /// sender-intrinsic gas alone exceeds `gas_limit` (the transaction is /// underfunded and cannot be included). + /// + /// The EIP-7623 calldata floor ([`Self::sender_floor`]) does not reduce this + /// budget — it is a post-execution minimum spend, not an intrinsic cost — so + /// the gas available to `calls` is still `gas_limit - sender_intrinsic`. The + /// floor is enforced separately as a lower bound on `gas_limit` and on the + /// settled charge. #[must_use] pub const fn execution_gas_available(&self, gas_limit: u64) -> Option { gas_limit.checked_sub(self.sender_intrinsic()) } + /// The EIP-7623 sender-side floor: the minimum sender gas a transaction is + /// metered, regardless of how little its `calls` execute. Mirrors EIP-7623's + /// floor branch by swapping the standard `tx_payload_cost` for the higher + /// per-token floor over the same bytes: + /// + /// ```text + /// sender_floor = (sender_intrinsic - payload) + payload_floor + /// ``` + /// + /// Always `>= sender_intrinsic` (since `payload_floor >= payload`), so a + /// transaction that clears the floor also clears sender-intrinsic gas. A + /// `gas_limit` below this is invalid at mempool acceptance, and settlement + /// charges at least this for the sender portion. + #[must_use] + pub const fn sender_floor(&self) -> u64 { + self.sender_intrinsic().saturating_sub(self.payload).saturating_add(self.payload_floor) + } + /// Computes the intrinsic gas for a signed EIP-8130 transaction. /// /// `encoded` is the EIP-2718-serialized signed transaction @@ -350,14 +382,6 @@ impl IntrinsicGas { )?; let payer_auth = Self::payer_auth_cost(signed, input.payer_policy_gated)?; - let payload = signed.fold_sender_billed_bytes(encoded, |byte| { - if byte == 0 { - Eip8130GasSchedule::TX_DATA_ZERO_BYTE - } else { - Eip8130GasSchedule::TX_DATA_NONZERO_BYTE - } - }); - let value_calls = tx .calls .iter() @@ -367,9 +391,20 @@ impl IntrinsicGas { let value_transfer = Eip8130GasSchedule::TX_VALUE_COST .saturating_mul(u64::try_from(value_calls).unwrap_or(u64::MAX)); + // `payload` and its EIP-7623 floor are priced over the sender-billed + // bytes: the encoding with an empty `payer_auth`. `payer_auth` bytes are + // metered separately (outside the sender's `gas_limit`). The fold + // rewrites the list header in place instead of re-encoding the body. + let (payload, payload_floor) = + signed.fold_sender_billed_bytes(encoded, (0u64, 0u64), |costs, byte| { + let (standard, floor) = Self::byte_payload_costs(byte); + (costs.0.saturating_add(standard), costs.1.saturating_add(floor)) + }); + Ok(Self { base: Eip8130GasSchedule::AA_BASE_COST, payload, + payload_floor, nonce_key, value_transfer, bytecode, @@ -410,7 +445,9 @@ impl IntrinsicGas { /// Payer-authentication gas billed on top of `gas_limit`: authenticator /// execution gas, its `authorize` SLOADs (plus the policy-gate read when - /// `policy_gated`), and the data cost of the `payer_auth` bytes. `0` for + /// `policy_gated`), and the `payer_auth` bytes at the EIP-7623 floor rate. + /// The bytes are never executed and sit outside the sender's floor, so the + /// floor rate is what keeps a payer from padding them cheaply. `0` for /// self-pay. /// /// Returns [`IntrinsicGasError::PayerAuthGasExceeded`] above @@ -429,23 +466,34 @@ impl IntrinsicGas { AuthWireForm::Prefixed }; let cost = Self::auth_cost(payer_auth, form, policy_gated)? - .saturating_add(Self::data_cost(payer_auth)); + .saturating_add(Self::floor_data_cost(payer_auth)); if cost > Eip8130GasSchedule::MAX_AUTHENTICATION_GAS { return Err(IntrinsicGasError::PayerAuthGasExceeded(cost)); } Ok(cost) } - /// EIP-2028 data cost of `bytes`. - fn data_cost(bytes: &[u8]) -> u64 { - bytes.iter().fold(0u64, |acc, &byte| { - let cost = if byte == 0 { - Eip8130GasSchedule::TX_DATA_ZERO_BYTE - } else { - Eip8130GasSchedule::TX_DATA_NONZERO_BYTE - }; - acc.saturating_add(cost) - }) + /// Standard EIP-2028 cost and EIP-7623 floor cost of one payload byte. + /// + /// A zero byte is one token (`TX_DATA_ZERO_BYTE` / `TX_TOTAL_COST_FLOOR_PER_TOKEN`). + /// A non-zero byte is four tokens. + const fn byte_payload_costs(byte: u8) -> (u64, u64) { + if byte == 0 { + ( + Eip8130GasSchedule::TX_DATA_ZERO_BYTE, + Eip8130GasSchedule::TX_TOTAL_COST_FLOOR_PER_TOKEN, + ) + } else { + ( + Eip8130GasSchedule::TX_DATA_NONZERO_BYTE, + Eip8130GasSchedule::TX_TOTAL_COST_FLOOR_PER_TOKEN.saturating_mul(4), + ) + } + } + + /// EIP-7623 floor data cost of `bytes`. + fn floor_data_cost(bytes: &[u8]) -> u64 { + bytes.iter().fold(0u64, |acc, &byte| acc.saturating_add(Self::byte_payload_costs(byte).1)) } /// Cost of authenticating one auth blob: authenticator execution gas plus the @@ -739,6 +787,49 @@ mod tests { assert_eq!(gas.sender_intrinsic(), gas.total()); } + #[test] + fn eip7623_calldata_floor_prices_payload_above_the_standard_rate() { + // The floor swaps the standard EIP-2028 4/16-per-byte rate for EIP-7623's + // 10/40-per-token rate over the same serialized bytes, so `payload_floor` + // is exactly `TX_TOTAL_COST_FLOOR_PER_TOKEN` per token and the sender floor + // sits `(10 - 4)` gas per token above sender-intrinsic gas. + let gas = intrinsic(&signed(TxEip8130::default(), vec![0xcd; 65], vec![]), &EXISTING_KEY); + + assert!(gas.payload > 0); + // Standard payload == TX_DATA_ZERO_BYTE (4) per token; recover the token + // count from it, then check the floor is the per-token floor rate. + assert_eq!(gas.payload % Eip8130GasSchedule::TX_DATA_ZERO_BYTE, 0); + let tokens = gas.payload / Eip8130GasSchedule::TX_DATA_ZERO_BYTE; + assert_eq!(gas.payload_floor, Eip8130GasSchedule::TX_TOTAL_COST_FLOOR_PER_TOKEN * tokens); + assert!(gas.payload_floor > gas.payload); + + // sender_floor == (sender_intrinsic - payload) + payload_floor, and exceeds + // sender-intrinsic gas by exactly (10 - 4) gas per token. + assert_eq!(gas.sender_floor(), gas.sender_intrinsic() - gas.payload + gas.payload_floor); + let per_token_premium = Eip8130GasSchedule::TX_TOTAL_COST_FLOOR_PER_TOKEN + - Eip8130GasSchedule::TX_DATA_ZERO_BYTE; + assert_eq!(gas.sender_floor() - gas.sender_intrinsic(), per_token_premium * tokens); + assert!(gas.sender_floor() >= gas.sender_intrinsic()); + } + + #[test] + fn eip7623_floor_grows_with_payload_size() { + // A data-heavy transaction pays a strictly larger floor premium than a + // minimal one: the floor is what stops an 8130 transaction from posting + // data availability more cheaply than a standard EIP-7623 transaction. + let minimal = + intrinsic(&signed(TxEip8130::default(), vec![0xcd; 65], vec![]), &EXISTING_KEY); + let heavy_tx = + TxEip8130 { metadata: Bytes::from(vec![0x11u8; 20_000]), ..Default::default() }; + let heavy = intrinsic(&signed(heavy_tx, vec![0xcd; 65], vec![]), &EXISTING_KEY); + + assert!(heavy.payload > minimal.payload); + assert!( + heavy.sender_floor() - heavy.sender_intrinsic() + > minimal.sender_floor() - minimal.sender_intrinsic() + ); + } + #[test] fn nonce_free_and_first_use_costs() { let mut tx = TxEip8130 { nonce_key: Eip8130Constants::NONCE_KEY_MAX, ..Default::default() }; @@ -1322,7 +1413,7 @@ mod tests { gas.payer_auth, Eip8130GasSchedule::AUTH_EXEC_P256 + Eip8130GasSchedule::COLD_SLOAD - + IntrinsicGas::data_cost(&payer_auth) + + IntrinsicGas::floor_data_cost(&payer_auth) ); // payer_auth is metered on top of gas_limit, so it is excluded here. assert_eq!(gas.sender_intrinsic(), gas.total() - gas.payer_auth); @@ -1344,7 +1435,7 @@ mod tests { policy_gated.payer_auth, Eip8130GasSchedule::AUTH_EXEC_P256 + Eip8130GasSchedule::COLD_SLOAD * 2 - + IntrinsicGas::data_cost(&payer_auth) + + IntrinsicGas::floor_data_cost(&payer_auth) ); } @@ -1357,7 +1448,7 @@ mod tests { gas.payer_auth, Eip8130GasSchedule::AUTH_EXEC_K1 + Eip8130GasSchedule::COLD_SLOAD - + IntrinsicGas::data_cost(&payer_auth) + + IntrinsicGas::floor_data_cost(&payer_auth) ); } @@ -1378,7 +1469,8 @@ mod tests { assert_eq!(short.sender_intrinsic(), long.sender_intrinsic()); assert_eq!( long.payer_auth - short.payer_auth, - 200 * Eip8130GasSchedule::TX_DATA_NONZERO_BYTE + 200 * 4 * Eip8130GasSchedule::TX_TOTAL_COST_FLOOR_PER_TOKEN, + "payer_auth bytes are billed at the calldata floor rate" ); } @@ -1402,10 +1494,15 @@ mod tests { payer_auth.extend_from_slice(&[0x00; 4_000]); let signed_tx = signed(tx, configured_auth(K1), payer_auth); let gas = intrinsic(&signed_tx, &EXISTING_KEY); - assert_eq!( - gas.payload, - IntrinsicGas::data_cost(&signed_tx.encoded_2718_without_payer_auth()) + let (payload, payload_floor) = signed_tx.encoded_2718_without_payer_auth().iter().fold( + (0u64, 0u64), + |costs, &byte| { + let (standard, floor) = IntrinsicGas::byte_payload_costs(byte); + (costs.0.saturating_add(standard), costs.1.saturating_add(floor)) + }, ); + assert_eq!(gas.payload, payload); + assert_eq!(gas.payload_floor, payload_floor); } #[test] diff --git a/crates/common/eip8130/src/schedule.rs b/crates/common/eip8130/src/schedule.rs index c821d3de71..009124bc57 100644 --- a/crates/common/eip8130/src/schedule.rs +++ b/crates/common/eip8130/src/schedule.rs @@ -44,6 +44,18 @@ impl Eip8130GasSchedule { /// Cost of a non-zero byte of serialized transaction data. pub const TX_DATA_NONZERO_BYTE: u64 = 16; + // ── EIP-7623 calldata floor ────────────────────────────────────────────── + /// `TOTAL_COST_FLOOR_PER_TOKEN`: the per-token gas an EIP-7623 transaction + /// pays for data availability under the floor branch. An 8130 transaction has + /// no single `data` field, so the floor is evaluated over the serialized + /// transaction (one token per zero byte, four per non-zero byte). A data-heavy + /// transaction whose execution is cheap pays `payload_tokens * this` instead + /// of the standard `payload_tokens * TX_DATA_ZERO_BYTE`, so it cannot post data + /// availability more cheaply than a standard EIP-7623 transaction on the same + /// chain. Pinned to revm's `TOTAL_COST_FLOOR_PER_TOKEN` by the + /// `gas_primitives_match_evm_reference` drift tripwire. + pub const TX_TOTAL_COST_FLOOR_PER_TOKEN: u64 = 10; + // ── EIP-8130 table values ──────────────────────────────────────────────── /// Base intrinsic cost for any AA transaction (`AA_BASE_COST`). pub const AA_BASE_COST: u64 = Eip8130Constants::EIP8130_BASE_COST; diff --git a/crates/common/evm/src/eip8130.rs b/crates/common/evm/src/eip8130.rs index 6bcddc9849..01e7876d45 100644 --- a/crates/common/evm/src/eip8130.rs +++ b/crates/common/evm/src/eip8130.rs @@ -118,6 +118,12 @@ pub struct Eip8130Outcome { pub gas_limit: u64, /// Sender-intrinsic gas (intrinsic gas excluding payer authentication). pub sender_intrinsic: u64, + /// EIP-7623 sender-side calldata floor: the minimum sender gas the + /// transaction is metered regardless of how little its `calls` execute + /// (`>= sender_intrinsic`). `gas_limit` is guaranteed `>= sender_floor` (the + /// transaction is rejected otherwise), and settlement charges at least this + /// for the sender portion. + pub sender_floor: u64, /// Payer-authentication gas, metered on top of `gas_limit`. pub payer_auth: u64, /// Gas available to `calls` (`gas_limit - sender_intrinsic`). @@ -131,6 +137,17 @@ pub struct Eip8130Outcome { pub bump_protocol_nonce: bool, } +impl Eip8130Outcome { + /// Applies the EIP-7623 floor to the sender portion of metered gas. + /// + /// Every path that reports or charges sender gas goes through this, so the + /// floor is applied the same way in estimation and settlement. + #[must_use] + pub const fn floored_sender_gas(&self, sender_gas: u64) -> u64 { + if sender_gas < self.sender_floor { self.sender_floor } else { sender_gas } + } +} + /// The result of dispatching an EIP-8130 transaction's `calls`. #[derive(Debug, Clone, PartialEq, Eq)] struct CallsResult { @@ -548,9 +565,13 @@ impl Eip8130Executor { } }; let logs = evm.ctx_mut().journal_mut().take_logs(); + // EIP-7623: the sender portion is floored at the calldata floor even + // on a revert, so the reported gas reflects the minimum a data-heavy + // transaction is metered. let gross = outcome - .sender_intrinsic - .saturating_add(final_calls.call_gas_spent) + .floored_sender_gas( + outcome.sender_intrinsic.saturating_add(final_calls.call_gas_spent), + ) .saturating_add(outcome.payer_auth); Self::end_inspection( evm, @@ -612,14 +633,15 @@ impl Eip8130Executor { }; let logs = evm.ctx_mut().journal_mut().take_logs(); - // gas_limit = intrinsic + feasible_pool + payer_auth. The on-chain call - // pool at this limit is `gas_limit - intrinsic = feasible_pool + payer_auth` - // (payer authentication is billed on top of the limit, not drawn from the - // pool), so it is at least `feasible_pool` — the verified-feasible amount — - // and the limit also covers the net charge (which never exceeds it). + // gas_limit = max(intrinsic + feasible_pool, sender_floor) + payer_auth. + // The on-chain call pool at this limit is `gas_limit - intrinsic` (payer + // authentication is billed on top of the limit, not drawn from the pool), + // so it is at least `feasible_pool` — the verified-feasible amount — and + // the limit also covers the net charge (which never exceeds it). The + // EIP-7623 floor raises the returned limit for a data-heavy transaction so + // it is never rejected at admission (`gas_limit >= sender_floor`). let estimate_gas = outcome - .sender_intrinsic - .saturating_add(feasible_pool) + .floored_sender_gas(outcome.sender_intrinsic.saturating_add(feasible_pool)) .saturating_add(outcome.payer_auth); Self::end_inspection( evm, @@ -866,13 +888,12 @@ impl Eip8130Executor { // The monotonic, body-derivable nonce first-use cost stays resolved. // Execution reprices all of these precisely against the // authenticated actors and real state. - let (sender_intrinsic, payer_auth, execution_gas_available) = - Self::resolve_execution_gas( - signed, - encoded, - &IntrinsicGasInput::worst_case(sender, nonce_key_first_use, tx.payer.is_some()), - gas_limit, - )?; + let (intrinsic, execution_gas_available) = Self::resolve_execution_gas( + signed, + encoded, + &IntrinsicGasInput::worst_case(sender, nonce_key_first_use, tx.payer.is_some()), + gas_limit, + )?; // 5. Publish the transaction context for the `TxContext` precompile. TxContextStorage::new(sctx) @@ -886,8 +907,9 @@ impl Eip8130Executor { policy_gated, policy_target, gas_limit, - sender_intrinsic, - payer_auth, + sender_intrinsic: intrinsic.sender_intrinsic(), + sender_floor: intrinsic.sender_floor(), + payer_auth: intrinsic.payer_auth, execution_gas_available, effective: FeeCheck::effective_gas_price(max_fee, max_priority, base_fee), base_fee, @@ -1049,15 +1071,14 @@ impl Eip8130Executor { }; // 4. Intrinsic gas under the EIP-8130 schedule. - let (sender_intrinsic, payer_auth, execution_gas_available) = - Self::resolve_execution_gas( - signed, - encoded, - &IntrinsicGasInput::new(sender, nonce_key_first_use) - .with_policy_gates(sender_actor.is_policy_gated(), payer_policy_gated) - .with_revoke_discount_slots(applied_tx.revoke_discount_slots), - gas_limit, - )?; + let (intrinsic, execution_gas_available) = Self::resolve_execution_gas( + signed, + encoded, + &IntrinsicGasInput::new(sender, nonce_key_first_use) + .with_policy_gates(sender_actor.is_policy_gated(), payer_policy_gated) + .with_revoke_discount_slots(applied_tx.revoke_discount_slots), + gas_limit, + )?; // 5. Fee caps and payer balance. FeeCheck::validate_fees(max_fee, max_priority, base_fee) @@ -1065,7 +1086,7 @@ impl Eip8130Executor { let payer_balance = sctx .with_account_info(payer, |info| Ok(info.balance)) .map_err(BaseTransactionError::eip8130)?; - FeeCheck::validate_balance(payer_balance, gas_limit, payer_auth, max_fee) + FeeCheck::validate_balance(payer_balance, gas_limit, intrinsic.payer_auth, max_fee) .map_err(BaseTransactionError::eip8130)?; // 6. Publish the transaction context (sender / payer / actor id) so it @@ -1081,8 +1102,9 @@ impl Eip8130Executor { policy_gated: sender_actor.is_policy_gated(), policy_target: sender_actor.policy_target, gas_limit, - sender_intrinsic, - payer_auth, + sender_intrinsic: intrinsic.sender_intrinsic(), + sender_floor: intrinsic.sender_floor(), + payer_auth: intrinsic.payer_auth, execution_gas_available, effective: FeeCheck::effective_gas_price(max_fee, max_priority, base_fee), base_fee, @@ -1656,11 +1678,17 @@ impl Eip8130Executor { /// `sender_intrinsic + call_gas_spent + payer_auth` — because refunds are /// credited after execution and are never available to the call pool during /// execution, so the gas limit must cover the full gross spend. + /// + /// The sender portion is floored at [`Eip8130Outcome::sender_floor`] per + /// EIP-7623: a data-heavy transaction whose `calls` execute cheaply still pays + /// the calldata floor. Mirrors revm's `eip7623_check_gas_floor`, which compares + /// the floor against the post-refund gas used. `payer_auth` is added on top and + /// is not part of the floor (it is metered outside `gas_limit`). fn billable_gas(outcome: &Eip8130Outcome, calls: &CallsResult) -> u64 { let gross_used = outcome.sender_intrinsic.saturating_add(calls.call_gas_spent); let refund = Self::capped_refund(calls.refund, gross_used); let net_used = gross_used.saturating_sub(refund); - net_used.saturating_add(outcome.payer_auth) + outcome.floored_sender_gas(net_used).saturating_add(outcome.payer_auth) } /// Applies the transaction's account-configuration changes and installs the @@ -1763,23 +1791,32 @@ impl Eip8130Executor { sctx.set_code(address, bytecode).map_err(BaseTransactionError::eip8130) } - /// Computes the EIP-8130 intrinsic gas and the gas left for `calls`, returning - /// `(sender_intrinsic, payer_auth, execution_gas_available)`. Shared by both - /// pipelines so intrinsic pricing is computed identically for execution and - /// estimation. Errors when sender-intrinsic gas exceeds the gas limit. + /// Computes the EIP-8130 intrinsic gas and the gas left for `calls`. + /// Shared by both pipelines so intrinsic pricing is computed identically for + /// execution and estimation. + /// + /// Errors when `gas_limit` is below either the EIP-7623 sender floor or + /// sender-intrinsic gas. Both are checked: the floor dominates intrinsic gas + /// only while the floor rate is at least the standard data rate, and a + /// repriced schedule must be rejected rather than panic. fn resolve_execution_gas( signed: &base_common_consensus::Eip8130Signed, encoded: &[u8], input: &IntrinsicGasInput, gas_limit: u64, - ) -> Result<(u64, u64, u64), BaseTransactionError> { + ) -> Result<(IntrinsicGas, u64), BaseTransactionError> { let intrinsic = IntrinsicGas::compute(signed, encoded, input).map_err(BaseTransactionError::eip8130)?; + if gas_limit < intrinsic.sender_floor() { + return Err(BaseTransactionError::eip8130( + "EIP-8130 gas limit is below the EIP-7623 calldata floor", + )); + } let execution_gas_available = intrinsic.execution_gas_available(gas_limit).ok_or_else(|| { - BaseTransactionError::eip8130("EIP-8130 sender-intrinsic gas exceeds the gas limit") + BaseTransactionError::eip8130("EIP-8130 gas limit is below intrinsic gas") })?; - Ok((intrinsic.sender_intrinsic(), intrinsic.payer_auth, execution_gas_available)) + Ok((intrinsic, execution_gas_available)) } /// ABI-encodes the `ActorPolicyViolation(bytes32 actorId, address target)` @@ -1832,6 +1869,24 @@ mod tests { const BASE_FEE: u64 = 1_000_000_000; const BENEFICIARY: Address = address!("0x00000000000000000000000000000000000000bb"); + /// Extra execution gas the warmth probes burn via `JUMPDEST` no-ops (1 gas + /// each). These probes carry little calldata and do almost no work, so their + /// `calls` otherwise spend less than the EIP-7623 calldata-floor premium + /// (`6 gas/token`) — the floor would then clamp both the cold and the warm + /// outcome to the same value and mask the 2,100-vs-100 SLOAD delta the tests + /// measure. Padding execution above the floor keeps that delta observable. + const WARMTH_PROBE_PAD_GAS: u64 = 5_000; + + /// `JUMPDEST * WARMTH_PROBE_PAD_GAS` (each a 1-gas straight-line no-op) then + /// `PUSH1 0; SLOAD; STOP`. The padding is *deployed* code, not transaction + /// bytes, so it raises only execution gas — never `tx_payload_cost` — and the + /// intrinsic breakdown is unchanged from the bare `60005400` probe. + fn warmth_probe_loader_code() -> Bytes { + let mut code = vec![0x5bu8; WARMTH_PROBE_PAD_GAS as usize]; + code.extend_from_slice(&[0x60, 0x00, 0x54, 0x00]); + Bytes::from(code) + } + fn signing_key(byte: u8) -> SigningKey { SigningKey::from_slice(&[byte; 32]).unwrap() } @@ -1947,6 +2002,52 @@ mod tests { .map_or_else(Bytes::new, Bytecode::original_bytes) } + /// The EIP-7623 sender-side calldata floor is applied to the settled charge: + /// a data-heavy transaction whose `calls` execute cheaply is billed at least + /// `sender_floor`, while a transaction that outspends the floor pays the + /// standard branch unchanged. `payer_auth` is added on top of both and is not + /// part of the floor. + #[test] + fn billable_gas_applies_the_eip7623_sender_floor() { + let base = Eip8130Outcome { + sender: Address::ZERO, + payer: Address::ZERO, + sender_actor_id: B256::ZERO, + policy_gated: false, + policy_target: Address::ZERO, + gas_limit: 1_000_000, + sender_intrinsic: 100_000, + sender_floor: 130_000, + payer_auth: 7_000, + execution_gas_available: 900_000, + effective: u128::from(BASE_FEE), + base_fee: u128::from(BASE_FEE), + bump_protocol_nonce: false, + }; + + // Cheap execution: sender_intrinsic + call_gas_spent (110_000) is below the + // 130_000 floor, so the sender portion is raised to the floor. + let cheap = CallsResult { + call_gas_spent: 10_000, + refund: 0, + reverted: false, + output: Bytes::new(), + phase_statuses: Vec::new(), + }; + assert_eq!( + Eip8130Executor::billable_gas(&base, &cheap), + base.sender_floor + base.payer_auth, + ); + + // Heavy execution: sender_intrinsic + call_gas_spent (600_000) clears the + // floor, so the standard post-refund branch is billed unchanged. + let heavy = CallsResult { call_gas_spent: 500_000, ..cheap }; + assert_eq!( + Eip8130Executor::billable_gas(&base, &heavy), + base.sender_intrinsic + heavy.call_gas_spent + base.payer_auth, + ); + } + #[test] fn eoa_self_pay_transaction_executes_and_charges_sender() { let key = signing_key(0x22); @@ -2004,9 +2105,17 @@ mod tests { let key = signing_key(0x53); let sender = eoa_address(&key); let recipient = address!("0x00000000000000000000000000000000000000e3"); + // Burns enough gas that both runs bill above the EIP-7623 calldata floor. + let burner = address!("0x00000000000000000000000000000000000000e5"); + let mut burner_code = vec![0x5bu8; WARMTH_PROBE_PAD_GAS as usize]; + burner_code.push(0x00); + let burner_code = Bytes::from(burner_code); let initial_balance = U256::from(10u64).pow(U256::from(18u64)); let mut tx = base_tx(); - tx.calls = vec![vec![Call { to: recipient, value: U256::from(1u64), data: Bytes::new() }]]; + tx.calls = vec![vec![ + Call { to: recipient, value: U256::from(1u64), data: Bytes::new() }, + Call { to: burner, value: U256::ZERO, data: Bytes::new() }, + ]]; let signed = eoa_signed(tx, &key); let gas_used = |accounts: &[(Address, Bytes)]| { @@ -2017,7 +2126,8 @@ mod tests { }; assert_eq!( - gas_used(&[]) - gas_used(&[(recipient, bytes!("00"))]), + gas_used(&[(burner, burner_code.clone())]) + - gas_used(&[(recipient, bytes!("00")), (burner, burner_code)]), Eip8130GasSchedule::NEW_ACCOUNT_COST ); } @@ -2037,6 +2147,7 @@ mod tests { let mut gas_limit = shortfall; let mut signed = eoa_signed(tx.clone(), &key); let mut sender_intrinsic = 0; + let mut sender_floor = 0; for _ in 0..8 { let mut trial = tx.clone(); trial.gas_limit = gas_limit; @@ -2047,6 +2158,7 @@ mod tests { IntrinsicGas::compute(&signed, &encoded, &IntrinsicGasInput::new(sender, true)) .expect("intrinsic gas"); sender_intrinsic = intrinsic.sender_intrinsic(); + sender_floor = intrinsic.sender_floor(); let next = sender_intrinsic.saturating_add(shortfall); if next == gas_limit { break; @@ -2060,9 +2172,11 @@ mod tests { let outcome = evm.transact_raw(into_base_tx(&signed)).expect("tx should be included"); assert!(!outcome.result.is_success(), "phase should revert when creation gas does not fit"); + // No call gas was spent, so the bill is sender-intrinsic gas raised to + // the EIP-7623 floor. assert_eq!( outcome.result.tx_gas_used(), - sender_intrinsic, + sender_intrinsic.max(sender_floor), "unspent call gas must not be billed" ); assert!(outcome.result.tx_gas_used() < signed.tx().gas_limit); @@ -2979,8 +3093,10 @@ mod tests { let sender = eoa_address(&key); let loader = address!("0x00000000000000000000000000000000000000c8"); - // PUSH1 0, SLOAD, STOP - let loader_code = bytes!("60005400"); + // JUMPDEST padding (see `warmth_probe_loader_code`) then PUSH1 0, SLOAD, + // STOP: the padding keeps execution above the EIP-7623 floor so the + // cold-vs-warm SLOAD delta below is not masked by the floor clamp. + let loader_code = warmth_probe_loader_code(); let mut evm = evm_with_accounts( U256::from(10u64).pow(U256::from(18u64)), @@ -3002,8 +3118,9 @@ mod tests { // payload EIP-2028 DA over the tx 1_716 // nonce_key existing channel 0: COLD_SLOAD 2_100 + SSTORE_RESET 2_900 5_000 // sender_auth ECRECOVER 3_000 + cold SLOAD 2_100 5_100 - // call PUSH1 (3) + COLD SLOAD (2_100) + STOP 2_103 - // total 28_919 (the call's zero value adds one 0x80 calldata byte) + // call JUMPDEST pad (WARMTH_PROBE_PAD_GAS) + PUSH1 (3) + COLD SLOAD (2_100) + STOP + // total 28_919 + WARMTH_PROBE_PAD_GAS (the call's zero value adds one 0x80 + // calldata byte) let mut load_tx = base_tx(); load_tx.nonce_sequence = 1; load_tx.calls = vec![vec![Call { to: loader, value: U256::ZERO, data: Bytes::new() }]]; @@ -3014,7 +3131,7 @@ mod tests { assert_eq!( load_outcome.result.gas().tx_gas_used(), - 28_919, + 28_919 + WARMTH_PROBE_PAD_GAS, "loader SLOAD must be COLD (2_100); a warm read (100) would be 2_000 \ less, meaning tx 1's warmth leaked across the transaction boundary", ); @@ -3032,8 +3149,10 @@ mod tests { let sender = eoa_address(&key); let loader = address!("0x00000000000000000000000000000000000000c8"); - // PUSH1 0, SLOAD, STOP - let loader_code = bytes!("60005400"); + // JUMPDEST padding (see `warmth_probe_loader_code`) then PUSH1 0, SLOAD, + // STOP: the padding keeps each phase's execution above the EIP-7623 floor + // so the intra-transaction cold-vs-warm SLOAD delta is not masked. + let loader_code = warmth_probe_loader_code(); let mut evm = evm_with_accounts( U256::from(10u64).pow(U256::from(18u64)), @@ -3055,13 +3174,13 @@ mod tests { // payload EIP-2028 DA over the two-phase tx 1_888 // nonce_key first use of channel 0: COLD_SLOAD 2_100 + SSTORE_SET 20_000 22_100 // sender_auth ECRECOVER 3_000 + cold SLOAD 2_100 5_100 - // call phase 0 PUSH1 (3) + COLD SLOAD (2_100) + STOP 2_103 - // call phase 1 PUSH1 (3) + WARM SLOAD (100) + STOP 103 - // total 46_294 (the two calls each carry a value word; a zero value adds - // one 0x80 calldata byte per call to the DA payload) + // call phase 0 JUMPDEST pad + PUSH1 (3) + COLD SLOAD (2_100) + STOP + // call phase 1 JUMPDEST pad + PUSH1 (3) + WARM SLOAD (100) + STOP + // total 46_294 + 2 * WARMTH_PROBE_PAD_GAS (the pad runs once per phase; the + // two calls each carry a value word, adding one 0x80 calldata byte per call) assert_eq!( outcome.result.gas().tx_gas_used(), - 46_294, + 46_294 + 2 * WARMTH_PROBE_PAD_GAS, "phase 1's SLOAD must be WARM (100): committed phase 0 warmed \ (loader, slot 0). A cold read (2_100) would be 2_000 more, meaning \ the committed phase's warmth failed to carry across phases", @@ -3074,8 +3193,10 @@ mod tests { let sender = eoa_address(&key); let loader = address!("0x00000000000000000000000000000000000000c8"); - // PUSH1 0, SLOAD, STOP - let loader_code = bytes!("60005400"); + // JUMPDEST padding (see `warmth_probe_loader_code`) then PUSH1 0, SLOAD, + // STOP: the padding keeps execution above the EIP-7623 floor so the + // cold-vs-warm SLOAD delta below is not masked by the floor clamp. + let loader_code = warmth_probe_loader_code(); let mut evm = evm_with_accounts( U256::from(10u64).pow(U256::from(18u64)), @@ -3108,13 +3229,14 @@ mod tests { // payload EIP-2028 DA over the 123-byte tx 1_716 // nonce_key first use of channel 0: COLD_SLOAD 2_100 + SSTORE_SET 20_000 22_100 // sender_auth ECRECOVER 3_000 + cold SLOAD 2_100 5_100 - // call PUSH1 (3) + SLOAD + STOP (0) 2_103 - // total 46_019 (the call's zero value adds one 0x80 calldata byte) + // call JUMPDEST pad (WARMTH_PROBE_PAD_GAS) + PUSH1 (3) + COLD SLOAD (2_100) + STOP + // total 46_019 + WARMTH_PROBE_PAD_GAS (the call's zero value adds one 0x80 + // calldata byte) assert_eq!( load_outcome.result.gas().tx_gas_used(), - 46_019, - "loader SLOAD must be COLD (2_100); a warm read (100) would total \ - 44_019, meaning the discarded invalid tx leaked warmth", + 46_019 + WARMTH_PROBE_PAD_GAS, + "loader SLOAD must be COLD (2_100); a warm read (100) would be 2_000 \ + less, meaning the discarded invalid tx leaked warmth", ); } diff --git a/crates/execution/eip8130/README.md b/crates/execution/eip8130/README.md index 87e98ee9a1..894f2ab34a 100644 --- a/crates/execution/eip8130/README.md +++ b/crates/execution/eip8130/README.md @@ -74,7 +74,8 @@ intrinsic_gas = AA_BASE_COST + tx_payload_cost + nonce_key_cost + value_transfer | Component | Source | |---|---| | `base` | `AA_BASE_COST` (15,000) | -| `payload` | EIP-2028 data-availability cost (16/non-zero, 4/zero byte) over the EIP-2718 serialization of the signed transaction with an empty `payer_auth` | +| `payload` | EIP-2028 data-availability cost (16/non-zero, 4/zero byte) over the EIP-2718 serialization of the signed transaction with an empty `payer_auth` — the *standard* rate, subject to the EIP-7623 calldata floor below | +| `payload_floor` | EIP-7623 calldata floor over the same bytes: `TX_TOTAL_COST_FLOOR_PER_TOKEN` (10) per payload token instead of the standard 4, so a data-heavy transaction cannot post data availability more cheaply than a standard EIP-7623 transaction on the same chain (always `>= payload`) | | `nonce_key` | nonce-free `13,000`; otherwise first-use `22,100` / existing `5,000` (a cold SLOAD plus an SSTORE set or reset) | | `value_transfer` | `6,000` per call with `value > 0` and `to != sender` | | `bytecode` | per create entry: `32,000 + 200 · code_len` | @@ -88,6 +89,27 @@ account creation when it runs, since existence is only known at dispatch. of `gas_limit`), so `execution_gas_available(gas_limit) = gas_limit - sender_intrinsic`. +### EIP-7623 calldata floor + +Following [EIP-7623](https://eips.ethereum.org/EIPS/eip-7623), the sender's +metered gas is floored so a transaction that posts many bytes relative to its +execution cannot drive the maximum block size back up. An 8130 transaction has +no single `data` field — its bytes live in `account_changes`, `sender_auth`, +`calls`, `metadata`, and `payer_auth` — so the floor is evaluated over the +serialized transaction: + +```text +sender_floor = (sender_intrinsic - payload) + payload_floor +sender_metered_gas = max(sender_intrinsic + execution_gas_used, sender_floor) +``` + +`sender_floor >= sender_intrinsic`, so a `gas_limit` below the floor is rejected +at mempool acceptance and at inclusion, and settlement charges at least +`sender_floor` for the sender portion (`payer_auth` is added on top and is not +part of the floor). The `10`-per-token rate is normative structure: an L2-profile +chain may reprice `TX_TOTAL_COST_FLOOR_PER_TOKEN` but must not set it below the +standard `4` or drop the floor. + ### Authenticator execution gas The EIP lets a chain enshrine the canonical authenticators and charge a fixed diff --git a/crates/execution/eip8130/tests/primitives_parity.rs b/crates/execution/eip8130/tests/primitives_parity.rs index 56286bccd5..77389f6683 100644 --- a/crates/execution/eip8130/tests/primitives_parity.rs +++ b/crates/execution/eip8130/tests/primitives_parity.rs @@ -1,16 +1,19 @@ //! Parity guards for the EIP-8130 gas primitives, which now live in the //! engine-neutral `base-common-eip8130` crate and are re-exported here. //! -//! These two checks are kept in `base-execution-eip8130` — rather than beside the +//! These checks are kept in `base-execution-eip8130` — rather than beside the //! primitives — because each pins the revm-free schedule/metering against -//! something only the revm execution path has: revm's canonical gas constants, -//! and the validating account-change decoder ([`AccountChangeApplier`]). +//! something only the revm execution path has: revm's canonical gas constants +//! and calldata token counter, and the validating account-change decoder +//! ([`AccountChangeApplier`]). use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_sol_types::SolValue; -use base_common_consensus::Eip8130Constants; -use base_execution_eip8130::{AccountChangeApplier, Eip8130GasSchedule, IntrinsicGas}; -use revm::interpreter::gas; +use base_common_consensus::{Eip8130Constants, Eip8130Signed, TxEip8130}; +use base_execution_eip8130::{ + AccountChangeApplier, Eip8130GasSchedule, IntrinsicGas, IntrinsicGasInput, +}; +use revm::{context_interface::cfg::gas::get_tokens_in_calldata_istanbul, interpreter::gas}; alloy_sol_types::sol! { // Mirror of the contract's `ActorConfig` authorize payload, used only to pin @@ -40,6 +43,9 @@ fn gas_primitives_match_evm_reference() { // (Istanbul) cost, not the EIP-7623 floor token. assert_eq!(Eip8130GasSchedule::TX_DATA_ZERO_BYTE, gas::STANDARD_TOKEN_COST); assert_eq!(Eip8130GasSchedule::TX_DATA_NONZERO_BYTE, gas::NON_ZERO_BYTE_DATA_COST_ISTANBUL); + // The EIP-7623 per-token floor an 8130 transaction pays over its serialized + // payload, pinned to revm's `TOTAL_COST_FLOOR_PER_TOKEN`. + assert_eq!(Eip8130GasSchedule::TX_TOTAL_COST_FLOOR_PER_TOKEN, gas::TOTAL_COST_FLOOR_PER_TOKEN); assert_eq!(Eip8130GasSchedule::CODE_DEPOSIT_PER_BYTE, gas::CODEDEPOSIT); assert_eq!(Eip8130GasSchedule::CREATE_BASE_COST, gas::CREATE); @@ -78,6 +84,39 @@ fn gas_primitives_match_evm_reference() { assert_eq!(Eip8130GasSchedule::NONCE_FREE_COST, 13_000); } +/// `payload` and `payload_floor` count tokens by hand (one per zero byte, four +/// per non-zero byte) over bytes streamed from several pieces of the +/// transaction. This pins that count to revm's calldata token counter over the +/// same sender-billed bytes, so a change to either formula fails here. +#[test] +fn payload_token_count_matches_evm_reference() { + let mut metadata = vec![0u8; 40]; + metadata.extend((1..=200u8).collect::>()); + let tx = TxEip8130 { + chain_id: 8453, + gas_limit: 1_000_000, + max_fee_per_gas: 1, + metadata: Bytes::from(metadata), + payer: Some(Address::repeat_byte(0x22)), + ..Default::default() + }; + let mut payer_auth = Eip8130Constants::K1_AUTHENTICATOR.to_vec(); + payer_auth.extend([0xabu8; 65]); + let signed = Eip8130Signed::new(tx, Bytes::from(vec![0xcdu8; 65]), Bytes::from(payer_auth)); + let mut encoded = vec![Eip8130Constants::EIP8130_TX_TYPE]; + signed.rlp_encode_signed(&mut encoded); + + let gas = IntrinsicGas::compute( + &signed, + &encoded, + &IntrinsicGasInput::new(Address::repeat_byte(0x11), false), + ) + .expect("intrinsic gas"); + let tokens = get_tokens_in_calldata_istanbul(&signed.encoded_2718_without_payer_auth()); + assert_eq!(gas.payload, tokens * gas::STANDARD_TOKEN_COST); + assert_eq!(gas.payload_floor, tokens * gas::TOTAL_COST_FLOOR_PER_TOKEN); +} + #[test] fn authorize_attaches_policy_agrees_with_apply_decode() { // Cross-crate coupling guard: whenever the apply-side (validating) decoder diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index 51c44ef246..411bd62964 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -1107,7 +1107,13 @@ where _ => "intrinsic gas computation failed", }) })?; - if intrinsic.execution_gas_available(signed.tx().gas_limit).is_none() { + // EIP-7623 calldata floor: a `gas_limit` below the floor branch is + // invalid. Sender-intrinsic gas is checked too, matching execution, so + // a floor rate repriced below the standard data rate cannot admit an + // underfunded transaction. + if signed.tx().gas_limit < intrinsic.sender_floor() + || intrinsic.execution_gas_available(signed.tx().gas_limit).is_none() + { return Err(InvalidTransactionError::GasTooLow.into()); } From 8dfd3693de1beb12169843fda77c8e0fba1d98af Mon Sep 17 00:00:00 2001 From: Francis Li Date: Tue, 29 Sep 2026 17:20:48 +0000 Subject: [PATCH 161/299] Validate Denim timestamps on execution import paths (#5334) Amp-Thread-ID: https://ampcode.com/threads/T-01a0d9fd-e2b7-77f2-a493-f9ce60f14dad Co-authored-by: Amp --- crates/execution/chainspec/README.md | 10 + crates/execution/consensus/src/lib.rs | 175 +++++++++++++++++- .../execution/consensus/src/validation/mod.rs | 50 +++-- .../eip8130-rpc-node/tests/receipt.rs | 4 +- .../runner/src/test_utils/harness.rs | 8 +- .../runner/tests/rpc_timestamp_ms.rs | 6 +- crates/utilities/test-utils/src/genesis.rs | 1 + 7 files changed, 223 insertions(+), 31 deletions(-) diff --git a/crates/execution/chainspec/README.md b/crates/execution/chainspec/README.md index 38d75d1cab..2668df7171 100644 --- a/crates/execution/chainspec/README.md +++ b/crates/execution/chainspec/README.md @@ -19,6 +19,16 @@ other fields for Base. Chain names are resolved from CLI strings via `SUPPORTED_CHAINS`, which maps `"base"`, `"base_sepolia"`, `"base-sepolia"`, and `"dev"` to specs built from `base-common-chains`. +### Denim timestamps + +Execution validates Denim timestamps against the same block-number schedule as consensus. +The schedule uses the genesis header's number and timestamp, the legacy block interval, and +the runtime-aware Denim activation time. Built-in networks supply the interval from `ChainConfig`; +integrated `base rpc`, `base sequencer`, and `base follow` use the loaded rollup config's +`block_time`. Standalone execution nodes using a custom genesis must set a nonzero +`config.blockTime` (seconds). Once Denim is scheduled, validation rejects missing or zero +intervals rather than assuming a two-second cadence. + ### Base fee computation Two helpers handle upgrade-specific base fee logic: diff --git a/crates/execution/consensus/src/lib.rs b/crates/execution/consensus/src/lib.rs index a6b53bead6..4ffbc100de 100644 --- a/crates/execution/consensus/src/lib.rs +++ b/crates/execution/consensus/src/lib.rs @@ -20,6 +20,7 @@ use alloy_primitives::{B64, B256}; use base_common_chains::Upgrades; use base_common_consensus::{BaseTxEnvelope, DepositReceiptExt}; use base_execution_chainspec::BaseChainSpec; +use base_protocol::BaseTimeScheduleError; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom}; use reth_consensus_common::validation::{ validate_against_parent_eip1559_base_fee, validate_against_parent_hash_number, @@ -79,6 +80,13 @@ where receipt_root_bloom: Option, _block_access_list_hash: Option, ) -> Result<(), ConsensusError> { + // Staged execution calls this hook without calling `validate_block_pre_execution`. + validate_base_time_metadata( + &self.chain_spec, + block.timestamp(), + block.number(), + block.body().transactions(), + )?; validate_block_post_execution(block.header(), &self.chain_spec, result, receipt_root_bloom) } } @@ -101,7 +109,6 @@ where header.number(), body.transactions(), ) - .map_err(ConsensusError::other) } fn validate_block_pre_execution(&self, block: &SealedBlock) -> Result<(), ConsensusError> { @@ -127,8 +134,7 @@ where block.timestamp(), block.number(), block.body().transactions(), - ) - .map_err(ConsensusError::other)?; + )?; // Check empty shanghai-withdrawals if self.chain_spec.is_canyon_active_at_timestamp(block.timestamp()) { @@ -193,6 +199,20 @@ impl HeaderValidator
for BaseBeaconConsensus { validate_header_gas(header)?; validate_header_base_fee(header, &self.chain_spec)?; + if let Some(schedule) = + self.chain_spec.denim_timestamp_schedule().map_err(ConsensusError::other)? + && header.number() != schedule.genesis_block_number + && schedule.is_denim_active_at_block(header.number()) + { + let (expected, _) = schedule.block_timestamp_parts(header.number()); + if header.timestamp() != expected { + return Err(ConsensusError::other(BaseTimeScheduleError::InvalidTimestamp { + expected, + actual: header.timestamp(), + })); + } + } + // After Isthmus, every block header must carry `requests_hash = sha256("")` // (i.e. `EMPTY_REQUESTS_HASH`) because Base does not support EL-triggered execution // requests (EIP-7685). Before Isthmus, the field must be omitted entirely. @@ -283,18 +303,21 @@ impl HeaderValidator
for BaseBeaconConsensus { mod tests { use std::sync::Arc; - use alloy_consensus::{BlockBody, Eip658Value, Header, Receipt, TxEip7702, TxReceipt}; + use alloy_consensus::{ + BlockBody, Eip658Value, Header, Receipt, Sealable, TxEip7702, TxReceipt, + }; use alloy_eips::{ eip4895::Withdrawals, eip7685::{EMPTY_REQUESTS_HASH, Requests}, }; use alloy_primitives::{Address, B256, Bytes, Log, Signature, U256}; use base_common_consensus::{ - BasePrimitives, BaseReceipt, BaseTransactionSigned, BaseTypedTransaction, - HoloceneExtraData, JovianExtraData, + BaseBlock, BasePrimitives, BaseReceipt, BaseTransactionSigned, BaseTypedTransaction, + HoloceneExtraData, JovianExtraData, TxDeposit, }; use base_common_genesis::BaseUpgrade; use base_execution_chainspec::{BaseChainSpec, BaseChainSpecBuilder}; + use base_protocol::{BaseTimeScheduleError, BaseTimeUpdateTx}; use reth_chainspec::{BaseFeeParams, EthChainSpec, ForkCondition}; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator}; use reth_primitives_traits::{RecoveredBlock, SealedBlock, SealedHeader, proofs}; @@ -302,6 +325,146 @@ mod tests { use crate::BaseBeaconConsensus; + fn timestamp_schedule_consensus() -> BaseBeaconConsensus { + let mut chain_spec = BaseChainSpec::mainnet(); + // A nonzero genesis and an activation between legacy slots catch offset and rounding + // mistakes: genesis #7 at 100, legacy interval 2s, Denim #10 at 106.000. + chain_spec.inner.genesis_header = + SealedHeader::seal_slow(Header { number: 7, timestamp: 100, ..Default::default() }); + chain_spec.set_fork(BaseUpgrade::Denim, ForkCondition::Timestamp(105)); + BaseBeaconConsensus::new(Arc::new(chain_spec)) + } + + fn timestamp_schedule_block( + number: u64, + timestamp: u64, + millis: u16, + ) -> RecoveredBlock { + let transactions = vec![ + TxDeposit::default().seal_slow().into(), + BaseTimeUpdateTx::new(millis).unwrap().into_deposit_tx(number).into(), + ]; + let block = BaseBlock { + header: Header { + number, + timestamp, + base_fee_per_gas: Some(1), + transactions_root: proofs::calculate_transaction_root(&transactions), + ..Default::default() + }, + body: BlockBody { transactions, ..Default::default() }, + }; + RecoveredBlock::new_sealed(SealedBlock::seal_slow(block), vec![Address::ZERO; 2]) + } + + #[test] + fn denim_timestamp_schedule_rejects_invalid_imports() { + let consensus = timestamp_schedule_consensus(); + for (number, timestamp, millis) in + [(10, 107, 0), (10, 104, 0), (11, 106, 400), (15, 106, 0)] + { + let block = timestamp_schedule_block(number, timestamp, millis); + assert!( + Consensus::::validate_body_against_header( + &consensus, + block.body(), + block.sealed_header() + ) + .is_err(), + "body import accepted an off-schedule block: {number}, {timestamp}.{millis:03}" + ); + assert!( + consensus.validate_block_pre_execution(block.sealed_block()).is_err(), + "Engine pre-execution accepted an off-schedule block: {number}, {timestamp}.{millis:03}" + ); + assert!( + FullConsensus::::validate_block_post_execution( + &consensus, + &block, + &BlockExecutionResult::default(), + None, + None, + ) + .is_err(), + "staged execution accepted an off-schedule block: {number}, {timestamp}.{millis:03}" + ); + } + } + + #[test] + fn denim_timestamp_schedule_accepts_scheduled_imports() { + let consensus = timestamp_schedule_consensus(); + for (number, timestamp, millis) in + [(9, 104, 0), (10, 106, 0), (11, 106, 200), (14, 106, 800), (15, 107, 0)] + { + let block = timestamp_schedule_block(number, timestamp, millis); + consensus.validate_header(block.sealed_header()).unwrap(); + Consensus::::validate_body_against_header( + &consensus, + block.body(), + block.sealed_header(), + ) + .unwrap(); + consensus.validate_block_pre_execution(block.sealed_block()).unwrap(); + FullConsensus::::validate_block_post_execution( + &consensus, + &block, + &BlockExecutionResult::default(), + None, + None, + ) + .unwrap(); + } + } + + #[test] + fn denim_timestamp_schedule_validates_header_seconds() { + let consensus = timestamp_schedule_consensus(); + for (number, timestamp, expected) in [(10, 104, 106), (10, 107, 106), (15, 106, 107)] { + let block = timestamp_schedule_block(number, timestamp, 0); + let ConsensusError::Other(error) = + consensus.validate_header(block.sealed_header()).unwrap_err() + else { + panic!("expected a timestamp schedule error"); + }; + assert_eq!( + error.downcast_ref::(), + Some(&BaseTimeScheduleError::InvalidTimestamp { expected, actual: timestamp }) + ); + } + } + + #[test] + fn denim_timestamp_schedule_preserves_empty_genesis_and_pre_denim_blocks() { + let mut chain_spec = timestamp_schedule_consensus().chain_spec.as_ref().clone(); + for (number, timestamp, activation) in [(7, 100, 0), (9, 104, 105)] { + chain_spec.set_fork(BaseUpgrade::Denim, ForkCondition::Timestamp(activation)); + let consensus = BaseBeaconConsensus::new(Arc::new(chain_spec.clone())); + let block = RecoveredBlock::new_sealed( + SealedBlock::seal_slow(BaseBlock { + header: Header { + number, + timestamp, + base_fee_per_gas: Some(1), + ..Default::default() + }, + body: BlockBody::default(), + }), + vec![], + ); + consensus.validate_header(block.sealed_header()).unwrap(); + consensus.validate_block_pre_execution(block.sealed_block()).unwrap(); + FullConsensus::::validate_block_post_execution( + &consensus, + &block, + &BlockExecutionResult::default(), + None, + None, + ) + .unwrap(); + } + } + fn mock_tx(nonce: u64) -> BaseTransactionSigned { let tx = TxEip7702 { chain_id: 1u64, diff --git a/crates/execution/consensus/src/validation/mod.rs b/crates/execution/consensus/src/validation/mod.rs index 48450377f2..71c0dae7bf 100644 --- a/crates/execution/consensus/src/validation/mod.rs +++ b/crates/execution/consensus/src/validation/mod.rs @@ -11,7 +11,8 @@ use alloy_primitives::{B256, Bloom, Bytes}; use alloy_trie::EMPTY_ROOT_HASH; use base_common_chains::Upgrades; use base_common_consensus::{BaseTxEnvelope, DepositReceiptExt}; -use base_protocol::{BaseTimeMetadataError, BaseTimeUpdateTx}; +use base_execution_chainspec::BaseChainSpec; +use base_protocol::BaseTimeUpdateTx; use reth_consensus::ConsensusError; use reth_execution_types::BlockExecutionResult; use reth_primitives_traits::{BlockBody, GotExpected, receipt::gas_spent_by_transactions}; @@ -23,18 +24,24 @@ fn should_trust_precomputed_receipt_root(chain_spec: &impl Upgrades, timestamp: chain_spec.is_canyon_active_at_timestamp(timestamp) } -/// Validates the `BaseTime` metadata deposit when its activation gate is active. +/// Validates the full Denim timestamp and metadata schedule. pub fn validate_base_time_metadata( - chain_spec: &impl Upgrades, + chain_spec: &BaseChainSpec, timestamp: u64, block_number: u64, transactions: &[BaseTxEnvelope], -) -> Result<(), BaseTimeMetadataError> { - if chain_spec.is_denim_active_at_timestamp(timestamp) { - BaseTimeUpdateTx::extract_from_transactions(transactions, block_number)?; - } - - Ok(()) +) -> Result<(), ConsensusError> { + let Some(schedule) = chain_spec.denim_timestamp_schedule().map_err(ConsensusError::other)? + else { + return Ok(()); + }; + // Genesis is the configured chain anchor, not a produced block, and has no metadata deposit + // even when Denim is active at genesis. + if block_number == schedule.genesis_block_number { + return Ok(()); + } + BaseTimeUpdateTx::validate_timestamp_schedule(&schedule, transactions, block_number, timestamp) + .map_err(ConsensusError::other) } /// Ensures the block response data matches the header. @@ -249,7 +256,7 @@ mod tests { use base_common_consensus::{BaseReceipt, BaseTxEnvelope, DepositReceipt, TxDeposit}; use base_common_genesis::BaseUpgrade; use base_execution_chainspec::BaseChainSpec; - use base_protocol::{BaseTimeMetadataError, BaseTimeUpdateTx}; + use base_protocol::{BaseTimeMetadataError, BaseTimeScheduleError, BaseTimeUpdateTx}; use reth_chainspec::{BaseFeeParams, EthChainSpec, ForkCondition}; use super::*; @@ -299,14 +306,21 @@ mod tests { #[test] fn validates_base_time_metadata_only_after_activation() { let mut chain_spec = BaseChainSpec::sepolia(); - chain_spec.set_fork(BaseUpgrade::Denim, ForkCondition::Timestamp(10)); - - validate_base_time_metadata(&chain_spec, 10, 9, &base_time_transactions(9, 400)).unwrap(); - assert!(matches!( - validate_base_time_metadata(&chain_spec, 10, 9, &[]), - Err(BaseTimeMetadataError::Missing) - )); - validate_base_time_metadata(&chain_spec, 9, 9, &[]).unwrap(); + let activation = chain_spec.genesis_header().timestamp + 4; + chain_spec.set_fork(BaseUpgrade::Denim, ForkCondition::Timestamp(activation)); + + validate_base_time_metadata(&chain_spec, activation, 4, &base_time_transactions(4, 400)) + .unwrap(); + let ConsensusError::Other(error) = + validate_base_time_metadata(&chain_spec, activation, 4, &[]).unwrap_err() + else { + panic!("expected a BaseTime metadata error"); + }; + assert_eq!( + error.downcast_ref::(), + Some(&BaseTimeScheduleError::InvalidMetadata(BaseTimeMetadataError::Missing)) + ); + validate_base_time_metadata(&chain_spec, activation - 2, 1, &[]).unwrap(); } fn plain_precomputed_receipt_root_bloom(receipts: &[BaseReceipt]) -> (B256, Bloom) { diff --git a/crates/execution/eip8130-rpc-node/tests/receipt.rs b/crates/execution/eip8130-rpc-node/tests/receipt.rs index ffdd223b47..976c182506 100644 --- a/crates/execution/eip8130-rpc-node/tests/receipt.rs +++ b/crates/execution/eip8130-rpc-node/tests/receipt.rs @@ -21,8 +21,8 @@ use base_test_utils::{Account, DEVNET_CHAIN_ID, build_test_genesis_everest}; const EIP8130_TX_TYPE: u8 = 0x79; fn base_time_deposit() -> Bytes { - BaseTimeUpdateTx::new(0) - .expect("zero millisecond component must be valid") + BaseTimeUpdateTx::new(200) + .expect("200 millisecond component must be valid") .into_deposit_tx(1) .encoded_2718() .into() diff --git a/crates/execution/runner/src/test_utils/harness.rs b/crates/execution/runner/src/test_utils/harness.rs index 2b3dcaf58b..ef67dcf17a 100644 --- a/crates/execution/runner/src/test_utils/harness.rs +++ b/crates/execution/runner/src/test_utils/harness.rs @@ -168,10 +168,14 @@ impl TestHarness { let new_block_number = latest_block.header.number + 1; let parent_beacon_block_root = latest_block.header.parent_beacon_block_root.unwrap_or(B256::ZERO); - let next_timestamp = latest_block.header.timestamp + BLOCK_TIME_SECONDS; + let chain_spec = self.node.blockchain_provider().chain_spec(); + let next_timestamp = if let Some(schedule) = chain_spec.denim_timestamp_schedule()? { + schedule.block_timestamp_parts(new_block_number).0 + } else { + latest_block.header.timestamp + BLOCK_TIME_SECONDS + }; let min_base_fee = latest_block.header.base_fee_per_gas.unwrap_or_default(); - let chain_spec = self.node.blockchain_provider().chain_spec(); let base_fee_params = chain_spec.base_fee_params_at_timestamp(next_timestamp); let eip_1559_params = ((base_fee_params.max_change_denominator as u64) << 32) | (base_fee_params.elasticity_multiplier as u64); diff --git a/crates/execution/runner/tests/rpc_timestamp_ms.rs b/crates/execution/runner/tests/rpc_timestamp_ms.rs index 30037f1938..860d9036a4 100644 --- a/crates/execution/runner/tests/rpc_timestamp_ms.rs +++ b/crates/execution/runner/tests/rpc_timestamp_ms.rs @@ -15,7 +15,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; const BLOCK_NUMBER: u64 = 1; const TIMESTAMP_MILLIS_PART: u16 = 200; -const TIMESTAMP_MS_QUANTITY: &str = "0xc80"; +const TIMESTAMP_MS_QUANTITY: &str = "0x4b0"; async fn request(client: &RpcClient, method: &'static str, params: Value) -> eyre::Result { Ok(client.request(method, params).await?) @@ -45,7 +45,7 @@ fn receipt_logs(receipts: &Value, transaction_hash: &str) -> Value { #[tokio::test] async fn pending_denim_timestamp_is_independent_of_request_order() -> eyre::Result<()> { let mut genesis = build_test_genesis(); - genesis.config.extra_fields.insert("base".into(), json!({ "denim": 3 })); + genesis.config.extra_fields.insert("base".into(), json!({ "denim": 0 })); let harness = TestHarness::builder() .with_chain_spec(Arc::new(BaseChainSpec::from_genesis(genesis))) .build() @@ -89,7 +89,7 @@ async fn pending_denim_timestamp_is_independent_of_request_order() -> eyre::Resu #[tokio::test] async fn canonical_denim_rpc_responses_include_millisecond_timestamps() -> eyre::Result<()> { let mut genesis = build_test_genesis(); - genesis.config.extra_fields.insert("base".into(), json!({ "denim": 3 })); + genesis.config.extra_fields.insert("base".into(), json!({ "denim": 0 })); let harness = TestHarness::builder() .with_chain_spec(Arc::new(BaseChainSpec::from_genesis(genesis))) .build() diff --git a/crates/utilities/test-utils/src/genesis.rs b/crates/utilities/test-utils/src/genesis.rs index 435c31d251..6d0be430e9 100644 --- a/crates/utilities/test-utils/src/genesis.rs +++ b/crates/utilities/test-utils/src/genesis.rs @@ -56,6 +56,7 @@ pub fn build_test_genesis() -> Genesis { terminal_total_difficulty_passed: true, // Rollup upgrades and settings via extra_fields extra_fields: [ + ("blockTime", serde_json::json!(2)), ("bedrockBlock", serde_json::json!(0)), ("regolithTime", serde_json::json!(0)), ("canyonTime", serde_json::json!(0)), From 49fda0df3214fc5443150d305306f8f51f8ef3d6 Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Tue, 29 Sep 2026 18:03:19 +0000 Subject: [PATCH 162/299] fix(audit): accept 1000-event transaction batches (#5152) Align audit-archiver's transaction-event batch limit with Vector's max_events: 1000 so full replay batches are accepted instead of rejected with HTTP 400 and permanently dropped. --- Cargo.lock | 1 + crates/infra/audit/Cargo.toml | 1 + crates/infra/audit/src/transaction_events.rs | 164 +++++++++++++++++- .../tests/postgres_transaction_events.rs | 122 ++++++++++++- 4 files changed, 283 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc78f1795f..289a71fb36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2094,6 +2094,7 @@ dependencies = [ "testcontainers-modules", "thiserror 2.0.19", "tokio", + "tower 0.5.3", "tower-http", "tracing", "uuid", diff --git a/crates/infra/audit/Cargo.toml b/crates/infra/audit/Cargo.toml index 4eaa11fef9..3ce7addc48 100644 --- a/crates/infra/audit/Cargo.toml +++ b/crates/infra/audit/Cargo.toml @@ -39,6 +39,7 @@ tower-http.workspace = true [dev-dependencies] base-bundles = { workspace = true, features = ["test-utils"] } testcontainers = { workspace = true, features = ["blocking"] } +tower = { workspace = true, features = ["util"] } aws-config = { workspace = true, features = ["default-https-client", "rt-tokio"] } testcontainers-modules = { workspace = true, features = ["postgres", "minio"] } diff --git a/crates/infra/audit/src/transaction_events.rs b/crates/infra/audit/src/transaction_events.rs index f5ad7fc9a2..c9b16498f3 100644 --- a/crates/infra/audit/src/transaction_events.rs +++ b/crates/infra/audit/src/transaction_events.rs @@ -40,7 +40,11 @@ use crate::Metrics; pub const DEFAULT_TRANSACTION_EVENT_BATCH_PATH: &str = "/v1/transaction-events/batch"; /// Default maximum number of events accepted in one HTTP request. -pub const DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE: usize = 500; +/// +/// Matches Vector's `max_events: 1000` (`etc/docker/transaction-events-vector.yaml`) +/// so a full Vector batch is accepted instead of rejected with HTTP 400 and +/// permanently dropped. +pub const DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE: usize = 1000; /// Default maximum serialized JSON bytes for a single event. pub const DEFAULT_TRANSACTION_EVENT_MAX_EVENT_BYTES: usize = 256 * 1024; @@ -54,7 +58,8 @@ pub const DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024; /// Maximum events inserted in one Postgres statement. /// /// Each row uses 14 bind parameters, so this stays below Postgres' 65,535 bind -/// parameter limit (4,000 x 14 = 56,000). +/// parameter limit (4,000 x 14 = 56,000). A full 1,000-event HTTP batch +/// inserts in a single statement (14,000 parameters). pub const MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE: usize = 4_000; /// Session `lock_timeout` applied to each persist INSERT. @@ -1862,9 +1867,14 @@ fn response_from_results( mod tests { use std::sync::Mutex; - use axum::http::StatusCode; + use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode}, + }; use chrono::Utc; use serde_json::{Map, json}; + use tower::ServiceExt; use super::*; @@ -1932,6 +1942,20 @@ mod tests { DayPartition { class, day: day(value) } } + #[derive(Debug, Default)] + struct FailingSink; + + #[async_trait] + impl TransactionEventSink for FailingSink { + async fn insert_events( + &self, + _events: &[TransactionEvent], + ) -> std::result::Result + { + Err(TransactionEventStorageError::new(anyhow::anyhow!("database unavailable"))) + } + } + fn config() -> TransactionEventIngestConfig { TransactionEventIngestConfig { path: DEFAULT_TRANSACTION_EVENT_BATCH_PATH.to_string(), @@ -1942,6 +1966,16 @@ mod tests { } } + fn default_config() -> TransactionEventIngestConfig { + TransactionEventIngestConfig { + path: DEFAULT_TRANSACTION_EVENT_BATCH_PATH.to_string(), + max_batch_size: DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, + max_event_bytes: DEFAULT_TRANSACTION_EVENT_MAX_EVENT_BYTES, + max_data_bytes: DEFAULT_TRANSACTION_EVENT_MAX_DATA_BYTES, + max_request_bytes: DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES, + } + } + fn state(sink: Arc) -> TransactionEventIngestState { TransactionEventIngestState { sink, config: config() } } @@ -1974,6 +2008,23 @@ mod tests { Bytes::from(body) } + fn boundary_events(prefix: &str, count: usize) -> Bytes { + ndjson((0..count).map(|index| event(&format!("{prefix}-{index}"))).collect::>()) + } + + async fn post_ndjson(app: Router, body: Bytes) -> (StatusCode, Value) { + let request = Request::builder() + .method("POST") + .uri(DEFAULT_TRANSACTION_EVENT_BATCH_PATH) + .header("content-type", "application/x-ndjson") + .body(Body::from(body)) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&body).unwrap()) + } + fn raw_event(value: Value) -> RawTransactionEvent { let byte_len = serde_json::to_string(&value).unwrap().len(); RawTransactionEvent { value, byte_len } @@ -2443,4 +2494,111 @@ mod tests { assert_eq!(hex_lookup_keys("not-a-hash"), vec!["not-a-hash".to_string()]); assert!(hex_lookup_keys(" ").is_empty()); } + + #[test] + fn default_batch_matches_vector_max_events() { + // Vector's HTTP sink is configured with `max_events: 1000`; the endpoint + // default must accept a full Vector batch instead of rejecting it with + // HTTP 400 (which Vector permanently drops). + assert_eq!(DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, 1000); + } + + #[test] + fn default_batch_fits_in_single_insert_statement() { + // Each row binds 12 parameters and Postgres allows 65,535 per statement, + // so a full HTTP batch inserts in one statement without chunking. + const { + assert!(DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE * 12 <= 65_535); + assert!( + DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE <= MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE + ); + } + } + + #[tokio::test] + async fn accepts_exactly_default_max_batch_events() { + let sink = Arc::new(FakeSink::default()); + let state_sink: Arc = Arc::::clone(&sink); + let state = TransactionEventIngestState { sink: state_sink, config: default_config() }; + + let (status, Json(response)) = ingest_transaction_event_batch( + &state, + boundary_events("boundary", DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(response.status, TransactionEventBatchStatus::Accepted); + assert_eq!(response.accepted, DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE); + assert_eq!(response.duplicate, 0); + assert_eq!(response.rejected, 0); + assert_eq!(sink.inserted.lock().unwrap().len(), DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE); + } + + #[tokio::test] + async fn rejects_one_event_over_default_max_batch() { + let sink = Arc::new(FakeSink::default()); + let state_sink: Arc = Arc::::clone(&sink); + let state = TransactionEventIngestState { sink: state_sink, config: default_config() }; + + let (status, Json(response)) = ingest_transaction_event_batch( + &state, + boundary_events("overflow", DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE + 1), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(response.status, TransactionEventBatchStatus::Rejected); + assert_eq!(response.results[0].reason.as_deref(), Some("batch size exceeds maximum 1000")); + assert!(sink.inserted.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn endpoint_accepts_1000_events() { + let app = default_config().into_router(Arc::new(FakeSink::default())); + + let (status, json) = + post_ndjson(app, boundary_events("endpoint", DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE)) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "accepted"); + assert_eq!(json["accepted"], 1000); + assert_eq!(json["duplicate"], 0); + assert_eq!(json["rejected"], 0); + } + + #[tokio::test] + async fn endpoint_rejects_1001_events() { + let app = default_config().into_router(Arc::new(FakeSink::default())); + + let (status, json) = post_ndjson( + app, + boundary_events("endpoint-overflow", DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE + 1), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["status"], "rejected"); + assert_eq!(json["results"][0]["reason"], "batch size exceeds maximum 1000"); + } + + #[tokio::test] + async fn database_failure_returns_retryable_503() { + let state = + TransactionEventIngestState { sink: Arc::new(FailingSink), config: default_config() }; + + let (status, Json(response)) = + ingest_transaction_event_batch(&state, boundary_events("db-failure", 3)).await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status, TransactionEventBatchStatus::Rejected); + assert_eq!(response.accepted, 0); + assert_eq!(response.results.len(), 1); + assert_eq!(response.results[0].status, TransactionEventItemStatus::Rejected); + assert_eq!( + response.results[0].reason.as_deref(), + Some("database unavailable; retry batch") + ); + } } diff --git a/crates/infra/audit/tests/postgres_transaction_events.rs b/crates/infra/audit/tests/postgres_transaction_events.rs index d54d4e4496..0f3e15c688 100644 --- a/crates/infra/audit/tests/postgres_transaction_events.rs +++ b/crates/infra/audit/tests/postgres_transaction_events.rs @@ -9,19 +9,28 @@ use std::{ path::PathBuf, + sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use audit_archiver_lib::{ - MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE, PgTransactionEventSink, RejectedTransactionEventQuery, + DEFAULT_TRANSACTION_EVENT_BATCH_PATH, DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, + DEFAULT_TRANSACTION_EVENT_MAX_DATA_BYTES, DEFAULT_TRANSACTION_EVENT_MAX_EVENT_BYTES, + DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES, MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE, + PgTransactionEventSink, RejectedTransactionEventQuery, TransactionEventIngestConfig, TransactionEventRetentionConfig, TransactionEventSchemaReadinessError, TransactionEventSink, }; +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, +}; use base_observability_events::TransactionEvent; use chrono::Utc; -use serde_json::json; +use serde_json::{Value, json}; use sqlx::{Executor, PgPool, migrate::Migrator, postgres::PgPoolOptions}; use testcontainers::{ImageExt, core::ExecCommand, runners::AsyncRunner}; use testcontainers_modules::postgres::Postgres; +use tower::ServiceExt; /// Production RDS is Postgres 17. `testcontainers-modules` still defaults to /// Postgres 11, which lacks the partitioning features the schema relies on. @@ -194,6 +203,71 @@ fn legacy_migrations_through(last_version: i64) -> anyhow::Result { Ok(target) } +fn default_ingest_config() -> TransactionEventIngestConfig { + TransactionEventIngestConfig { + path: DEFAULT_TRANSACTION_EVENT_BATCH_PATH.to_string(), + max_batch_size: DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, + max_event_bytes: DEFAULT_TRANSACTION_EVENT_MAX_EVENT_BYTES, + max_data_bytes: DEFAULT_TRANSACTION_EVENT_MAX_DATA_BYTES, + max_request_bytes: DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES, + } +} + +/// Builds an NDJSON batch of `count` valid events, each padded so the request +/// approximates a full-size Vector payload. +fn ndjson_batch(event_prefix: &str, count: usize, data_padding_len: usize) -> Vec { + let padding = "p".repeat(data_padding_len); + (0..count) + .map(|index| { + serde_json::to_string(&json!({ + "schema_version": "transaction-event/v1", + "event_id": format!("{event_prefix}-{index}"), + "event_time": Utc::now(), + "producer": "base-builder", + "event_type": "BUILDER_ACCEPTED", + "network": "base-mainnet", + "tx_hash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "block_hash": null, + "block_number": 123, + "payload_id": "payload-1", + "request_id": "request-1", + "data": { + "position": 1, + "padding": padding, + } + })) + .unwrap() + }) + .collect::>() + .join("\n") + .into_bytes() +} + +async fn post_batch( + sink: PgTransactionEventSink, + body: Vec, +) -> anyhow::Result<(StatusCode, Value)> { + let app = default_ingest_config().into_router(Arc::new(sink)); + let request = Request::builder() + .method("POST") + .uri(DEFAULT_TRANSACTION_EVENT_BATCH_PATH) + .header("content-type", "application/x-ndjson") + .body(Body::from(body))?; + let response = app.oneshot(request).await?; + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await?; + Ok((status, serde_json::from_slice(&body)?)) +} + +async fn count_events_with_prefix(pool: &PgPool, event_prefix: &str) -> anyhow::Result { + let count: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM transaction_events WHERE event_id LIKE $1") + .bind(format!("{event_prefix}-%")) + .fetch_one(pool) + .await?; + Ok(count.0) +} + #[tokio::test] async fn transaction_events_ready_without_postgres_sink() { PgTransactionEventSink::check_optional_schema_ready(None).await.unwrap(); @@ -544,6 +618,50 @@ async fn postgres_sink_chunks_large_direct_inserts() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn postgres_http_ingest_accepts_1000_event_batch() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + PgTransactionEventSink::migrate(&harness.database_url).await?; + let sink = PgTransactionEventSink::connect(&harness.database_url, 2).await?; + let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + let event_prefix = unique_event_id(); + + // ~1 KiB per event so the batch exercises a realistic ~1 MiB Vector payload. + let body = ndjson_batch(&event_prefix, DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, 550); + assert!(body.len() > 512 * 1024, "expected a large batch, got {} bytes", body.len()); + + let (status, json) = post_batch(sink, body).await?; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "accepted"); + assert_eq!(json["accepted"], 1000); + assert_eq!(json["duplicate"], 0); + assert_eq!(json["rejected"], 0); + assert_eq!(count_events_with_prefix(&pool, &event_prefix).await?, 1000); + + Ok(()) +} + +#[tokio::test] +async fn postgres_http_ingest_rejects_1001_event_batch_without_writes() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + PgTransactionEventSink::migrate(&harness.database_url).await?; + let sink = PgTransactionEventSink::connect(&harness.database_url, 2).await?; + let pool = PgPoolOptions::new().max_connections(1).connect(&harness.database_url).await?; + let event_prefix = unique_event_id(); + + let body = ndjson_batch(&event_prefix, DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE + 1, 0); + + let (status, json) = post_batch(sink, body).await?; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["status"], "rejected"); + assert_eq!(json["results"][0]["reason"], "batch size exceeds maximum 1000"); + assert_eq!(count_events_with_prefix(&pool, &event_prefix).await?, 0); + + Ok(()) +} + #[tokio::test] async fn postgres_sink_dedupes_retried_events_and_routes_by_class() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; From 07980bc7c1af9df475921a70c1c8d94b27c6fee3 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 20:30:05 +0000 Subject: [PATCH 163/299] fix(batcher): set the default Brotli quality to 9 (#5353) --- crates/batcher/cli/README.md | 2 +- crates/batcher/cli/src/cli.rs | 6 +++--- crates/batcher/comp/src/types.rs | 2 +- crates/batcher/encoder/src/config.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/batcher/cli/README.md b/crates/batcher/cli/README.md index ed32c4d201..3522ab1316 100644 --- a/crates/batcher/cli/README.md +++ b/crates/batcher/cli/README.md @@ -17,7 +17,7 @@ from the rollup RPC. `--compressed-size-target` optionally closes a channel after an accepted batch reaches the target. `--max-blobs-per-tx` caps blob packing per L1 transaction, while `--max-calldata-size-bytes` caps calldata transactions. `--brotli-quality` -selects Brotli quality `0..=11` (default 10). `--data-availability-type` +selects Brotli quality `0..=11` (default 9). `--data-availability-type` selects blobs or calldata; `--max-channel-duration` and `--sub-safety-margin` control channel lifetime. For calldata configurations, `--no-force-blobs-when-throttling` disables the throttle-driven blob override. diff --git a/crates/batcher/cli/src/cli.rs b/crates/batcher/cli/src/cli.rs index e3f3715079..e5b6f14504 100644 --- a/crates/batcher/cli/src/cli.rs +++ b/crates/batcher/cli/src/cli.rs @@ -472,7 +472,7 @@ mod tests { ); assert_eq!(config.encoder_config.compressed_size_target, None); assert_eq!(config.encoder_config.max_blobs_per_tx, 6); - assert_eq!(config.encoder_config.brotli_level, base_batcher_encoder::BrotliLevel::Brotli10); + assert_eq!(config.encoder_config.brotli_level, base_batcher_encoder::BrotliLevel::Brotli9); } #[test] @@ -486,10 +486,10 @@ mod tests { #[test] fn into_config_accepts_brotli_quality() { - let cli = parse_cli(&["--brotli-quality", "9"]); + let cli = parse_cli(&["--brotli-quality", "11"]); let config = cli.into_config(false).expect("config should build"); - assert_eq!(config.encoder_config.brotli_level, base_batcher_encoder::BrotliLevel::Brotli9); + assert_eq!(config.encoder_config.brotli_level, base_batcher_encoder::BrotliLevel::Brotli11); } #[test] diff --git a/crates/batcher/comp/src/types.rs b/crates/batcher/comp/src/types.rs index 20339fd009..2ec2c4486e 100644 --- a/crates/batcher/comp/src/types.rs +++ b/crates/batcher/comp/src/types.rs @@ -51,7 +51,7 @@ pub enum BrotliLevel { impl BrotliLevel { /// Default quality used by the batcher. - pub const DEFAULT: Self = Self::Brotli10; + pub const DEFAULT: Self = Self::Brotli9; /// Prepended to Brotli channels so `BatchReader` selects Brotli decompression. pub const CHANNEL_VERSION: u8 = 0x01; diff --git a/crates/batcher/encoder/src/config.rs b/crates/batcher/encoder/src/config.rs index 2eeabdcfca..6ca208800d 100644 --- a/crates/batcher/encoder/src/config.rs +++ b/crates/batcher/encoder/src/config.rs @@ -64,7 +64,7 @@ pub struct EncoderConfig { /// /// Brotli channels require Fjord. /// - /// Default: [`BrotliLevel::Brotli10`]. + /// Default: [`BrotliLevel::Brotli9`]. pub brotli_level: BrotliLevel, } From 606cc81bf9956f2f8e8d9b1043c6308b7fa5ca1f Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Tue, 29 Sep 2026 20:55:02 +0000 Subject: [PATCH 164/299] ci: gate costly Depot suites on relevant changes (#5385) * ci: gate costly Depot suites on relevant changes * ci: address Depot cost-control review * ci: select system tests from affected crates --- .agents/skills/depot-ci/SKILL.md | 13 +++++ .depot/workflows/base-std-fork-tests.yml | 55 +++++++++++++++++- .depot/workflows/ci-core.yml | 71 +++++++++++++++++++++++- .depot/workflows/ci-main-cache.yml | 16 ++++++ .depot/workflows/ci-pr.yml | 7 ++- 5 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/depot-ci/SKILL.md diff --git a/.agents/skills/depot-ci/SKILL.md b/.agents/skills/depot-ci/SKILL.md new file mode 100644 index 0000000000..be3ece0f50 --- /dev/null +++ b/.agents/skills/depot-ci/SKILL.md @@ -0,0 +1,13 @@ +--- +name: depot-ci +description: "Modify Base Depot CI workflows, especially PR cost controls, path filters, runner sizing, or optional integration checks." +--- + +# Depot CI + +Use the workflows in `.depot/workflows/` for Depot CI changes. Keep normal PR feedback focused on checks relevant to the changed subsystem, while preserving exhaustive validation in `ci-merge-queue.yml`. + +- Treat path filters as a risk boundary: include shared workspace, lockfile, toolchain, setup-action, and workflow inputs when they can affect a suite. Prefer running an unnecessary job to omitting relevant coverage. +- Do not make a required check disappear at workflow dispatch time without confirming branch-protection behavior. Prefer a stable workflow with a skipped job, and preserve manual dispatch or a reviewer-label escape hatch for optional work. +- `ci:system`, `ci:base-std`, and `ci:perf` opt a PR into the corresponding expensive suite; `ci:full` opts into all of them. Merge-queue runs are intentionally unconditional. +- Measure a proposal using runner-size-weighted duration and report the affected suites. Depot billing and wall-clock latency are separate metrics. diff --git a/.depot/workflows/base-std-fork-tests.yml b/.depot/workflows/base-std-fork-tests.yml index 825a6510fc..d6a492e3a2 100644 --- a/.depot/workflows/base-std-fork-tests.yml +++ b/.depot/workflows/base-std-fork-tests.yml @@ -7,12 +7,14 @@ name: Base Std Interface Tests on: + # ci:base-std and ci:full must start the matrix immediately when applied. pull_request: + types: [opened, reopened, synchronize, labeled] merge_group: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event.action != 'labeled' }} env: CARGO_TERM_COLOR: always RUSTC_WRAPPER: "sccache" @@ -20,8 +22,48 @@ permissions: contents: read pull-requests: write jobs: + changes: + name: Detect Base Std Interface Changes + # Merge-queue and manual runs always execute the matrix, so path detection + # is only useful for a PR. + if: github.event_name == 'pull_request' + runs-on: depot-ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + base-std: ${{ steps.filter.outputs.base-std }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + # These are the two base/base crates patched into base-anvil below. + # Workspace/toolchain inputs can alter that build, so retain them. + base-std: + - 'Cargo.toml' + - 'Cargo.lock' + - '.cargo/**' + - 'rust-toolchain.toml' + - 'crates/common/chains/**' + - 'crates/common/precompiles/**' + - '.depot/actions/setup/**' + - '.depot/workflows/base-std-fork-tests.yml' fork-tests: name: Base Std Interface Tests (${{ matrix.fork }}) + needs: changes + if: >- + always() && (github.event_name != 'pull_request' || + needs.changes.outputs.base-std == 'true' || + contains(github.event.pull_request.labels.*.name, 'ci:base-std') || + contains(github.event.pull_request.labels.*.name, 'ci:full')) runs-on: depot-ubuntu-24.04-8 timeout-minutes: 120 strategy: @@ -189,8 +231,15 @@ jobs: fi aggregate: name: Base Std Interface Tests - if: always() - needs: fork-tests + # Keep the stable aggregate check for selected runs, but skip it with the + # matrix when this PR did not touch either patched base-anvil dependency. + if: >- + always() && ( + github.event_name != 'pull_request' || + needs.changes.outputs.base-std == 'true' || + contains(github.event.pull_request.labels.*.name, 'ci:base-std') || + contains(github.event.pull_request.labels.*.name, 'ci:full')) + needs: [changes, fork-tests] runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent. steps: - name: Harden the runner (Audit all outbound calls) diff --git a/.depot/workflows/ci-core.yml b/.depot/workflows/ci-core.yml index 7f27efed47..fbc1fecafd 100644 --- a/.depot/workflows/ci-core.yml +++ b/.depot/workflows/ci-core.yml @@ -35,13 +35,15 @@ permissions: contents: read jobs: changes: - name: Detect Docker Changes + name: Detect Expensive CI Changes runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent. permissions: contents: read pull-requests: read outputs: docker: ${{ steps.filter.outputs.docker }} + proof-mpt-bench: ${{ steps.filter.outputs.proof-mpt-bench }} + system-tests: ${{ steps.system-tests.outputs.system-tests }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -49,7 +51,9 @@ jobs: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 1 + # PRs are checked out at their test merge commit. The affected-crate + # calculation below compares it to its first parent. + fetch-depth: 2 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: @@ -62,6 +66,55 @@ jobs: - '.env.mainnet' - '.env.sepolia' - '.dockerignore' + # This benchmark only exercises the MPT implementation. Keep it in + # merge-queue CI, but do not spend a runner on unrelated PRs. + proof-mpt-bench: + - 'Cargo.toml' + - 'Cargo.lock' + - '.cargo/**' + - 'rust-toolchain.toml' + - 'crates/proof/mpt/**' + - 'crates/proof/primitives/**' + - 'crates/execution/trie/**' + - 'etc/scripts/ci/**' + - '.depot/workflows/ci-core.yml' + # Non-crate inputs which can affect the system-test environment. + # Crate changes are classified by the dependency-aware step below. + system-test-inputs: + - 'Cargo.toml' + - 'Cargo.lock' + - '.cargo/**' + - 'rust-toolchain.toml' + - 'Justfile' + - 'etc/just/**' + - 'etc/docker/**' + - 'etc/systems/**' + - 'docker-compose.yml' + - '.env.mainnet' + - '.env.sepolia' + - '.depot/actions/setup/**' + - '.depot/workflows/ci-core.yml' + - name: Detect system-test dependency changes + id: system-tests + shell: bash + env: + SYSTEM_TEST_INPUTS: ${{ steps.filter.outputs.system-test-inputs }} + run: | + set -euo pipefail + + # Merge-queue CI is exhaustive. This job still runs there to select + # Docker work, but no dependency classification is necessary. + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + echo "system-tests=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$SYSTEM_TEST_INPUTS" = true ] || \ + python3 etc/scripts/local/affected-crates.py HEAD^1 | grep -Fxq base-system-tests; then + echo "system-tests=true" >> "$GITHUB_OUTPUT" + else + echo "system-tests=false" >> "$GITHUB_OUTPUT" + fi metadata-checks: name: Metadata Checks runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent. @@ -180,6 +233,12 @@ jobs: report_paths: target/nextest/ci/test-results.xml bench: name: Benchmarks + needs: changes + if: >- + github.event_name != 'pull_request' || + needs.changes.outputs.proof-mpt-bench == 'true' || + contains(github.event.pull_request.labels.*.name, 'ci:perf') || + contains(github.event.pull_request.labels.*.name, 'ci:full') runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent. steps: - name: Harden the runner (Audit all outbound calls) @@ -243,7 +302,13 @@ jobs: run: cargo test --locked -p base-cli-utils --test sigsegv_test --target x86_64-unknown-linux-musl system-tests: name: System Tests - if: inputs.run_system_tests + needs: changes + if: >- + inputs.run_system_tests && ( + github.event_name != 'pull_request' || + needs.changes.outputs.system-tests == 'true' || + contains(github.event.pull_request.labels.*.name, 'ci:system') || + contains(github.event.pull_request.labels.*.name, 'ci:full')) runs-on: depot-ubuntu-24.04-16 timeout-minutes: 90 steps: diff --git a/.depot/workflows/ci-main-cache.yml b/.depot/workflows/ci-main-cache.yml index db856c8309..9a3eaa1b87 100644 --- a/.depot/workflows/ci-main-cache.yml +++ b/.depot/workflows/ci-main-cache.yml @@ -9,6 +9,22 @@ name: CI Main Cache on: push: branches: [main] + # The persistent sccache is keyed by compiler inputs. Warming it after a + # documentation-only merge consumes two 8-vCPU runners without helping a + # later Rust build. + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - '.cargo/**' + - 'rust-toolchain.toml' + - 'Justfile' + - 'crates/**' + - 'bin/**' + - 'etc/just/build.just' + - 'etc/scripts/local/affected-crates.py' + - 'crates/utilities/test-utils/contracts/**' + - '.depot/actions/setup/**' + - '.depot/workflows/ci-main-cache.yml' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.depot/workflows/ci-pr.yml b/.depot/workflows/ci-pr.yml index 2e10c09403..c161dae6b3 100644 --- a/.depot/workflows/ci-pr.yml +++ b/.depot/workflows/ci-pr.yml @@ -1,9 +1,14 @@ name: CI on: + # A reviewer can add ci:system, ci:perf, or ci:full to request an + # integration/performance rerun without needing an otherwise-empty push. pull_request: + types: [opened, reopened, synchronize, labeled] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true + # A label is an explicit request for an optional suite. Do not discard useful + # build/test work already in flight merely to service that request. + cancel-in-progress: ${{ github.event.action != 'labeled' }} permissions: checks: write contents: read From 787124529314f5fd0be0e3a090b47fbc051933db Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 29 Sep 2026 21:07:46 +0000 Subject: [PATCH 165/299] feat(eip8130): gate the Keystore to Zenith (#5366) * feat(eip8130): apply EIP-7623 calldata floor to intrinsic gas EIP-8130 priced tx_payload_cost at a flat EIP-2028 16/4 per byte with no floor, so a data-heavy 0x79 transaction could post data availability ~40% cheaper than an equivalent type-2 transaction on the same chain: standard txs get the EIP-7623 floor via revm, but the 8130 path bypasses revm through Eip8130Executor. This reopens the max-block-size hole EIP-7623 was created to close. Port EIP-7623's max(standard, floor) form over the serialized 8130 transaction (its bytes live in account_changes, sender_auth, calls, metadata, and payer_auth, so there is no single `data` field to floor): sender_floor = (sender_intrinsic - payload) + payload_floor sender_metered_gas = max(sender_intrinsic + execution_gas_used, sender_floor) payload_floor charges TX_TOTAL_COST_FLOOR_PER_TOKEN (10) per payload token instead of the standard 4, so computation-heavy transactions are unaffected and only data-heavy ones pay the floor. Enforced at mempool admission and inclusion (gas_limit >= sender_floor), and applied to the settled charge and to gas estimation. payer_auth is metered outside gas_limit and its bytes are already part of payload, so the transaction-level floor covers that padding vector too. The 10-per-token rate is pinned to revm's TOTAL_COST_FLOOR_PER_TOKEN by the gas_primitives_match_evm_reference drift tripwire. Warmth tests that carry little calldata are padded with JUMPDEST no-ops so their cold/warm SLOAD delta is not masked by the floor clamp. Mirrors ethereum/EIPs#12320. * fix(eip8130): price the calldata floor without re-encoding the body Sponsored intrinsic gas was re-RLP-encoding the transaction on every admission and execution check. Fold the standard and floor byte costs over the existing encoding instead. * fix(eip8130): reject gas limits below intrinsic gas instead of panicking resolve_execution_gas relied on a debug_assert that the calldata floor is at least sender-intrinsic gas, then expected the subtraction to succeed. A floor rate repriced below the standard data rate would have panicked in release. Both bounds are now real checks, the helper returns IntrinsicGas by name instead of a four-u64 tuple, and every site that floors sender gas goes through Eip8130Outcome::floored_sender_gas. The new-account test now expects the floored bill, since settlement on this branch applies the calldata floor. * test(eip8130): pin payload token counting to revm's calldata token count payload and payload_floor count EIP-7623 tokens by hand over bytes streamed from several pieces of the transaction. The parity test now checks both against revm's get_tokens_in_calldata_istanbul over the same sender-billed bytes, so a change to either formula fails the build. * test(eip8130): set the actor fields in the calldata-floor billing fixture * fix(eip8130): bill payer_auth bytes at the calldata floor rate payer_auth is excluded from the sender's payload and floor and is never executed, so billing it at the standard 4/16 rate let a sponsored transaction pad it up to MAX_AUTHENTICATION_GAS without meeting the EIP-7623 floor. Charge its bytes at the 10/40 floor rate. * feat(eip8130): gate the Keystore to Zenith Before Zenith an EIP-8130 transaction may use only delegation and native secp256k1 keys. Eip8130Signed::requires_keystore flags the Keystore surfaces: a Create or ConfigChange account change, or a named sender or payer authorized by a non-k1 authenticator. Everything else the Keystore does (non-k1 actors, the policy gate, lock, high-rate payers) is only reachable through those. - Block execution rejects a Keystore transaction before Zenith (Eip8130KeystoreBeforeZenith), the consensus check for building and validation. - Txpool admission and eth_estimateGas reject it before Zenith. From Zenith both accept the canonical Keystore authenticators again. - Account-change types follow the split specs: Delegation stays 0x01, Create moves to 0x02 and ConfigChange to 0x03, and 0x00 is reserved and no longer decodes. * feat(eip8130): no Keystore interaction before Zenith Resolve the EVM spec to Zenith when the genesis gate is active and gate the Keystore on it. Before Zenith, authorization is pure secp256k1 recovery (TransactionAuthorizer::authorize_without_keystore): no AccountConfiguration reads in execution, estimation, or pool admission, and no lock, high-rate, or config-slot watches. The flat K1_AUTH_COST still prices the assumed read. * refactor(eip8130): derive Keystore activation from the EVM spec everywhere Pool, estimator, and block executor resolve BaseSpecId::from_timestamp and check is_enabled_in(Zenith), the same definition the executor reads from its spec. Normalize the pool lock-status branch to a single polarity. * refactor(eip8130): reject Keystore features as unsupported before Zenith Before Zenith, Create and ConfigChange are an unsupported account change type and non-secp256k1 authenticators are unsupported, with no mention of the Keystore or Zenith. The pool rejects both as TxTypeNotSupported, like any other unsupported selector. TransactionAuthorizer::check_without_keystore is the single shape check; drop Eip8130Signed::requires_keystore and the block executor and estimator pre-checks, which execution now covers. * chore: retrigger CI --- crates/common/chains/src/upgrade.rs | 4 +- crates/common/chains/src/upgrades.rs | 12 ++ .../transaction/eip8130/account_changes.rs | 15 +- .../src/transaction/eip8130/constants.rs | 23 ++- crates/common/evm/src/eip8130.rs | 157 +++++++++++++--- crates/common/rpc-types/src/eip8130.rs | 26 +-- crates/common/rpc-types/src/reth.rs | 55 +++--- .../rpc-types/src/transaction/request.rs | 22 +-- .../execution/eip8130/src/account_config.rs | 13 +- crates/execution/eip8130/src/transaction.rs | 169 +++++++++++++++++- crates/execution/eip8130/src/tx_error.rs | 15 ++ crates/execution/txpool/src/pool.rs | 20 +-- crates/execution/txpool/src/validator.rs | 162 +++++++++++++---- 13 files changed, 544 insertions(+), 149 deletions(-) diff --git a/crates/common/chains/src/upgrade.rs b/crates/common/chains/src/upgrade.rs index cf0e147173..63f510a276 100644 --- a/crates/common/chains/src/upgrade.rs +++ b/crates/common/chains/src/upgrade.rs @@ -87,7 +87,9 @@ impl BaseUpgradeExt for BaseUpgrade { } fn from_timestamp(chain_spec: impl Upgrades, timestamp: u64) -> BaseUpgrade { - if chain_spec.is_everest_active_at_timestamp(timestamp) { + if chain_spec.is_zenith_active_at_timestamp(timestamp) { + Self::Zenith + } else if chain_spec.is_everest_active_at_timestamp(timestamp) { Self::Everest } else if chain_spec.is_denim_active_at_timestamp(timestamp) { Self::Denim diff --git a/crates/common/chains/src/upgrades.rs b/crates/common/chains/src/upgrades.rs index 3af1239f75..7fa542b788 100644 --- a/crates/common/chains/src/upgrades.rs +++ b/crates/common/chains/src/upgrades.rs @@ -175,6 +175,7 @@ impl Upgrades for RollupConfig { #[cfg(test)] mod tests { use super::*; + use crate::BaseUpgradeExt; #[test] fn rollup_config_upgrade_activation_cascade() { @@ -251,4 +252,15 @@ mod tests { assert!(!cfg.is_zenith_active_at_timestamp(ACTIVATION - 1)); assert!(cfg.is_zenith_active_at_timestamp(ACTIVATION)); } + + #[test] + fn active_zenith_is_the_execution_upgrade() { + const ACTIVATION: u64 = 42; + let mut cfg = RollupConfig::default(); + cfg.upgrades.base.everest = Some(0); + cfg.upgrades.base.zenith = Some(ACTIVATION); + + assert_eq!(BaseUpgrade::from_timestamp(cfg.clone(), ACTIVATION - 1), BaseUpgrade::Everest); + assert_eq!(BaseUpgrade::from_timestamp(cfg, ACTIVATION), BaseUpgrade::Zenith); + } } diff --git a/crates/common/consensus/src/transaction/eip8130/account_changes.rs b/crates/common/consensus/src/transaction/eip8130/account_changes.rs index 2a4dd317fa..7f57f85c71 100644 --- a/crates/common/consensus/src/transaction/eip8130/account_changes.rs +++ b/crates/common/consensus/src/transaction/eip8130/account_changes.rs @@ -349,9 +349,12 @@ pub struct Delegation { /// /// On the wire each entry is a single RLP list whose first element is the type /// byte, followed by the body fields inline (per [EIP-8130]): -/// - `rlp([0x00, user_salt, code, initial_actors])` -> [`AccountChange::Create`] /// - `rlp([0x01, target])` -> [`AccountChange::Delegation`] -/// - `rlp([0x02, channel, sequence, changes, signature])` -> [`AccountChange::ConfigChange`] +/// - `rlp([0x02, user_salt, code, initial_actors])` -> [`AccountChange::Create`] +/// - `rlp([0x03, channel, sequence, changes, signature])` -> [`AccountChange::ConfigChange`] +/// +/// `0x00` is reserved and does not decode. `Create` and `ConfigChange` are the +/// Keystore's entries and are only valid once Zenith is active. /// /// The type byte is a genuine list element (not an EIP-2718-style `type_byte || /// rlp(...)` prefix), so each entry is one self-contained RLP item and the @@ -641,6 +644,14 @@ mod tests { assert!(res.is_err()); } + #[test] + fn reserved_type_byte_zero_does_not_decode() { + // `0x00` is reserved by the AA transaction type; Create moved to `0x02`. + // `[0x00]` is the RLP list `0xc1 0x80`. + let buf = [0xc1u8, 0x80]; + assert!(AccountChange::decode(&mut &buf[..]).is_err()); + } + #[test] fn account_change_entry_is_single_rlp_item() { // Each entry must be exactly one self-contained RLP list item: the type diff --git a/crates/common/consensus/src/transaction/eip8130/constants.rs b/crates/common/consensus/src/transaction/eip8130/constants.rs index a2377249a1..748761b1b7 100644 --- a/crates/common/consensus/src/transaction/eip8130/constants.rs +++ b/crates/common/consensus/src/transaction/eip8130/constants.rs @@ -116,25 +116,22 @@ impl Eip8130Constants { /// [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 pub const DELEGATION_INDICATOR_SIZE: usize = 23; - /// `account_changes` entry type byte: account creation. - /// - /// Still decoded on this wire. A follow-up deletes `Create` rather than - /// gating it behind a feature. - pub const ACCOUNT_CHANGE_TYPE_CREATE: u8 = 0x00; - /// `account_changes` entry type byte: code delegation. /// - /// The launch wire keeps delegation, so it takes the low `0x01` slot. - /// `Create` stays at `0x00` and `ConfigChange` moves to `0x02` until the - /// follow-ups that delete them. + /// Defined by the AA transaction type. `0x00` is reserved there and never + /// decodes. pub const ACCOUNT_CHANGE_TYPE_DELEGATION: u8 = 0x01; - /// `account_changes` entry type byte: a signed account-change batch + /// `account_changes` entry type byte: Keystore account creation. + /// + /// Assigned by the Keystore integration and only valid once Zenith is active. + pub const ACCOUNT_CHANGE_TYPE_CREATE: u8 = 0x02; + + /// `account_changes` entry type byte: a Keystore signed account-change batch /// (`SignedAccountChanges`, applied via `applySignedAccountChanges`). /// - /// Still decoded on this wire. A follow-up deletes `ConfigChange` rather - /// than gating it behind a feature. - pub const ACCOUNT_CHANGE_TYPE_CONFIG: u8 = 0x02; + /// Assigned by the Keystore integration and only valid once Zenith is active. + pub const ACCOUNT_CHANGE_TYPE_CONFIG: u8 = 0x03; /// `SignedAccountChanges.channel` byte: the Local channel (binds /// `block.chainid`; carries epoch + sequence and the unsequenced JIT mode). diff --git a/crates/common/evm/src/eip8130.rs b/crates/common/evm/src/eip8130.rs index 01e7876d45..9530f8c3b1 100644 --- a/crates/common/evm/src/eip8130.rs +++ b/crates/common/evm/src/eip8130.rs @@ -76,7 +76,8 @@ use revm::{ use crate::{ BaseContext, BaseContextTr, BaseEvm, BaseHaltReason, BaseSpecId, BaseTransaction, - BaseTransactionError, BaseTxTr, Eip8130PhaseStatuses, L1BlockInfo, handler::BaseHandler, + BaseTransactionError, BaseTxTr, BaseUpgrade, Eip8130PhaseStatuses, L1BlockInfo, + handler::BaseHandler, }; /// EIP-3529 maximum gas refund quotient: refunds are capped at `gas_used / 5`. @@ -813,6 +814,11 @@ impl Eip8130Executor { // to; before a payer has signed (a stub that does not recover) the payer // is unknown and published as `UNSIGNED_OPEN_PAYER`, never the sender. let payer = signed.resolved_payer(sender).unwrap_or(Self::UNSIGNED_OPEN_PAYER); + let keystore = ctx.cfg().spec().is_enabled_in(BaseUpgrade::Zenith); + if !keystore { + TransactionAuthorizer::check_without_keystore(signed) + .map_err(BaseTransactionError::eip8130)?; + } let internals = EvmInternals::from_context(ctx); let mut provider = JournalStorageProvider::new(internals, Address::ZERO); @@ -853,25 +859,15 @@ impl Eip8130Executor { // so the returned ceiling stays valid even if the gate flips // between estimation and inclusion (the gate is a non-monotonic // state-dependent cost). - let acc = AccountConfigurationStorage::new(sctx); + // Before Zenith there is no Keystore: the sender is always its own + // ungated self-actor and no configuration is read. let sender_actor_id = acting_actor_hint + .filter(|_| keystore) .unwrap_or_else(|| AccountConfigurationStorage::self_actor_id(sender)); - // Resolve the acting scope via the effective-config resolver: an - // explicit `actor_config` entry, or the inline secp256k1 self (a - // revoked default EOA resolves to the empty config, i.e. scope 0). - // Then read the policy target with `get_policy_manager` only when - // gated, avoiding `get_policy`'s extra `policy_commitment` SLOAD on - // this estimation hot path (the commitment is unused here). - let actor_scope = acc - .resolve_actor_config(sender, sender_actor_id) - .map_err(BaseTransactionError::eip8130)? - .scope; - let policy_gated = Eip8130Constants::sender_is_policy_gated(actor_scope); - let policy_target = if policy_gated { - acc.get_policy_manager(sender, sender_actor_id) - .map_err(BaseTransactionError::eip8130)? + let (policy_gated, policy_target) = if keystore { + Self::simulate_policy_gate(sctx, sender, sender_actor_id)? } else { - Address::ZERO + (false, Address::ZERO) }; // 4. Intrinsic gas (auth gas is priced from the auth-blob shape, so a @@ -918,6 +914,34 @@ impl Eip8130Executor { }) } + /// Resolves the simulated acting actor's policy gate from the Keystore: + /// whether it is policy-gated and, if so, its policy target. + fn simulate_policy_gate( + sctx: StorageCtx<'_>, + sender: Address, + sender_actor_id: B256, + ) -> Result<(bool, Address), BaseTransactionError> { + // Resolve the acting scope via the effective-config resolver: an + // explicit `actor_config` entry, or the inline secp256k1 self (a + // revoked default EOA resolves to the empty config, i.e. scope 0). + // Then read the policy target with `get_policy_manager` only when + // gated, avoiding `get_policy`'s extra `policy_commitment` SLOAD on + // this estimation hot path (the commitment is unused here). + let acc = AccountConfigurationStorage::new(sctx); + let actor_scope = acc + .resolve_actor_config(sender, sender_actor_id) + .map_err(BaseTransactionError::eip8130)? + .scope; + let policy_gated = Eip8130Constants::sender_is_policy_gated(actor_scope); + let policy_target = if policy_gated { + acc.get_policy_manager(sender, sender_actor_id) + .map_err(BaseTransactionError::eip8130)? + } else { + Address::ZERO + }; + Ok((policy_gated, policy_target)) + } + /// Runs the storage-backed pre-call pipeline (authorize, nonce, intrinsic /// gas, fee-cap check, account-change apply) over a gas-free /// journal view and publishes the transaction context, returning the resolved @@ -967,6 +991,7 @@ impl Eip8130Executor { // the respective bound). The nonce-free replay ring separately enforces // its own admission window (`valid_before > now_ms`) when it records the // nonce, so a nonce-free transaction at the boundary still fails there. + let keystore = ctx.cfg().spec().is_enabled_in(BaseUpgrade::Zenith); let now_ms = now.saturating_mul(1_000); if valid_after != 0 && now_ms < valid_after { return Err(BaseTransactionError::eip8130("transaction is not yet valid")); @@ -992,9 +1017,14 @@ impl Eip8130Executor { // resulting post-apply state. `AccountConfiguration` storage // transitions are written here; the deferred account-code effects // are installed in step 2. - let applied_tx = + // Before Zenith there is no Keystore: authorization is pure + // secp256k1 recovery and never reads `AccountConfiguration`. + let applied_tx = if keystore { TransactionAuthorizer::authorize_and_apply(signed, &mut acc, chain_id, now) - .map_err(BaseTransactionError::eip8130)?; + } else { + TransactionAuthorizer::authorize_without_keystore(signed) + } + .map_err(BaseTransactionError::eip8130)?; let sender_actor = applied_tx.actors.sender.resolved; let payer_policy_gated = applied_tx .actors @@ -1842,7 +1872,8 @@ mod tests { use alloy_sol_types::{SolEvent, SolValue, sol}; use base_common_consensus::{ AccountChange, AccountChangeChannel, BaseTxEnvelope, Call, ChangeType, CreateEntry, - Eip8130Signed, InitialActor, Predeploys, SignedAccountChanges, SignedChange, TxEip8130, + Eip8130Contracts, Eip8130Signed, InitialActor, Predeploys, SignedAccountChanges, + SignedChange, TxEip8130, }; use base_common_precompiles::INonceManager; use base_execution_eip8130::AccountChangeApplier; @@ -1934,6 +1965,14 @@ mod tests { BaseTransaction::from_encoded_tx(&envelope, Address::ZERO, encoded) } + /// Runs `evm` under the Zenith spec, where the Keystore is active. + fn with_zenith( + mut evm: BaseEvm, + ) -> BaseEvm { + evm.ctx_mut().cfg.spec = BaseSpecId::new(BaseUpgrade::Zenith); + evm + } + /// Builds an EVM with `balance` funded to `sender`, optionally deploying /// `code` at the given contract addresses. fn evm_with_accounts( @@ -2792,11 +2831,11 @@ mod tests { tx.calls = vec![vec![Call { to: allowed, value: U256::ZERO, data: Bytes::new() }]]; let signed = configured_signed(tx, &owner); - let mut evm = evm_with_accounts( + let mut evm = with_zenith(evm_with_accounts( U256::from(10u64).pow(U256::from(18u64)), account, &[(allowed, bytes!("00")), (wrong, bytes!("00"))], - ); + )); // Gate the account's self-actor away from `allowed` so the no-hint path // hits the node policy gate. seed_gated_sender(&mut evm, account, account, wrong); @@ -3338,7 +3377,7 @@ mod tests { tx.calls = vec![vec![Call { to: forbidden, value: U256::ZERO, data: Bytes::new() }]]; let signed = configured_signed(tx, &signer); - let mut evm = evm_with(U256::from(10u64).pow(U256::from(18u64)), account); + let mut evm = with_zenith(evm_with(U256::from(10u64).pow(U256::from(18u64)), account)); seed_gated_sender(&mut evm, account, signer_addr, allowed); let outcome = evm.transact_raw(into_base_tx(&signed)).expect("tx should be included"); @@ -3362,17 +3401,76 @@ mod tests { tx.calls = vec![vec![Call { to: allowed, value: U256::ZERO, data: Bytes::new() }]]; let signed = configured_signed(tx, &signer); - let mut evm = evm_with_accounts( + let mut evm = with_zenith(evm_with_accounts( U256::from(10u64).pow(U256::from(18u64)), account, &[(allowed, bytes!("00"))], - ); + )); seed_gated_sender(&mut evm, account, signer_addr, allowed); let outcome = evm.transact_raw(into_base_tx(&signed)).expect("tx should execute"); assert!(outcome.result.is_success(), "expected success, got {:?}", outcome.result); } + #[test] + fn pre_zenith_ignores_keystore_actors() { + // The Keystore authorizes `signer` for `account`, but before Zenith + // there is no Keystore: a named sender must sign with its own key. + let account = address!("0x00000000000000000000000000000000000000ca"); + let allowed = address!("0x00000000000000000000000000000000000000cb"); + let signer = signing_key(0x99); + let signer_addr = eoa_address(&signer); + + let mut tx = base_tx(); + tx.sender = Some(account); + tx.calls = vec![vec![Call { to: allowed, value: U256::ZERO, data: Bytes::new() }]]; + let signed = configured_signed(tx, &signer); + + let mut evm = evm_with(U256::from(10u64).pow(U256::from(18u64)), account); + seed_gated_sender(&mut evm, account, signer_addr, allowed); + + let err = evm.transact_raw(into_base_tx(&signed)).unwrap_err(); + let EVMError::Transaction(BaseTransactionError::Eip8130(reason)) = err else { + panic!("expected an Eip8130 validity rejection, got {err:?}"); + }; + assert!(reason.contains("does not recover to the named account"), "got {reason:?}"); + } + + /// Before Zenith, a non-secp256k1 authenticator and a `Create` are rejected + /// as unsupported, in both execution and simulation. + #[test] + fn pre_zenith_rejects_unsupported_authenticator_and_account_change() { + let account = address!("0x00000000000000000000000000000000000000ce"); + let mut tx = base_tx(); + tx.sender = Some(account); + let mut auth = Eip8130Contracts::P256_AUTHENTICATOR.to_vec(); + auth.extend_from_slice(&[0u8; 128]); + let p256 = Eip8130Signed::new(tx, Bytes::from(auth), Bytes::new()); + let (derived, create) = + counterfactual_create_signed(&signing_key(0x9b), bytes!("00"), Vec::new()); + + for (signed, sender, reason) in [ + (p256, account, "unsupported authenticator"), + (create, derived, "unsupported account change type"), + ] { + let mut evm = evm_with(U256::from(10u64).pow(U256::from(18u64)), sender); + let err = evm.transact_raw(into_base_tx(&signed)).unwrap_err(); + let EVMError::Transaction(BaseTransactionError::Eip8130(got)) = err else { + panic!("expected an Eip8130 validity rejection, got {err:?}"); + }; + assert!(got.contains(reason), "got {got:?}, expected {reason:?}"); + + let mut sim = into_base_tx(&signed); + sim.base.caller = sender; + if let Some(parts) = sim.eip8130.as_mut() { + parts.mode = Eip8130ExecutionMode::Simulate; + } + evm.ctx_mut().tx = sim; + let err = Eip8130Executor::simulate(&mut evm).unwrap_err(); + assert!(err.to_string().contains(reason), "got {err}, expected {reason:?}"); + } + } + #[test] fn actor_policy_violation_data_is_abi_encoded() { let actor_id = B256::repeat_byte(0xab); @@ -3430,7 +3528,7 @@ mod tests { let (derived, signed) = counterfactual_create_signed(&key, bytes!("00"), Vec::new()); let initial_balance = U256::from(10u64).pow(U256::from(18u64)); - let mut evm = evm_with(initial_balance, derived); + let mut evm = with_zenith(evm_with(initial_balance, derived)); let outcome = evm.transact_raw(into_base_tx(&signed)).expect("counterfactual create should execute"); @@ -3450,7 +3548,7 @@ mod tests { fn assert_create_rejected(byte: u8, code: Bytes, reason: &str) { let key = signing_key(byte); let (derived, signed) = counterfactual_create_signed(&key, code, Vec::new()); - let mut evm = evm_with(U256::from(10u64).pow(U256::from(18u64)), derived); + let mut evm = with_zenith(evm_with(U256::from(10u64).pow(U256::from(18u64)), derived)); let err = evm.transact_raw(into_base_tx(&signed)).unwrap_err(); let EVMError::Transaction(BaseTransactionError::Eip8130(got)) = err else { panic!("expected an Eip8130 validity rejection, got {err:?}"); @@ -3499,7 +3597,7 @@ mod tests { // preexisting (non-8130) bytecode instead of overwriting it. let key = signing_key(0xb5); let (derived, signed) = counterfactual_create_signed(&key, bytes!("6001"), Vec::new()); - let mut evm = evm_with(U256::from(10u64).pow(U256::from(18u64)), derived); + let mut evm = with_zenith(evm_with(U256::from(10u64).pow(U256::from(18u64)), derived)); seed_account_code(&mut evm, derived, Bytes::from_static(&[0xfe, 0xfe, 0xfe])); let err = evm.transact_raw(into_base_tx(&signed)).unwrap_err(); @@ -3525,7 +3623,8 @@ mod tests { ); let initial_balance = U256::from(10u64).pow(U256::from(18u64)); - let mut evm = evm_with_accounts(initial_balance, derived, &[(target, bytes!("00"))]); + let mut evm = + with_zenith(evm_with_accounts(initial_balance, derived, &[(target, bytes!("00"))])); let outcome = evm .transact_raw(into_base_tx(&signed)) .expect("counterfactual create + call should execute"); diff --git a/crates/common/rpc-types/src/eip8130.rs b/crates/common/rpc-types/src/eip8130.rs index 1e08fe25b3..3da9f71c71 100644 --- a/crates/common/rpc-types/src/eip8130.rs +++ b/crates/common/rpc-types/src/eip8130.rs @@ -88,11 +88,6 @@ impl BaseTransactionRequest { let (sender, sender_auth) = match &aa.sender_auth { Some(blob) => { - // A recognized non-k1 selector must not fall through to the bare - // EOA path: the pool rejects it, so the simulation does too. - if Self::is_disallowed_authenticator(blob) { - return None; - } let prefixed = Self::is_prefixed_auth(blob); Self::check_auth_len(blob, prefixed)?; (prefixed.then_some(account), blob.clone()) @@ -185,20 +180,15 @@ impl BaseTransactionRequest { .then(|| Address::from_slice(&blob[..AUTHENTICATOR_SELECTOR_LEN])) } - /// Whether the blob is a configured-account authorization the launch wire - /// accepts. Only the native k1 authenticator qualifies, matching txpool - /// admission. Other selectors are either rejected - /// ([`Self::is_disallowed_authenticator`]) or treated as a bare EOA signature. + /// Whether the blob is a configured-account authorization: its leading 20 + /// bytes name native k1 or a canonical authenticator. Any other prefix is + /// treated as a bare EOA signature. Simulation rejects an authenticator the + /// chain does not support, matching txpool admission. fn is_prefixed_auth(blob: &Bytes) -> bool { - Self::authenticator_selector(blob) - .is_some_and(|selector| selector == Eip8130Constants::K1_AUTHENTICATOR) - } - - /// Whether the blob names a canonical authenticator the launch wire does not - /// admit (P256, `WebAuthn`, or delegate). - fn is_disallowed_authenticator(blob: &Bytes) -> bool { - Self::authenticator_selector(blob) - .is_some_and(|selector| Eip8130Contracts::is_canonical_authenticator(&selector)) + Self::authenticator_selector(blob).is_some_and(|selector| { + selector == Eip8130Constants::K1_AUTHENTICATOR + || Eip8130Contracts::is_canonical_authenticator(&selector) + }) } fn stub_prefixed_auth(scheme: Eip8130AuthScheme, data_len: usize) -> Bytes { diff --git a/crates/common/rpc-types/src/reth.rs b/crates/common/rpc-types/src/reth.rs index 83067e7422..283c3655c3 100644 --- a/crates/common/rpc-types/src/reth.rs +++ b/crates/common/rpc-types/src/reth.rs @@ -157,31 +157,33 @@ mod tests { } #[test] - fn prefixed_p256_sender_auth_is_rejected() { + fn prefixed_p256_sender_auth_is_priced() { let req: BaseTransactionRequest = serde_json::from_value(json!({ "sender": SENDER, "calls": [], "senderAuth": blob(Some(Eip8130Contracts::P256_AUTHENTICATOR), 128), })) .expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), - "a P256 sender authenticator is rejected, matching pool admission", - ); + let tx = req + .to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP) + .expect("a canonical authenticator is priced"); + let s = signed(&tx); + assert_eq!(s.tx().sender, Some(SENDER), "the blob selects the configured path"); } #[test] - fn prefixed_webauthn_sender_auth_is_rejected() { + fn prefixed_webauthn_sender_auth_is_priced() { let req: BaseTransactionRequest = serde_json::from_value(json!({ "sender": SENDER, "calls": [], "senderAuth": blob(Some(Eip8130Contracts::WEBAUTHN_AUTHENTICATOR), 512), })) .expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), - "a WebAuthn sender authenticator is rejected, matching pool admission", - ); + let tx = req + .to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP) + .expect("a canonical authenticator is priced"); + let s = signed(&tx); + assert_eq!(s.tx().sender, Some(SENDER), "the blob selects the configured path"); } #[test] @@ -259,7 +261,7 @@ mod tests { } #[test] - fn delegate_prefixed_sender_auth_is_rejected() { + fn delegate_prefixed_sender_auth_is_priced() { let delegate_account = address!("0x00000000000000000000000000000000000000d4"); let mut nested = Eip8130Constants::K1_AUTHENTICATOR.to_vec(); nested.extend_from_slice(&[STUB_AUTH_FILL; 65]); @@ -272,14 +274,15 @@ mod tests { "senderAuth": alloy_primitives::hex::encode_prefixed(&blob), })) .expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), - "a delegate sender authenticator is rejected, not priced as a bare EOA", - ); + let tx = req + .to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP) + .expect("a canonical authenticator is priced"); + let s = signed(&tx); + assert_eq!(s.tx().sender, Some(SENDER), "the blob selects the configured path"); } #[test] - fn delegate_prefixed_payer_auth_is_rejected() { + fn delegate_prefixed_payer_auth_is_priced() { let payer = address!("0x00000000000000000000000000000000000000b2"); let delegate_account = address!("0x00000000000000000000000000000000000000d4"); let mut nested = Eip8130Constants::K1_AUTHENTICATOR.to_vec(); @@ -294,10 +297,11 @@ mod tests { "payerAuth": alloy_primitives::hex::encode_prefixed(&blob), })) .expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), - "a delegate payer authenticator is rejected, matching pool admission", - ); + let tx = req + .to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP) + .expect("a canonical authenticator is priced"); + let s = signed(&tx); + assert_eq!(s.tx().payer, Some(payer), "the payer is set on the transaction"); } #[test] @@ -462,7 +466,7 @@ mod tests { } #[test] - fn prefixed_p256_payer_auth_is_rejected() { + fn prefixed_p256_payer_auth_is_priced() { let payer = address!("0x00000000000000000000000000000000000000b2"); let req: BaseTransactionRequest = serde_json::from_value(json!({ "sender": SENDER, @@ -471,10 +475,11 @@ mod tests { "payerAuth": blob(Some(Eip8130Contracts::P256_AUTHENTICATOR), 128), })) .expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), - "a P256 payer authenticator is rejected, matching pool admission", - ); + let tx = req + .to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP) + .expect("a canonical authenticator is priced"); + let s = signed(&tx); + assert_eq!(s.tx().payer, Some(payer), "the payer is set on the transaction"); } #[test] diff --git a/crates/common/rpc-types/src/transaction/request.rs b/crates/common/rpc-types/src/transaction/request.rs index 6247a37e64..9ab236a585 100644 --- a/crates/common/rpc-types/src/transaction/request.rs +++ b/crates/common/rpc-types/src/transaction/request.rs @@ -19,10 +19,10 @@ use crate::Transaction; /// Named EIP-8130 authenticator selectors. /// -/// [`Self::Secp256k1`] sizes the default authorization when a blob is absent -/// and is the only selector launch-wire simulation prices. P256, `WebAuthn`, and -/// the delegate authenticator are rejected by `eth_call` / `eth_estimateGas`, -/// matching txpool admission. +/// [`Self::Secp256k1`] sizes the default authorization when a blob is absent. +/// P256, `WebAuthn`, and the delegate authenticator are priced when the chain +/// supports them; otherwise `eth_estimateGas` rejects them, matching txpool +/// admission. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Eip8130AuthScheme { @@ -67,8 +67,8 @@ impl Eip8130AuthScheme { /// `eip8130_auth_scheme_all_lists_every_variant` test for the /// compile-time guard that keeps this in sync with the enum. /// - /// Simulation prices only [`Self::Secp256k1`]. The other variants name - /// authenticators the launch wire rejects. + /// The variants other than [`Self::Secp256k1`] are accepted only where the + /// chain supports them. pub const ALL: [Self; 3] = [Self::Secp256k1, Self::P256, Self::WebAuthn]; } @@ -138,7 +138,8 @@ pub struct Eip8130RequestFields { /// - `authenticator(20) || data` prefixed with the native k1 authenticator /// prices the configured-account path. /// - A prefix that names P256, `WebAuthn`, or the delegate authenticator is - /// rejected, matching txpool admission. + /// priced when the chain supports it and rejected otherwise, matching + /// txpool admission. /// /// An absent blob defaults by intent: a declared `sender` synthesizes a /// k1-prefixed configured-account authorization; a `from`-only request @@ -158,9 +159,10 @@ pub struct Eip8130RequestFields { /// /// For a named payer this is `authenticator(20) || data`, and absent /// defaults to a representative secp256k1 payer authorization. A supplied - /// blob's leading 20 bytes must be the native k1 authenticator. Any other - /// selector, including P256, `WebAuthn`, and the delegate authenticator, is - /// rejected as `INVALID_PARAMS` rather than priced. + /// blob's leading 20 bytes must name the native k1 authenticator or a + /// canonical authenticator the chain supports. Any other selector is + /// rejected as `INVALID_PARAMS` rather than priced, and a canonical one the + /// chain does not support is rejected by the simulation. /// /// In open payer mode this is the payer's raw 65-byte signature, from which /// the payer is recovered. It is optional: before any payer has signed, an diff --git a/crates/execution/eip8130/src/account_config.rs b/crates/execution/eip8130/src/account_config.rs index 0561d53832..a8c8ea8590 100644 --- a/crates/execution/eip8130/src/account_config.rs +++ b/crates/execution/eip8130/src/account_config.rs @@ -538,12 +538,7 @@ impl AccountState { #[must_use] pub const fn lock_status(&self, now: u64) -> LockStatus { if self.flags & Eip8130Constants::FLAG_LOCKED == 0 { - return LockStatus { - locked: false, - has_initiated_unlock: false, - unlocks_at: 0, - unlock_delay: 0, - }; + return LockStatus::UNLOCKED; } if self.flags & Eip8130Constants::FLAG_UNLOCK_INITIATED == 0 { // Hard-locked: lock_union holds the configured delay; synthesize the @@ -626,6 +621,12 @@ pub struct LockStatus { pub unlock_delay: u16, } +impl LockStatus { + /// The status of an account that is not locked. + pub const UNLOCKED: Self = + Self { locked: false, has_initiated_unlock: false, unlocks_at: 0, unlock_delay: 0 }; +} + #[cfg(test)] mod tests { use alloy_primitives::{Address, B256, U256, address, b256}; diff --git a/crates/execution/eip8130/src/transaction.rs b/crates/execution/eip8130/src/transaction.rs index 6eb276ccc8..7ba1bebac7 100644 --- a/crates/execution/eip8130/src/transaction.rs +++ b/crates/execution/eip8130/src/transaction.rs @@ -3,7 +3,8 @@ //! their authorization, then authenticates the final sender/payer signatures //! against the resulting post-apply state. -use base_common_consensus::{AccountChange, Delegation, Eip8130Signed}; +use alloy_primitives::{Address, B256}; +use base_common_consensus::{AccountChange, Delegation, Eip8130Constants, Eip8130Signed}; use crate::{ AccountChangeApplier, AccountConfigurationStorage, ActorTxVerifier, AppliedAccountChanges, @@ -194,6 +195,110 @@ impl TransactionAuthorizer { Ok(AppliedTransaction { actors, config_changes, applied, revoke_discount_slots }) } + /// Authorizes `signed` without the Keystore, as before Zenith. + /// + /// Never reads or writes `AccountConfiguration` storage. Every sender and + /// payer is authenticated by native secp256k1 recovery alone, and the + /// recovered key is the account's own full-authority key: + /// + /// - a bare `sender_auth` recovers the sender; a named sender needs + /// `K1_AUTHENTICATOR || sig` recovering to that address; + /// - an open payer is whoever signed `payer_auth`; a named payer needs + /// `K1_AUTHENTICATOR || sig` recovering to that address; + /// - the only account change is delegation, recorded as a deferred code + /// effect. + /// + /// Anything else is rejected by [`Self::check_without_keystore`]. + pub fn authorize_without_keystore( + signed: &Eip8130Signed, + ) -> Result { + Self::check_without_keystore(signed)?; + let tx = signed.tx(); + + let sender = match signed.explicit_sender() { + Some(account) => { + Self::recover_named_k1(account, tx.sender_signature_hash(), signed.sender_auth())? + } + None => RecoveredActorId::recover_eoa_sender(signed) + .map_err(|_| TxAuthError::SenderRecovery)? + .ok_or(TxAuthError::SenderRecovery)? + .address(), + }; + + let payer = match tx.payer { + None => None, + Some(_) if tx.is_open_payer() => Some( + RecoveredActorId::recover_k1(tx.payer_signature_hash(sender), signed.payer_auth()) + .map_err(|_| TxAuthError::PayerRecovery)? + .address(), + ), + Some(account) => Some(Self::recover_named_k1( + account, + tx.payer_signature_hash(sender), + signed.payer_auth(), + )?), + }; + + let mut applied = AppliedAccountChanges::default(); + for change in &tx.account_changes { + let AccountChange::Delegation(Delegation { target }) = change else { + return Err(TxAuthError::UnsupportedAccountChange); + }; + if applied.delegation.is_some() { + return Err(ApplyError::MultipleDelegations.into()); + } + applied.delegation = Some(DelegationEffect::new(sender, *target)); + } + + let owner = |account| AuthorizedActor { + account, + resolved: ResolvedActor::unrestricted(AccountConfigurationStorage::self_actor_id( + account, + )), + }; + Ok(AppliedTransaction { + actors: TxActors { sender: owner(sender), payer: payer.map(owner) }, + config_changes: Vec::new(), + applied, + revoke_discount_slots: 0, + }) + } + + /// Checks the shape of `signed` without the Keystore, before any signature + /// is recovered: delegation is the only account change, and a named sender + /// or payer selects the native secp256k1 authenticator. An open payer's + /// `payer_auth` is a raw signature and carries no selector. + pub fn check_without_keystore(signed: &Eip8130Signed) -> Result<(), TxAuthError> { + let tx = signed.tx(); + if tx.account_changes.iter().any(|change| !matches!(change, AccountChange::Delegation(_))) { + return Err(TxAuthError::UnsupportedAccountChange); + } + let named_k1 = + |auth: &[u8]| auth.starts_with(Eip8130Constants::K1_AUTHENTICATOR.as_slice()); + if (tx.sender.is_some() && !named_k1(signed.sender_auth())) + || (tx.payer.is_some() && !tx.is_open_payer() && !named_k1(signed.payer_auth())) + { + return Err(TxAuthError::UnsupportedAuthenticator); + } + Ok(()) + } + + /// Recovers `auth` (`K1_AUTHENTICATOR || r || s || v`) over `hash` and + /// requires it to be `account`. + fn recover_named_k1(account: Address, hash: B256, auth: &[u8]) -> Result { + let Some(signature) = auth.strip_prefix(Eip8130Constants::K1_AUTHENTICATOR.as_slice()) + else { + return Err(TxAuthError::UnsupportedAuthenticator); + }; + let recovered = RecoveredActorId::recover_k1(hash, signature) + .map_err(|error| TxAuthError::Authorize(AuthorizeError::Authenticate(error)))? + .address(); + if recovered != account { + return Err(TxAuthError::SignerMismatch); + } + Ok(account) + } + /// Requires a delegation's final sender to be an admin (unrestricted) actor /// on the unlocked account. /// @@ -1190,4 +1295,66 @@ mod tests { ); }); } + + /// Without the Keystore, delegation and native k1 keys (named, bare, or an + /// open payer's raw signature) authorize; every other account change or + /// authenticator is unsupported. + #[test] + fn authorizes_without_keystore_only_delegation_and_k1() { + let sender_key = key(0x61); + let payer_key = key(0x62); + let sender = addr(&sender_key); + let payer = addr(&payer_key); + let p256 = address!("0x0000000000000000000000000000000000000100"); + let delegation = AccountChange::Delegation(Delegation { target: payer }); + + let eoa = eoa_signed(tx_with(None, None, vec![delegation]), &sender_key); + let applied = TransactionAuthorizer::authorize_without_keystore(&eoa).unwrap(); + assert_eq!(applied.actors.sender.account, sender); + assert!(applied.actors.sender.resolved.is_admin()); + assert!(applied.applied.delegation.is_some()); + + let named = configured_signed( + tx_with(Some(sender), Some(payer), Vec::new()), + &sender_key, + Some(&payer_key), + ); + let applied = TransactionAuthorizer::authorize_without_keystore(&named).unwrap(); + assert_eq!(applied.actors.payer.map(|actor| actor.account), Some(payer)); + + let open_tx = tx_with(None, Some(Eip8130Constants::OPEN_PAYER), Vec::new()); + let payer_auth = sig(&payer_key, open_tx.payer_signature_hash(sender)); + let sender_auth = sig(&sender_key, open_tx.sender_signature_hash()); + let open = Eip8130Signed::new(open_tx, Bytes::from(sender_auth), Bytes::from(payer_auth)); + let applied = TransactionAuthorizer::authorize_without_keystore(&open).unwrap(); + assert_eq!(applied.actors.payer.map(|actor| actor.account), Some(payer)); + + let p256_sender = Eip8130Signed::new( + tx_with(Some(sender), None, Vec::new()), + auth_blob(p256, &[0; 64]), + Bytes::new(), + ); + assert!(matches!( + TransactionAuthorizer::authorize_without_keystore(&p256_sender), + Err(TxAuthError::UnsupportedAuthenticator) + )); + + let config_change = eoa_signed( + tx_with( + None, + None, + vec![AccountChange::ConfigChange(SignedAccountChanges { + channel: AccountChangeChannel::Local, + sequence: 0, + changes: Vec::new(), + signature: Bytes::new(), + })], + ), + &sender_key, + ); + assert!(matches!( + TransactionAuthorizer::authorize_without_keystore(&config_change), + Err(TxAuthError::UnsupportedAccountChange) + )); + } } diff --git a/crates/execution/eip8130/src/tx_error.rs b/crates/execution/eip8130/src/tx_error.rs index f8d91cbe3c..2bfcf14943 100644 --- a/crates/execution/eip8130/src/tx_error.rs +++ b/crates/execution/eip8130/src/tx_error.rs @@ -51,6 +51,21 @@ pub enum TxAuthError { #[error("delegation requires an admin actor")] DelegationUnauthorized, + /// The transaction carries an account change type this chain does not + /// support. + #[error("unsupported account change type")] + UnsupportedAccountChange, + + /// A named sender or payer selects an authenticator this chain does not + /// support. + #[error("unsupported authenticator")] + UnsupportedAuthenticator, + + /// A named sender or payer's native secp256k1 signature recovered to a + /// different address than the one named. + #[error("secp256k1 signature does not recover to the named account")] + SignerMismatch, + /// A signed account-change batch's sequence does not match the account's /// current sequence for its channel. The contract reads the sequence from /// state, so a mismatch means the batch is stale or out of order (and its diff --git a/crates/execution/txpool/src/pool.rs b/crates/execution/txpool/src/pool.rs index be0d4d3358..b5f50a33ad 100644 --- a/crates/execution/txpool/src/pool.rs +++ b/crates/execution/txpool/src/pool.rs @@ -2374,16 +2374,10 @@ mod tests { let signer = signer(); fund(&client, signer.address()); - let nonce_free = self_paid_eoa_8130( - &signer, - Eip8130Constants::NONCE_KEY_MAX, - 0, - INTEGRATION_POOL_NOW_MS + Eip8130Constants::NONCE_FREE_MAX_EXPIRY_WINDOW, - 1_000, - ); - let nonce_free_hash = *nonce_free.hash(); - pool.add_transaction(TransactionOrigin::Local, nonce_free).await.unwrap(); - let pooled = pool.get(&nonce_free_hash).unwrap(); + let keyed = self_paid_eoa_8130(&signer, U256::from(2), 0, 0, 1_000); + let keyed_hash = *keyed.hash(); + pool.add_transaction(TransactionOrigin::Local, keyed).await.unwrap(); + let pooled = pool.get(&keyed_hash).unwrap(); let (address, slot) = pooled .transaction .watch_set() @@ -2393,7 +2387,7 @@ mod tests { InvalidationKey::Slot { address, slot } => Some((*address, *slot)), _ => None, }) - .expect("EOA authorization must expose an exact config-slot dependency"); + .expect("a 2D nonce channel must expose an exact nonce-slot dependency"); let removed = pool.apply_state_diff(&[AccountStateDiff { address, @@ -2401,8 +2395,8 @@ mod tests { ..Default::default() }]); assert_eq!(removed.len(), 1); - assert_eq!(*removed[0].hash(), nonce_free_hash); - assert!(pool.get(&nonce_free_hash).is_none()); + assert_eq!(*removed[0].hash(), keyed_hash); + assert!(pool.get(&keyed_hash).is_none()); let channel = self_paid_eoa_8130(&signer, U256::from(1), 0, 0, 1_000); let channel_hash = *channel.hash(); diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index 411bd62964..488002f254 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -12,7 +12,7 @@ use std::{ use alloy_consensus::{BlockHeader, Transaction, constants::KECCAK_EMPTY}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{Address, B256, LogData, U256, map::AddressSet}; -use base_common_chains::Upgrades; +use base_common_chains::{BaseUpgrade, Upgrades}; use base_common_consensus::{ AccountChange, ChangeType, Eip8130Constants, Eip8130Contracts, Eip8130Signed, Eip8130TimestampError, InitialActor, SignedChange, @@ -1001,6 +1001,10 @@ where let classification_generation = self.limit_class_cache_generation(); let local_chain_id = self.inner.chain_spec().chain().id(); let now = self.block_timestamp(); + // Before Zenith there is no Keystore: authorization, lock state, and the + // high-rate payer classification never read `AccountConfiguration`. + let keystore = + BaseSpecId::from_timestamp(self.chain_spec(), now).is_enabled_in(BaseUpgrade::Zenith); let state = self.client().latest().map_err(|error| Self::provider_unavailable(error))?; // Authorize *and apply* the account changes against a writable overlay so @@ -1015,7 +1019,7 @@ where )); let auth_start = Instant::now(); let auth_result = StorageCtx::enter(&mut storage, |ctx| { - let applied = { + let applied = if keystore { let mut account_config = AccountConfigurationStorage::new(ctx); TransactionAuthorizer::authorize_and_apply( signed, @@ -1023,6 +1027,8 @@ where local_chain_id, now, )? + } else { + TransactionAuthorizer::authorize_without_keystore(signed)? }; if let Some(delegation) = applied.applied.delegation { delegation.install(ctx).map_err(TxAuthError::from)?; @@ -1173,25 +1179,30 @@ where }); } - let sender_status = self.account_lock( - &*state, - local_chain_id, - now, - sender, - classification_generation, - Self::prefetched_account_state(&config_reads, sender), - ); - let payer_status = if payer == sender { - sender_status - } else { - self.account_lock( + let (sender_status, payer_status) = if keystore { + let sender_status = self.account_lock( &*state, local_chain_id, now, - payer, + sender, classification_generation, - Self::prefetched_account_state(&config_reads, payer), - ) + Self::prefetched_account_state(&config_reads, sender), + ); + let payer_status = if payer == sender { + sender_status + } else { + self.account_lock( + &*state, + local_chain_id, + now, + payer, + classification_generation, + Self::prefetched_account_state(&config_reads, payer), + ) + }; + (sender_status, payer_status) + } else { + (LockStatus::UNLOCKED, LockStatus::UNLOCKED) }; // Only a pending unlock has a knowable timestamp; a hard lock reports // `UNLOCKS_AT_MAX`, which must never surface as a timed expiry-bucket (it @@ -1523,6 +1534,9 @@ where fn map_tx_auth_error(error: TxAuthError) -> InvalidPoolTransactionError { tracing::debug!(error = ?error, "EIP-8130 actor authorization failed"); let reason = match error { + TxAuthError::UnsupportedAccountChange | TxAuthError::UnsupportedAuthenticator => { + return InvalidTransactionError::TxTypeNotSupported.into(); + } TxAuthError::Authorize(AuthorizeError::Authenticate(_)) => { "actor authentication failed" } @@ -1550,6 +1564,9 @@ where TxAuthError::BadSequence { .. } => "config change sequence mismatch", TxAuthError::StaleEpoch { .. } => "config change local epoch is stale", TxAuthError::SequenceSaturated => "config change channel sequence is saturated", + TxAuthError::SignerMismatch => { + "secp256k1 signature does not recover to the named account" + } TxAuthError::Apply(apply) => Self::map_apply_error(apply), }; Self::eip8130_error(reason) @@ -1718,6 +1735,13 @@ where if !self.chain_spec().is_everest_active_at_timestamp(now) { return Err(InvalidTransactionError::TxTypeNotSupported.into()); } + // Without the Keystore an EIP-8130 transaction may use only delegation + // and native secp256k1 keys; anything else is an unsupported type. + if !BaseSpecId::from_timestamp(self.chain_spec(), now).is_enabled_in(BaseUpgrade::Zenith) + && TransactionAuthorizer::check_without_keystore(signed).is_err() + { + return Err(InvalidTransactionError::TxTypeNotSupported.into()); + } let local_chain_id = self.inner.chain_spec().chain().id(); signed.validate_static(local_chain_id).map_err(InvalidPoolTransactionError::from)?; // The validity window is evaluated in milliseconds against @@ -1818,19 +1842,30 @@ where } /// Returns `true` when an authenticator selector may be used directly on the - /// EIP-8130 transaction validation path. Only the native k1 authenticator - /// is accepted. + /// EIP-8130 transaction validation path: native k1 or a canonical Keystore + /// authenticator. Non-k1 selectors only reach this after the Zenith gate in + /// [`Self::validate_eip8130_structural`]. fn authenticator_allowed_for_tx_path(authenticator: &Address) -> bool { *authenticator == Eip8130Constants::K1_AUTHENTICATOR + || Eip8130Contracts::is_canonical_authenticator(authenticator) } - /// Performs the cheap k1 wire check that does not require running an - /// authenticator. Native k1 must carry exactly `r || s || v`. - /// - /// Callers reject every other selector in [`Self::authenticator_allowed_for_tx_path`] - /// before this runs, so a delegate-authenticator shape is not checked here. + /// Performs cheap selector-specific wire checks that do not require running + /// an authenticator. Native k1 must carry exactly `r || s || v`; delegated + /// auth must be depth-1 and name a canonical nested authenticator. fn authenticator_payload_well_formed(authenticator: &Address, data: &[u8]) -> bool { - *authenticator == Eip8130Constants::K1_AUTHENTICATOR && data.len() == 65 + if *authenticator == Eip8130Constants::K1_AUTHENTICATOR { + return data.len() == 65; + } + if *authenticator == Eip8130Contracts::DELEGATE_AUTHENTICATOR { + if data.len() < 40 { + return false; + } + let nested = Address::from_slice(&data[20..40]); + return nested != Eip8130Contracts::DELEGATE_AUTHENTICATOR + && Self::authenticator_allowed_for_tx_path(&nested); + } + true } /// Enforces the interim total-account-changes admission cap @@ -2207,6 +2242,20 @@ mod tests { Arc::new(BaseChainSpec::from_genesis(genesis)) } + /// An Everest chain spec with Zenith (and so the Keystore) also active at + /// genesis. + fn zenith_chain_spec() -> Arc { + let mut genesis = build_test_genesis_everest(); + genesis.config.chain_id = test_chain_id(); + genesis.config.extra_fields.insert( + "base".to_string(), + serde_json::json!({ + "azul": 0, "beryl": 0, "cobalt": 0, "denim": 0, "everest": 0, "zenith": 0 + }), + ); + Arc::new(BaseChainSpec::from_genesis(genesis)) + } + /// Builds a [`BaseTransactionValidator`] configured against the given chain spec with /// no accounts seeded. fn build_test_validator_with_spec(chain_spec: Arc) -> TestValidator { @@ -2248,7 +2297,15 @@ mod tests { address: Address, account: ExtendedAccount, ) -> TestValidator { - let chain_spec = everest_chain_spec(); + build_test_validator_with_account_and_spec(address, account, everest_chain_spec()) + } + + /// [`build_test_validator_with_account`] against the given chain spec. + fn build_test_validator_with_account_and_spec( + address: Address, + account: ExtendedAccount, + chain_spec: Arc, + ) -> TestValidator { let client = MockEthProvider::::new() .with_chain_spec(Arc::clone(&chain_spec)) .with_genesis_block(); @@ -2873,15 +2930,24 @@ mod tests { assert_unsupported(TestValidator::validate_payer_auth(&signed)); } - /// A configured sender naming a canonical non-k1 authenticator is rejected - /// at admission. + /// A configured sender naming a canonical non-k1 authenticator is an + /// unsupported transaction type before Zenith, and admissible from it. #[test] - fn rejects_eip8130_non_k1_sender_authenticator() { + fn non_k1_sender_authenticator_is_unsupported_before_zenith() { let tx = TxEip8130 { sender: Some(Address::repeat_byte(0xaa)), ..minimal_valid_eoa_tx() }; let mut auth = Eip8130Contracts::P256_AUTHENTICATOR.as_slice().to_vec(); auth.extend_from_slice(&[0u8; 64]); let signed = Eip8130Signed::new(tx, Bytes::from(auth), Bytes::new()); - assert_unsupported(TestValidator::validate_sender_auth(&signed)); + + assert_unsupported(build_test_validator().validate_eip8130_structural(&signed)); + let zenith = build_test_validator_with_spec(zenith_chain_spec()); + assert!(!matches!( + zenith.validate_eip8130_structural(&signed), + Err(InvalidPoolTransactionError::Consensus( + InvalidTransactionError::TxTypeNotSupported + )) + )); + assert!(TestValidator::validate_sender_auth(&signed).is_ok()); } /// Returns an authenticator address comfortably above the `K1_AUTHENTICATOR` @@ -3629,6 +3695,39 @@ mod tests { assert_eq!(state.manifest.payer_max_cost(), state.payer_max_cost); } + /// Before Zenith, admitting an EOA transaction reads no Keystore state, so + /// no `AccountConfiguration` slot is captured or watched; at Zenith the + /// same transaction depends on the account's Keystore state. + #[test] + fn eip8130_admission_reads_keystore_only_at_zenith() { + let signer = PrivateKeySigner::random(); + let sender = signer.address(); + let tx = TxEip8130 { gas_limit: 100_000, ..minimal_valid_eoa_tx() }; + let signature = signer.sign_hash_sync(&tx.sender_signature_hash()).unwrap(); + let signed = + Eip8130Signed::new(tx, Bytes::from(signature.as_bytes().to_vec()), Bytes::new()); + let funded = || ExtendedAccount::new(0, U256::from(1_000_000_000_000u64)); + let watches_keystore = |state: &Eip8130ValidationState| { + state.watch_set.iter().any(|key| { + matches!(key, InvalidationKey::Slot { address, .. } + if *address == AccountConfigurationStorage::ADDRESS) + }) + }; + + let everest = build_test_validator_with_account(sender, funded()) + .validate_eip8130_full(&signed) + .expect("EOA transaction is admitted before Zenith"); + assert!(everest.manifest.has_no_config_slots()); + assert!(!watches_keystore(&everest)); + + let zenith = + build_test_validator_with_account_and_spec(sender, funded(), zenith_chain_spec()) + .validate_eip8130_full(&signed) + .expect("EOA transaction is admitted at Zenith"); + assert!(!zenith.manifest.has_no_config_slots()); + assert!(watches_keystore(&zenith)); + } + /// A self-paying sender reserves its call value on top of gas, and is /// rejected when its balance cannot cover both. #[test] @@ -3866,9 +3965,10 @@ mod tests { // Fund the counterfactual address so the self-paid fee check passes; it // is still "fresh" (nonce 0, no code) for the create freshness gate. - let validator = build_test_validator_with_account( + let validator = build_test_validator_with_account_and_spec( derived, ExtendedAccount::new(0, U256::from(1_000_000_000_000_000_000u64)), + zenith_chain_spec(), ); let state = validator From bfe0156a8ca59a8e25a80755fce09f59fc4c3c9e Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 21:34:00 +0000 Subject: [PATCH 166/299] refactor(batcher): drop guards and derives nothing reaches (#5395) --- crates/batcher/core/Cargo.toml | 2 +- crates/batcher/core/src/submissions.rs | 37 +++---------------- crates/batcher/core/src/throttle.rs | 32 +--------------- crates/batcher/service/src/l2_block_parity.rs | 36 +++--------------- 4 files changed, 12 insertions(+), 95 deletions(-) diff --git a/crates/batcher/core/Cargo.toml b/crates/batcher/core/Cargo.toml index ab16c84b2e..8c5eb6340c 100644 --- a/crates/batcher/core/Cargo.toml +++ b/crates/batcher/core/Cargo.toml @@ -29,7 +29,7 @@ thiserror = { workspace = true, features = ["std"] } serde = { workspace = true, features = ["derive", "std"] } alloy-primitives = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["sync", "macros", "rt"] } -derive_more = { workspace = true, features = ["debug", "display", "from_str"] } +derive_more = { workspace = true, features = ["debug"] } # optional — activated by the `test-utils` feature async-trait = { workspace = true, optional = true } diff --git a/crates/batcher/core/src/submissions.rs b/crates/batcher/core/src/submissions.rs index 340d23afff..ec8c741839 100644 --- a/crates/batcher/core/src/submissions.rs +++ b/crates/batcher/core/src/submissions.rs @@ -4,8 +4,8 @@ use std::{future::Future, pin::Pin, sync::Arc}; use alloy_primitives::{Address, Bytes, U256}; use base_batcher_encoder::{ - BatchPipeline, BatcherMetrics, BlobPayload, DaEgress, DaType, EncoderConfig, FrameEncoder, - SubmissionId, SubmissionPayload, + BatchPipeline, BatcherMetrics, BlobPayload, DaEgress, DaType, FrameEncoder, SubmissionId, + SubmissionPayload, }; use base_blobs::{BlobEncodeError, BlobEncoder}; use base_protocol::Frame; @@ -26,14 +26,6 @@ pub struct BatchTxCandidateBuilder; /// Failure while building a batch transaction candidate. #[derive(Debug, thiserror::Error)] pub enum BatchTxCandidateError { - /// A blob transaction must contain a protocol-valid number of blobs. - #[error("blob transaction contains {count} blobs; expected 1..={maximum}")] - InvalidBlobCount { - /// Supplied blob count. - count: usize, - /// Protocol transaction maximum. - maximum: usize, - }, /// One packed payload could not be encoded as a blob. #[error(transparent)] BlobEncoding(#[from] BlobEncodeError), @@ -42,19 +34,15 @@ pub enum BatchTxCandidateError { impl BatchTxCandidateBuilder { /// Build a blob transaction candidate from packed frame payloads. /// + /// `payloads` holds one to `max_blobs_per_tx` payloads, as the encoder leases them, and + /// its validated config caps `max_blobs_per_tx` at the protocol maximum. + /// /// The returned byte count is the total derivation payload submitted across /// all blobs, including each blob's derivation-version prefix and frame metadata. pub fn blob_tx_candidate( inbox: Address, payloads: &[BlobPayload], ) -> Result<(TxCandidate, u64), BatchTxCandidateError> { - if payloads.is_empty() || payloads.len() > EncoderConfig::MAX_BLOBS_PER_TX { - return Err(BatchTxCandidateError::InvalidBlobCount { - count: payloads.len(), - maximum: EncoderConfig::MAX_BLOBS_PER_TX, - }); - } - let mut blobs = Vec::with_capacity(payloads.len()); let mut payload_size = 0usize; @@ -242,18 +230,3 @@ impl SubmissionQueue { self.in_flight.len() } } - -#[cfg(test)] -mod tests { - use alloy_primitives::Address; - - use super::*; - - #[test] - fn blob_candidate_rejects_empty_transaction() { - assert!(matches!( - BatchTxCandidateBuilder::blob_tx_candidate(Address::ZERO, &[]), - Err(BatchTxCandidateError::InvalidBlobCount { count: 0, .. }) - )); - } -} diff --git a/crates/batcher/core/src/throttle.rs b/crates/batcher/core/src/throttle.rs index 82d4ffd5a9..dc7f87833c 100644 --- a/crates/batcher/core/src/throttle.rs +++ b/crates/batcher/core/src/throttle.rs @@ -119,26 +119,14 @@ impl ThrottleParams { } /// Strategy for calculating throttle intensity from DA backlog. -#[derive( - Debug, - Clone, - PartialEq, - Eq, - derive_more::Display, - derive_more::FromStr, - serde::Serialize, - serde::Deserialize, -)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum ThrottleStrategy { /// No throttling. - #[display("off")] Off, /// Step function: either 0 or `max_intensity` when above threshold. - #[display("step")] Step, /// Linear interpolation between 0 and `max_intensity` based on backlog. - #[display("linear")] Linear, } @@ -431,22 +419,4 @@ mod tests { expected.map_err(String::from) ); } - - #[test] - fn strategy_display_parse_roundtrip() { - for (input, expected) in [ - (ThrottleStrategy::Off, "off"), - (ThrottleStrategy::Step, "step"), - (ThrottleStrategy::Linear, "linear"), - ] { - assert_eq!(input.to_string(), expected); - assert_eq!(expected.parse::().unwrap(), input); - } - } - - #[test] - fn strategy_parse_rejects_invalid() { - assert!("foo".parse::().is_err()); - assert!("".parse::().is_err()); - } } diff --git a/crates/batcher/service/src/l2_block_parity.rs b/crates/batcher/service/src/l2_block_parity.rs index 4e2bc2858a..b71e6b682a 100644 --- a/crates/batcher/service/src/l2_block_parity.rs +++ b/crates/batcher/service/src/l2_block_parity.rs @@ -16,7 +16,7 @@ use crate::L2BlockParityMetrics; type BaseRpcBlock = ::BlockResponse; /// Default maximum derived L2 blocks compared in one monitor pass. -pub const DEFAULT_MAX_BLOCKS_PER_TICK: usize = 25; +pub const DEFAULT_MAX_BLOCKS_PER_TICK: u64 = 25; /// Provider abstraction for derived L2 block parity checks. #[async_trait] @@ -129,8 +129,8 @@ pub struct L2BlockParityMonitorConfig { pub start_block: u64, /// Monitor polling interval. pub poll_interval: Duration, - /// Maximum number of blocks compared in one monitor pass. - pub max_blocks_per_tick: usize, + /// Maximum number of blocks compared in one monitor pass, greater than zero. + pub max_blocks_per_tick: u64, } impl L2BlockParityMonitorConfig { @@ -138,14 +138,6 @@ impl L2BlockParityMonitorConfig { pub const fn new(start_block: u64, poll_interval: Duration) -> Self { Self { start_block, poll_interval, max_blocks_per_tick: DEFAULT_MAX_BLOCKS_PER_TICK } } - - /// Returns the validated per-tick block cap. - pub fn validated_max_blocks_per_tick(&self) -> eyre::Result { - if self.max_blocks_per_tick == 0 { - eyre::bail!("max_blocks_per_tick must be greater than 0"); - } - Ok(self.max_blocks_per_tick as u64) - } } /// Result counts from one derived L2 block parity pass. @@ -308,8 +300,8 @@ where return Ok(L2BlockParityStats::default()); } - let max_blocks = self.config.validated_max_blocks_per_tick()?; - let last_block = common_unsafe.min(self.next_block.saturating_add(max_blocks - 1)); + let last_block = + common_unsafe.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 { @@ -598,22 +590,4 @@ mod tests { // not backlog. assert_eq!(monitor.verification_backlog(2), 0); } - - #[tokio::test] - async fn process_once_rejects_zero_max_blocks_per_tick() { - let sequencer = Arc::new(Mutex::new(MockL2BlockProvider::new(1, [snapshot(1, 1, &[10])]))); - let validator = Arc::new(Mutex::new(MockL2BlockProvider::new(1, [snapshot(1, 1, &[10])]))); - let config = L2BlockParityMonitorConfig { - max_blocks_per_tick: 0, - ..L2BlockParityMonitorConfig::new(1, Duration::from_secs(1)) - }; - let mut monitor = L2BlockParityMonitor::new(sequencer, validator, config); - - let err = monitor - .process_once() - .await - .expect_err("zero max_blocks_per_tick should fail validation"); - - assert!(err.to_string().contains("max_blocks_per_tick must be greater than 0")); - } } From ab4ac36d2c1145894830d5c634db6e8fd4e3a8a4 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Tue, 29 Sep 2026 21:34:02 +0000 Subject: [PATCH 167/299] docs(batcher): describe shadow mode as a check of the derived inbox and drop two stale doc claims (#5394) * docs(batcher): describe shadow mode as a check of the derived inbox and drop two stale doc claims * docs(batcher): say what a failed tx outcome is and reflow the shadow mode README --- crates/batcher/cli/README.md | 38 +++++++++++---------------- crates/batcher/cli/src/cli.rs | 9 +++---- crates/batcher/core/src/config.rs | 1 - crates/batcher/core/src/outcome.rs | 2 +- crates/batcher/service/src/config.rs | 15 ++++++----- crates/batcher/service/src/service.rs | 2 +- 6 files changed, 29 insertions(+), 38 deletions(-) diff --git a/crates/batcher/cli/README.md b/crates/batcher/cli/README.md index 3522ab1316..5a806118a0 100644 --- a/crates/batcher/cli/README.md +++ b/crates/batcher/cli/README.md @@ -25,31 +25,23 @@ The corresponding environment variables use the `BASE_BATCHER_` prefix. ## Shadow mode -`base batcher` normally reads `batch_inbox_address` from the rollup RPC's -`optimism_rollupConfig` response and submits DA transactions to that canonical -inbox. +`base batcher` reads `batch_inbox_address` from the rollup RPC's +`optimism_rollupConfig` response and posts DA transactions to that inbox. It +follows the derivation of the same node, so it always posts to the inbox that +node derives. Outside shadow mode, `base batcher` refuses to start unless its signer is the current `SystemConfig` batcher address, since derivation would ignore every batch it posts. -Shadow deployments may set `--shadow-mode` together with -`--dangerously-override-batch-inbox-address` to submit to a non-canonical inbox. -The flags must be set together so production deployments cannot redirect DA by -accident. Shadow deployments can use either the local `--private-key` signer or -the production remote-signer path with `--signer-endpoint` and -`--signer-address`. - -This override only changes where the batcher writes. It does not make a stock -`base-consensus` verifier derive those batches: derivation filters DA by both -`RollupConfig.batch_inbox_address` and the current `SystemConfig.batcher_address`. -A shadow verifier must therefore use accepted inbox and signer inputs that match -the shadow submissions. Do not add permanent production consensus bypass logic -just to support this rollout. - -Shadow deployments use an isolated parity validator to derive their submitted -data. `--rollup-rpc-url` points at the validator's rollup node, because the -batcher follows the derivation of the inbox it posts to. The batcher refuses to -start if that node derives another inbox. `--parity-validator-l2-rpc-url` points -at the validator's L2 RPC, whose derived blocks the batcher compares with the -canonical sequencer's. +A shadow deployment posts to a non-canonical inbox that an isolated parity +validator derives. `--rollup-rpc-url` points at the validator's rollup node, +whose rollup config names that inbox. `--shadow-mode` and +`--dangerously-override-batch-inbox-address` are set together and name the +shadow inbox, and the batcher refuses to start if the rollup node derives +another one. The parity validator must accept the shadow inbox and signer, +since derivation filters batches by both. `--parity-validator-l2-rpc-url` +points at the validator's L2 RPC, whose derived blocks the batcher compares with +the canonical sequencer's. Shadow deployments can use either the local +`--private-key` signer or the production remote-signer path with +`--signer-endpoint` and `--signer-address`. diff --git a/crates/batcher/cli/src/cli.rs b/crates/batcher/cli/src/cli.rs index e5b6f14504..aaef2928ac 100644 --- a/crates/batcher/cli/src/cli.rs +++ b/crates/batcher/cli/src/cli.rs @@ -71,15 +71,14 @@ pub struct BatcherArgs { #[command(flatten)] pub signer: SignerCli, - /// Enable explicit shadow-mode guardrails for dangerous overrides. + /// Run as a shadow batcher. /// - /// This flag does nothing by itself. It must be set together with - /// `--dangerously-override-batch-inbox-address` so canonical deployments - /// cannot accidentally redirect DA submissions. + /// Must be set together with `--dangerously-override-batch-inbox-address`, so a + /// canonical deployment cannot turn into a shadow one by setting a single flag. #[arg(long = "shadow-mode", env = "BASE_BATCHER_SHADOW_MODE")] pub shadow_mode: bool, - /// Dangerous shadow-mode batch inbox override. + /// The shadow inbox, which the `--rollup-rpc-url` node must derive. /// /// Requires `--shadow-mode`. Canonical deployments must not set this flag. #[arg( diff --git a/crates/batcher/core/src/config.rs b/crates/batcher/core/src/config.rs index c7def3c1e6..dc2642f175 100644 --- a/crates/batcher/core/src/config.rs +++ b/crates/batcher/core/src/config.rs @@ -19,7 +19,6 @@ pub struct BatchDriverConfig { /// congested with batcher data. /// /// No-op when the encoder is already configured for blob DA. - /// Default: `true`. pub force_blobs_when_throttling: bool, /// Start with block ingestion stopped, as if [`AdminCommand::Stop`] had been /// received at startup; [`AdminCommand::Start`] resumes it. diff --git a/crates/batcher/core/src/outcome.rs b/crates/batcher/core/src/outcome.rs index 480a744270..95f2b5c97b 100644 --- a/crates/batcher/core/src/outcome.rs +++ b/crates/batcher/core/src/outcome.rs @@ -8,6 +8,6 @@ pub enum TxOutcome { /// The L1 block number at which the transaction was included. l1_block: u64, }, - /// Transaction failed or timed out; frames should be requeued. + /// The transaction manager returned an error for the transaction, so its frames are requeued. Failed, } diff --git a/crates/batcher/service/src/config.rs b/crates/batcher/service/src/config.rs index 8c60d52b03..55312ff67b 100644 --- a/crates/batcher/service/src/config.rs +++ b/crates/batcher/service/src/config.rs @@ -11,9 +11,10 @@ use url::Url; /// Full batcher configuration combining RPC endpoints, identity, encoding /// parameters, submission limits, and optional throttling. /// -/// By default the batch inbox address is sourced from the rollup config fetched -/// at startup via `optimism_rollupConfig`. Shadow deployments may set -/// [`batch_inbox_override`](Self::batch_inbox_override) to submit to a non-canonical inbox. +/// The batch inbox is the one the [`rollup_rpc_url`](Self::rollup_rpc_url) node derives, read at +/// startup through `optimism_rollupConfig`. Shadow deployments point that node at a parity +/// validator deriving a non-canonical inbox and name that inbox in +/// [`batch_inbox_override`](Self::batch_inbox_override). #[derive(Debug, Clone)] pub struct BatcherConfig { /// L1 RPC endpoint(s). @@ -60,11 +61,11 @@ pub struct BatcherConfig { /// /// When enabled, the service starts the signer account balance monitor. pub metrics_enabled: bool, - /// Dangerous shadow-mode batch inbox override. + /// The shadow inbox, set only in shadow mode. /// - /// When set, the batcher submits L1 transactions to this address instead of the - /// canonical inbox. This is only intended for explicit shadow deployments. Canonical - /// deployments must leave it unset. + /// Setup refuses to start unless the [`rollup_rpc_url`](Self::rollup_rpc_url) node + /// derives this inbox, and skips the check that the signer is the `SystemConfig` + /// batcher. Canonical deployments must leave it unset. pub batch_inbox_override: Option
, /// L2 block polling interval. pub poll_interval: Duration, diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index f968548647..59cea6f876 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -478,7 +478,7 @@ impl BatcherService { let rollup_config = Arc::new(rollup_config); let batch_inbox = self.config.batch_inbox(rollup_config.batch_inbox_address)?; if self.config.batch_inbox_override.is_some() { - warn!(inbox = %batch_inbox, "using dangerous shadow batch inbox override"); + warn!(inbox = %batch_inbox, "shadow mode, posting to the shadow batch inbox"); } else { info!(inbox = %batch_inbox, "rollup config loaded"); } From 4127ff328b8dd1fbcd5b576a0a06f8ff6b2d0f59 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 29 Sep 2026 22:39:09 +0000 Subject: [PATCH 168/299] feat(eip8130): standard JSON-RPC shape for EIP-8130 transactions (#5386) * fix(eip8130): name account changes by type byte in JSON accountChanges entries serialize with "type": "0x1" (delegation), "0x2" (create), "0x3" (config change), matching the wire type byte as a JSON-RPC quantity instead of the Rust variant name; zero-padded forms also parse. * fix(eip8130): encode signed account changes with JSON-RPC quantities Channel and change type serialize as their wire bytes ("0x0", "0x1", ...), and the batch sequence and initial-actor scope as hex quantities, so a ConfigChange or Create in an eth_estimateGas request matches the wire encoding. Zero-padded discriminants also parse. * feat(eip8130): standard JSON-RPC shape for EIP-8130 transactions - Transactions serialize flat, like every other type, with hex quantities for every integer and the standard names nonce and gas (nonceSequence and gasLimit are still accepted). They also carry the standard single-call fields to: null, value: 0x0, input: 0x; the calls are in calls. - Receipts report to: null instead of the zero address. - An open payer is accepted as the zero address or a short all-zero hex such as 0x00, in transactions and estimate requests. - validAfter / validBefore on estimate requests are hex quantities. - Eip8130ReceiptFields always serializes phaseStatuses, matching the served receipt. - Estimate requests fail with a specific reason instead of one catch-all. * fix(eip8130): build the payer deserializer without std and forward thiserror/std * feat(eip8130): accept a top-level call on estimate requests, reject mixing it with calls A standard request's to / value / data is simulated as one call when calls is absent, instead of being silently dropped. Setting both is rejected as ambiguous, as is a value or data without a to, or a contract-creation to. * fix(eip8130): treat a default zero value and empty data as no top-level call Clients send value: 0x0 and data: 0x as defaults on every request, so those alone must not reject a calls request or demand a to. --- Cargo.lock | 1 + crates/common/consensus/src/lib.rs | 4 +- .../transaction/eip8130/account_changes.rs | 82 +++++++- .../consensus/src/transaction/eip8130/mod.rs | 5 + .../src/transaction/eip8130/payer_serde.rs | 78 ++++++++ .../src/transaction/eip8130/signed.rs | 36 +++- .../consensus/src/transaction/eip8130/tx.rs | 95 ++++++++-- .../common/consensus/src/transaction/mod.rs | 2 + crates/common/rpc-types/Cargo.toml | 2 + crates/common/rpc-types/src/eip8130.rs | 179 ++++++++++++++++-- crates/common/rpc-types/src/lib.rs | 2 +- crates/common/rpc-types/src/receipt.rs | 8 +- crates/common/rpc-types/src/reth.rs | 32 ++-- crates/common/rpc-types/src/transaction.rs | 20 +- .../rpc-types/src/transaction/request.rs | 22 ++- .../eip8130-rpc-node/tests/receipt.rs | 14 ++ crates/execution/eip8130-rpc/src/estimate.rs | 7 +- crates/execution/rpc/src/eth/receipt.rs | 5 + 18 files changed, 512 insertions(+), 82 deletions(-) create mode 100644 crates/common/consensus/src/transaction/eip8130/payer_serde.rs diff --git a/Cargo.lock b/Cargo.lock index 289a71fb36..544280255b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3772,6 +3772,7 @@ dependencies = [ "serde", "serde_json", "similar-asserts", + "thiserror 2.0.19", ] [[package]] diff --git a/crates/common/consensus/src/lib.rs b/crates/common/consensus/src/lib.rs index a6c08bb141..33b2e90dd4 100644 --- a/crates/common/consensus/src/lib.rs +++ b/crates/common/consensus/src/lib.rs @@ -25,8 +25,6 @@ pub use receipts::{ }; mod transaction; -#[cfg(feature = "serde")] -pub use transaction::serde_deposit_tx_rpc; pub use transaction::{ AccountChange, AccountChangeChannel, BasePooledTransaction, BaseTransaction, BaseTransactionInfo, BaseTxEnvelope, BaseTypedTransaction, Call, ChangeType, CoinbaseTip, @@ -35,6 +33,8 @@ pub use transaction::{ Eip8130StaticError, Eip8130TimestampError, IDefaultAccount, InitialActor, OpTxType, Scope, SignedAccountChanges, SignedChange, TxDeposit, TxEip8130, decode_2718_canonical, }; +#[cfg(feature = "serde")] +pub use transaction::{Eip8130PayerSerde, serde_deposit_tx_rpc}; mod extra; pub use extra::{EIP1559ParamEncoder, EIP1559ParamError, HoloceneExtraData, JovianExtraData}; diff --git a/crates/common/consensus/src/transaction/eip8130/account_changes.rs b/crates/common/consensus/src/transaction/eip8130/account_changes.rs index 7f57f85c71..980cd7b346 100644 --- a/crates/common/consensus/src/transaction/eip8130/account_changes.rs +++ b/crates/common/consensus/src/transaction/eip8130/account_changes.rs @@ -110,6 +110,7 @@ pub struct InitialActor { /// Address of the authenticator contract (e.g. an ERC-1271 authenticator). pub authenticator: Address, /// Scope bitfield (`uint16`; `0x0000` = unrestricted admin), stored verbatim. + #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] pub scope: u16, /// Policy data: empty (no policy attached) or exactly /// `manager (20) || commitment (32)`. Attachment is length-based and @@ -141,14 +142,19 @@ impl InitialActor { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum ChangeType { /// Authorize (upsert) an actor. Payload: `abi.encode(actorId, ActorConfig, policyData)`. + #[cfg_attr(feature = "serde", serde(rename = "0x0", alias = "0x00"))] AuthorizeActor, /// Revoke an actor. Payload: `abi.encode(actorId)`. + #[cfg_attr(feature = "serde", serde(rename = "0x1", alias = "0x01"))] RevokeActor, /// Increment the local epoch (either channel; empty payload). + #[cfg_attr(feature = "serde", serde(rename = "0x2", alias = "0x02"))] IncrementLocalEpoch, /// Lock the account (Local only; standalone; payload: `abi.encode(uint16 unlockDelay)`). + #[cfg_attr(feature = "serde", serde(rename = "0x3", alias = "0x03"))] Lock, /// Unlock the account (Local only; standalone; empty payload). + #[cfg_attr(feature = "serde", serde(rename = "0x4", alias = "0x04"))] Unlock, } @@ -189,8 +195,10 @@ impl ChangeType { pub enum AccountChangeChannel { /// Local channel: binds `block.chainid`; epoch + sequence + JIT mode. #[default] + #[cfg_attr(feature = "serde", serde(rename = "0x0", alias = "0x00"))] Local, /// Multichain channel: binds `chain_id == 0`; plain monotonic counter. + #[cfg_attr(feature = "serde", serde(rename = "0x1", alias = "0x01"))] Multichain, } @@ -323,6 +331,7 @@ pub struct SignedAccountChanges { /// `localEpoch(32, high) || localSequence(32, low)` (low half /// [`Eip8130Constants::UNSEQUENCED`] marks an unsequenced JIT batch); on /// Multichain it is a plain monotonic `u64` counter. + #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] pub sequence: u64, /// The ordered ops, applied all-or-nothing. pub changes: Vec, @@ -356,6 +365,11 @@ pub struct Delegation { /// `0x00` is reserved and does not decode. `Create` and `ConfigChange` are the /// Keystore's entries and are only valid once Zenith is active. /// +/// In JSON the entry is an object whose `type` field is the type byte as a hex +/// quantity (`"0x1"`; `"0x01"` is also accepted), alongside the body fields. +/// Every other discriminant (channel, change type) and integer is likewise a +/// hex quantity. +/// /// The type byte is a genuine list element (not an EIP-2718-style `type_byte || /// rlp(...)` prefix), so each entry is one self-contained RLP item and the /// surrounding `account_changes` list frames as one item per entry. This mirrors @@ -366,15 +380,18 @@ pub struct Delegation { #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "camelCase"))] +#[cfg_attr(feature = "serde", serde(tag = "type"))] pub enum AccountChange { /// Create a new account. + #[cfg_attr(feature = "serde", serde(rename = "0x2", alias = "0x02"))] Create(CreateEntry), /// Apply a signed batch of account changes (`applySignedAccountChanges`). + #[cfg_attr(feature = "serde", serde(rename = "0x3", alias = "0x03"))] ConfigChange(SignedAccountChanges), /// Set or clear an [EIP-7702]-style delegation. /// /// [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 + #[cfg_attr(feature = "serde", serde(rename = "0x1", alias = "0x01"))] Delegation(Delegation), } @@ -652,6 +669,69 @@ mod tests { assert!(AccountChange::decode(&mut &buf[..]).is_err()); } + /// JSON names each entry by its type byte, as a hex quantity. + #[cfg(feature = "serde")] + #[test] + fn account_change_json_type_is_the_type_byte() { + let target = Address::repeat_byte(0x11); + let delegation = AccountChange::Delegation(Delegation { target }); + let json = serde_json::to_value(&delegation).unwrap(); + assert_eq!(json, serde_json::json!({ "type": "0x1", "target": target })); + + for tag in ["0x1", "0x01"] { + let parsed: AccountChange = + serde_json::from_value(serde_json::json!({ "type": tag, "target": target })) + .unwrap(); + assert_eq!(parsed, delegation); + } + assert!( + serde_json::from_value::( + serde_json::json!({ "type": "delegation", "target": target }) + ) + .is_err() + ); + } + + /// A signed account-change batch and a create entry encode every + /// discriminant and integer as a hex quantity. + #[cfg(feature = "serde")] + #[test] + fn signed_account_changes_json_uses_quantities() { + let config = AccountChange::ConfigChange(SignedAccountChanges { + channel: AccountChangeChannel::Multichain, + sequence: 7, + changes: vec![SignedChange { + change_type: ChangeType::RevokeActor, + payload: Bytes::from_static(&[0xab]), + }], + signature: Bytes::from_static(&[0xcd]), + }); + let json = serde_json::json!({ + "type": "0x3", + "channel": "0x1", + "sequence": "0x7", + "changes": [{ "changeType": "0x1", "payload": "0xab" }], + "signature": "0xcd", + }); + assert_eq!(serde_json::to_value(&config).unwrap(), json); + assert_eq!(serde_json::from_value::(json).unwrap(), config); + + let create = AccountChange::Create(CreateEntry { + user_salt: B256::ZERO, + code: Bytes::from_static(&[0x00]), + initial_actors: vec![InitialActor { + actor_id: B256::ZERO, + authenticator: Address::ZERO, + scope: 0x0a, + policy_data: Bytes::new(), + }], + }); + let json = serde_json::to_value(&create).unwrap(); + assert_eq!(json["type"], "0x2"); + assert_eq!(json["initialActors"][0]["scope"], "0xa"); + assert_eq!(serde_json::from_value::(json).unwrap(), create); + } + #[test] fn account_change_entry_is_single_rlp_item() { // Each entry must be exactly one self-contained RLP list item: the type diff --git a/crates/common/consensus/src/transaction/eip8130/mod.rs b/crates/common/consensus/src/transaction/eip8130/mod.rs index 761ef60e0e..a67aaaa18c 100644 --- a/crates/common/consensus/src/transaction/eip8130/mod.rs +++ b/crates/common/consensus/src/transaction/eip8130/mod.rs @@ -25,6 +25,11 @@ pub use account_changes::{ mod tx; pub use tx::TxEip8130; +#[cfg(feature = "serde")] +mod payer_serde; +#[cfg(feature = "serde")] +pub use payer_serde::Eip8130PayerSerde; + mod coinbase_tip; pub use coinbase_tip::CoinbaseTip; diff --git a/crates/common/consensus/src/transaction/eip8130/payer_serde.rs b/crates/common/consensus/src/transaction/eip8130/payer_serde.rs new file mode 100644 index 0000000000..e8d145a142 --- /dev/null +++ b/crates/common/consensus/src/transaction/eip8130/payer_serde.rs @@ -0,0 +1,78 @@ +//! JSON form of an EIP-8130 `payer`. + +use alloc::string::String; + +use alloy_primitives::Address; +use serde::{Deserialize, Deserializer, de}; + +use super::Eip8130Constants; + +/// Deserializes an optional EIP-8130 `payer`. +/// +/// A payer is a 20-byte address. Open payer mode is the zero address, written +/// either in full or as a short all-zero hex string such as `"0x00"`, matching +/// its one-byte wire form. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Eip8130PayerSerde; + +impl Eip8130PayerSerde { + /// Deserializes `null` as no payer, a full address as that payer, and a + /// short all-zero hex string as [`Eip8130Constants::OPEN_PAYER`]. + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let Some(raw) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + let digits = raw + .strip_prefix("0x") + .or_else(|| raw.strip_prefix("0X")) + .ok_or_else(|| de::Error::custom("payer must be 0x-prefixed hex"))?; + if !digits.is_empty() + && digits.len() < 2 * Address::len_bytes() + && digits.bytes().all(|digit| digit == b'0') + { + return Ok(Some(Eip8130Constants::OPEN_PAYER)); + } + raw.parse::
().map(Some).map_err(de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::address; + use serde::Deserialize; + + use super::*; + + #[derive(Deserialize)] + struct Payer { + #[serde(default, deserialize_with = "Eip8130PayerSerde::deserialize")] + payer: Option
, + } + + fn parse(json: &str) -> Result, serde_json::Error> { + serde_json::from_str::(json).map(|parsed| parsed.payer) + } + + #[test] + fn open_payer_accepts_short_and_full_zero() { + for zero in ["0x00", "0x0", "0x0000000000000000000000000000000000000000"] { + assert_eq!( + parse(&format!(r#"{{"payer":"{zero}"}}"#)).unwrap(), + Some(Eip8130Constants::OPEN_PAYER) + ); + } + } + + #[test] + fn named_absent_and_malformed_payers() { + let named = address!("0x00000000000000000000000000000000000000b2"); + assert_eq!(parse(&format!(r#"{{"payer":"{named}"}}"#)).unwrap(), Some(named)); + assert_eq!(parse(r#"{"payer":null}"#).unwrap(), None); + assert_eq!(parse("{}").unwrap(), None); + assert!(parse(r#"{"payer":"0x01"}"#).is_err(), "a short non-zero payer is not an address"); + assert!(parse(r#"{"payer":"0x"}"#).is_err()); + } +} diff --git a/crates/common/consensus/src/transaction/eip8130/signed.rs b/crates/common/consensus/src/transaction/eip8130/signed.rs index cf1f79d541..999e85496a 100644 --- a/crates/common/consensus/src/transaction/eip8130/signed.rs +++ b/crates/common/consensus/src/transaction/eip8130/signed.rs @@ -60,15 +60,34 @@ pub struct Eip8130Signed { hash: B256, } +/// JSON is flat, like every other transaction type: the [`TxEip8130`] fields +/// sit at the top level beside `senderAuth` and `payerAuth`. Serialization also +/// emits the standard single-call fields generic tooling reads, as a +/// transaction with no single recipient: `to: null`, `value: "0x0"`, and +/// `input: "0x"`. The calls themselves are in `calls`. #[cfg(feature = "serde")] mod serde_impl { - use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + use alloy_primitives::{Address, U256}; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::{Bytes, Eip8130Signed, TxEip8130}; - #[derive(Serialize, Deserialize)] + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Eip8130SignedJson<'a> { + #[serde(flatten)] + tx: &'a TxEip8130, + sender_auth: &'a Bytes, + payer_auth: &'a Bytes, + to: Option
, + value: U256, + input: Bytes, + } + + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Eip8130SignedRepr { + #[serde(flatten)] tx: TxEip8130, sender_auth: Bytes, payer_auth: Bytes, @@ -79,10 +98,13 @@ mod serde_impl { where S: Serializer, { - Eip8130SignedRepr { - tx: self.tx.clone(), - sender_auth: self.sender_auth.clone(), - payer_auth: self.payer_auth.clone(), + Eip8130SignedJson { + tx: &self.tx, + sender_auth: &self.sender_auth, + payer_auth: &self.payer_auth, + to: None, + value: U256::ZERO, + input: Bytes::new(), } .serialize(serializer) } @@ -93,7 +115,7 @@ mod serde_impl { where D: Deserializer<'de>, { - let repr = Eip8130SignedRepr::deserialize(deserializer).map_err(de::Error::custom)?; + let repr = Eip8130SignedRepr::deserialize(deserializer)?; Ok(Self::new(repr.tx, repr.sender_auth, repr.payer_auth)) } } diff --git a/crates/common/consensus/src/transaction/eip8130/tx.rs b/crates/common/consensus/src/transaction/eip8130/tx.rs index 06ad6cda87..5db746d12e 100644 --- a/crates/common/consensus/src/transaction/eip8130/tx.rs +++ b/crates/common/consensus/src/transaction/eip8130/tx.rs @@ -46,18 +46,25 @@ use crate::transaction::eip8130::{ #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] pub struct TxEip8130 { /// EIP-155 chain ID this transaction is bound to. + #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] pub chain_id: ChainId, /// Explicit sender account address, or `None` for the EOA path. pub sender: Option
, /// High 192 bits of the compound nonce; with `nonce_sequence` forms the /// per-account replay protection key. pub nonce_key: U256, - /// Sequence number within the nonce key. + /// Sequence number within the nonce key. Named `nonce` in JSON, like every + /// other transaction type; `nonceSequence` is also accepted. + #[cfg_attr( + feature = "serde", + serde(rename = "nonce", alias = "nonceSequence", with = "alloy_serde::quantity") + )] pub nonce_sequence: u64, /// Lower bound of the validity window: a Unix timestamp in **seconds or /// milliseconds** (auto-detected per EIP-8130 Timestamp Normalization; see /// [`Self::valid_after_ms`]). After normalization the transaction is invalid /// when `block.timestamp * 1000 < valid_after_ms`; `0` means no lower bound. + #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] pub valid_after: u64, /// Upper bound of the validity window: a Unix timestamp in **seconds or /// milliseconds** (auto-detected; see [`Self::valid_before_ms`]). After @@ -69,6 +76,7 @@ pub struct TxEip8130 { /// nonce is recorded: the nonce-manager replay ring's admission window is /// `(now, now + NONCE_FREE_MAX_EXPIRY_WINDOW]`, so a nonce-free transaction /// cannot actually be included exactly at `valid_before`. + #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] pub valid_before: u64, /// Max priority fee per gas (tip) the sender is willing to pay. #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] @@ -76,7 +84,12 @@ pub struct TxEip8130 { /// Max total fee per gas (base + tip cap) the sender is willing to pay. #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))] pub max_fee_per_gas: u128, - /// Gas limit for the entire AA transaction execution. + /// Gas limit for the entire AA transaction execution. Named `gas` in JSON, + /// like every other transaction type; `gasLimit` is also accepted. + #[cfg_attr( + feature = "serde", + serde(rename = "gas", alias = "gasLimit", with = "alloy_serde::quantity") + )] pub gas_limit: u64, /// Account-mutation entries applied before calls execute. pub account_changes: Vec, @@ -90,6 +103,10 @@ pub struct TxEip8130 { /// Optional payer; `None` means the resolved sender pays gas, and /// [`Eip8130Constants::OPEN_PAYER`] means the payer is recovered from /// `payer_auth`. + #[cfg_attr( + feature = "serde", + serde(default, deserialize_with = "super::Eip8130PayerSerde::deserialize") + )] pub payer: Option
, } @@ -1134,21 +1151,19 @@ mod tests { let json = serde_json::json!({ "type": "0x79", - "tx": { - "chainId": 1, - "sender": null, - "nonceKey": "0x0", - "nonceSequence": 1, - "validAfter": 0, - "validBefore": 0, - "maxPriorityFeePerGas": "0x3b9aca00", - "maxFeePerGas": "0x3b9aca00", - "gasLimit": 21000, - "accountChanges": [], - "calls": [], - "metadata": "0x", - "payer": null - }, + "chainId": "0x1", + "sender": null, + "nonceKey": "0x0", + "nonce": "0x1", + "validAfter": "0x0", + "validBefore": "0x0", + "maxPriorityFeePerGas": "0x3b9aca00", + "maxFeePerGas": "0x3b9aca00", + "gas": "0x5208", + "accountChanges": [], + "calls": [], + "metadata": "0x", + "payer": null, "senderAuth": "0x", "payerAuth": "0x" }); @@ -1164,6 +1179,52 @@ mod tests { assert!(matches!(envelope, BaseTxEnvelope::Eip8130(_))); } + /// The JSON is flat and uses hex quantities for every integer, with the + /// standard `nonce` / `gas` names and single-call fields generic tooling + /// reads. The EIP field names `nonceSequence` / `gasLimit` and a short + /// `"0x00"` open payer are also accepted. + #[cfg(feature = "serde")] + #[test] + fn eip8130_json_is_a_standard_transaction_shape() { + use crate::{BaseTxEnvelope, Eip8130Signed}; + + let tx = TxEip8130 { + chain_id: 8453, + nonce_sequence: 7, + valid_before: 1_700_000_000, + gas_limit: 21_000, + payer: Some(Eip8130Constants::OPEN_PAYER), + ..Default::default() + }; + let envelope = BaseTxEnvelope::Eip8130(Eip8130Signed::new( + tx, + Bytes::from_static(&[0xab]), + Bytes::from_static(&[0xcd]), + )); + let json = serde_json::to_value(&envelope).unwrap(); + assert_eq!(json["type"], "0x79"); + assert_eq!(json["chainId"], "0x2105"); + assert_eq!(json["nonce"], "0x7"); + assert_eq!(json["gas"], "0x5208"); + assert_eq!(json["validAfter"], "0x0"); + assert_eq!(json["validBefore"], "0x6553f100"); + assert_eq!(json["to"], serde_json::Value::Null); + assert_eq!(json["value"], "0x0"); + assert_eq!(json["input"], "0x"); + assert_eq!(json["senderAuth"], "0xab"); + assert!(json.get("tx").is_none(), "the transaction fields are not nested"); + assert_eq!(serde_json::from_value::(json.clone()).unwrap(), envelope); + + let mut eip_names = json; + let fields = eip_names.as_object_mut().unwrap(); + let nonce = fields.remove("nonce").unwrap(); + let gas = fields.remove("gas").unwrap(); + fields.insert("nonceSequence".into(), nonce); + fields.insert("gasLimit".into(), gas); + fields.insert("payer".into(), "0x00".into()); + assert_eq!(serde_json::from_value::(eip_names).unwrap(), envelope); + } + #[test] fn size_counts_empty_call_phases() { let empty = TxEip8130 { calls: Vec::new(), ..Default::default() }; diff --git a/crates/common/consensus/src/transaction/mod.rs b/crates/common/consensus/src/transaction/mod.rs index a57bc3102e..f51168be33 100644 --- a/crates/common/consensus/src/transaction/mod.rs +++ b/crates/common/consensus/src/transaction/mod.rs @@ -7,6 +7,8 @@ mod deposit; pub use deposit::{DepositTransaction, TxDeposit}; mod eip8130; +#[cfg(feature = "serde")] +pub use eip8130::Eip8130PayerSerde; pub use eip8130::{ AccountChange, AccountChangeChannel, Call, ChangeType, CoinbaseTip, CreateEntry, Delegation, Eip8130Constants, Eip8130Contracts, Eip8130Signed, Eip8130StaticError, Eip8130TimestampError, diff --git a/crates/common/rpc-types/Cargo.toml b/crates/common/rpc-types/Cargo.toml index 0575e53e15..4cd3605112 100644 --- a/crates/common/rpc-types/Cargo.toml +++ b/crates/common/rpc-types/Cargo.toml @@ -47,6 +47,7 @@ alloy-signer = { workspace = true, optional = true } arbitrary = { workspace = true, features = ["derive"], optional = true } # misc +thiserror.workspace = true derive_more = { workspace = true, features = ["as_ref", "deref_mut", "try_from"] } [dev-dependencies] @@ -74,6 +75,7 @@ std = [ "revm?/std", "serde/std", "serde_json/std", + "thiserror/std", ] arbitrary = [ "alloy-consensus/arbitrary", diff --git a/crates/common/rpc-types/src/eip8130.rs b/crates/common/rpc-types/src/eip8130.rs index 3da9f71c71..61534a1100 100644 --- a/crates/common/rpc-types/src/eip8130.rs +++ b/crates/common/rpc-types/src/eip8130.rs @@ -1,12 +1,12 @@ //! Reth-free EIP-8130 RPC request conversion. -use alloc::vec; +use alloc::{vec, vec::Vec}; use alloy_evm::FromRecoveredTx; -use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; use alloy_rpc_types_eth::state::StateOverride; use base_common_consensus::{ - BaseTxEnvelope, Eip8130Constants, Eip8130Contracts, Eip8130Signed, TxEip8130, + BaseTxEnvelope, Call, Eip8130Constants, Eip8130Contracts, Eip8130Signed, TxEip8130, }; use base_common_evm::{BaseTransaction as BaseRevm, Eip8130ExecutionMode}; use revm::context::TxEnv; @@ -19,6 +19,39 @@ pub(crate) const STUB_AUTH_FILL: u8 = 0xff; /// Length of the authenticator selector on a prefixed authentication blob. const AUTHENTICATOR_SELECTOR_LEN: usize = 20; +/// Why an EIP-8130 `eth_estimateGas` / `eth_call` request cannot be simulated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Eip8130SimulationRequestError { + /// The request carries none of the EIP-8130 fields. + #[error("request carries no EIP-8130 fields")] + NotEip8130, + /// Neither `sender` nor `from` is set. + #[error("no sender account: set `sender` or `from`")] + MissingSender, + /// `sender` and `from` are both set and differ. + #[error("`sender` and `from` differ")] + SenderFromMismatch, + /// The `senderAuth` data is larger than an authentication blob may be. + #[error("`senderAuth` data exceeds the maximum authentication size")] + SenderAuthTooLarge, + /// The `payerAuth` data is larger than an authentication blob may be. + #[error("`payerAuth` data exceeds the maximum authentication size")] + PayerAuthTooLarge, + /// A named payer's `payerAuth` does not start with a recognized + /// authenticator selector. + #[error("`payerAuth` for a named payer must start with a recognized authenticator")] + UnrecognizedPayerAuthenticator, + /// The request sets both `calls` and a top-level `to` / `value` / `data`. + #[error("set either `calls` or a top-level `to`/`value`/`data`, not both")] + CallsAndSingleCall, + /// A top-level `value` or `data` is set without a `to`. + #[error("a top-level `value` or `data` needs a `to`")] + SingleCallMissingTo, + /// The top-level `to` is a contract creation, which EIP-8130 calls cannot do. + #[error("an EIP-8130 call cannot create a contract")] + ContractCreation, +} + /// Maximum caller-supplied authentication payload length. pub(crate) const MAX_AUTH_SIZE: u32 = 8_192; @@ -66,30 +99,32 @@ impl Eip8130Nonce { impl BaseTransactionRequest { /// Builds an unsigned EIP-8130 simulation transaction. /// - /// Returns `None` when the request carries no EIP-8130 fields, does not resolve a sender, - /// contains conflicting `sender` and `from` values, or supplies an invalid authentication - /// blob. The returned transaction uses [`Eip8130ExecutionMode::Simulate`] so callers can run + /// Returns an [`Eip8130SimulationRequestError`] naming why the request cannot be simulated. + /// The returned transaction uses [`Eip8130ExecutionMode::Simulate`] so callers can run /// `eth_call` and `eth_estimateGas` without signature verification or committed state. pub fn to_eip8130_simulation_tx( &self, chain_id: u64, gas_limit_cap: u64, - ) -> Option> { - let aa = self.as_eip8130()?; + ) -> Result, Eip8130SimulationRequestError> { + let aa = self.as_eip8130().ok_or(Eip8130SimulationRequestError::NotEip8130)?; let req = self.as_ref(); let account = match (aa.sender, req.from) { - (Some(sender), Some(from)) if sender != from => return None, + (Some(sender), Some(from)) if sender != from => { + return Err(Eip8130SimulationRequestError::SenderFromMismatch); + } (Some(sender), _) => sender, (None, Some(from)) => from, - (None, None) => return None, + (None, None) => return Err(Eip8130SimulationRequestError::MissingSender), }; let sender_declared = aa.sender.is_some(); let (sender, sender_auth) = match &aa.sender_auth { Some(blob) => { let prefixed = Self::is_prefixed_auth(blob); - Self::check_auth_len(blob, prefixed)?; + Self::check_auth_len(blob, prefixed) + .ok_or(Eip8130SimulationRequestError::SenderAuthTooLarge)?; (prefixed.then_some(account), blob.clone()) } None if sender_declared => ( @@ -111,7 +146,8 @@ impl BaseTransactionRequest { // address or the sender. let blob = match &aa.payer_auth { Some(blob) => { - Self::check_auth_len(blob, false)?; + Self::check_auth_len(blob, false) + .ok_or(Eip8130SimulationRequestError::PayerAuthTooLarge)?; blob.clone() } None => Self::default_bare_auth(), @@ -122,9 +158,12 @@ impl BaseTransactionRequest { let blob = match &aa.payer_auth { Some(blob) => { if !Self::is_prefixed_auth(blob) { - return None; + return Err( + Eip8130SimulationRequestError::UnrecognizedPayerAuthenticator, + ); } - Self::check_auth_len(blob, true)?; + Self::check_auth_len(blob, true) + .ok_or(Eip8130SimulationRequestError::PayerAuthTooLarge)?; blob.clone() } None => Self::stub_prefixed_auth( @@ -147,7 +186,7 @@ impl BaseTransactionRequest { max_fee_per_gas: req.max_fee_per_gas.unwrap_or_default(), gas_limit: req.gas.unwrap_or(gas_limit_cap), account_changes: aa.account_changes.clone().unwrap_or_default(), - calls: aa.calls.clone().unwrap_or_default(), + calls: self.eip8130_calls(aa.calls.as_ref())?, metadata: aa.metadata.clone().unwrap_or_default(), payer, }; @@ -158,7 +197,38 @@ impl BaseTransactionRequest { parts.mode = Eip8130ExecutionMode::Simulate; parts.simulation_sender_actor_id = aa.sender_actor_id; } - Some(simulation) + Ok(simulation) + } + + /// The request's calls: `calls`, or a single call from the top-level `to` / + /// `value` / `data` of a standard request. Setting both is ambiguous and + /// rejected. A zero `value` and empty `data` count as unset, since clients + /// commonly send them as defaults on every request. + fn eip8130_calls( + &self, + calls: Option<&Vec>>, + ) -> Result>, Eip8130SimulationRequestError> { + let req = self.as_ref(); + let value = req.value.filter(|value| !value.is_zero()); + let data = req.input.input().filter(|data| !data.is_empty()); + let single_call = req.to.is_some() || value.is_some() || data.is_some(); + if let Some(calls) = calls { + if single_call { + return Err(Eip8130SimulationRequestError::CallsAndSingleCall); + } + return Ok(calls.clone()); + } + if !single_call { + return Ok(Vec::new()); + } + let to = match req.to { + Some(TxKind::Call(to)) => to, + Some(TxKind::Create) => return Err(Eip8130SimulationRequestError::ContractCreation), + None => return Err(Eip8130SimulationRequestError::SingleCallMissingTo), + }; + let call = + Call { to, value: value.unwrap_or_default(), data: data.cloned().unwrap_or_default() }; + Ok(vec![vec![call]]) } fn default_bare_auth() -> Bytes { @@ -246,14 +316,82 @@ mod tests { "calls": [] })) .unwrap(); - assert!(request.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none()); + assert_eq!( + request.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::SenderFromMismatch) + ); + } + + /// A standard request's top-level `to` / `value` / `data` is one call. + #[test] + fn top_level_call_fields_become_one_call() { + let to = address!("0x00000000000000000000000000000000000000c1"); + let tx = simulation(json!({ + "sender": SENDER, + "to": to, + "value": "0x5", + "data": "0xabcd", + })); + assert_eq!( + signed(&tx).tx().calls, + vec![vec![Call { to, value: U256::from(5), data: Bytes::from_static(&[0xab, 0xcd]) }]] + ); + assert!( + simulation(json!({ "sender": SENDER })).eip8130.unwrap().signed.tx().calls.is_empty() + ); + } + + /// `calls` and a top-level call are ambiguous together, and a top-level + /// call needs a recipient that is not a contract creation. + #[test] + fn top_level_call_fields_are_validated() { + let to = address!("0x00000000000000000000000000000000000000c1"); + let reject = |request: serde_json::Value| { + serde_json::from_value::(request) + .unwrap() + .to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP) + .err() + }; + assert_eq!( + reject(json!({ "sender": SENDER, "calls": [], "to": to })), + Some(Eip8130SimulationRequestError::CallsAndSingleCall) + ); + assert_eq!( + reject(json!({ "sender": SENDER, "calls": [], "data": "0x01" })), + Some(Eip8130SimulationRequestError::CallsAndSingleCall) + ); + assert_eq!( + reject(json!({ "sender": SENDER, "data": "0x01" })), + Some(Eip8130SimulationRequestError::SingleCallMissingTo) + ); + // Default `value: "0x0"` / `data: "0x"` are not a single call. + let calls = vec![vec![Call { to, value: U256::ZERO, data: Bytes::new() }]]; + let tx = simulation(json!({ + "sender": SENDER, + "calls": calls, + "value": "0x0", + "data": "0x", + })); + assert_eq!(signed(&tx).tx().calls, calls); + let tx = simulation(json!({ "sender": SENDER, "value": "0x0", "data": "0x" })); + assert!(signed(&tx).tx().calls.is_empty()); + let mut create = + serde_json::from_value::(json!({ "sender": SENDER })).unwrap(); + create.as_mut().to = Some(TxKind::Create); + assert_eq!( + create.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::ContractCreation) + ); } #[test] fn missing_sender_is_rejected() { let request = serde_json::from_value::(json!({ "calls": [] })).unwrap(); - assert!(request.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none()); + assert_eq!( + request.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::MissingSender) + ); } #[test] @@ -274,7 +412,10 @@ mod tests { "senderAuth": auth })) .unwrap(); - assert!(request.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none()); + assert_eq!( + request.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::SenderAuthTooLarge) + ); } #[test] diff --git a/crates/common/rpc-types/src/lib.rs b/crates/common/rpc-types/src/lib.rs index eba4faaac6..c044a562e0 100644 --- a/crates/common/rpc-types/src/lib.rs +++ b/crates/common/rpc-types/src/lib.rs @@ -27,7 +27,7 @@ pub use receipt::{ #[cfg(feature = "eip8130")] mod eip8130; #[cfg(feature = "eip8130")] -pub use eip8130::{EIP8130_PRE_EVEREST_RPC_ERROR, Eip8130Nonce}; +pub use eip8130::{EIP8130_PRE_EVEREST_RPC_ERROR, Eip8130Nonce, Eip8130SimulationRequestError}; mod transaction; pub use transaction::{ diff --git a/crates/common/rpc-types/src/receipt.rs b/crates/common/rpc-types/src/receipt.rs index 4d6f2f7263..67e785a423 100644 --- a/crates/common/rpc-types/src/receipt.rs +++ b/crates/common/rpc-types/src/receipt.rs @@ -54,8 +54,9 @@ pub struct Eip8130ReceiptFields { /// Resolved gas payer. #[serde(default, skip_serializing_if = "Option::is_none")] pub payer: Option
, - /// Per-phase execution statuses. - #[serde(default, skip_serializing_if = "Vec::is_empty", with = "alloy_serde::quantity::vec")] + /// Per-phase execution statuses. Always serialized, as `[]` when `calls` + /// was empty, matching [`BaseTransactionReceipt`]. + #[serde(default, with = "alloy_serde::quantity::vec")] pub phase_statuses: Vec, /// Opaque transaction metadata. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -507,5 +508,8 @@ mod tests { let other = OtherFields::try_from(fields).unwrap(); assert_eq!(other.get("phaseStatuses"), Some(&json!(["0x1", "0x0"]))); + + let empty_calls = Eip8130ReceiptFields { payer: Some(payer), ..Default::default() }; + assert_eq!(serde_json::to_value(&empty_calls).unwrap()["phaseStatuses"], json!([])); } } diff --git a/crates/common/rpc-types/src/reth.rs b/crates/common/rpc-types/src/reth.rs index 283c3655c3..64cc74d96b 100644 --- a/crates/common/rpc-types/src/reth.rs +++ b/crates/common/rpc-types/src/reth.rs @@ -105,7 +105,7 @@ mod tests { use super::*; use crate::{ - Eip8130AuthScheme, + Eip8130AuthScheme, Eip8130SimulationRequestError, eip8130::{MAX_AUTH_SIZE, STUB_AUTH_FILL}, }; @@ -312,8 +312,9 @@ mod tests { "calls": [], })) .expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + assert_eq!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::SenderFromMismatch), "a `from`/`sender` mismatch is rejected rather than guessing the account", ); } @@ -338,8 +339,9 @@ mod tests { // rather than defaulting the account to the zero address. let req: BaseTransactionRequest = serde_json::from_value(json!({ "calls": [] })).expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + assert_eq!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::MissingSender), "an 8130 request with no account is rejected", ); } @@ -389,8 +391,9 @@ mod tests { "senderAuth": blob(Some(Eip8130Constants::K1_AUTHENTICATOR), MAX_AUTH_SIZE as usize + 1), })) .expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + assert_eq!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::SenderAuthTooLarge), "an over-cap sender auth blob is rejected rather than priced", ); } @@ -405,7 +408,10 @@ mod tests { "senderAuth": blob(None, MAX_AUTH_SIZE as usize + 1), })) .expect("valid request"); - assert!(req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none()); + assert_eq!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::SenderAuthTooLarge) + ); } #[test] @@ -418,8 +424,9 @@ mod tests { "payerAuth": blob(Some(Eip8130Constants::K1_AUTHENTICATOR), MAX_AUTH_SIZE as usize + 1), })) .expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + assert_eq!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::PayerAuthTooLarge), "an over-cap payer auth blob is rejected rather than priced", ); } @@ -496,8 +503,9 @@ mod tests { "payerAuth": blob(Some(unrecognized), 65), })) .expect("valid request"); - assert!( - req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).is_none(), + assert_eq!( + req.to_eip8130_simulation_tx(CHAIN_ID, GAS_CAP).err(), + Some(Eip8130SimulationRequestError::UnrecognizedPayerAuthenticator), "an unrecognized payer authenticator selector is rejected rather than priced", ); } diff --git a/crates/common/rpc-types/src/transaction.rs b/crates/common/rpc-types/src/transaction.rs index faccd14754..e7f10ab878 100644 --- a/crates/common/rpc-types/src/transaction.rs +++ b/crates/common/rpc-types/src/transaction.rs @@ -462,15 +462,17 @@ mod tests { assert_eq!(value["blockTimestampMs"], "0x18bcfe568c8"); assert_eq!(value["transactionIndex"], "0x0"); - let tx_payload = &value["tx"]; - assert!(tx_payload.is_object(), "EIP-8130 inner tx payload present"); - assert_eq!(tx_payload["chainId"], 8453); - assert_eq!(tx_payload["nonceKey"], "0x0"); - assert_eq!(tx_payload["nonceSequence"], 7); - assert_eq!(tx_payload["gasLimit"], 1_000_000); - assert!(tx_payload["accountChanges"].is_array()); - assert!(tx_payload["calls"].is_array()); - assert_eq!(tx_payload["sender"], "0x0000000000000000000000000000000000000011"); + assert!(value.get("tx").is_none(), "EIP-8130 fields are flat, like any transaction"); + assert_eq!(value["chainId"], "0x2105"); + assert_eq!(value["nonceKey"], "0x0"); + assert_eq!(value["nonce"], "0x7"); + assert_eq!(value["gas"], "0xf4240"); + assert_eq!(value["to"], serde_json::Value::Null); + assert_eq!(value["value"], "0x0"); + assert_eq!(value["input"], "0x"); + assert!(value["accountChanges"].is_array()); + assert!(value["calls"].is_array()); + assert_eq!(value["sender"], "0x0000000000000000000000000000000000000011"); assert_eq!(value["senderAuth"], format!("0x{}", "ab".repeat(32))); assert_eq!(value["payerAuth"], "0x"); diff --git a/crates/common/rpc-types/src/transaction/request.rs b/crates/common/rpc-types/src/transaction/request.rs index 9ab236a585..53f4010752 100644 --- a/crates/common/rpc-types/src/transaction/request.rs +++ b/crates/common/rpc-types/src/transaction/request.rs @@ -11,7 +11,7 @@ use alloy_primitives::{Address, B256, Bytes, ChainId, Signature, TxKind, U256}; use alloy_rpc_types_eth::{AccessList, TransactionInput, TransactionRequest}; use base_common_consensus::{ AccountChange, BaseTxEnvelope, BaseTypedTransaction, Call, Eip8130Constants, Eip8130Contracts, - TxDeposit, + Eip8130PayerSerde, TxDeposit, }; use serde::{Deserialize, Serialize}; @@ -100,18 +100,22 @@ pub struct Eip8130RequestFields { #[serde(default, skip_serializing_if = "Option::is_none")] pub account_changes: Option>, /// The phased call batches dispatched by the sender account. + /// + /// Alternatively, a single call can be given as the standard top-level + /// `to` / `value` / `data`. Setting both `calls` and any of those is + /// rejected as ambiguous. #[serde(default, skip_serializing_if = "Option::is_none")] pub calls: Option>>, /// Optional lower bound of the validity window, in Unix seconds or /// milliseconds (the unit is detected from the magnitude; `0` or absent /// means no lower bound). Checked as `block.timestamp * 1000 >=` the /// bound in milliseconds. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none", with = "alloy_serde::quantity::opt")] pub valid_after: Option, /// Optional upper bound of the validity window, in Unix seconds or /// milliseconds (the unit is detected from the magnitude; `0` or absent /// means no expiry). Required (non-zero) for nonce-free transactions. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none", with = "alloy_serde::quantity::opt")] pub valid_before: Option, /// Opaque, non-executed transaction metadata. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -149,10 +153,14 @@ pub struct Eip8130RequestFields { /// filler-byte stub of the right length); you need not sign first. #[serde(default, skip_serializing_if = "Option::is_none")] pub sender_auth: Option, - /// Sponsoring payer account, or the zero address for open payer mode. When - /// set, the estimate includes payer authentication gas (metered on top of - /// the gas limit, as in execution). - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Sponsoring payer account, or the zero address (also accepted as `"0x00"`) + /// for open payer mode. When set, the estimate includes payer + /// authentication gas (metered on top of the gas limit, as in execution). + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "Eip8130PayerSerde::deserialize" + )] pub payer: Option
, /// Raw payer authentication blob whose shape is priced when a `payer` is /// declared. diff --git a/crates/execution/eip8130-rpc-node/tests/receipt.rs b/crates/execution/eip8130-rpc-node/tests/receipt.rs index 976c182506..c0d7c8a0c6 100644 --- a/crates/execution/eip8130-rpc-node/tests/receipt.rs +++ b/crates/execution/eip8130-rpc-node/tests/receipt.rs @@ -100,6 +100,20 @@ async fn eip8130_transaction_is_mined_and_has_a_receipt() -> eyre::Result<()> { json.get("metadata").is_none_or(serde_json::Value::is_null), "empty metadata must be omitted, not serialized as \"0x\"" ); + assert_eq!(json["to"], serde_json::Value::Null, "an EIP-8130 receipt has no single recipient"); + + // The transaction response is shaped like any other transaction: flat, with + // hex quantities and the standard single-call fields. + let tx_json: serde_json::Value = client.request("eth_getTransactionByHash", (tx_hash,)).await?; + assert!(tx_json.get("tx").is_none(), "the transaction fields are not nested"); + assert_eq!(tx_json["type"], "0x79"); + assert_eq!(tx_json["nonce"], "0x0"); + assert!(tx_json["gas"].as_str().is_some_and(|gas| gas.starts_with("0x"))); + assert!(tx_json["chainId"].as_str().is_some_and(|id| id.starts_with("0x"))); + assert_eq!(tx_json["to"], serde_json::Value::Null); + assert_eq!(tx_json["value"], "0x0"); + assert_eq!(tx_json["input"], "0x"); + assert_eq!(tx_json["from"], serde_json::to_value(alice.address())?); Ok(()) } diff --git a/crates/execution/eip8130-rpc/src/estimate.rs b/crates/execution/eip8130-rpc/src/estimate.rs index 010a6572c5..5b2446dc91 100644 --- a/crates/execution/eip8130-rpc/src/estimate.rs +++ b/crates/execution/eip8130-rpc/src/estimate.rs @@ -91,13 +91,10 @@ impl Eip8130GasEstimator { // Bound execution by the block gas limit when the request omits `gas`. let gas_cap = Block::gas_limit(&evm_env.block_env); - let sim_tx = request.to_eip8130_simulation_tx(chain_id, gas_cap).ok_or_else(|| { + let sim_tx = request.to_eip8130_simulation_tx(chain_id, gas_cap).map_err(|error| { ErrorObjectOwned::owned( INVALID_PARAMS_CODE, - "invalid EIP-8130 estimate request: missing EIP-8130 fields, no sender account \ - (neither `sender` nor `from`), a `sender`/`from` mismatch, a \ - `sender_auth`/`payer_auth` blob whose data exceeds the maximum size, or a \ - `payer_auth` with an unrecognized authenticator selector", + format!("invalid EIP-8130 estimate request: {error}"), None::<()>, ) })?; diff --git a/crates/execution/rpc/src/eth/receipt.rs b/crates/execution/rpc/src/eth/receipt.rs index a8e3acad3a..9cb6532b0f 100644 --- a/crates/execution/rpc/src/eth/receipt.rs +++ b/crates/execution/rpc/src/eth/receipt.rs @@ -385,6 +385,11 @@ impl BaseReceiptBuilder { }; mapped_receipt.into_with_bloom() }); + // An EIP-8130 transaction has no single recipient; its calls are in + // `calls`. Report `to: null`, not the zero address its `kind` carries. + if tx_signed.as_eip8130().is_some() { + core_receipt.to = None; + } // In jovian, we're using the blob gas used field to store the current da // footprint's value. From 2b4216be374fbd4995d8263b7e2f1234400c763e Mon Sep 17 00:00:00 2001 From: Niran Babalola Date: Wed, 30 Sep 2026 00:50:09 +0000 Subject: [PATCH 169/299] refactor(audit-archiver): remove S3 archiving (#5402) --- Cargo.lock | 13 - bin/audit-archiver/Cargo.toml | 8 +- bin/audit-archiver/README.md | 41 +- bin/audit-archiver/src/main.rs | 190 ++---- crates/builder/cli/src/args.rs | 18 - crates/builder/core/src/config.rs | 18 - crates/builder/core/src/execution.rs | 4 - .../builder/core/src/flashblocks/context.rs | 70 +- .../builder/core/src/flashblocks/payload.rs | 8 +- .../builder/core/src/flashblocks/service.rs | 15 +- crates/builder/core/src/lib.rs | 3 - crates/builder/core/src/metrics.rs | 8 - .../builder/core/src/rejected_tx_forwarder.rs | 86 --- crates/builder/core/src/transaction_events.rs | 33 + crates/common/bundles/src/rejected.rs | 3 +- crates/infra/audit/Cargo.toml | 24 +- crates/infra/audit/README.md | 26 +- crates/infra/audit/src/archiver.rs | 150 ----- crates/infra/audit/src/lib.rs | 128 ---- crates/infra/audit/src/metrics.rs | 38 +- crates/infra/audit/src/publisher.rs | 51 -- crates/infra/audit/src/reader.rs | 24 - crates/infra/audit/src/rpc.rs | 319 +-------- crates/infra/audit/src/rpc_publisher.rs | 71 -- crates/infra/audit/src/rpc_reader.rs | 94 --- crates/infra/audit/src/storage.rs | 609 ------------------ crates/infra/audit/src/transaction_events.rs | 10 - crates/infra/audit/src/types.rs | 160 ----- crates/infra/audit/tests/common/mod.rs | 58 -- .../tests/postgres_transaction_events.rs | 67 +- crates/infra/audit/tests/s3_test.rs | 95 --- etc/docker/devnet-env | 2 - .../docker-compose.tx-observability.yml | 37 -- 33 files changed, 217 insertions(+), 2264 deletions(-) delete mode 100644 crates/builder/core/src/rejected_tx_forwarder.rs delete mode 100644 crates/infra/audit/src/archiver.rs delete mode 100644 crates/infra/audit/src/publisher.rs delete mode 100644 crates/infra/audit/src/reader.rs delete mode 100644 crates/infra/audit/src/rpc_publisher.rs delete mode 100644 crates/infra/audit/src/rpc_reader.rs delete mode 100644 crates/infra/audit/src/storage.rs delete mode 100644 crates/infra/audit/src/types.rs delete mode 100644 crates/infra/audit/tests/common/mod.rs delete mode 100644 crates/infra/audit/tests/s3_test.rs diff --git a/Cargo.lock b/Cargo.lock index 544280255b..486ae1a8d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2051,15 +2051,11 @@ version = "0.0.0" dependencies = [ "anyhow", "audit-archiver-lib", - "aws-config", - "aws-credential-types", - "aws-sdk-s3", "axum 0.8.9", "base-cli-utils", "clap", "dotenvy", "jsonrpsee", - "moka", "serde", "tokio", "tower 0.5.3", @@ -2070,23 +2066,15 @@ dependencies = [ name = "audit-archiver-lib" version = "0.0.0" dependencies = [ - "alloy-consensus 2.4.1", - "alloy-primitives", "anyhow", "async-trait", - "aws-config", - "aws-sdk-s3", "axum 0.8.9", - "base-bundles", "base-metrics", "base-observability-events", - "bytes", "chrono", - "futures", "jsonrpsee", "jsonrpsee-types", "metrics", - "moka", "serde", "serde_json", "sqlx", @@ -2097,7 +2085,6 @@ dependencies = [ "tower 0.5.3", "tower-http", "tracing", - "uuid", ] [[package]] diff --git a/bin/audit-archiver/Cargo.toml b/bin/audit-archiver/Cargo.toml index 51e9b370be..fc8a300da7 100644 --- a/bin/audit-archiver/Cargo.toml +++ b/bin/audit-archiver/Cargo.toml @@ -15,18 +15,14 @@ path = "src/main.rs" workspace = true [dependencies] +axum.workspace = true serde.workspace = true tokio.workspace = true +tower.workspace = true anyhow.workspace = true dotenvy.workspace = true -axum.workspace = true jsonrpsee.workspace = true -tower.workspace = true -aws-config.workspace = true -aws-sdk-s3.workspace = true base-cli-utils.workspace = true audit-archiver-lib.workspace = true -aws-credential-types.workspace = true tracing = { workspace = true, features = ["std"] } -moka = { workspace = true, features = ["sync"] } clap = { workspace = true, features = ["derive", "env"] } diff --git a/bin/audit-archiver/README.md b/bin/audit-archiver/README.md index 4158a7067a..0733c9ed19 100644 --- a/bin/audit-archiver/README.md +++ b/bin/audit-archiver/README.md @@ -1,6 +1,11 @@ # `audit-archiver` -Reads audit log events via RPC and archives them to S3. +Accepts transaction observability events over HTTP, stores them in Postgres, +and serves Postgres-backed transaction event queries over JSON-RPC. +`TIPS_AUDIT_POSTGRES_URL` is required to serve; startup fails if the Postgres +schema is not ready. This S3 removal needs no new database migration. +If still on the pre-partition schema, `migrate up` resets the table and deletes +its old rows: export them or agree on retention before running that migration. ## Security Model @@ -8,21 +13,43 @@ The unauthenticated RPC and ingest APIs are internal endpoints. Restrict them to trusted producers with private-network controls; never expose them publicly. The wildcard bind supports container networking. -When `TIPS_AUDIT_POSTGRES_URL` is set, `audit-archiver` also accepts -transaction observability event batches over HTTP and stores them in Postgres. +`audit-archiver` accepts transaction observability event batches over HTTP and +stores them in Postgres. The HTTP ingest endpoint is intended for Vector and accepts newline-delimited JSON, with one `transaction-event/v1` object per line: ```bash -curl -sS -X POST "http://127.0.0.1:8080/v1/transaction-events/batch" \ - -H "content-type: application/x-ndjson" \ - --data-binary '{"schema_version":"transaction-event/v1","event_id":"example-builder-accepted-1","event_time":"2026-06-02T00:00:00Z","producer":"base-builder","event_type":"BUILDER_ACCEPTED","network":"base-mainnet","tx_hash":"0x1111111111111111111111111111111111111111111111111111111111111111","block_hash":null,"block_number":null,"payload_id":null,"request_id":null,"data":{"position":1}} -' +printf '{"schema_version":"transaction-event/v1","event_id":"example-builder-accepted-1","event_time":"%s","producer":"base-builder","event_type":"BUILDER_ACCEPTED","network":"base-mainnet","tx_hash":"0x1111111111111111111111111111111111111111111111111111111111111111","block_hash":null,"block_number":null,"payload_id":null,"request_id":null,"data":{"position":1}}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" | + curl -sS -X POST "http://127.0.0.1:9100/v1/transaction-events/batch" \ + -H "content-type: application/x-ndjson" \ + --data-binary @- ``` The endpoint is intended for Vector HTTP output from the dedicated transaction event journal. It is not a stdout/stderr log ingestion endpoint. +The legacy S3 archive, bundle-event RPC ingest, and rejected-transaction +S3 RPC ingest are removed. The old RPC methods return "method not found"; +they never acknowledge events that would be discarded. Existing S3 history is +not migrated; decide whether to export or retain it before deleting buckets. + +The old S3 rejection feed only covered enforced per-transaction execution-time +rejections. `BUILDER_REJECTED` records that decision, its reason, predicted time, +and limit in the journal, but not the full S3 `MeterBundleResponse`. Bundle +lifecycle events have no one-to-one transaction-journal equivalent. The old +builder forwarder and bundle connector log and drop failed RPC batches; verify +no legacy bundle sender remains and journal rejections reach Postgres before +deploying this binary. Chart settings alone are not proof. + +Roll out only after the old ingress bundle sender is gone and the builder's +transaction event journal is enabled. On each network, compare +`BUILDER_REJECTED` emissions with events queryable from Postgres and monitor +`transaction_events_persisted`, `transaction_events_rejected`, and Vector +discard/retry metrics during the soak. `TIPS_AUDIT_NOOP_ARCHIVE` previously +covered bundle workers only: rejected-transaction RPC calls could still write +to S3. Confirm S3 writes have stopped before removing S3 IAM or buckets. + To verify the local devnet path end-to-end: ```bash diff --git a/bin/audit-archiver/src/main.rs b/bin/audit-archiver/src/main.rs index e1fe574111..4b5ea1e532 100644 --- a/bin/audit-archiver/src/main.rs +++ b/bin/audit-archiver/src/main.rs @@ -4,18 +4,15 @@ use std::{net::SocketAddr, sync::Arc, time::Duration}; use anyhow::Result; use audit_archiver_lib::{ - AuditArchiver, AuditArchiverApiServer, AuditArchiverRpc, DEFAULT_TRANSACTION_EVENT_BATCH_PATH, + AuditArchiverApiServer, AuditArchiverRpc, DEFAULT_TRANSACTION_EVENT_BATCH_PATH, DEFAULT_TRANSACTION_EVENT_COLD_RETENTION_DAYS, DEFAULT_TRANSACTION_EVENT_HOT_RETENTION_DAYS, DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, DEFAULT_TRANSACTION_EVENT_MAX_DATA_BYTES, DEFAULT_TRANSACTION_EVENT_MAX_EVENT_BYTES, DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES, DEFAULT_TRANSACTION_EVENT_PARTITION_LOCK_TIMEOUT_MS, DEFAULT_TRANSACTION_EVENT_RETENTION_INTERVAL_SECS, - DEFAULT_TRANSACTION_EVENT_WARM_RETENTION_DAYS, Metrics, PgTransactionEventSink, RpcEventReader, - S3EventReaderWriter, TransactionEventIngestConfig, TransactionEventRetentionConfig, + DEFAULT_TRANSACTION_EVENT_WARM_RETENTION_DAYS, Metrics, PgTransactionEventSink, + TransactionEventIngestConfig, TransactionEventRetentionConfig, }; -use aws_config::{BehaviorVersion, Region}; -use aws_credential_types::Credentials; -use aws_sdk_s3::{Client as S3Client, config::Builder as S3ConfigBuilder}; use axum::{ BoxError, error_handling::HandleErrorLayer, @@ -27,10 +24,8 @@ use axum::{ use base_cli_utils::LogConfig; use clap::{Parser, ValueEnum}; use jsonrpsee::server::{ServerBuilder, stop_channel}; -use moka::{policy::EvictionPolicy, sync::Cache}; use tokio::{ net::TcpListener, - sync::mpsc, time::{MissedTickBehavior, interval}, }; use tower::ServiceBuilder; @@ -39,12 +34,6 @@ use tracing::{error, info}; base_cli_utils::define_log_args!("TIPS_AUDIT"); base_cli_utils::define_metrics_args!("TIPS_AUDIT", 9002); -#[derive(Debug, Clone, ValueEnum)] -enum S3ConfigType { - Aws, - Manual, -} - #[derive(Debug, Clone, Copy, ValueEnum)] enum Command { Serve, @@ -62,7 +51,7 @@ enum MigrationDirection { #[derive(Debug, Clone)] struct HealthState { - transaction_event_sink: Option, + transaction_event_sink: PgTransactionEventSink, } #[derive(Parser, Debug)] @@ -74,53 +63,17 @@ struct Args { #[arg(value_enum)] migration_direction: Option, - #[arg(long, env = "TIPS_AUDIT_S3_BUCKET")] - s3_bucket: Option, - #[command(flatten)] log: LogArgs, #[command(flatten)] metrics: MetricsArgs, - #[arg(long, env = "TIPS_AUDIT_S3_CONFIG_TYPE", default_value = "aws")] - s3_config_type: S3ConfigType, - - #[arg(long, env = "TIPS_AUDIT_S3_ENDPOINT")] - s3_endpoint: Option, - - #[arg(long, env = "TIPS_AUDIT_S3_REGION", default_value = "us-east-1")] - s3_region: String, - - #[arg(long, env = "TIPS_AUDIT_S3_ACCESS_KEY_ID")] - s3_access_key_id: Option, - - #[arg(long, env = "TIPS_AUDIT_S3_SECRET_ACCESS_KEY")] - s3_secret_access_key: Option, - - #[arg(long, env = "TIPS_AUDIT_WORKER_POOL_SIZE", default_value = "80")] - worker_pool_size: usize, - - #[arg(long, env = "TIPS_AUDIT_CHANNEL_BUFFER_SIZE", default_value = "1024")] - channel_buffer_size: usize, - #[arg(long, env = "TIPS_AUDIT_RPC_PORT", default_value = "9100")] rpc_port: u16, - #[arg(long, env = "TIPS_AUDIT_NOOP_ARCHIVE", default_value = "false")] - noop_archive: bool, - - /// Maximum number of dedup-cache entries (event-key → ()). Cross-pod dedup - /// is enforced at the S3 layer; this cache short-circuits in-pod dupes. - #[arg(long, env = "TIPS_AUDIT_RPC_CACHE_CAPACITY", default_value = "100000")] - rpc_cache_capacity: u64, - - /// Time-to-live in seconds for entries in the dedup cache. - #[arg(long, env = "TIPS_AUDIT_RPC_CACHE_TTL_SECS", default_value = "300")] - rpc_cache_ttl_secs: u64, - - /// Postgres connection URL for transaction observability events. When unset, - /// the HTTP transaction-event ingest endpoint is disabled. + /// Postgres connection URL for transaction observability events. Required + /// when serving HTTP ingest and RPC queries. #[arg(long, env = "TIPS_AUDIT_POSTGRES_URL")] postgres_url: Option, @@ -258,10 +211,10 @@ async fn run_migrations(args: &Args) -> Result<()> { } async fn run_server(args: Args) -> Result<()> { - let s3_bucket = args - .s3_bucket - .clone() - .ok_or_else(|| anyhow::anyhow!("TIPS_AUDIT_S3_BUCKET must be set for serve"))?; + let postgres_url = args + .postgres_url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("TIPS_AUDIT_POSTGRES_URL must be set for serve"))?; let retention_config = TransactionEventRetentionConfig { hot_days: args.transaction_event_hot_retention_days, @@ -274,51 +227,26 @@ async fn run_server(args: Args) -> Result<()> { let retention_interval = Duration::from_secs(retention_config.interval_secs); info!( - s3_bucket = %s3_bucket, metrics_addr = %args.metrics.addr, metrics_port = args.metrics.port, rpc_port = args.rpc_port, transaction_event_http_path = %args.transaction_event_http_path, - transaction_event_http_enabled = args.postgres_url.is_some(), transaction_event_hot_retention_days = retention_config.hot_days, transaction_event_warm_retention_days = retention_config.warm_days, transaction_event_cold_retention_days = retention_config.cold_days, transaction_event_partition_lock_timeout_ms = retention_config.partition_lock_timeout_ms, transaction_event_retention_interval_secs = retention_interval.as_secs(), - rpc_cache_capacity = args.rpc_cache_capacity, - rpc_cache_ttl_secs = args.rpc_cache_ttl_secs, - channel_buffer_size = args.channel_buffer_size, "Starting audit archiver" ); - let s3_client = create_s3_client(&args).await?; - let writer = S3EventReaderWriter::new(s3_client, s3_bucket); - - let dedup_cache: Cache = Cache::builder() - .max_capacity(args.rpc_cache_capacity) - .eviction_policy(EvictionPolicy::lru()) - .time_to_live(Duration::from_secs(args.rpc_cache_ttl_secs)) - .build(); - - let (event_tx, event_rx) = mpsc::channel(args.channel_buffer_size); - let reader = RpcEventReader::new(event_rx); - let rpc_addr = SocketAddr::from(([0, 0, 0, 0], args.rpc_port)); - let transaction_event_sink = if let Some(postgres_url) = &args.postgres_url { - Some( - PgTransactionEventSink::connect(postgres_url, args.postgres_max_connections) - .await? - .with_retention_config(retention_config)?, - ) - } else { - None - }; + let transaction_event_sink = + PgTransactionEventSink::connect(postgres_url, args.postgres_max_connections) + .await? + .with_retention_config(retention_config)?; + transaction_event_sink.check_schema_ready().await?; - let mut rpc_module = - AuditArchiverRpc::with_bundle_events(Arc::new(writer.clone()), dedup_cache, event_tx); - if let Some(sink) = transaction_event_sink.clone() { - rpc_module = rpc_module.with_transaction_event_store(sink); - } + let rpc_module = AuditArchiverRpc::new(transaction_event_sink.clone()); // The jsonrpsee service is driven by the axum listener below. Keep the // stop handle passed into the service builder; axum owns the HTTP server // lifecycle for this combined RPC and transaction-event endpoint. @@ -336,39 +264,27 @@ async fn run_server(args: Args) -> Result<()> { let retention_sink = transaction_event_sink.clone(); let health_router = health_router(transaction_event_sink.clone()); - let http_app = if let Some(sink) = transaction_event_sink { - let config = TransactionEventIngestConfig { - path: args.transaction_event_http_path.clone(), - max_batch_size: args.transaction_event_max_batch_size, - max_event_bytes: args.transaction_event_max_event_bytes, - max_data_bytes: args.transaction_event_max_data_bytes, - max_request_bytes: args.transaction_event_max_request_bytes, - }; - let path = config.path.clone(); - info!(rpc_addr = %rpc_addr, %path, "transaction event HTTP ingest enabled on audit RPC server"); - config.into_router(Arc::new(sink)).merge(health_router).fallback_service(rpc_service) - } else { - info!("transaction event HTTP ingest disabled; TIPS_AUDIT_POSTGRES_URL is not set"); - health_router.fallback_service(rpc_service) + let config = TransactionEventIngestConfig { + path: args.transaction_event_http_path.clone(), + max_batch_size: args.transaction_event_max_batch_size, + max_event_bytes: args.transaction_event_max_event_bytes, + max_data_bytes: args.transaction_event_max_data_bytes, + max_request_bytes: args.transaction_event_max_request_bytes, }; + let path = config.path.clone(); + info!(rpc_addr = %rpc_addr, %path, "transaction event HTTP ingest enabled on audit RPC server"); + let http_app = config + .into_router(Arc::new(transaction_event_sink)) + .merge(health_router) + .fallback_service(rpc_service); let http_listener = TcpListener::bind(rpc_addr).await?; let http_server = axum::serve(http_listener, http_app); info!(rpc_addr = %rpc_addr, "Audit archiver HTTP server started"); - let mut archiver = AuditArchiver::new( - reader, - writer, - args.worker_pool_size, - args.channel_buffer_size, - args.noop_archive, - ); - - info!("Audit archiver initialized, starting main loop"); let retention_worker = run_retention_worker(retention_sink, retention_interval); tokio::select! { - result = archiver.run() => result, result = http_server => { result.map_err(|e| anyhow::anyhow!("audit archiver HTTP server stopped unexpectedly: {e}")) } @@ -377,13 +293,9 @@ async fn run_server(args: Args) -> Result<()> { } async fn run_retention_worker( - transaction_event_sink: Option, + transaction_event_sink: PgTransactionEventSink, retention_interval: Duration, ) -> Result<()> { - let Some(sink) = transaction_event_sink else { - return std::future::pending().await; - }; - // First tick is immediate so a new replica creates today's and upcoming // partitions without waiting a full interval. Skip missed ticks so a slow // pass does not catch up. @@ -392,7 +304,7 @@ async fn run_retention_worker( loop { ticker.tick().await; - match sink.maintain_partitions().await { + match transaction_event_sink.maintain_partitions().await { Ok(outcome) if outcome.partitions_created > 0 || outcome.partitions_dropped > 0 @@ -414,7 +326,7 @@ async fn run_retention_worker( } } -fn health_router(transaction_event_sink: Option) -> axum::Router { +fn health_router(transaction_event_sink: PgTransactionEventSink) -> axum::Router { axum::Router::new() .route("/healthz", get(healthz_handler)) .route("/readyz", get(readyz_handler)) @@ -426,9 +338,7 @@ async fn healthz_handler() -> &'static str { } async fn readyz_handler(State(state): State) -> Response { - match PgTransactionEventSink::check_optional_schema_ready(state.transaction_event_sink.as_ref()) - .await - { + match state.transaction_event_sink.check_schema_ready().await { Ok(()) => (StatusCode::OK, "ready\n".to_string()).into_response(), Err(err) => { error!(error = %err, "audit archiver readiness check failed"); @@ -437,34 +347,16 @@ async fn readyz_handler(State(state): State) -> Response { } } -async fn create_s3_client(args: &Args) -> Result { - match args.s3_config_type { - S3ConfigType::Manual => { - let region = args.s3_region.clone(); - let mut config_builder = - aws_config::defaults(BehaviorVersion::latest()).region(Region::new(region)); - - if let Some(endpoint) = &args.s3_endpoint { - config_builder = config_builder.endpoint_url(endpoint); - } - - if let (Some(access_key), Some(secret_key)) = - (&args.s3_access_key_id, &args.s3_secret_access_key) - { - let credentials = Credentials::new(access_key, secret_key, None, None, "manual"); - config_builder = config_builder.credentials_provider(credentials); - } +#[cfg(test)] +mod tests { + use super::*; - let config = config_builder.load().await; - let s3_config_builder = S3ConfigBuilder::from(&config).force_path_style(true); + #[tokio::test] + async fn serve_without_postgres_fails_before_accepting_events() { + let mut args = Args::parse_from(["audit-archiver"]); + args.postgres_url = None; - info!(message = "manually configuring s3 client"); - Ok(S3Client::from_conf(s3_config_builder.build())) - } - S3ConfigType::Aws => { - info!(message = "using aws s3 client"); - let config = aws_config::load_defaults(BehaviorVersion::latest()).await; - Ok(S3Client::new(&config)) - } + let error = run_server(args).await.expect_err("serve must require durable event storage"); + assert!(error.to_string().contains("TIPS_AUDIT_POSTGRES_URL must be set for serve")); } } diff --git a/crates/builder/cli/src/args.rs b/crates/builder/cli/src/args.rs index 983ff86d3c..1be1bdc7ab 100644 --- a/crates/builder/cli/src/args.rs +++ b/crates/builder/cli/src/args.rs @@ -243,18 +243,6 @@ pub struct Args { )] pub predicate_bucket_ordered_threshold: usize, - /// URL of the audit-archiver RPC endpoint for forwarding rejected transactions - #[arg(long = "builder.audit-archiver-url", env = "BUILDER_AUDIT_ARCHIVER_URL")] - pub audit_archiver_url: Option, - - /// Bounded channel capacity for rejected transaction forwarding (drops on full) - #[arg(long = "builder.rejected-tx-channel-size", default_value = "500")] - pub rejected_tx_channel_size: usize, - - /// Maximum rejected transactions accumulated per block before dropping - #[arg(long = "builder.max-rejected-txs-per-block", default_value = "500")] - pub max_rejected_txs_per_block: usize, - /// Buffer size for tx data store (LRU eviction when full) #[arg(long = "builder.tx-data-store-buffer-size", default_value = "10000")] pub tx_data_store_buffer_size: usize, @@ -354,9 +342,6 @@ impl Default for Args { metering_wait_duration_ms: None, predicate_eval_hard_cutoff_ms: 10, predicate_bucket_ordered_threshold: 32, - audit_archiver_url: None, - rejected_tx_channel_size: 500, - max_rejected_txs_per_block: 500, tx_data_store_buffer_size: 10000, metering_store_ttl_secs: 30, rejection_cache_max_capacity: 100_000, @@ -435,9 +420,6 @@ impl Args { self.rejection_cache_max_capacity, Duration::from_secs(self.rejection_cache_ttl_secs), ), - audit_archiver_url: self.audit_archiver_url, - rejected_tx_channel_size: self.rejected_tx_channel_size, - max_rejected_txs_per_block: self.max_rejected_txs_per_block, manifest_precheck_enabled: self.manifest_precheck_enabled, }) } diff --git a/crates/builder/core/src/config.rs b/crates/builder/core/src/config.rs index 830dd78521..13d2af83b1 100644 --- a/crates/builder/core/src/config.rs +++ b/crates/builder/core/src/config.rs @@ -76,18 +76,6 @@ pub struct BuilderConfig { /// Transactions in this cache are skipped by the iterator without re-evaluation. pub rejection_cache: RejectionCache, - /// URL of the audit-archiver RPC endpoint for rejected transaction forwarding. - /// When set, rejected transactions will be forwarded to this endpoint. - pub audit_archiver_url: Option, - - /// Bounded channel capacity for rejected transaction forwarding. - /// When the channel is full, new rejected transactions are dropped. - pub rejected_tx_channel_size: usize, - - /// Maximum number of rejected transactions accumulated per block before - /// further rejections are dropped. Prevents unbounded `ExecutionInfo` growth. - pub max_rejected_txs_per_block: usize, - /// Whether to drop EIP-8130 transactions whose captured authorization /// predicates are positively stale before executing them. pub manifest_precheck_enabled: bool, @@ -131,9 +119,6 @@ impl core::fmt::Debug for BuilderConfig { .field("predicate_bucket_ordered_threshold", &self.predicate_bucket_ordered_threshold) .field("metering_provider", &self.metering_provider) .field("rejection_cache_size", &self.rejection_cache.entry_count()) - .field("audit_archiver_url", &self.audit_archiver_url) - .field("rejected_tx_channel_size", &self.rejected_tx_channel_size) - .field("max_rejected_txs_per_block", &self.max_rejected_txs_per_block) .field("manifest_precheck_enabled", &self.manifest_precheck_enabled) .field("state_provider_metrics", &self.state_provider_metrics) .finish() @@ -160,9 +145,6 @@ impl Default for BuilderConfig { predicate_bucket_ordered_threshold: DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, metering_provider: Arc::new(NoopMeteringProvider), rejection_cache: RejectionCache::default(), - audit_archiver_url: None, - rejected_tx_channel_size: 500, - max_rejected_txs_per_block: 500, manifest_precheck_enabled: true, state_provider_metrics: false, } diff --git a/crates/builder/core/src/execution.rs b/crates/builder/core/src/execution.rs index 83226bd31d..080881fadd 100644 --- a/crates/builder/core/src/execution.rs +++ b/crates/builder/core/src/execution.rs @@ -6,7 +6,6 @@ use core::fmt::Debug; use ExecutionMeteringLimitExceeded::TransactionExecutionTime; use alloy_primitives::{Address, U256}; -use base_bundles::RejectedTransaction; use base_common_consensus::{BaseReceipt, BaseTransactionSigned}; use base_common_evm::BaseTransactionError; use derive_more::Display; @@ -225,8 +224,6 @@ pub struct ExecutionInfo { pub extra: FlashblocksExecutionInfo, /// DA Footprint Scalar for Jovian pub da_footprint_scalar: Option, - /// Rejected transactions accumulated during block building, flushed after finalization. - pub rejected_txs: Vec, /// Validity-predicate state loads accumulated across the block's flashblock builds. pub predicate_loads: PredicateLoadTracker, /// Validity inclusion and EIP-1559 fee revenue accumulated across the block. @@ -246,7 +243,6 @@ impl ExecutionInfo { total_fees: U256::ZERO, extra: Default::default(), da_footprint_scalar: None, - rejected_txs: Vec::new(), predicate_loads: PredicateLoadTracker::default(), inclusion: InclusionTracker::default(), } diff --git a/crates/builder/core/src/flashblocks/context.rs b/crates/builder/core/src/flashblocks/context.rs index d4b804c57a..3dbbe7ec30 100644 --- a/crates/builder/core/src/flashblocks/context.rs +++ b/crates/builder/core/src/flashblocks/context.rs @@ -1,7 +1,7 @@ use core::fmt::Debug; use std::{ sync::Arc, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, + time::{Duration, Instant, SystemTime}, }; use alloy_consensus::{Eip658Value, Transaction}; @@ -11,7 +11,6 @@ use alloy_evm::Database; use alloy_primitives::B256; use alloy_primitives::{Address, BlockHash, Bytes, TxHash, U256}; use alloy_rpc_types_eth::Withdrawals; -use base_bundles::{MeterBundleResponse, RejectedTransaction, RejectionReason}; use base_common_chains::Upgrades; use base_common_consensus::{ BaseReceipt, BaseTransactionSigned, CoinbaseTip, DepositReceipt, OpTxType, @@ -42,7 +41,6 @@ use reth_revm::{State, context::Block}; use reth_transaction_pool::{BestTransactionsAttributes, PoolTransaction}; use revm::{DatabaseCommit, context::result::ResultAndState, interpreter::as_u64_saturated}; use serde::Serialize; -use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::{Level, debug, span, trace, warn}; @@ -272,8 +270,6 @@ pub struct BasePayloadBuilderCtx { pub extra: FlashblocksExtraCtx, /// Builder configuration containing limits and metering settings. pub builder_config: BuilderConfig, - /// Sender for forwarding per-block batches of rejected transactions to the audit-archiver. - pub rejected_tx_sender: Option>>, } impl BasePayloadBuilderCtx { @@ -430,54 +426,6 @@ impl BasePayloadBuilderCtx { self.chain_spec.chain_id() } - fn record_rejected_tx( - &self, - info: &mut ExecutionInfo, - tx_hash: TxHash, - reason: RejectionReason, - metering: MeterBundleResponse, - ) { - if self.rejected_tx_sender.is_none() { - return; - } - - if info.rejected_txs.len() >= self.builder_config.max_rejected_txs_per_block { - BuilderMetrics::rejected_tx_per_block_drops().increment(1); - return; - } - - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); - info.rejected_txs.push(RejectedTransaction { - tx_hash, - block_number: self.block_number(), - reason, - timestamp: now, - metering, - }); - } - - /// Flushes all accumulated rejected transactions to the audit-archiver channel - /// as a single per-block batch. - pub fn flush_rejected_txs(&self, info: &mut ExecutionInfo) { - if info.rejected_txs.is_empty() { - return; - } - - if let Some(sender) = &self.rejected_tx_sender { - let batch = std::mem::take(&mut info.rejected_txs); - let batch_size = batch.len(); - if let Err(e) = sender.try_send(batch) { - BuilderMetrics::rejected_tx_channel_drops().increment(batch_size as u64); - warn!( - target: "payload_builder", - error = %e, - batch_size, - "Rejected tx channel full or closed, dropping batch" - ); - } - } - } - fn builder_transaction_event_context( &self, payload_id: &str, @@ -1218,21 +1166,6 @@ impl BasePayloadBuilderCtx { diag.permanently_rejected_txs.push(tx_hash); } - let ExecutionMeteringLimitExceeded::TransactionExecutionTime( - tx_time_us, - limit_us, - ) = limit_err; - // Only record per-tx execution time limits for the audit trail for now - self.record_rejected_tx( - info, - tx_hash, - RejectionReason::ExecutionTimeExceeded { - tx_time_us: *tx_time_us, - limit_us: *limit_us, - }, - resource_usage.unwrap_or_default(), - ); - self.emit_builder_decision_event( &payload_id, TransactionEventType::BuilderRejected, @@ -1749,7 +1682,6 @@ impl BasePayloadBuilderCtx { cancel: CancellationToken::new(), extra: FlashblocksExtraCtx::default(), builder_config: crate::BuilderConfig::default(), - rejected_tx_sender: None, } } } diff --git a/crates/builder/core/src/flashblocks/payload.rs b/crates/builder/core/src/flashblocks/payload.rs index 7c5c48aa79..700d81f7af 100644 --- a/crates/builder/core/src/flashblocks/payload.rs +++ b/crates/builder/core/src/flashblocks/payload.rs @@ -16,7 +16,6 @@ use alloy_eips::{Encodable2718, eip7685::EMPTY_REQUESTS_HASH, merge::BEACON_NONC use alloy_evm::Database; use alloy_primitives::{Address, B256, Bloom, U256, logs_bloom, map::foldhash::HashMap}; use base_builder_publish::WebSocketPublisher; -use base_bundles::RejectedTransaction; use base_common_chains::Upgrades; use base_common_consensus::{BaseReceipt, BaseTransactionSigned}; use base_common_flashblocks::{ @@ -106,8 +105,6 @@ pub(super) struct BuilderOutputs { /// WebSocket publisher for broadcasting flashblocks /// to all connected subscribers. pub ws_pub: Arc, - /// Sender for forwarding per-block batches of rejected transactions to the audit-archiver. - pub rejected_tx_sender: Option>>, } /// Base payload builder @@ -121,8 +118,7 @@ pub(super) struct BasePayloadBuilder { pub client: Client, /// System configuration for the builder pub config: BuilderConfig, - /// The outbound channels the builder emits built payloads, flashblocks, and rejected - /// transactions to. + /// The outbound channels for built payloads and flashblocks. pub outputs: BuilderOutputs, /// Last flashblock emitted by this builder instance. last_emitted_flashblock_id: Arc, @@ -240,7 +236,6 @@ where cancel, extra, builder_config: self.config.clone(), - rejected_tx_sender: self.outputs.rejected_tx_sender.clone(), }) } @@ -961,7 +956,6 @@ where // Build the final block WITH state root computed let (final_payload, _, _) = build_block(state, ctx, info, FlashblockId::default(), true)?; - ctx.flush_rejected_txs(info); self.emit_final_inclusion_events(ctx, &final_payload); let elapsed = start_time.elapsed(); diff --git a/crates/builder/core/src/flashblocks/service.rs b/crates/builder/core/src/flashblocks/service.rs index fb02db243c..97b84035d9 100644 --- a/crates/builder/core/src/flashblocks/service.rs +++ b/crates/builder/core/src/flashblocks/service.rs @@ -24,7 +24,7 @@ use super::{ payload::{BasePayloadBuilder, BuilderOutputs}, }; use crate::{ - BuilderConfig, RejectedTxForwarder, + BuilderConfig, traits::{NodeBounds, PoolBounds}, }; @@ -54,17 +54,6 @@ impl FlashblocksServiceBuilder { { let (built_payload_tx, built_payload_rx) = tokio::sync::mpsc::channel(16); - let rejected_tx_sender = if let Some(ref url) = self.config.audit_archiver_url { - let (tx, rx) = tokio::sync::mpsc::channel(self.config.rejected_tx_channel_size); - let forwarder = RejectedTxForwarder::new(url, rx) - .map_err(|e| eyre::eyre!("Failed to create rejected tx forwarder: {e}"))?; - ctx.task_executor().spawn_task(Box::pin(forwarder.run())); - info!(audit_archiver_url = %url, "Rejected transaction forwarder started"); - Some(tx) - } else { - None - }; - let ws_pub: Arc = WebSocketPublisher::new(self.config.flashblocks_ws_addr)?.into(); let payload_builder = BasePayloadBuilder::new( @@ -72,7 +61,7 @@ impl FlashblocksServiceBuilder { pool, ctx.provider().clone(), self.config.clone(), - BuilderOutputs { payload_tx: built_payload_tx, ws_pub, rejected_tx_sender }, + BuilderOutputs { payload_tx: built_payload_tx, ws_pub }, ); let payload_generator = BlockPayloadJobGenerator::with_builder( ctx.provider().clone(), diff --git a/crates/builder/core/src/lib.rs b/crates/builder/core/src/lib.rs index c991070a05..47c2f4eec3 100644 --- a/crates/builder/core/src/lib.rs +++ b/crates/builder/core/src/lib.rs @@ -29,9 +29,6 @@ pub use base_execution_payload_builder::{ }; pub use traits::{ClientBounds, NodeBounds, PayloadTxsBounds, PoolBounds}; -mod rejected_tx_forwarder; -pub use rejected_tx_forwarder::RejectedTxForwarder; - // Internal-only helpers for emitting builder transaction events. The event surface // is shared via `base-observability-events`, while this module keeps // builder-specific payload construction private to the builder crate. diff --git a/crates/builder/core/src/metrics.rs b/crates/builder/core/src/metrics.rs index 0f996c5858..31f4622d30 100644 --- a/crates/builder/core/src/metrics.rs +++ b/crates/builder/core/src/metrics.rs @@ -189,14 +189,6 @@ base_metrics::define_metrics! { tx_accounts_modified: histogram, #[describe("Number of storage slots modified by a transaction (from EVM post-state)")] tx_storage_slots_modified: histogram, - #[describe("Rejected transaction batch drops due to full forwarding channel")] - rejected_tx_channel_drops: counter, - #[describe("Rejected transaction drops due to per-block accumulation limit")] - rejected_tx_per_block_drops: counter, - #[describe("Rejected txs forwarded to audit-archiver")] - rejected_txs_forwarded: counter, - #[describe("Number of failed rejected transaction batch forwards to audit-archiver")] - rejected_tx_forward_failures: counter, #[describe("Builder transaction events successfully enqueued")] #[label(event_type)] builder_transaction_events_emitted: counter, diff --git a/crates/builder/core/src/rejected_tx_forwarder.rs b/crates/builder/core/src/rejected_tx_forwarder.rs deleted file mode 100644 index cf0855db71..0000000000 --- a/crates/builder/core/src/rejected_tx_forwarder.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Rejected transaction forwarder. -//! -//! Forwards rejected transactions from the builder to the audit-archiver -//! service via the `base_persistRejectedTransactionBatch` RPC method. - -use core::time::Duration; - -use base_bundles::RejectedTransaction; -use jsonrpsee::{ - core::client::ClientT, - http_client::{HttpClient, HttpClientBuilder}, - rpc_params, -}; -use tokio::sync::mpsc; -use tracing::{info, warn}; - -use crate::BuilderMetrics; - -/// Forwards rejected transactions to the audit-archiver via RPC. -/// -/// Runs as a background task, reading batches of rejected transactions -/// (one batch per canonical block) from a bounded channel and making -/// fire-and-forget RPC calls to the audit-archiver service. -#[derive(Debug)] -pub struct RejectedTxForwarder { - client: HttpClient, - rx: mpsc::Receiver>, -} - -impl RejectedTxForwarder { - /// Creates a new `RejectedTxForwarder`. - pub fn new( - audit_archiver_url: &str, - rx: mpsc::Receiver>, - ) -> eyre::Result { - let client = HttpClientBuilder::default() - .request_timeout(Duration::from_secs(1)) - .build(audit_archiver_url) - .map_err(|e| eyre::eyre!("Failed to build audit archiver HTTP client: {e}"))?; - Ok(Self { client, rx }) - } - - /// Runs the forwarder loop, consuming per-block batches of rejected transactions - /// from the channel and forwarding each batch to the audit-archiver via RPC. - pub async fn run(mut self) { - info!("Rejected transaction forwarder started"); - while let Some(batch) = self.rx.recv().await { - let batch_size = batch.len(); - let block_number = batch.first().map(|tx| tx.block_number); - match self - .client - .request::("base_persistRejectedTransactionBatch", rpc_params![&batch]) - .await - { - Ok(persisted) if persisted as usize == batch_size => { - BuilderMetrics::rejected_txs_forwarded().increment(batch_size as u64); - info!( - batch_size, - block_number = ?block_number, - "Forwarded rejected transaction batch to audit-archiver" - ); - } - Ok(persisted) => { - BuilderMetrics::rejected_txs_forwarded().increment(persisted as u64); - warn!( - persisted, - failed = batch_size.saturating_sub(persisted as usize), - batch_size, - block_number = ?block_number, - "Partial failure persisting rejected transaction batch" - ); - } - Err(e) => { - BuilderMetrics::rejected_tx_forward_failures().increment(1); - warn!( - error = %e, - batch_size, - block_number = ?block_number, - "Failed to forward rejected transaction batch to audit-archiver" - ); - } - } - } - warn!("Rejected transaction forwarder channel closed"); - } -} diff --git a/crates/builder/core/src/transaction_events.rs b/crates/builder/core/src/transaction_events.rs index acf8533637..105c413d38 100644 --- a/crates/builder/core/src/transaction_events.rs +++ b/crates/builder/core/src/transaction_events.rs @@ -166,6 +166,8 @@ impl BuilderConsideredEventData { } /// Fields emitted when the builder rejects a transaction. +/// This journal event replaces the legacy S3 rejected-transaction RPC; retain +/// the rejection reason and metering limit details here. #[derive(Debug, Serialize)] pub(crate) struct BuilderRejectedEventData { #[serde(flatten)] @@ -651,6 +653,37 @@ mod tests { ); } + #[test] + fn enforced_metering_rejection_keeps_its_reason_and_limit_in_the_journal() { + let error = TxnExecutionError::ExecutionMeteringLimitExceeded( + ExecutionMeteringLimitExceeded::TransactionExecutionTime(2_000, 1_000), + ); + let limits = + ResourceLimits { tx_execution_time_limit_us: Some(1_000), ..Default::default() }; + let resources = TxResources { + da_size: 0, + gas_limit: 21_000, + payer_auth: 0, + execution_time_us: Some(2_000), + uncompressed_size: 0, + }; + let data = serde_json::to_value( + BuilderRejectedEventData::from_error( + &error, + &ExecutionInfo::default(), + &limits, + Some(&resources), + ) + .with_dry_run(false), + ) + .unwrap(); + + assert_eq!(data["rejection_reason"], "tx_execution_time_exceeded"); + assert_eq!(data["tx_execution_time_us"], 2_000); + assert_eq!(data["tx_execution_time_limit_us"], 1_000); + assert_eq!(data["dry_run"], false); + } + #[test] fn deferred_rejected_and_expired_event_payloads_are_distinguishable() { let info = ExecutionInfo::default(); diff --git a/crates/common/bundles/src/rejected.rs b/crates/common/bundles/src/rejected.rs index cc91c57913..312bafccc1 100644 --- a/crates/common/bundles/src/rejected.rs +++ b/crates/common/bundles/src/rejected.rs @@ -1,4 +1,5 @@ -//! Rejected transaction types shared between builder and audit-archiver. +//! Legacy rejected transaction types retained for downstream compatibility. +//! Builder rejection events now use the transaction event journal. use alloy_primitives::TxHash; use serde::{Deserialize, Serialize}; diff --git a/crates/infra/audit/Cargo.toml b/crates/infra/audit/Cargo.toml index 3ce7addc48..71e48cebc4 100644 --- a/crates/infra/audit/Cargo.toml +++ b/crates/infra/audit/Cargo.toml @@ -11,37 +11,27 @@ edition.workspace = true workspace = true [dependencies] -axum = { workspace = true, features = ["tokio", "http1", "json"] } -bytes.workspace = true -futures.workspace = true +tower-http.workspace = true async-trait.workspace = true -base-bundles.workspace = true +jsonrpsee-types.workspace = true base-observability-events.workspace = true metrics = { workspace = true, optional = true } tokio = { workspace = true, features = ["full"] } anyhow = { workspace = true, features = ["std"] } tracing = { workspace = true, features = ["std"] } -serde_json = { workspace = true, features = ["std"] } chrono = { workspace = true, features = ["serde"] } thiserror = { workspace = true, features = ["std"] } -jsonrpsee = { workspace = true, features = ["server", "macros", "http-client"] } -uuid = { workspace = true, features = ["v5", "serde"] } +serde_json = { workspace = true, features = ["std"] } serde = { workspace = true, features = ["std", "derive"] } -alloy-consensus = { workspace = true, features = ["std"] } base-metrics = { workspace = true, features = ["metrics"] } -alloy-primitives = { workspace = true, features = ["map-foldhash", "serde"] } -aws-sdk-s3 = { workspace = true, features = ["rustls", "default-https-client", "rt-tokio"] } -moka = { workspace = true, features = ["sync"] } -jsonrpsee-types.workspace = true +jsonrpsee = { workspace = true, features = ["server", "macros"] } +axum = { workspace = true, features = ["tokio", "http1", "json"] } sqlx = { workspace = true, features = ["runtime-tokio", "postgres", "chrono", "json", "migrate", "macros", "tls-native-tls"] } -tower-http.workspace = true [dev-dependencies] -base-bundles = { workspace = true, features = ["test-utils"] } -testcontainers = { workspace = true, features = ["blocking"] } +testcontainers.workspace = true tower = { workspace = true, features = ["util"] } -aws-config = { workspace = true, features = ["default-https-client", "rt-tokio"] } -testcontainers-modules = { workspace = true, features = ["postgres", "minio"] } +testcontainers-modules = { workspace = true, features = ["postgres"] } [features] default = [ "metrics" ] diff --git a/crates/infra/audit/README.md b/crates/infra/audit/README.md index 6bd9d2a157..486492a3fa 100644 --- a/crates/infra/audit/README.md +++ b/crates/infra/audit/README.md @@ -1,28 +1,14 @@ # `audit-archiver-lib` -Audit library for tracking and archiving bundle events. +Audit library for Postgres-backed transaction observability events. ## Overview -Provides event publishing, storage, and retrieval for bundle lifecycle events. `AuditConnector` -wires an event receiver to a publisher, `RpcBundleEventPublisher` publishes events over RPC, -and `S3EventReaderWriter` archives events to S3 for long-term retention. Also exposes -`LoggingBundleEventPublisher` for local development. - -## Usage - -Add the dependency to your `Cargo.toml`: - -```toml -[dependencies] -audit-archiver-lib = { workspace = true } -``` - -```rust,ignore -use audit_archiver_lib::{AuditConnector, RpcBundleEventPublisher}; - -let publisher = RpcBundleEventPublisher::new(rpc_url, timeout)?; -AuditConnector::connect_batched(event_rx, publisher, batch_size, batch_wait); +Accepts Vector's `transaction-event/v1` batches over HTTP, persists them +idempotently in Postgres, and provides JSON-RPC queries by transaction, block, +bundle, and rejection. Postgres schema readiness is checked before the +`audit-archiver` binary starts serving; see the binary README for the +S3 removal and rollout prerequisites. ``` ## Postgres schema diff --git a/crates/infra/audit/src/archiver.rs b/crates/infra/audit/src/archiver.rs deleted file mode 100644 index 87343a410e..0000000000 --- a/crates/infra/audit/src/archiver.rs +++ /dev/null @@ -1,150 +0,0 @@ -use std::{ - fmt, - marker::PhantomData, - sync::Arc, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, -}; - -use anyhow::Result; -use tokio::{ - sync::{Mutex, mpsc}, - time::sleep, -}; -use tracing::{debug, error, info, warn}; - -use crate::{ - metrics::Metrics, - reader::{Event, EventReader}, - storage::EventWriter, -}; - -/// Archives audit events from a generic [`EventReader`] to -/// an [`EventWriter`] (typically S3) via a worker pool. -pub struct AuditArchiver -where - R: EventReader, - W: EventWriter + Clone + Send + 'static, -{ - reader: R, - event_tx: mpsc::Sender, - _phantom: PhantomData, -} - -impl fmt::Debug for AuditArchiver -where - R: EventReader, - W: EventWriter + Clone + Send + 'static, -{ - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("AuditArchiver").finish_non_exhaustive() - } -} - -impl AuditArchiver -where - R: EventReader, - W: EventWriter + Clone + Send + 'static, -{ - /// Creates a new archiver with the given reader and writer. - pub fn new( - reader: R, - writer: W, - worker_pool_size: usize, - channel_buffer_size: usize, - noop_archive: bool, - ) -> Self { - let (event_tx, event_rx) = mpsc::channel(channel_buffer_size); - - Self::spawn_workers(writer, event_rx, worker_pool_size, noop_archive); - - Self { reader, event_tx, _phantom: PhantomData } - } - - fn spawn_workers( - writer: W, - event_rx: mpsc::Receiver, - worker_pool_size: usize, - noop_archive: bool, - ) { - let event_rx = Arc::new(Mutex::new(event_rx)); - - for worker_id in 0..worker_pool_size { - let writer = writer.clone(); - let event_rx = Arc::clone(&event_rx); - - tokio::spawn(async move { - loop { - let event = { - let mut rx = event_rx.lock().await; - rx.recv().await - }; - - match event { - Some(event) => { - let archive_start = Instant::now(); - if noop_archive { - debug!( - worker_id, - bundle_id = %event.event.bundle_id(), - tx_ids = ?event.event.transaction_ids(), - timestamp = event.timestamp, - "Noop archive - skipping event" - ); - Metrics::events_processed().increment(1); - Metrics::in_flight_archive_tasks().decrement(1.0); - continue; - } - if let Err(e) = writer.archive_event(event).await { - warn!(worker_id, error = %e, "Failed to write event"); - } else { - Metrics::archive_event_duration() - .record(archive_start.elapsed().as_secs_f64()); - Metrics::events_processed().increment(1); - } - Metrics::in_flight_archive_tasks().decrement(1.0); - } - None => { - info!(worker_id, "Worker stopped - channel closed"); - break; - } - } - } - }); - } - } - - /// Runs the archiver loop, reading events and writing them to storage. - pub async fn run(&mut self) -> Result<()> { - loop { - let read_start = Instant::now(); - match self.reader.read_event().await { - Ok(event) => { - Metrics::read_duration().record(read_start.elapsed().as_secs_f64()); - - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - let event_age_ms = now_ms.saturating_sub(event.timestamp); - Metrics::event_age().record(event_age_ms as f64); - - Metrics::in_flight_archive_tasks().increment(1.0); - if let Err(e) = self.event_tx.send(event).await { - error!(error = %e, "Failed to send event to worker pool"); - Metrics::in_flight_archive_tasks().decrement(1.0); - } - - let commit_start = Instant::now(); - if let Err(e) = self.reader.commit().await { - error!(error = %e, "Failed to commit message"); - } - Metrics::commit_duration().record(commit_start.elapsed().as_secs_f64()); - } - Err(e) => { - error!(error = %e, "Error reading events"); - sleep(Duration::from_secs(1)).await; - } - } - } - } -} diff --git a/crates/infra/audit/src/lib.rs b/crates/infra/audit/src/lib.rs index a1ad86f755..932bd12e9b 100644 --- a/crates/infra/audit/src/lib.rs +++ b/crates/infra/audit/src/lib.rs @@ -7,33 +7,12 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -mod archiver; -pub use archiver::AuditArchiver; - mod metrics; pub use metrics::Metrics; -mod publisher; -pub use publisher::{BundleEventPublisher, LoggingBundleEventPublisher}; - -mod reader; -pub use reader::{Event, EventReader}; - mod rpc; pub use rpc::{AuditArchiverApiServer, AuditArchiverRpc}; -mod rpc_publisher; -pub use rpc_publisher::{DEFAULT_RPC_TIMEOUT, RpcBundleEventPublisher}; - -mod rpc_reader; -pub use rpc_reader::RpcEventReader; - -mod storage; -pub use storage::{ - BundleEventS3Reader, BundleHistory, BundleHistoryEvent, EventWriter, S3EventReaderWriter, - S3Key, TransactionMetadata, -}; - mod transaction_events; pub use transaction_events::{ DEFAULT_TRANSACTION_EVENT_BATCH_PATH, DEFAULT_TRANSACTION_EVENT_COLD_RETENTION_DAYS, @@ -52,110 +31,3 @@ pub use transaction_events::{ TransactionEventRetentionOutcome, TransactionEventSchemaReadinessError, TransactionEventSink, TransactionEventStorageError, }; - -mod types; -use core::time::Duration; - -use tokio::{ - sync::mpsc, - time::{Instant, sleep_until}, -}; -use tracing::{error, trace}; -pub use types::{BundleEvent, BundleId, DropReason, Transaction, TransactionId}; - -/// Connects bundle event receivers to publishers. -#[derive(Debug)] -pub struct AuditConnector; - -impl AuditConnector { - /// Connects a bundle event receiver to a publisher, batching events and - /// forwarding them in groups via [`BundleEventPublisher::publish_all`]. - /// - /// The batching policy is "deadline per batch": when the first event of an - /// otherwise-empty buffer arrives, a deadline of `now + batch_max_wait` is - /// established. The buffer is flushed when either: - /// - /// - the buffer reaches `batch_max_size`, or - /// - the deadline elapses with at least one buffered event. - /// - /// On flush, the deadline is dropped and the next incoming event starts a - /// fresh deadline. When `event_rx` is closed, any remaining buffered events - /// are flushed before the spawned task exits. - /// - /// Publish failures are logged and the offending batch is dropped; the - /// connector does not retry and does not apply backpressure to `event_rx`. - pub fn connect_batched

( - event_rx: mpsc::Receiver, - publisher: P, - batch_max_size: usize, - batch_max_wait: Duration, - ) where - P: BundleEventPublisher + 'static, - { - tokio::spawn(async move { - let mut event_rx = event_rx; - let mut buffer: Vec = Vec::with_capacity(batch_max_size); - let mut deadline: Option = None; - - loop { - let recv_result = match deadline { - Some(d) => { - tokio::select! { - maybe_event = event_rx.recv() => maybe_event, - () = sleep_until(d) => { - Self::flush(&publisher, &mut buffer).await; - deadline = None; - continue; - } - } - } - None => event_rx.recv().await, - }; - - match recv_result { - Some(event) => { - buffer.push(event); - if deadline.is_none() { - deadline = Some(Instant::now() + batch_max_wait); - } - if buffer.len() >= batch_max_size { - Self::flush(&publisher, &mut buffer).await; - deadline = None; - } - } - None => { - // Channel closed: flush any remaining events and exit. - if !buffer.is_empty() { - Self::flush(&publisher, &mut buffer).await; - } - break; - } - } - } - }); - } - - /// Drains `buffer` and ships it via `publisher.publish_all`. Errors are - /// logged and swallowed; the batch is dropped. - async fn flush

(publisher: &P, buffer: &mut Vec) - where - P: BundleEventPublisher, - { - if buffer.is_empty() { - return; - } - let batch: Vec = std::mem::take(buffer); - let batch_size = batch.len(); - match publisher.publish_all(batch).await { - Ok(()) => trace!(batch_size, "Flushed bundle event batch"), - Err(e) => { - error!( - error = %e, - batch_size, - "Failed to publish bundle event batch; batch dropped" - ); - Metrics::rpc_publish_failures("rpc_error").increment(1); - } - } - } -} diff --git a/crates/infra/audit/src/metrics.rs b/crates/infra/audit/src/metrics.rs index 5f29aaf74f..584ef1a436 100644 --- a/crates/infra/audit/src/metrics.rs +++ b/crates/infra/audit/src/metrics.rs @@ -1,43 +1,7 @@ -//! Metrics for audit operations including event reads, S3 writes, and event processing. +//! Metrics for Postgres-backed transaction event ingest and retention. base_metrics::define_metrics! { tips_audit - #[describe("Duration of archive_event")] - archive_event_duration: histogram, - #[describe("Age of event when processed (now - event timestamp)")] - event_age: histogram, - #[describe("Duration of read_event")] - read_duration: histogram, - #[describe("Duration of event commit")] - commit_duration: histogram, - #[describe("Duration of update_bundle_history")] - update_bundle_history_duration: histogram, - #[describe("Duration of update all transaction indexes")] - update_tx_indexes_duration: histogram, - #[describe("Duration of S3 get_object")] - s3_get_duration: histogram, - #[describe("Duration of S3 put_object")] - s3_put_duration: histogram, - #[describe("Total events processed")] - events_processed: counter, - #[describe("Total S3 writes skipped due to dedup")] - s3_writes_skipped: counter, - #[describe("Total S3 conditional write conflicts (412/409)")] - s3_conditional_conflicts: counter, - #[describe("Number of in-flight archive tasks")] - in_flight_archive_tasks: gauge, - #[describe("Number of failed archive tasks")] - failed_archive_tasks: counter, - #[describe("Bundle event batches that failed to publish over RPC and were dropped")] - #[label(name = "reason", default = ["rpc_error"])] - rpc_publish_failures: counter, - #[describe("Bundle events deduplicated by the RPC reader cache")] - rpc_cache_hits: counter, - #[describe("Bundle events that missed the RPC reader cache and were forwarded")] - rpc_cache_misses: counter, - #[describe("Bundle events dropped because the RPC reader channel could not accept them")] - #[label(name = "kind", default = ["full", "closed"])] - rpc_channel_send_failures: counter, #[describe("Transaction observability events received over HTTP")] transaction_events_received: counter, #[describe("Transaction observability events newly persisted to Postgres")] diff --git a/crates/infra/audit/src/publisher.rs b/crates/infra/audit/src/publisher.rs deleted file mode 100644 index d4fef6fee0..0000000000 --- a/crates/infra/audit/src/publisher.rs +++ /dev/null @@ -1,51 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use tracing::info; - -use crate::types::BundleEvent; - -/// Trait for publishing bundle events. -#[async_trait] -pub trait BundleEventPublisher: Send + Sync { - /// Publishes a single bundle event. - async fn publish(&self, event: BundleEvent) -> Result<()>; - - /// Publishes multiple bundle events. - async fn publish_all(&self, events: Vec) -> Result<()>; -} - -/// Publishes bundle events to logs (for testing/debugging). -#[derive(Clone, Debug)] -pub struct LoggingBundleEventPublisher; - -impl LoggingBundleEventPublisher { - /// Creates a new logging bundle event publisher. - pub const fn new() -> Self { - Self - } -} - -impl Default for LoggingBundleEventPublisher { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl BundleEventPublisher for LoggingBundleEventPublisher { - async fn publish(&self, event: BundleEvent) -> Result<()> { - info!( - bundle_id = %event.bundle_id(), - event = ?event, - "Received bundle event" - ); - Ok(()) - } - - async fn publish_all(&self, events: Vec) -> Result<()> { - for event in events { - self.publish(event).await?; - } - Ok(()) - } -} diff --git a/crates/infra/audit/src/reader.rs b/crates/infra/audit/src/reader.rs deleted file mode 100644 index 6f4740d02a..0000000000 --- a/crates/infra/audit/src/reader.rs +++ /dev/null @@ -1,24 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; - -use crate::types::BundleEvent; - -/// A bundle event with metadata. -#[derive(Debug, Clone)] -pub struct Event { - /// The event key. - pub key: String, - /// The bundle event. - pub event: BundleEvent, - /// The event timestamp in milliseconds. - pub timestamp: i64, -} - -/// Trait for reading bundle events. -#[async_trait] -pub trait EventReader { - /// Reads the next event. - async fn read_event(&mut self) -> Result; - /// Commits the last read message. - async fn commit(&mut self) -> Result<()>; -} diff --git a/crates/infra/audit/src/rpc.rs b/crates/infra/audit/src/rpc.rs index 97b159a12b..00a78162ad 100644 --- a/crates/infra/audit/src/rpc.rs +++ b/crates/infra/audit/src/rpc.rs @@ -1,47 +1,18 @@ //! RPC server for the audit archiver. -//! -//! Exposes the `base_persistRejectedTransactionBatch` method for receiving batches -//! of rejected transactions from the builder and persisting them to S3. +//! Serves Postgres-backed transaction event queries. -use std::{ - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use base_bundles::RejectedTransaction; -use futures::stream::{self, StreamExt}; use jsonrpsee::{core::RpcResult, proc_macros::rpc, types::error::ErrorObjectOwned}; use jsonrpsee_types::error::ErrorCode; -use moka::sync::Cache; -use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; +use tracing::error; use crate::{ DEFAULT_TRANSACTION_EVENT_QUERY_LIMIT, PgTransactionEventSink, RejectedTransactionEventQuery, - TransactionEventRecord, metrics::Metrics, reader::Event, storage::S3EventReaderWriter, - types::BundleEvent, + TransactionEventRecord, }; -const MAX_BATCH_SIZE: usize = 500; - /// RPC trait for the audit archiver. #[rpc(server, namespace = "base")] pub trait AuditArchiverApi { - /// Persists a batch of rejected transactions to S3 storage. - /// Returns the number of items successfully persisted. - #[method(name = "persistRejectedTransactionBatch")] - async fn persist_rejected_transaction_batch( - &self, - batch: Vec, - ) -> RpcResult; - - /// Forwards a batch of bundle lifecycle events to the in-process event - /// reader for archival, deduplicating by `BundleEvent::generate_event_key` - /// using an in-memory LRU cache. Returns the number of unique events - /// forwarded (after dedup). - #[method(name = "persistBatchedBundleEvent")] - async fn persist_batched_bundle_event(&self, batch: Vec) -> RpcResult; - /// Returns Postgres-backed transaction event history for one transaction hash. #[method(name = "getTransactionEventsByHash")] async fn get_transaction_events_by_hash( @@ -85,195 +56,24 @@ pub trait AuditArchiverApi { /// RPC handler for audit archiver requests. #[derive(Debug)] pub struct AuditArchiverRpc { - storage: Arc, - bundle_events: Option, - transaction_events: Option, -} - -/// In-memory dedup + forwarding pipeline for bundle events received over RPC. -/// -/// Uses [`moka::sync::Cache`] intentionally: `forward_batch` is synchronous -/// (no `.await`), so the sync variant avoids async overhead on every -/// get/insert in the hot path. Eviction housekeeping runs inline but is -/// bounded and fast at the configured capacity. -#[derive(Debug, Clone)] -struct BundleEventForwarder { - cache: Cache, - event_tx: mpsc::Sender, -} - -impl BundleEventForwarder { - /// Forwards a batch of bundle events into the event channel, deduplicating - /// against `self.cache`. Returns the count of unique events forwarded. - fn forward_batch(&self, batch: Vec, timestamp: i64) -> u32 { - let mut forwarded: u32 = 0; - for bundle_event in batch { - let key = bundle_event.generate_event_key(); - - // Best-effort dedup: concurrent RPC calls for the same key can - // both miss the cache (TOCTOU), but S3 enforces final dedup via - // conditional PUTs, so duplicates here are harmless. - if self.cache.get(&key).is_some() { - Metrics::rpc_cache_hits().increment(1); - continue; - } - Metrics::rpc_cache_misses().increment(1); - - let event = Event { key: key.clone(), event: bundle_event, timestamp }; - match self.event_tx.try_send(event) { - Ok(()) => { - // Insert after successful send so a Full/Closed drop - // doesn't poison the cache — the event can be retried on - // a later RPC call. Worst case: a concurrent call also - // forwards the same key; S3 dedup handles that. - self.cache.insert(key, ()); - forwarded += 1; - } - Err(mpsc::error::TrySendError::Full(_)) => { - warn!(key = %key, "audit event channel full; dropping event"); - Metrics::rpc_channel_send_failures("full").increment(1); - } - Err(mpsc::error::TrySendError::Closed(_)) => { - error!("audit event channel closed; dropping batch remainder"); - Metrics::rpc_channel_send_failures("closed").increment(1); - break; - } - } - } - forwarded - } + transaction_events: PgTransactionEventSink, } impl AuditArchiverRpc { - /// Creates a new `AuditArchiverRpc` that only handles rejected-transaction - /// batches. The bundle-event RPC method will return an error. - pub const fn new(storage: Arc) -> Self { - Self { storage, bundle_events: None, transaction_events: None } - } - - /// Creates a new `AuditArchiverRpc` configured to also accept bundle-event - /// batches over RPC, deduplicating via `cache` and forwarding unique - /// events to `event_tx`. - pub const fn with_bundle_events( - storage: Arc, - cache: Cache, - event_tx: mpsc::Sender, - ) -> Self { - Self { - storage, - bundle_events: Some(BundleEventForwarder { cache, event_tx }), - transaction_events: None, - } - } - - /// Attaches the Postgres transaction event store used by read APIs. - pub fn with_transaction_event_store(mut self, store: PgTransactionEventSink) -> Self { - self.transaction_events = Some(store); - self - } - - fn transaction_event_store(&self) -> RpcResult<&PgTransactionEventSink> { - self.transaction_events.as_ref().ok_or_else(|| { - ErrorObjectOwned::owned( - ErrorCode::InternalError.code(), - "Transaction event Postgres store is not configured on this audit archiver", - None::<()>, - ) - }) + /// Creates an RPC handler backed by the transaction event Postgres store. + pub const fn new(transaction_events: PgTransactionEventSink) -> Self { + Self { transaction_events } } } #[async_trait::async_trait] impl AuditArchiverApiServer for AuditArchiverRpc { - async fn persist_rejected_transaction_batch( - &self, - batch: Vec, - ) -> RpcResult { - if batch.is_empty() { - return Ok(0); - } - - let batch_size = batch.len(); - if batch_size > MAX_BATCH_SIZE { - return Err(ErrorObjectOwned::owned( - ErrorCode::InvalidParams.code(), - format!("Batch size {batch_size} exceeds maximum of {MAX_BATCH_SIZE}"), - None::<()>, - )); - } - - let block_number = batch.first().map(|tx| tx.block_number).unwrap_or(0); - - info!(batch_size, block_number, "Persisting rejected transaction batch"); - - // Clone the Arc to release the borrow on `&self` so the jsonrpsee server can dispatch - // additional concurrent batch RPC calls while this batch's S3 writes are in flight. - let storage = Arc::clone(&self.storage); - - // Peform the S3 operations in parallel on the batch. Up to 5 concurrent operations at a time. - let persisted = stream::iter(batch) - .map(move |tx| { - let storage = Arc::clone(&storage); - async move { - let result = storage.store_rejected_transaction(&tx).await; - (tx, result) - } - }) - .buffer_unordered(5) - .fold(0u32, |persisted, (tx, result)| async move { - if let Err(e) = result { - error!( - error = %e, - tx_hash = %tx.tx_hash, - "Failed to persist rejected transaction" - ); - persisted - } else { - persisted + 1 - } - }) - .await; - - Ok(persisted) - } - - async fn persist_batched_bundle_event(&self, batch: Vec) -> RpcResult { - if batch.is_empty() { - return Ok(0); - } - - let batch_size = batch.len(); - if batch_size > MAX_BATCH_SIZE { - return Err(ErrorObjectOwned::owned( - ErrorCode::InvalidParams.code(), - format!("Batch size {batch_size} exceeds maximum of {MAX_BATCH_SIZE}"), - None::<()>, - )); - } - - let forwarder = self.bundle_events.as_ref().ok_or_else(|| { - ErrorObjectOwned::owned( - ErrorCode::InternalError.code(), - "Bundle event forwarding not configured on this audit archiver", - None::<()>, - ) - })?; - - let timestamp = - SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64; - - let forwarded = forwarder.forward_batch(batch, timestamp); - - debug!(batch_size, forwarded, "Forwarded bundle event batch"); - Ok(forwarded) - } - async fn get_transaction_events_by_hash( &self, tx_hash: String, limit: Option, ) -> RpcResult> { - self.transaction_event_store()? + self.transaction_events .events_by_transaction_hash( &tx_hash, limit.unwrap_or(DEFAULT_TRANSACTION_EVENT_QUERY_LIMIT), @@ -287,7 +87,7 @@ impl AuditArchiverApiServer for AuditArchiverRpc { block_number: u64, limit: Option, ) -> RpcResult> { - self.transaction_event_store()? + self.transaction_events .events_by_block_number( block_number, limit.unwrap_or(DEFAULT_TRANSACTION_EVENT_QUERY_LIMIT), @@ -301,7 +101,7 @@ impl AuditArchiverApiServer for AuditArchiverRpc { block_hash: String, limit: Option, ) -> RpcResult> { - self.transaction_event_store()? + self.transaction_events .events_by_block_hash( &block_hash, limit.unwrap_or(DEFAULT_TRANSACTION_EVENT_QUERY_LIMIT), @@ -315,7 +115,7 @@ impl AuditArchiverApiServer for AuditArchiverRpc { bundle_key: String, limit: Option, ) -> RpcResult> { - self.transaction_event_store()? + self.transaction_events .events_by_bundle(&bundle_key, limit.unwrap_or(DEFAULT_TRANSACTION_EVENT_QUERY_LIMIT)) .await .map_err(internal_rpc_error) @@ -325,10 +125,7 @@ impl AuditArchiverApiServer for AuditArchiverRpc { &self, query: RejectedTransactionEventQuery, ) -> RpcResult> { - self.transaction_event_store()? - .rejected_transaction_events(query) - .await - .map_err(internal_rpc_error) + self.transaction_events.rejected_transaction_events(query).await.map_err(internal_rpc_error) } } @@ -343,82 +140,26 @@ fn internal_rpc_error(error: anyhow::Error) -> ErrorObjectOwned { #[cfg(test)] mod tests { - use std::time::Duration; - - use base_bundles::{BundleExtensions, test_utils::create_bundle_from_txn_data}; - use moka::sync::Cache; - use tokio::sync::mpsc; - use uuid::Uuid; - use super::*; - fn build_forwarder(channel_capacity: usize) -> (BundleEventForwarder, mpsc::Receiver) { - let cache: Cache = - Cache::builder().max_capacity(1024).time_to_live(Duration::from_secs(60)).build(); - let (tx, rx) = mpsc::channel(channel_capacity); - (BundleEventForwarder { cache, event_tx: tx }, rx) - } - - fn received_event() -> BundleEvent { - let bundle = create_bundle_from_txn_data(); - let bundle_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, bundle.bundle_hash().as_slice()); - BundleEvent::Received { bundle_id, bundle: Box::new(bundle) } - } - - #[tokio::test] - async fn forward_batch_dedupes_within_a_single_batch() { - let (forwarder, mut rx) = build_forwarder(8); - let event = received_event(); - let batch = vec![event.clone(), event.clone(), event]; - - let forwarded = forwarder.forward_batch(batch, 0); - - assert_eq!(forwarded, 1, "duplicates within one batch should collapse to a single forward"); - let first = rx.recv().await.expect("first event should arrive"); - assert!(rx.try_recv().is_err(), "no more events should be forwarded"); - assert!(first.key.starts_with("received-"), "key should be derived from bundle hash"); - } - - #[tokio::test] - async fn forward_batch_dedupes_across_batches() { - let (forwarder, mut rx) = build_forwarder(8); - let event = received_event(); - - let first = forwarder.forward_batch(vec![event.clone()], 0); - let second = forwarder.forward_batch(vec![event], 0); - - assert_eq!(first, 1, "first occurrence should forward"); - assert_eq!(second, 0, "second occurrence should be dropped by cache"); - rx.recv().await.expect("exactly one event should land"); - assert!(rx.try_recv().is_err(), "no second event should land"); - } - #[tokio::test] - async fn forward_batch_propagates_unique_events() { - let (forwarder, mut rx) = build_forwarder(8); - let bundle_a = create_bundle_from_txn_data(); - let id_a = Uuid::new_v5(&Uuid::NAMESPACE_OID, bundle_a.bundle_hash().as_slice()); - let event_a = BundleEvent::Received { bundle_id: id_a, bundle: Box::new(bundle_a) }; - let event_b = BundleEvent::Cancelled { bundle_id: Uuid::new_v4() }; - - let forwarded = forwarder.forward_batch(vec![event_a, event_b], 12345); - - assert_eq!(forwarded, 2, "two distinct events should both forward"); - let first = rx.recv().await.unwrap(); - let second = rx.recv().await.unwrap(); - assert_eq!(first.timestamp, 12345, "timestamp should propagate"); - assert_eq!(second.timestamp, 12345, "timestamp should propagate"); - } - - #[tokio::test] - async fn forward_batch_stops_on_closed_channel() { - let (forwarder, rx) = build_forwarder(8); - drop(rx); - - let event_a = received_event(); - let event_b = BundleEvent::Cancelled { bundle_id: Uuid::new_v4() }; - let forwarded = forwarder.forward_batch(vec![event_a, event_b], 0); - - assert_eq!(forwarded, 0, "closed channel should forward zero events"); + async fn retired_write_rpcs_do_not_acknowledge_events() -> anyhow::Result<()> { + let pool = sqlx::PgPool::connect_lazy("postgres://localhost/audit_test")?; + let rpc = AuditArchiverRpc::new(PgTransactionEventSink::new(pool)).into_rpc(); + + for method in ["base_persistBatchedBundleEvent", "base_persistRejectedTransactionBatch"] { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + "params": [[]], + "id": 1, + }) + .to_string(); + let (response, _subscriptions) = rpc.raw_json_request(&request, 1).await?; + let response: serde_json::Value = serde_json::from_str(response.get())?; + assert_eq!(response["error"]["code"], -32601, "{method} must fail explicitly"); + assert!(response.get("result").is_none(), "{method} must not claim persistence"); + } + Ok(()) } } diff --git a/crates/infra/audit/src/rpc_publisher.rs b/crates/infra/audit/src/rpc_publisher.rs deleted file mode 100644 index 855308788c..0000000000 --- a/crates/infra/audit/src/rpc_publisher.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! HTTP-RPC bundle event publisher. -//! -//! `RpcBundleEventPublisher` implements [`BundleEventPublisher`] by sending -//! batches of [`BundleEvent`]s to the audit-archiver service over JSON-RPC -//! using the `base_persistBatchedBundleEvent` method. The batching cadence -//! (size + max-wait) is owned by `AuditConnector::connect_batched`; this -//! type only takes a `Vec` and ships it. - -use core::time::Duration; - -use anyhow::Result; -use async_trait::async_trait; -use jsonrpsee::{ - core::client::ClientT, - http_client::{HttpClient, HttpClientBuilder}, - rpc_params, -}; -use tracing::debug; - -use crate::{publisher::BundleEventPublisher, types::BundleEvent}; - -/// Default request timeout for the audit-archiver RPC client. -pub const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(2); - -/// Publishes bundle events to the audit-archiver service over JSON-RPC. -#[derive(Debug, Clone)] -pub struct RpcBundleEventPublisher { - client: HttpClient, -} - -impl RpcBundleEventPublisher { - /// Creates a new RPC publisher targeting the audit-archiver at `url` with - /// the given request timeout. - pub fn new(url: &str, request_timeout: Duration) -> Result { - let client = HttpClientBuilder::default() - .request_timeout(request_timeout) - .build(url) - .map_err(|e| anyhow::anyhow!("Failed to build audit-archiver HTTP client: {e}"))?; - Ok(Self { client }) - } - - /// Convenience constructor using [`DEFAULT_RPC_TIMEOUT`]. - pub fn with_default_timeout(url: &str) -> Result { - Self::new(url, DEFAULT_RPC_TIMEOUT) - } -} - -#[async_trait] -impl BundleEventPublisher for RpcBundleEventPublisher { - async fn publish(&self, event: BundleEvent) -> Result<()> { - self.publish_all(vec![event]).await - } - - async fn publish_all(&self, events: Vec) -> Result<()> { - if events.is_empty() { - return Ok(()); - } - let batch_size = events.len(); - match self - .client - .request::("base_persistBatchedBundleEvent", rpc_params![&events]) - .await - { - Ok(forwarded) => { - debug!(batch_size, forwarded, "Forwarded bundle event batch to audit-archiver"); - Ok(()) - } - Err(e) => Err(anyhow::anyhow!("RPC call failed: {e}")), - } - } -} diff --git a/crates/infra/audit/src/rpc_reader.rs b/crates/infra/audit/src/rpc_reader.rs deleted file mode 100644 index 30e329104e..0000000000 --- a/crates/infra/audit/src/rpc_reader.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! RPC-fed event reader. -//! -//! `RpcEventReader` implements [`EventReader`] by receiving [`Event`]s from a -//! tokio mpsc channel. The other side of the channel is fed by the audit -//! service's RPC handler (`AuditArchiverRpc::persist_batched_bundle_event`), -//! which performs in-memory dedup via a moka cache before forwarding events -//! into this reader. -//! -//! Because RPC delivery is at-most-once and there is no broker offset to -//! commit, [`commit`](RpcEventReader::commit) is a no-op. - -use anyhow::Result; -use async_trait::async_trait; -use tokio::sync::mpsc; - -use crate::reader::{Event, EventReader}; - -/// Reads bundle audit events from an in-process mpsc channel fed by the audit -/// RPC handler. -#[derive(Debug)] -pub struct RpcEventReader { - event_rx: mpsc::Receiver, -} - -impl RpcEventReader { - /// Creates a new RPC event reader that pulls events from `event_rx`. - pub const fn new(event_rx: mpsc::Receiver) -> Self { - Self { event_rx } - } -} - -#[async_trait] -impl EventReader for RpcEventReader { - async fn read_event(&mut self) -> Result { - self.event_rx.recv().await.ok_or_else(|| anyhow::anyhow!("RPC event channel closed")) - } - - async fn commit(&mut self) -> Result<()> { - // RPC delivery is at-most-once; no offset to commit. - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use base_bundles::{BundleExtensions, test_utils::create_bundle_from_txn_data}; - use tokio::sync::mpsc; - use uuid::Uuid; - - use super::*; - use crate::types::BundleEvent; - - fn sample_event(key: &str) -> Event { - let bundle = create_bundle_from_txn_data(); - let bundle_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, bundle.bundle_hash().as_slice()); - Event { - key: key.to_string(), - event: BundleEvent::Received { bundle_id, bundle: Box::new(bundle) }, - timestamp: 0, - } - } - - #[tokio::test] - async fn read_event_returns_sent_event() { - let (tx, rx) = mpsc::channel(8); - let mut reader = RpcEventReader::new(rx); - - let event = sample_event("received-0xabc"); - tx.send(event.clone()).await.unwrap(); - - let got = reader.read_event().await.unwrap(); - assert_eq!(got.key, event.key, "key should round-trip through the channel"); - } - - #[tokio::test] - async fn read_event_errors_when_channel_closed() { - let (tx, rx) = mpsc::channel::(1); - let mut reader = RpcEventReader::new(rx); - drop(tx); - - let err = reader.read_event().await.expect_err("closed channel should error"); - assert!( - err.to_string().contains("RPC event channel closed"), - "error should mention closed channel, got: {err}", - ); - } - - #[tokio::test] - async fn commit_is_noop() { - let (_tx, rx) = mpsc::channel::(1); - let mut reader = RpcEventReader::new(rx); - reader.commit().await.expect("commit should be a no-op success"); - } -} diff --git a/crates/infra/audit/src/storage.rs b/crates/infra/audit/src/storage.rs deleted file mode 100644 index ec27c5e1bb..0000000000 --- a/crates/infra/audit/src/storage.rs +++ /dev/null @@ -1,609 +0,0 @@ -use std::{fmt, fmt::Debug, time::Instant}; - -use alloy_primitives::TxHash; -use anyhow::Result; -use async_trait::async_trait; -use aws_sdk_s3::{ - Client as S3Client, - error::SdkError, - operation::{ - get_object::GetObjectError, list_objects_v2::ListObjectsV2Output, - put_object::PutObjectError, - }, - primitives::ByteStream, -}; -use base_bundles::{AcceptedBundle, BundleExtensions, RejectedTransaction}; -use futures::future; -use serde::{Deserialize, Serialize}; -use tracing::{debug, info, warn}; - -use crate::{ - metrics::Metrics, - reader::Event, - types::{BundleEvent, DropReason, TransactionId}, -}; - -/// S3 key types for storing different event types. -#[derive(Debug)] -pub enum S3Key { - /// Key for transaction lookups by hash. - TransactionByHash(TxHash), - /// Key for rejected transaction storage. - Rejected(u64, TxHash), -} - -impl fmt::Display for S3Key { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::TransactionByHash(hash) => write!(f, "transactions/by_hash/{hash}"), - Self::Rejected(block_number, tx_hash) => { - write!(f, "rejected/{block_number}/{tx_hash}") - } - } - } -} - -/// Metadata for a transaction, tracking which bundles it belongs to. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TransactionMetadata { - /// Bundle identifiers that contain this transaction. - /// - /// Stored as strings for backwards compatibility — old S3 objects contain - /// UUIDs, new objects contain `B256` hex hashes. - pub bundle_ids: Vec, -} - -/// History event for a bundle. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "event", content = "data")] -pub enum BundleHistoryEvent { - /// Bundle was received. - Received { - /// Event key. - key: String, - /// Event timestamp. - timestamp: i64, - /// The accepted bundle. - bundle: Box, - }, - /// Bundle was cancelled. - Cancelled { - /// Event key. - key: String, - /// Event timestamp. - timestamp: i64, - }, - /// Bundle was included by a builder. - BuilderIncluded { - /// Event key. - key: String, - /// Event timestamp. - timestamp: i64, - /// Builder identifier. - builder: String, - /// Block number. - block_number: u64, - /// Flashblock index. - flashblock_index: u64, - }, - /// Bundle was included in a block. - BlockIncluded { - /// Event key. - key: String, - /// Event timestamp. - timestamp: i64, - /// Block number. - block_number: u64, - /// Block hash. - block_hash: TxHash, - }, - /// Bundle was dropped. - Dropped { - /// Event key. - key: String, - /// Event timestamp. - timestamp: i64, - /// Drop reason. - reason: DropReason, - }, -} - -impl BundleHistoryEvent { - /// Returns the event key. - pub fn key(&self) -> &str { - match self { - Self::Received { key, .. } - | Self::Cancelled { key, .. } - | Self::BuilderIncluded { key, .. } - | Self::BlockIncluded { key, .. } - | Self::Dropped { key, .. } => key, - } - } -} - -/// History of events for a bundle. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct BundleHistory { - /// List of history events. - pub history: Vec, -} - -fn to_history_event(event: &Event) -> BundleHistoryEvent { - match &event.event { - BundleEvent::Received { bundle, .. } => BundleHistoryEvent::Received { - key: event.key.clone(), - timestamp: event.timestamp, - bundle: bundle.clone(), - }, - BundleEvent::Cancelled { .. } => { - BundleHistoryEvent::Cancelled { key: event.key.clone(), timestamp: event.timestamp } - } - BundleEvent::BuilderIncluded { builder, block_number, flashblock_index, .. } => { - BundleHistoryEvent::BuilderIncluded { - key: event.key.clone(), - timestamp: event.timestamp, - builder: builder.clone(), - block_number: *block_number, - flashblock_index: *flashblock_index, - } - } - BundleEvent::BlockIncluded { block_number, block_hash, .. } => { - BundleHistoryEvent::BlockIncluded { - key: event.key.clone(), - timestamp: event.timestamp, - block_number: *block_number, - block_hash: *block_hash, - } - } - BundleEvent::Dropped { reason, .. } => BundleHistoryEvent::Dropped { - key: event.key.clone(), - timestamp: event.timestamp, - reason: reason.clone(), - }, - } -} - -fn update_transaction_metadata_transform( - transaction_metadata: TransactionMetadata, - bundle_key: String, -) -> Option { - let mut bundle_ids = transaction_metadata.bundle_ids; - - if bundle_ids.contains(&bundle_key) { - return None; - } - - bundle_ids.push(bundle_key); - Some(TransactionMetadata { bundle_ids }) -} - -/// Trait for writing bundle events to storage. -#[async_trait] -pub trait EventWriter { - /// Archives a bundle event. - async fn archive_event(&self, event: Event) -> Result<()>; -} - -/// Trait for reading bundle events from S3. -#[async_trait] -pub trait BundleEventS3Reader { - /// Gets the bundle history by its S3 key suffix (`bundle_hash` or `bundle_id`). - async fn get_bundle_history(&self, bundle_key: &str) -> Result>; - /// Gets transaction metadata for a given transaction hash. - async fn get_transaction_metadata( - &self, - tx_hash: TxHash, - ) -> Result>; -} - -/// S3-backed event reader and writer. -#[derive(Clone, Debug)] -pub struct S3EventReaderWriter { - s3_client: S3Client, - bucket: String, -} - -impl S3EventReaderWriter { - /// Creates a new S3 event reader/writer. - pub const fn new(s3_client: S3Client, bucket: String) -> Self { - Self { s3_client, bucket } - } - - /// Stores a rejected transaction to S3. - pub async fn store_rejected_transaction( - &self, - rejected_tx: &RejectedTransaction, - ) -> Result<()> { - let s3_key = S3Key::Rejected(rejected_tx.block_number, rejected_tx.tx_hash).to_string(); - let content = serde_json::to_string(rejected_tx)?; - self.s3_client - .put_object() - .bucket(&self.bucket) - .key(&s3_key) - .body(ByteStream::from(content.into_bytes())) - .send() - .await - .map_err(|e| anyhow::anyhow!("Failed to store rejected transaction: {e}"))?; - Ok(()) - } - - /// Retrieves a rejected transaction from S3. - pub async fn get_rejected_transaction( - &self, - block_number: u64, - tx_hash: TxHash, - ) -> Result> { - let s3_key = S3Key::Rejected(block_number, tx_hash).to_string(); - let (rejected_tx, _) = self.get_object_with_etag::(&s3_key).await?; - Ok(rejected_tx) - } - - /// Writes a single event as a standalone S3 object using `If-None-Match: *`. - /// - /// If the object already exists (412), another writer succeeded first — return Ok. - async fn write_event(&self, event: &Event) -> Result<()> { - let s3_key = event.event.s3_event_key(); - let history_event = to_history_event(event); - let content = serde_json::to_string(&history_event)?; - - let put_request = self - .s3_client - .put_object() - .bucket(&self.bucket) - .key(&s3_key) - .body(ByteStream::from(content.into_bytes())) - .if_none_match("*"); - - let put_start = Instant::now(); - match put_request.send().await { - Ok(_) => { - Metrics::s3_put_duration().record(put_start.elapsed().as_secs_f64()); - debug!(s3_key = %s3_key, "wrote event to S3"); - Ok(()) - } - Err(ref e) if Self::is_conditional_write_conflict(e) => { - Metrics::s3_put_duration().record(put_start.elapsed().as_secs_f64()); - Metrics::s3_conditional_conflicts().increment(1); - debug!(s3_key = %s3_key, "event already exists in S3, skipping"); - Ok(()) - } - Err(e) => { - // TODO: retry with exponential backoff - Metrics::s3_put_duration().record(put_start.elapsed().as_secs_f64()); - Err(anyhow::anyhow!("failed to write event to S3: {e}")) - } - } - } - - async fn update_transaction_by_hash_index( - &self, - tx_id: &TransactionId, - bundle_key: String, - ) -> Result<()> { - let s3_key = S3Key::TransactionByHash(tx_id.hash); - let key = s3_key.to_string(); - - self.idempotent_write::(&key, |current_metadata| { - update_transaction_metadata_transform(current_metadata, bundle_key.clone()) - }) - .await - } - - /// Returns true if the error is a conditional write conflict (412 or 409). - /// - /// S3 returns 412 Precondition Failed when `If-Match` / `If-None-Match` conditions - /// are not met, and 409 Conditional Request Conflict for concurrent writes. In both - /// cases another writer already succeeded, so the caller can re-read and skip. - fn is_conditional_write_conflict(err: &SdkError) -> bool { - match err { - SdkError::ServiceError(service_err) => { - matches!( - service_err.err().meta().code(), - Some("PreconditionFailed" | "ConditionalRequestConflict") - ) - } - SdkError::ResponseError(resp) => { - let status = resp.raw().status().as_u16(); - status == 412 || status == 409 - } - _ => false, - } - } - - async fn idempotent_write(&self, key: &str, mut transform_fn: F) -> Result<()> - where - T: for<'de> Deserialize<'de> + Serialize + Default + Debug, - F: FnMut(T) -> Option, - { - const MAX_RETRIES: usize = 5; - const BASE_DELAY_MS: u64 = 100; - - for attempt in 0..MAX_RETRIES { - let get_start = Instant::now(); - let (current_value, etag) = self.get_object_with_etag::(key).await?; - Metrics::s3_get_duration().record(get_start.elapsed().as_secs_f64()); - - let value = current_value.unwrap_or_default(); - - match transform_fn(value) { - Some(new_value) => { - let content = serde_json::to_string(&new_value)?; - - let mut put_request = self - .s3_client - .put_object() - .bucket(&self.bucket) - .key(key) - .body(ByteStream::from(content.into_bytes())); - - if let Some(etag) = etag { - put_request = put_request.if_match(etag); - } else { - put_request = put_request.if_none_match("*"); - } - - let put_start = Instant::now(); - match put_request.send().await { - Ok(_) => { - Metrics::s3_put_duration().record(put_start.elapsed().as_secs_f64()); - debug!( - s3_key = %key, - attempt = attempt + 1, - "Successfully wrote object with idempotent write" - ); - return Ok(()); - } - Err(ref e) if Self::is_conditional_write_conflict(e) => { - Metrics::s3_put_duration().record(put_start.elapsed().as_secs_f64()); - Metrics::s3_conditional_conflicts().increment(1); - debug!( - s3_key = %key, - attempt = attempt + 1, - "Conditional write conflict, another writer succeeded" - ); - return Ok(()); - } - Err(e) => { - Metrics::s3_put_duration().record(put_start.elapsed().as_secs_f64()); - - if attempt < MAX_RETRIES - 1 { - let delay = BASE_DELAY_MS * 2_u64.pow(attempt as u32); - warn!( - s3_key = %key, - attempt = attempt + 1, - delay_ms = delay, - error = %e, - "S3 put failed, retrying with backoff" - ); - tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await; - } else { - return Err(anyhow::anyhow!( - "Failed to write after {MAX_RETRIES} attempts: {e}" - )); - } - } - } - } - None => { - Metrics::s3_writes_skipped().increment(1); - info!(s3_key = %key, "transform returned None, no write required"); - return Ok(()); - } - } - } - - Err(anyhow::anyhow!("Exceeded maximum retry attempts")) - } - - async fn get_object_with_etag(&self, key: &str) -> Result<(Option, Option)> - where - T: for<'de> Deserialize<'de>, - { - match self.s3_client.get_object().bucket(&self.bucket).key(key).send().await { - Ok(response) => { - let etag = response.e_tag().map(|s| s.to_string()); - let body = response.body.collect().await?; - let value: T = serde_json::from_slice(&body.into_bytes())?; - Ok((Some(value), etag)) - } - Err(e) => match &e { - SdkError::ServiceError(service_err) => match service_err.err() { - GetObjectError::NoSuchKey(_) => Ok((None, None)), - _ => Err(anyhow::anyhow!("Failed to get object: {e}")), - }, - _ => { - let error_string = e.to_string(); - if error_string.contains("NoSuchKey") - || error_string.contains("NotFound") - || error_string.contains("404") - { - Ok((None, None)) - } else { - Err(anyhow::anyhow!("Failed to get object: {e}")) - } - } - }, - } - } -} - -#[async_trait] -impl EventWriter for S3EventReaderWriter { - async fn archive_event(&self, event: Event) -> Result<()> { - let bundle_key = match &event.event { - BundleEvent::Received { bundle, .. } => format!("{}", bundle.bundle_hash()), - // TODO: support other event types using bundle hash - _ => { - anyhow::bail!("archive_event only supports Received events") - } - }; - let transaction_ids = event.event.transaction_ids(); - - let event_start = Instant::now(); - let event_future = self.write_event(&event); - - let tx_start = Instant::now(); - let tx_futures: Vec<_> = transaction_ids - .into_iter() - .map(|tx_id| { - let bk = bundle_key.clone(); - async move { self.update_transaction_by_hash_index(&tx_id, bk).await } - }) - .collect(); - - tokio::try_join!(event_future, future::try_join_all(tx_futures))?; - - Metrics::update_bundle_history_duration().record(event_start.elapsed().as_secs_f64()); - Metrics::update_tx_indexes_duration().record(tx_start.elapsed().as_secs_f64()); - - Ok(()) - } -} - -#[async_trait] -impl BundleEventS3Reader for S3EventReaderWriter { - async fn get_bundle_history(&self, bundle_key: &str) -> Result> { - let prefix = format!("bundles/{bundle_key}/"); - let list_output: ListObjectsV2Output = - self.s3_client.list_objects_v2().bucket(&self.bucket).prefix(&prefix).send().await?; - - let keys: Vec = - list_output.contents().iter().filter_map(|obj| obj.key().map(String::from)).collect(); - - if keys.is_empty() { - return Ok(None); - } - - let mut history = Vec::new(); - for key in &keys { - let (event, _) = self.get_object_with_etag::(key).await?; - if let Some(event) = event { - history.push(event); - } - } - - Ok(Some(BundleHistory { history })) - } - - async fn get_transaction_metadata( - &self, - tx_hash: TxHash, - ) -> Result> { - let s3_key = S3Key::TransactionByHash(tx_hash).to_string(); - let (transaction_metadata, _) = - self.get_object_with_etag::(&s3_key).await?; - Ok(transaction_metadata) - } -} - -#[cfg(test)] -mod tests { - use alloy_primitives::TxHash; - use base_bundles::{BundleExtensions, test_utils::create_bundle_from_txn_data}; - use uuid::Uuid; - - use super::*; - use crate::{ - reader::Event, - types::{BundleEvent, DropReason}, - }; - - fn create_test_event(key: &str, timestamp: i64, bundle_event: BundleEvent) -> Event { - Event { key: key.to_string(), timestamp, event: bundle_event } - } - - #[test] - fn test_to_history_event_received() { - let bundle = create_bundle_from_txn_data(); - let bundle_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, bundle.bundle_hash().as_slice()); - let bundle_event = BundleEvent::Received { bundle_id, bundle: Box::new(bundle.clone()) }; - let event = create_test_event("test-key", 1234567890, bundle_event); - - let history_event = to_history_event(&event); - - match &history_event { - BundleHistoryEvent::Received { key, timestamp, bundle: b } => { - assert_eq!(key, "test-key"); - assert_eq!(*timestamp, 1234567890); - assert_eq!(b.txs.len(), bundle.txs.len()); - } - _ => panic!("expected Received event"), - } - } - - #[test] - fn test_to_history_event_all_types() { - let bundle = create_bundle_from_txn_data(); - let bundle_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, bundle.bundle_hash().as_slice()); - - let cases: Vec<(&str, BundleEvent)> = vec![ - ("received", BundleEvent::Received { bundle_id, bundle: Box::new(bundle) }), - ("cancelled", BundleEvent::Cancelled { bundle_id }), - ( - "builder-included", - BundleEvent::BuilderIncluded { - bundle_id, - builder: "test-builder".to_string(), - block_number: 12345, - flashblock_index: 1, - }, - ), - ( - "block-included", - BundleEvent::BlockIncluded { - bundle_id, - block_number: 12345, - block_hash: TxHash::from([1u8; 32]), - }, - ), - ("dropped", BundleEvent::Dropped { bundle_id, reason: DropReason::TimedOut }), - ]; - - for (name, bundle_event) in cases { - let event = create_test_event(&format!("key-{name}"), 1234567890, bundle_event); - let history_event = to_history_event(&event); - assert_eq!(history_event.key(), format!("key-{name}"), "key mismatch for {name}"); - } - } - - #[test] - fn test_update_transaction_metadata_transform_adds_new_bundle() { - let metadata = TransactionMetadata { bundle_ids: vec![] }; - let bundle = create_bundle_from_txn_data(); - let key = format!("{}", bundle.bundle_hash()); - - let result = update_transaction_metadata_transform(metadata, key.clone()); - - assert!(result.is_some()); - let metadata = result.unwrap(); - assert_eq!(metadata.bundle_ids.len(), 1); - assert_eq!(metadata.bundle_ids[0], key); - } - - #[test] - fn test_update_transaction_metadata_transform_skips_existing_bundle() { - let bundle = create_bundle_from_txn_data(); - let key = format!("{}", bundle.bundle_hash()); - let metadata = TransactionMetadata { bundle_ids: vec![key.clone()] }; - - let result = update_transaction_metadata_transform(metadata, key); - - assert!(result.is_none()); - } - - #[test] - fn test_update_transaction_metadata_transform_adds_to_existing_bundles() { - let existing = "0xaaaa".to_string(); - let new = "0xbbbb".to_string(); - - let metadata = TransactionMetadata { bundle_ids: vec![existing.clone()] }; - - let result = update_transaction_metadata_transform(metadata, new.clone()); - - assert!(result.is_some()); - let metadata = result.unwrap(); - assert_eq!(metadata.bundle_ids.len(), 2); - assert!(metadata.bundle_ids.contains(&existing)); - assert!(metadata.bundle_ids.contains(&new)); - } -} diff --git a/crates/infra/audit/src/transaction_events.rs b/crates/infra/audit/src/transaction_events.rs index c9b16498f3..ba7a335388 100644 --- a/crates/infra/audit/src/transaction_events.rs +++ b/crates/infra/audit/src/transaction_events.rs @@ -909,16 +909,6 @@ impl PgTransactionEventSink { outcome } - /// Checks optional transaction event storage readiness. - pub async fn check_optional_schema_ready( - sink: Option<&Self>, - ) -> std::result::Result<(), TransactionEventSchemaReadinessError> { - match sink { - Some(sink) => sink.check_schema_ready().await, - None => Ok(()), - } - } - async fn insert_event_chunk( &self, events: &[TransactionEvent], diff --git a/crates/infra/audit/src/types.rs b/crates/infra/audit/src/types.rs deleted file mode 100644 index 471bf6cef6..0000000000 --- a/crates/infra/audit/src/types.rs +++ /dev/null @@ -1,160 +0,0 @@ -use alloy_consensus::transaction::{SignerRecoverable, Transaction as ConsensusTransaction}; -use alloy_primitives::{Address, TxHash, U256}; -use base_bundles::{AcceptedBundle, BundleExtensions}; -use bytes::Bytes; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// Unique identifier for a transaction. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct TransactionId { - /// The sender address. - pub sender: Address, - /// The transaction nonce. - pub nonce: U256, - /// The transaction hash. - pub hash: TxHash, -} - -/// Unique identifier for a bundle. -pub type BundleId = Uuid; - -/// Reason a bundle was dropped. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DropReason { - /// Bundle timed out. - TimedOut, - /// Bundle transaction reverted. - Reverted, -} - -/// A transaction with its data. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Transaction { - /// Transaction identifier. - pub id: TransactionId, - /// Raw transaction data. - pub data: Bytes, -} - -/// Bundle lifecycle event. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "event", content = "data")] -pub enum BundleEvent { - /// Bundle was received. - Received { - /// Bundle identifier. - bundle_id: BundleId, - /// The accepted bundle. - bundle: Box, - }, - /// Bundle was cancelled. - Cancelled { - /// Bundle identifier. - bundle_id: BundleId, - }, - /// Bundle was included by a builder. - BuilderIncluded { - /// Bundle identifier. - bundle_id: BundleId, - /// Builder identifier. - builder: String, - /// Block number. - block_number: u64, - /// Flashblock index. - flashblock_index: u64, - }, - /// Bundle was included in a block. - BlockIncluded { - /// Bundle identifier. - bundle_id: BundleId, - /// Block number. - block_number: u64, - /// Block hash. - block_hash: TxHash, - }, - /// Bundle was dropped. - Dropped { - /// Bundle identifier. - bundle_id: BundleId, - /// Drop reason. - reason: DropReason, - }, -} - -impl BundleEvent { - /// Returns the bundle ID for this event. - pub const fn bundle_id(&self) -> BundleId { - match self { - Self::Received { bundle_id, .. } - | Self::Cancelled { bundle_id, .. } - | Self::BuilderIncluded { bundle_id, .. } - | Self::BlockIncluded { bundle_id, .. } - | Self::Dropped { bundle_id, .. } => *bundle_id, - } - } - - /// Returns transaction IDs from this event (only for Received events). - pub fn transaction_ids(&self) -> Vec { - match self { - Self::Received { bundle, .. } => bundle - .txs - .iter() - .filter_map(|envelope| { - envelope.recover_signer().ok().map(|sender| TransactionId { - sender, - nonce: U256::from(envelope.nonce()), - hash: *envelope.hash(), - }) - }) - .collect(), - Self::Cancelled { .. } - | Self::BuilderIncluded { .. } - | Self::BlockIncluded { .. } - | Self::Dropped { .. } => vec![], - } - } - - /// Returns the `bundle_hash` for events that carry bundle data. - pub fn bundle_hash(&self) -> Option { - match self { - Self::Received { bundle, .. } => Some(bundle.bundle_hash()), - _ => None, - } - } - - /// Generates the event key used as the S3 object name. - /// - /// For `Received` events, derived from `bundle_hash` so that the same - /// bundle on different ingress pods produces the same key. - pub fn generate_event_key(&self) -> String { - match self { - Self::Received { bundle, .. } => { - let hash = bundle.bundle_hash(); - format!("received-{hash}") - } - Self::BlockIncluded { bundle_id, block_hash, .. } => { - format!("block-included-{bundle_id}-{block_hash}") - } - _ => { - let id = self.bundle_id(); - let event_type = match self { - Self::Cancelled { .. } => "cancelled", - Self::BuilderIncluded { .. } => "builder-included", - Self::Dropped { .. } => "dropped", - _ => unreachable!(), - }; - format!("{event_type}-{id}") - } - } - } - - /// Returns the full S3 key for this event: `bundles/{prefix}/{event_key}`. - pub fn s3_event_key(&self) -> String { - let prefix = match self { - Self::Received { bundle, .. } => format!("{}", bundle.bundle_hash()), - _ => format!("{}", self.bundle_id()), - }; - format!("bundles/{prefix}/{}", self.generate_event_key()) - } -} diff --git a/crates/infra/audit/tests/common/mod.rs b/crates/infra/audit/tests/common/mod.rs deleted file mode 100644 index 8f8365907b..0000000000 --- a/crates/infra/audit/tests/common/mod.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Common test harness for audit integration tests with S3 fixtures. - -use testcontainers::{ - ImageExt, - core::{IntoContainerPort, Mount}, - runners::AsyncRunner, -}; -use testcontainers_modules::minio::MinIO; -use uuid::Uuid; - -pub(crate) struct TestHarness { - pub s3_client: aws_sdk_s3::Client, - pub bucket_name: String, - _minio_container: testcontainers::ContainerAsync, -} - -impl TestHarness { - pub(crate) async fn new() -> anyhow::Result { - // MinIO no longer publishes community images: Docker Hub dropped them and quay.io - // refuses anonymous pulls. Run Chainguard's build of MinIO RELEASE.2026-09-22T19-25-18Z, - // pinned by digest (`docker buildx imagetools inspect cgr.dev/chainguard/minio:latest` - // prints the current one). Unlike MinIO's image it declares no volume and exposes no - // port, so give `/data` a tmpfs (MinIO cannot rename directories across overlay layers) - // and publish the S3 port. - let minio_container = MinIO::default() - .with_name("cgr.dev/chainguard/minio") - .with_tag( - "latest@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1", - ) - .with_mount(Mount::tmpfs_mount("/data")) - .with_mapped_port(0, 9000.tcp()) - .start() - .await?; - let s3_port = minio_container.get_host_port_ipv4(9000).await?; - let s3_endpoint = format!("http://127.0.0.1:{s3_port}"); - - let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) - .region("us-east-1") - .endpoint_url(&s3_endpoint) - .credentials_provider(aws_sdk_s3::config::Credentials::new( - "minioadmin", - "minioadmin", - None, - None, - "test", - )) - .load() - .await; - - let s3_client = aws_sdk_s3::Client::new(&config); - let bucket_name = - format!("test-bucket-{}", Uuid::new_v5(&Uuid::NAMESPACE_OID, s3_endpoint.as_bytes())); - - s3_client.create_bucket().bucket(&bucket_name).send().await?; - - Ok(Self { s3_client, bucket_name, _minio_container: minio_container }) - } -} diff --git a/crates/infra/audit/tests/postgres_transaction_events.rs b/crates/infra/audit/tests/postgres_transaction_events.rs index 0f3e15c688..b13ad1c8e1 100644 --- a/crates/infra/audit/tests/postgres_transaction_events.rs +++ b/crates/infra/audit/tests/postgres_transaction_events.rs @@ -14,11 +14,12 @@ use std::{ }; use audit_archiver_lib::{ - DEFAULT_TRANSACTION_EVENT_BATCH_PATH, DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, - DEFAULT_TRANSACTION_EVENT_MAX_DATA_BYTES, DEFAULT_TRANSACTION_EVENT_MAX_EVENT_BYTES, - DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES, MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE, - PgTransactionEventSink, RejectedTransactionEventQuery, TransactionEventIngestConfig, - TransactionEventRetentionConfig, TransactionEventSchemaReadinessError, TransactionEventSink, + AuditArchiverApiServer, AuditArchiverRpc, DEFAULT_TRANSACTION_EVENT_BATCH_PATH, + DEFAULT_TRANSACTION_EVENT_MAX_BATCH_SIZE, DEFAULT_TRANSACTION_EVENT_MAX_DATA_BYTES, + DEFAULT_TRANSACTION_EVENT_MAX_EVENT_BYTES, DEFAULT_TRANSACTION_EVENT_MAX_REQUEST_BYTES, + MAX_TRANSACTION_EVENT_INSERT_BATCH_SIZE, PgTransactionEventSink, RejectedTransactionEventQuery, + TransactionEventIngestConfig, TransactionEventRetentionConfig, + TransactionEventSchemaReadinessError, TransactionEventSink, }; use axum::{ body::{Body, to_bytes}, @@ -268,11 +269,6 @@ async fn count_events_with_prefix(pool: &PgPool, event_prefix: &str) -> anyhow:: Ok(count.0) } -#[tokio::test] -async fn transaction_events_ready_without_postgres_sink() { - PgTransactionEventSink::check_optional_schema_ready(None).await.unwrap(); -} - #[tokio::test] async fn transaction_events_unready_before_required_migration() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; @@ -642,6 +638,57 @@ async fn postgres_http_ingest_accepts_1000_event_batch() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn builder_rejection_journal_survives_http_ingest_and_rpc_query() -> anyhow::Result<()> { + let harness = PostgresHarness::new().await?; + PgTransactionEventSink::migrate(&harness.database_url).await?; + let sink = PgTransactionEventSink::connect(&harness.database_url, 2).await?; + sink.check_schema_ready().await?; + + let event_id = unique_event_id(); + let mut rejected = event_with_type(&event_id, "BUILDER_REJECTED"); + rejected.data = json!({ + "rejection_reason": "tx_execution_time_exceeded", + "tx_execution_time_us": 2000, + "tx_execution_time_limit_us": 1000, + }) + .as_object() + .unwrap() + .clone(); + let body = serde_json::to_vec(&rejected)?; + + let (status, first) = post_batch(sink.clone(), body.clone()).await?; + assert_eq!(status, StatusCode::OK); + assert_eq!(first["accepted"], 1); + assert_eq!(first["rejected"], 0); + let (status, retry) = post_batch(sink.clone(), body).await?; + assert_eq!(status, StatusCode::OK); + assert_eq!(retry["duplicate"], 1); + + let rpc = AuditArchiverRpc::new(sink); + let by_hash = rpc + .get_transaction_events_by_hash( + "0x1111111111111111111111111111111111111111111111111111111111111111".into(), + None, + ) + .await + .expect("Postgres-backed RPC lookup must work without S3"); + assert_eq!(by_hash.len(), 1); + assert_eq!(by_hash[0].event.event_id, event_id); + assert_eq!(by_hash[0].event.data["rejection_reason"], "tx_execution_time_exceeded"); + + let rejections = rpc + .get_rejected_transaction_events(RejectedTransactionEventQuery { + limit: Some(10), + ..Default::default() + }) + .await + .expect("rejected event RPC must read Postgres without S3"); + assert_eq!(rejections.len(), 1); + assert_eq!(rejections[0].event.event_id, event_id); + Ok(()) +} + #[tokio::test] async fn postgres_http_ingest_rejects_1001_event_batch_without_writes() -> anyhow::Result<()> { let harness = PostgresHarness::new().await?; diff --git a/crates/infra/audit/tests/s3_test.rs b/crates/infra/audit/tests/s3_test.rs deleted file mode 100644 index e1f8863e2f..0000000000 --- a/crates/infra/audit/tests/s3_test.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! S3 event storage tests. - -use alloy_primitives::TxHash; -use audit_archiver_lib::{ - BundleEvent, BundleEventS3Reader, Event, EventWriter, S3EventReaderWriter, -}; -use uuid::Uuid; - -mod common; -use base_bundles::{ - BundleExtensions, - test_utils::{TXN_HASH, create_bundle_from_txn_data}, -}; -use common::TestHarness; - -fn create_test_event(key: &str, timestamp: i64, bundle_event: BundleEvent) -> Event { - Event { key: key.to_string(), timestamp, event: bundle_event } -} - -#[tokio::test] -async fn system_test_event_write_and_read() -> anyhow::Result<()> { - let harness = TestHarness::new().await?; - let writer = S3EventReaderWriter::new(harness.s3_client.clone(), harness.bucket_name.clone()); - - let bundle = create_bundle_from_txn_data(); - let bundle_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, bundle.bundle_hash().as_slice()); - let bundle_hash = bundle.bundle_hash(); - let event = create_test_event( - &format!("received-{bundle_hash}"), - 1234567890, - BundleEvent::Received { bundle_id, bundle: Box::new(bundle.clone()) }, - ); - - writer.archive_event(event).await?; - - let bundle_key = format!("{bundle_hash}"); - let bundle_history = writer.get_bundle_history(&bundle_key).await?; - assert!(bundle_history.is_some(), "bundle history should exist after write"); - - let history = bundle_history.unwrap(); - assert_eq!(history.history.len(), 1, "should have exactly 1 event"); - - let metadata = writer.get_transaction_metadata(TXN_HASH).await?; - assert!(metadata.is_some(), "transaction metadata should exist"); - - if let Some(metadata) = metadata { - let hash_str = format!("{bundle_hash}"); - assert!(metadata.bundle_ids.contains(&hash_str), "metadata should contain the bundle hash"); - } - - Ok(()) -} - -#[tokio::test] -async fn system_test_event_deduplication() -> anyhow::Result<()> { - let harness = TestHarness::new().await?; - let writer = S3EventReaderWriter::new(harness.s3_client.clone(), harness.bucket_name.clone()); - - let bundle = create_bundle_from_txn_data(); - let bundle_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, bundle.bundle_hash().as_slice()); - let bundle_hash = bundle.bundle_hash(); - let event = create_test_event( - &format!("received-{bundle_hash}"), - 1234567890, - BundleEvent::Received { bundle_id, bundle: Box::new(bundle.clone()) }, - ); - - writer.archive_event(event.clone()).await?; - writer.archive_event(event).await?; - - let bundle_key = format!("{bundle_hash}"); - let bundle_history = writer.get_bundle_history(&bundle_key).await?; - assert!(bundle_history.is_some()); - - let history = bundle_history.unwrap(); - assert_eq!(history.history.len(), 1, "duplicate write should not create a second event"); - - Ok(()) -} - -#[tokio::test] -async fn system_test_nonexistent_data() -> anyhow::Result<()> { - let harness = TestHarness::new().await?; - let writer = S3EventReaderWriter::new(harness.s3_client.clone(), harness.bucket_name.clone()); - - let nonexistent_key = format!("{}", TxHash::from([255u8; 32])); - let bundle_history = writer.get_bundle_history(&nonexistent_key).await?; - assert!(bundle_history.is_none()); - - let nonexistent_tx_hash = TxHash::from([255u8; 32]); - let metadata = writer.get_transaction_metadata(nonexistent_tx_hash).await?; - assert!(metadata.is_none()); - - Ok(()) -} diff --git a/etc/docker/devnet-env b/etc/docker/devnet-env index 187700cba1..23fc1faaa3 100644 --- a/etc/docker/devnet-env +++ b/etc/docker/devnet-env @@ -205,8 +205,6 @@ AUDIT_RPC_PORT=9100 AUDIT_METRICS_PORT=9102 # Transaction Observability Infrastructure Ports -MINIO_API_PORT=7000 -MINIO_CONSOLE_PORT=7001 TRANSACTION_EVENTS_POSTGRES_PORT=5434 TRANSACTION_EVENTS_VECTOR_METRICS_PORT=8686 TRANSACTION_EVENTS_VECTOR_IMAGE=timberio/vector:0.47.0-debian diff --git a/etc/docker/docker-compose.tx-observability.yml b/etc/docker/docker-compose.tx-observability.yml index d81f83e0a8..4b333c30cb 100644 --- a/etc/docker/docker-compose.tx-observability.yml +++ b/etc/docker/docker-compose.tx-observability.yml @@ -12,35 +12,6 @@ x-overlay-healthcheck: &overlay-healthcheck start_interval: 250ms services: - minio: - image: minio/minio:latest - container_name: minio - ports: - - "${MINIO_API_PORT}:9000" - - "${MINIO_CONSOLE_PORT}:9001" - environment: - - MINIO_ROOT_USER=minioadmin - - MINIO_ROOT_PASSWORD=minioadmin - command: server /data --console-address ":9001" - healthcheck: - test: ["CMD", "mc", "ready", "local"] - <<: *overlay-healthcheck - start_period: 5s - restart: unless-stopped - - minio-init: - image: minio/mc:latest - container_name: minio-init - depends_on: - minio: - condition: service_healthy - entrypoint: > - /bin/sh -c " - mc alias set local http://minio:9000 minioadmin minioadmin && - mc mb --ignore-existing local/tips && - echo 'Bucket tips created' - " - proxyd: build: context: . @@ -76,8 +47,6 @@ services: target: audit-archiver container_name: audit-archiver depends_on: - minio-init: - condition: service_completed_successfully transaction-events-postgres: condition: service_healthy audit-archiver-migrate: @@ -89,12 +58,6 @@ services: - TIPS_AUDIT_METRICS_ADDR=0.0.0.0 - TIPS_AUDIT_METRICS_PORT=${AUDIT_METRICS_PORT} - TIPS_AUDIT_RPC_PORT=${AUDIT_RPC_PORT} - - TIPS_AUDIT_S3_BUCKET=tips - - TIPS_AUDIT_S3_CONFIG_TYPE=manual - - TIPS_AUDIT_S3_ENDPOINT=http://minio:9000 - - TIPS_AUDIT_S3_REGION=us-east-1 - - TIPS_AUDIT_S3_ACCESS_KEY_ID=minioadmin - - TIPS_AUDIT_S3_SECRET_ACCESS_KEY=minioadmin - TIPS_AUDIT_POSTGRES_URL=postgres://audit_archiver:audit_archiver@transaction-events-postgres:5432/transaction_events?sslmode=disable networks: default: From 247f03b8297a1ac680418dc606a7a13449134c37 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Wed, 30 Sep 2026 01:38:21 +0000 Subject: [PATCH 170/299] refactor(consensus-rpc): deduplicate request plumbing and name error codes (#5410) * refactor(consensus-rpc): deduplicate request plumbing and name error codes Co-authored-by: Toshi * refactor(consensus-rpc): route is_peer_connected through query Co-authored-by: Toshi --------- Co-authored-by: Toshi --- crates/consensus/rpc/src/admin.rs | 126 ++++++++------- crates/consensus/rpc/src/config.rs | 5 +- crates/consensus/rpc/src/dev.rs | 12 +- crates/consensus/rpc/src/health.rs | 2 +- crates/consensus/rpc/src/jsonrpsee.rs | 2 +- crates/consensus/rpc/src/p2p.rs | 215 ++++++++------------------ crates/consensus/rpc/src/rollup.rs | 46 +++--- 7 files changed, 157 insertions(+), 251 deletions(-) diff --git a/crates/consensus/rpc/src/admin.rs b/crates/consensus/rpc/src/admin.rs index c79e93a632..0938b46116 100644 --- a/crates/consensus/rpc/src/admin.rs +++ b/crates/consensus/rpc/src/admin.rs @@ -98,18 +98,39 @@ where ) -> Self { Self { upgrade_signal_refresher, ..self } } + + /// Returns the sequencer admin client, or an error when this node runs without a sequencer + /// (validator mode). + fn sequencer_client(&self) -> RpcResult<&SequencerAdminAPIClient_> { + self.sequencer_admin_client.as_ref().ok_or_else(sequencer_unavailable) + } } +/// JSON-RPC error code for sequencer admin methods called on a node without a sequencer. +const SEQUENCER_UNAVAILABLE_CODE: i32 = -32001; +/// JSON-RPC error code for starting the sequencer on a node that is not the conductor leader. +const NOT_LEADER_CODE: i32 = -32002; +/// JSON-RPC error code for a failed upgrade-signal refresh. +const UPGRADE_SIGNAL_REFRESH_FAILED_CODE: i32 = -32003; +/// JSON-RPC error code for upgrade-signal methods called on a node without a refresher. +const UPGRADE_SIGNAL_UNAVAILABLE_CODE: i32 = -32004; +/// JSON-RPC error code for network-backed admin methods called on an isolated sequencer. +const ISOLATED_NETWORK_ACCESS_REJECTED_CODE: i32 = -32005; + /// Returns an RPC error indicating the sequencer is not available on this node. fn sequencer_unavailable() -> ErrorObject<'static> { - ErrorObject::owned(-32001, "sequencer not available on this node", None::<()>) + ErrorObject::owned( + SEQUENCER_UNAVAILABLE_CODE, + "sequencer not available on this node", + None::<()>, + ) } /// Maps public sequencer admin failures without exposing internal details. fn sequencer_admin_error(error: SequencerAdminAPIError) -> ErrorObject<'static> { match error { SequencerAdminAPIError::NotLeader => { - ErrorObject::owned(-32002, "Node is not the conductor leader.", None::<()>) + ErrorObject::owned(NOT_LEADER_CODE, "Node is not the conductor leader.", None::<()>) } SequencerAdminAPIError::RequestError(_) | SequencerAdminAPIError::ResponseError @@ -122,18 +143,26 @@ fn sequencer_admin_error(error: SequencerAdminAPIError) -> ErrorObject<'static> /// Returns an RPC error indicating the upgrade signal is not available on this node. fn upgrade_signal_unavailable() -> ErrorObject<'static> { - ErrorObject::owned(-32004, "upgrade signal not configured on this node", None::<()>) + ErrorObject::owned( + UPGRADE_SIGNAL_UNAVAILABLE_CODE, + "upgrade signal not configured on this node", + None::<()>, + ) } /// Returns an RPC error for a failed upgrade-signal refresh without exposing internals. fn upgrade_signal_refresh_failed() -> ErrorObject<'static> { - ErrorObject::owned(-32003, "failed to refresh upgrade signal", None::<()>) + ErrorObject::owned( + UPGRADE_SIGNAL_REFRESH_FAILED_CODE, + "failed to refresh upgrade signal", + None::<()>, + ) } /// Returns an RPC error indicating network-backed methods are disabled for an isolated sequencer. fn isolated_network_access_rejected() -> ErrorObject<'static> { ErrorObject::owned( - -32005, + ISOLATED_NETWORK_ACCESS_REJECTED_CODE, "network-backed admin methods disabled on isolated sequencer", None::<()>, ) @@ -142,7 +171,7 @@ fn isolated_network_access_rejected() -> ErrorObject<'static> { #[async_trait] impl AdminApiServer for AdminRpc where - SequencerAdminAPIClient_: SequencerAdminAPIClient + 'static + Send + Sync, + SequencerAdminAPIClient_: SequencerAdminAPIClient + 'static, { async fn admin_post_unsafe_payload( &self, @@ -172,90 +201,50 @@ where } async fn admin_sequencer_active(&self) -> RpcResult { - // If the sequencer is not enabled (mode runs in validator mode), return an error. - let Some(ref sequencer_client) = self.sequencer_admin_client else { - return Err(sequencer_unavailable()); - }; - - sequencer_client + self.sequencer_client()? .is_sequencer_active() .await .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) } async fn admin_start_sequencer(&self, unsafe_head: B256) -> RpcResult<()> { - // If the sequencer is not enabled (mode runs in validator mode), return an error. - let Some(ref sequencer_client) = self.sequencer_admin_client else { - return Err(sequencer_unavailable()); - }; - - sequencer_client.start_sequencer(unsafe_head).await.map_err(sequencer_admin_error) + self.sequencer_client()?.start_sequencer(unsafe_head).await.map_err(sequencer_admin_error) } async fn admin_stop_sequencer(&self) -> RpcResult { - // If the sequencer is not enabled (mode runs in validator mode), return an error. - let Some(ref sequencer_client) = self.sequencer_admin_client else { - return Err(sequencer_unavailable()); - }; - - sequencer_client.stop_sequencer().await.map_err(sequencer_admin_error) + self.sequencer_client()?.stop_sequencer().await.map_err(sequencer_admin_error) } async fn admin_conductor_enabled(&self) -> RpcResult { - // If the sequencer is not enabled (mode runs in validator mode), return an error. - let Some(ref sequencer_client) = self.sequencer_admin_client else { - return Err(sequencer_unavailable()); - }; - - sequencer_client + self.sequencer_client()? .is_conductor_enabled() .await .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) } async fn admin_recover_mode(&self) -> RpcResult { - // If the sequencer is not enabled (mode runs in validator mode), return an error. - let Some(ref sequencer_client) = self.sequencer_admin_client else { - return Err(sequencer_unavailable()); - }; - - sequencer_client + self.sequencer_client()? .is_recovery_mode() .await .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) } async fn admin_set_recover_mode(&self, mode: bool) -> RpcResult<()> { - // If the sequencer is not enabled (mode runs in validator mode), return an error. - let Some(ref sequencer_client) = self.sequencer_admin_client else { - return Err(sequencer_unavailable()); - }; - - sequencer_client + self.sequencer_client()? .set_recovery_mode(mode) .await .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) } async fn admin_override_leader(&self) -> RpcResult<()> { - // If the sequencer is not enabled (mode runs in validator mode), return an error. - let Some(ref sequencer_client) = self.sequencer_admin_client else { - return Err(sequencer_unavailable()); - }; - - sequencer_client + self.sequencer_client()? .override_leader() .await .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) } async fn admin_reset_derivation_pipeline(&self) -> RpcResult<()> { - // If the sequencer is not enabled (mode runs in validator mode), return an error. - let Some(ref sequencer_client) = self.sequencer_admin_client else { - return Err(sequencer_unavailable()); - }; - - sequencer_client + self.sequencer_client()? .reset_derivation_pipeline() .await .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) @@ -268,17 +257,14 @@ where return Err(upgrade_signal_unavailable()); }; - match refresher.refresh().await { - Ok(summary) => Ok(summary), - Err(error) => { - warn!( - target: "upgrade_signal", - error = %error, - "failed to refresh consensus runtime upgrade signal" - ); - Err(upgrade_signal_refresh_failed()) - } - } + refresher.refresh().await.map_err(|error| { + warn!( + target: "upgrade_signal", + error = %error, + "failed to refresh consensus runtime upgrade signal" + ); + upgrade_signal_refresh_failed() + }) } } @@ -383,6 +369,16 @@ mod tests { assert_eq!(error.message(), "Node is not the conductor leader."); } + #[tokio::test] + async fn sequencer_methods_reject_nodes_without_sequencer() { + let rpc = AdminRpc::::new_isolated(None); + + let error = rpc.admin_sequencer_active().await.unwrap_err(); + + assert_eq!(error.code(), -32001); + assert_eq!(error.message(), "sequencer not available on this node"); + } + #[test] fn upgrade_signal_unavailable_uses_distinct_error_code() { let error = upgrade_signal_unavailable(); diff --git a/crates/consensus/rpc/src/config.rs b/crates/consensus/rpc/src/config.rs index ab61277191..57a16436e2 100644 --- a/crates/consensus/rpc/src/config.rs +++ b/crates/consensus/rpc/src/config.rs @@ -25,6 +25,9 @@ pub struct RpcBuilder { } impl RpcBuilder { + /// Number of restart attempts made when restarts are allowed. + const RESTART_ATTEMPTS: u32 = 3; + /// Returns whether WebSocket RPC endpoint is enabled pub const fn ws_enabled(&self) -> bool { self.ws_enabled @@ -47,7 +50,7 @@ impl RpcBuilder { /// Returns the number of times the RPC server will attempt to restart if it stops. pub const fn restart_count(&self) -> u32 { - if self.no_restart { 0 } else { 3 } + if self.no_restart { 0 } else { Self::RESTART_ATTEMPTS } } /// Sets the given [`SocketAddr`] on the [`RpcBuilder`]. diff --git a/crates/consensus/rpc/src/dev.rs b/crates/consensus/rpc/src/dev.rs index 211061c662..2f8d6c02d5 100644 --- a/crates/consensus/rpc/src/dev.rs +++ b/crates/consensus/rpc/src/dev.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use jsonrpsee::{ PendingSubscriptionSink, SubscriptionSink, - core::{RpcResult, SubscriptionResult, to_json_raw_value}, + core::{RpcResult, SubscriptionError, SubscriptionResult, to_json_raw_value}, types::ErrorCode, }; use tracing::warn; @@ -29,22 +29,22 @@ impl DevEngineRpc { /// Gets an engine queue length watcher for subscriptions. async fn engine_queue_length_watcher( &self, - ) -> Result, jsonrpsee::core::SubscriptionError> { - self.engine_client.dev_subscribe_to_engine_queue_length().await.map_err(|_| jsonrpsee::core::SubscriptionError::from("Internal error. Failed to receive engine task receiver query. The engine query handler is likely closed.")) + ) -> Result, SubscriptionError> { + self.engine_client.dev_subscribe_to_engine_queue_length().await.map_err(|_| SubscriptionError::from("Internal error. Failed to receive engine task receiver query. The engine query handler is likely closed.")) } async fn send_queue_length_update( sink: &SubscriptionSink, queue_length: &usize, - ) -> Result<(), jsonrpsee::core::SubscriptionError> { + ) -> Result<(), SubscriptionError> { sink.send(to_json_raw_value(queue_length).map_err(|_| { - jsonrpsee::core::SubscriptionError::from( + SubscriptionError::from( "Internal error. Impossible to convert engine queue length to json", ) })?) .await .map_err(|_| { - jsonrpsee::core::SubscriptionError::from( + SubscriptionError::from( "Failed to send engine queue length update. Subscription likely dropped.", ) }) diff --git a/crates/consensus/rpc/src/health.rs b/crates/consensus/rpc/src/health.rs index 1a831bcc7c..b3c658f18d 100644 --- a/crates/consensus/rpc/src/health.rs +++ b/crates/consensus/rpc/src/health.rs @@ -12,7 +12,7 @@ pub struct HealthzResponse { /// The healthz rpc server. #[derive(Debug, Clone)] -pub struct HealthzRpc {} +pub struct HealthzRpc; #[async_trait] impl HealthzApiServer for HealthzRpc { diff --git a/crates/consensus/rpc/src/jsonrpsee.rs b/crates/consensus/rpc/src/jsonrpsee.rs index e01abe867b..25fe73d254 100644 --- a/crates/consensus/rpc/src/jsonrpsee.rs +++ b/crates/consensus/rpc/src/jsonrpsee.rs @@ -310,7 +310,7 @@ pub trait AdminApi { async fn admin_refresh_upgrade_signal(&self) -> RpcResult; } -/// The admin namespace for the consensus node. +/// The healthcheck API for the consensus node. #[cfg_attr(not(feature = "client"), rpc(server))] #[cfg_attr(feature = "client", rpc(server, client))] pub trait HealthzApi { diff --git a/crates/consensus/rpc/src/p2p.rs b/crates/consensus/rpc/src/p2p.rs index 0668840933..cbd838e2ab 100644 --- a/crates/consensus/rpc/src/p2p.rs +++ b/crates/consensus/rpc/src/p2p.rs @@ -10,6 +10,8 @@ use jsonrpsee::{ core::RpcResult, types::{ErrorCode, ErrorObject}, }; +use libp2p::PeerId; +use tokio::sync::oneshot; use crate::{BaseP2PApiServer, net::P2pRpc}; @@ -19,171 +21,87 @@ const PEER_STATE_WAIT_TIMEOUT: Duration = Duration::from_secs(10); impl BaseP2PApiServer for P2pRpc { async fn opp2p_self(&self) -> RpcResult { Metrics::rpc_calls("opp2p_self").increment(1.0); - let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send(P2pRpcRequest::PeerInfo(tx)) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - rx.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + self.query(P2pRpcRequest::PeerInfo).await } async fn opp2p_peer_count(&self) -> RpcResult { Metrics::rpc_calls("opp2p_peerCount").increment(1.0); - let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send(P2pRpcRequest::PeerCount(tx)) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - let (connected_discovery, connected_gossip) = - rx.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - + let (connected_discovery, connected_gossip) = self.query(P2pRpcRequest::PeerCount).await?; Ok(PeerCount { connected_discovery, connected_gossip }) } async fn opp2p_peers(&self, connected: bool) -> RpcResult { Metrics::rpc_calls("opp2p_peers").increment(1.0); - let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send(P2pRpcRequest::Peers { out: tx, connected }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - let dump = rx.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - Ok(dump) + self.query(|out| P2pRpcRequest::Peers { out, connected }).await } async fn opp2p_peer_stats(&self) -> RpcResult { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send(P2pRpcRequest::PeerStats(tx)) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - let stats = rx.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - Ok(stats) + self.query(P2pRpcRequest::PeerStats).await } async fn opp2p_discovery_table(&self) -> RpcResult> { Metrics::rpc_calls("opp2p_discoveryTable").increment(1.0); - let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send(P2pRpcRequest::DiscoveryTable(tx)) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - rx.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + self.query(P2pRpcRequest::DiscoveryTable).await } async fn opp2p_block_peer(&self, peer_id: String) -> RpcResult<()> { Metrics::rpc_calls("opp2p_blockPeer").increment(1.0); - let id = libp2p::PeerId::from_str(&peer_id) - .map_err(|_| ErrorObject::from(ErrorCode::InvalidParams))?; - self.sender - .send(P2pRpcRequest::BlockPeer { id }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + let id = Self::parse_peer_id(&peer_id)?; + self.send(P2pRpcRequest::BlockPeer { id }).await } async fn opp2p_unblock_peer(&self, peer_id: String) -> RpcResult<()> { Metrics::rpc_calls("opp2p_unblockPeer").increment(1.0); - let id = libp2p::PeerId::from_str(&peer_id) - .map_err(|_| ErrorObject::from(ErrorCode::InvalidParams))?; - self.sender - .send(P2pRpcRequest::UnblockPeer { id }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + let id = Self::parse_peer_id(&peer_id)?; + self.send(P2pRpcRequest::UnblockPeer { id }).await } async fn opp2p_list_blocked_peers(&self) -> RpcResult> { Metrics::rpc_calls("opp2p_listBlockedPeers").increment(1.0); - let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send(P2pRpcRequest::ListBlockedPeers(tx)) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - rx.await - .map(|peers| peers.iter().map(|p| p.to_string()).collect()) - .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + let peers = self.query(P2pRpcRequest::ListBlockedPeers).await?; + Ok(peers.iter().map(ToString::to_string).collect()) } async fn opp2p_block_addr(&self, address: IpAddr) -> RpcResult<()> { Metrics::rpc_calls("opp2p_blockAddr").increment(1.0); - self.sender - .send(P2pRpcRequest::BlockAddr { address }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + self.send(P2pRpcRequest::BlockAddr { address }).await } async fn opp2p_unblock_addr(&self, address: IpAddr) -> RpcResult<()> { Metrics::rpc_calls("opp2p_unblockAddr").increment(1.0); - self.sender - .send(P2pRpcRequest::UnblockAddr { address }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + self.send(P2pRpcRequest::UnblockAddr { address }).await } async fn opp2p_list_blocked_addrs(&self) -> RpcResult> { Metrics::rpc_calls("opp2p_listBlockedAddrs").increment(1.0); - let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send(P2pRpcRequest::ListBlockedAddrs(tx)) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - rx.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + self.query(P2pRpcRequest::ListBlockedAddrs).await } async fn opp2p_block_subnet(&self, subnet: IpNet) -> RpcResult<()> { Metrics::rpc_calls("opp2p_blockSubnet").increment(1.0); - self.sender - .send(P2pRpcRequest::BlockSubnet { address: subnet }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + self.send(P2pRpcRequest::BlockSubnet { address: subnet }).await } async fn opp2p_unblock_subnet(&self, subnet: IpNet) -> RpcResult<()> { Metrics::rpc_calls("opp2p_unblockSubnet").increment(1.0); - - self.sender - .send(P2pRpcRequest::UnblockSubnet { address: subnet }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + self.send(P2pRpcRequest::UnblockSubnet { address: subnet }).await } async fn opp2p_list_blocked_subnets(&self) -> RpcResult> { Metrics::rpc_calls("opp2p_listBlockedSubnets").increment(1.0); - let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send(P2pRpcRequest::ListBlockedSubnets(tx)) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - rx.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + self.query(P2pRpcRequest::ListBlockedSubnets).await } async fn opp2p_protect_peer(&self, id: String) -> RpcResult<()> { Metrics::rpc_calls("opp2p_protectPeer").increment(1.0); - let peer_id = libp2p::PeerId::from_str(&id) - .map_err(|_| ErrorObject::from(ErrorCode::InvalidParams))?; - self.sender - .send(P2pRpcRequest::ProtectPeer { peer_id }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + let peer_id = Self::parse_peer_id(&id)?; + self.send(P2pRpcRequest::ProtectPeer { peer_id }).await } async fn opp2p_unprotect_peer(&self, id: String) -> RpcResult<()> { Metrics::rpc_calls("opp2p_unprotectPeer").increment(1.0); - let peer_id = libp2p::PeerId::from_str(&id) - .map_err(|_| ErrorObject::from(ErrorCode::InvalidParams))?; - self.sender - .send(P2pRpcRequest::UnprotectPeer { peer_id }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + let peer_id = Self::parse_peer_id(&id)?; + self.send(P2pRpcRequest::UnprotectPeer { peer_id }).await } async fn opp2p_connect_peer(&self, peer: String) -> RpcResult<()> { @@ -206,6 +124,33 @@ impl BaseP2PApiServer for P2pRpc { } impl P2pRpc { + /// Sends `request` to the network actor. + async fn send(&self, request: P2pRpcRequest) -> RpcResult<()> { + self.sender.send(request).await.map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + } + + /// Sends the request built around a reply channel and awaits the network actor's reply. + async fn query( + &self, + request: impl FnOnce(oneshot::Sender) -> P2pRpcRequest, + ) -> RpcResult { + let (tx, rx) = oneshot::channel(); + self.send(request(tx)).await?; + rx.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + } + + /// Parses a peer ID parameter, rejecting malformed input as invalid params. + fn parse_peer_id(peer_id: &str) -> RpcResult { + PeerId::from_str(peer_id).map_err(|_| ErrorObject::from(ErrorCode::InvalidParams)) + } + + /// Returns whether `peer_id` is among the currently connected peers. + async fn is_peer_connected(&self, peer_id: &PeerId) -> RpcResult { + let peers: PeerDump = + self.query(|out| P2pRpcRequest::Peers { out, connected: true }).await?; + Ok(peers.peers.contains_key(&peer_id.to_string())) + } + async fn connect_peer_with_backoff( &self, peer: String, @@ -237,23 +182,10 @@ impl P2pRpc { ) })?; - // We need to wait until both peers are connected to each other to return from this method. - // We try with an exponential backoff and return an error if we fail to connect to the peer. - let is_connected = async || { - let (tx, rx) = tokio::sync::oneshot::channel(); - - self.sender - .send(P2pRpcRequest::Peers { out: tx, connected: true }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - let peers = rx.await.map_err(|_| { - ErrorObject::borrowed(ErrorCode::InternalError.code(), "Failed to get peers", None) - })?; - - // InvalidParams = "not connected yet" (retryable). InternalError = channel failure - // (fail fast via .when() below). - if peers.peers.contains_key(&peer_id.to_string()) { + // Return only once both peers are connected to each other, or fail after the backoff. + // InvalidParams = "not connected yet" (retryable); InternalError = channel failure (fatal). + let peer_connected = async || { + if self.is_peer_connected(&peer_id).await? { Ok(()) } else { Err(ErrorObject::borrowed( @@ -264,12 +196,10 @@ impl P2pRpc { } }; - // Retry only peer-state misses; do not retry channel failures. - is_connected + peer_connected .retry(backoff) .when(|error| error.code() == ErrorCode::InvalidParams.code()) - .await?; - Ok(()) + .await } async fn disconnect_peer_with_backoff( @@ -285,29 +215,12 @@ impl P2pRpc { } }; - self.sender - .send(P2pRpcRequest::DisconnectPeer { peer_id }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - // We need to wait until both peers are fully disconnected to each other to return from this - // method. We try with an exponential backoff and return an error if we fail to - // disconnect from the peer. - let is_not_connected = async || { - let (tx, rx) = tokio::sync::oneshot::channel(); - - self.sender - .send(P2pRpcRequest::Peers { out: tx, connected: true }) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; + self.send(P2pRpcRequest::DisconnectPeer { peer_id }).await?; - let peers = rx.await.map_err(|_| { - ErrorObject::borrowed(ErrorCode::InternalError.code(), "Failed to get peers", None) - })?; - - // InvalidParams = "still connected" (retryable). InternalError = channel failure - // (fail fast via .when() below). - if peers.peers.contains_key(&peer_id.to_string()) { + // Return only once both peers are fully disconnected, or fail after the backoff. + // InvalidParams = "still connected" (retryable); InternalError = channel failure (fatal). + let peer_disconnected = async || { + if self.is_peer_connected(&peer_id).await? { Err(ErrorObject::borrowed( ErrorCode::InvalidParams.code(), "Peers are still connected", @@ -318,12 +231,10 @@ impl P2pRpc { } }; - // Retry only peer-state misses; do not retry channel failures. - is_not_connected + peer_disconnected .retry(backoff) .when(|error| error.code() == ErrorCode::InvalidParams.code()) - .await?; - Ok(()) + .await } } diff --git a/crates/consensus/rpc/src/rollup.rs b/crates/consensus/rpc/src/rollup.rs index 7aa127df96..6c1c9263ec 100644 --- a/crates/consensus/rpc/src/rollup.rs +++ b/crates/consensus/rpc/src/rollup.rs @@ -31,6 +31,9 @@ use crate::{ static RPC_REQUEST_ID: AtomicU64 = AtomicU64::new(1); +/// JSON-RPC server error code for safe head lookups the node cannot serve. +const SAFE_HEAD_UNAVAILABLE_CODE: i32 = -32000; + /// `RollupRpc` /// /// This is a server implementation of [`crate::RollupNodeApiServer`]. @@ -54,6 +57,16 @@ impl RollupRpc { Self { engine_client, l1_watcher_sender, safe_db_reader } } + /// Queries the L1 watcher for its current [`L1State`]. + async fn l1_state(&self) -> RpcResult { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.l1_watcher_sender + .send(L1WatcherQueries::L1State(tx)) + .await + .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; + rx.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError)) + } + // Important note: we zero-out the fields that can't be derived yet to follow the reference node's // behaviour. fn sync_status_from_actor_queries( @@ -84,7 +97,6 @@ impl RollupNodeApiServer Metrics::rpc_calls("base_outputAtBlock").increment(1.0); let request_id = RPC_REQUEST_ID.fetch_add(1, Ordering::Relaxed); - let (l1_sync_status_send, l1_sync_status_recv) = tokio::sync::oneshot::channel(); let request_started_at = Instant::now(); let span = info_span!( target: "rpc", @@ -98,15 +110,7 @@ impl RollupNodeApiServer let ((l2_block_info, output_root, l2_sync_status), l1_sync_status) = tokio::try_join!( self.engine_client.output_at_block(block_num).instrument(span.clone()), - async { - self.l1_watcher_sender - .send(L1WatcherQueries::L1State(l1_sync_status_send)) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - - l1_sync_status_recv.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError)) - } - .instrument(span.clone()) + self.l1_state().instrument(span.clone()) ) .map_err(|error| { warn!( @@ -145,7 +149,7 @@ impl RollupNodeApiServer BlockNumberOrTag::Number(n) => n, _ => { return Err(ErrorObject::owned( - -32602, + ErrorCode::InvalidParams.code(), "optimism_safeHeadAtL1Block requires an explicit block number, not latest/earliest/pending", None::<()>, )); @@ -153,9 +157,11 @@ impl RollupNodeApiServer }; self.safe_db_reader.safe_head_at_l1(number).await.map_err(|e| match e { - SafeDBError::NotFound => ErrorObject::owned(-32000, "safe head not found", None::<()>), + SafeDBError::NotFound => { + ErrorObject::owned(SAFE_HEAD_UNAVAILABLE_CODE, "safe head not found", None::<()>) + } SafeDBError::Disabled => ErrorObject::owned( - -32000, + SAFE_HEAD_UNAVAILABLE_CODE, "safe head tracking is disabled on this node", None::<()>, ), @@ -172,7 +178,6 @@ impl RollupNodeApiServer Metrics::rpc_calls("base_syncStatus").increment(1.0); let request_id = RPC_REQUEST_ID.fetch_add(1, Ordering::Relaxed); - let (l1_sync_status_send, l1_sync_status_recv) = tokio::sync::oneshot::channel(); let request_started_at = Instant::now(); let span = info_span!( target: "rpc", @@ -184,14 +189,7 @@ impl RollupNodeApiServer debug!(target: "rpc", request_id, rpc_method = RPC_METHOD, "Started rollup RPC request"); let (l1_sync_status, l2_sync_status) = tokio::try_join!( - async { - self.l1_watcher_sender - .send(L1WatcherQueries::L1State(l1_sync_status_send)) - .await - .map_err(|_| ErrorObject::from(ErrorCode::InternalError))?; - l1_sync_status_recv.await.map_err(|_| ErrorObject::from(ErrorCode::InternalError)) - } - .instrument(span.clone()), + self.l1_state().instrument(span.clone()), self.engine_client.get_state().instrument(span.clone()) ) .map_err(|error| { @@ -226,8 +224,6 @@ impl RollupNodeApiServer async fn version(&self) -> RpcResult { Metrics::rpc_calls("base_version").increment(1.0); - const RPC_VERSION: &str = env!("CARGO_PKG_VERSION"); - - return Ok(RPC_VERSION.to_string()); + Ok(env!("CARGO_PKG_VERSION").to_string()) } } From e0c64e981dd057dcfc83c342d672d862a3897622 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Wed, 30 Sep 2026 01:51:53 +0000 Subject: [PATCH 171/299] fix(rpc): stop abandoned payload witness requests early (#5321) * fix(rpc): stop abandoned payload witness requests early * fix(payload): check witness cancellation only on the witness path --- crates/execution/payload/src/builder.rs | 25 ++++++++++++++++++++++++- crates/execution/payload/src/error.rs | 3 +++ crates/execution/rpc/src/debug.rs | 10 ++++++++-- crates/execution/rpc/src/witness.rs | 6 +++++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/execution/payload/src/builder.rs b/crates/execution/payload/src/builder.rs index b21f79e978..fcdb0d46a6 100644 --- a/crates/execution/payload/src/builder.rs +++ b/crates/execution/payload/src/builder.rs @@ -255,10 +255,13 @@ where } /// Computes the witness for the payload. + /// + /// Stops early with [`BasePayloadBuilderError::Cancelled`] once `cancel` is cancelled. pub fn payload_witness( &self, parent: SealedHeader, attributes: Attrs::RpcPayloadAttributes, + cancel: CancelOnDrop, ) -> Result where Attrs: Attributes, @@ -273,7 +276,7 @@ where builder_config: self.config.clone(), chain_spec: self.client.chain_spec(), config, - cancel: Default::default(), + cancel, best_payload: Default::default(), }; @@ -544,6 +547,10 @@ impl Builder<'_, Txs> { Txs: PayloadTransactions>, Attrs: Attributes, { + if ctx.cancel.is_cancelled() { + return Err(PayloadBuilderError::other(BasePayloadBuilderError::Cancelled)); + } + let mut db = State::builder() .with_database(StateProviderDatabase::new(&state_provider)) .with_bundle_update() @@ -556,6 +563,10 @@ impl Builder<'_, Txs> { ctx.execute_sequencer_transactions(&mut builder)?; builder.into_executor().apply_post_execution_changes()?; + if ctx.cancel.is_cancelled() { + return Err(PayloadBuilderError::other(BasePayloadBuilderError::Cancelled)); + } + if ctx.chain_spec.is_isthmus_active_at_timestamp(ctx.attributes().timestamp()) { // force load `L2ToL1MessagePasser.sol` so l2 withdrawals root can be computed even if // no l2 withdrawals in block @@ -2099,6 +2110,18 @@ mod tests { )); } + #[test] + fn cancelled_ctx_stops_witness() { + let mut ctx = pool_payload_context(DENIM_TIMESTAMP - 1); + ctx.config.attributes.transactions = vec![sequencer_attribute_tx(&pool_transaction(0))]; + drop(ctx.cancel.clone()); + + let err = Builder::new(|_| NoopPayloadTransactions::::default()) + .witness(test_state_provider(), NoopProvider::default(), &ctx) + .unwrap_err(); + assert!(err.to_string().contains("cancelled"), "{err}"); + } + #[derive(Debug)] struct MapProvider(Mutex>); diff --git a/crates/execution/payload/src/error.rs b/crates/execution/payload/src/error.rs index 9b86f32cc6..c9ba259ecb 100644 --- a/crates/execution/payload/src/error.rs +++ b/crates/execution/payload/src/error.rs @@ -29,4 +29,7 @@ pub enum BasePayloadBuilderError { /// executor. #[error("sequencer transaction commit was refused")] SequencerTransactionCommitRefused, + /// Thrown when the job was cancelled, e.g. because the requesting client went away. + #[error("payload job cancelled")] + Cancelled, } diff --git a/crates/execution/rpc/src/debug.rs b/crates/execution/rpc/src/debug.rs index 7dad68d254..9216631018 100644 --- a/crates/execution/rpc/src/debug.rs +++ b/crates/execution/rpc/src/debug.rs @@ -26,7 +26,10 @@ use reth_provider::{ BlockReaderIdExt, ChainSpecProvider, HeaderProvider, NodePrimitivesProvider, ProviderError, ProviderResult, StateProviderFactory, }; -use reth_revm::{State, database::StateProviderDatabase, witness::ExecutionWitnessRecord}; +use reth_revm::{ + State, cancelled::CancelOnDrop, database::StateProviderDatabase, + witness::ExecutionWitnessRecord, +}; use reth_rpc_api::eth::helpers::FullEthApi; use reth_rpc_eth_types::EthApiError; use reth_rpc_server_types::{ToRpcResult, result::internal_rpc_err}; @@ -195,6 +198,9 @@ where let parent_header = self.parent_header(parent_block_hash).to_rpc_result()?; + // Cancels the blocking task if this future is dropped (e.g. the client disconnected). + let cancel = CancelOnDrop::default(); + let task_cancel = cancel.clone(); let (tx, rx) = oneshot::channel(); let this = Arc::clone(&self.inner); let eth_api = self.inner.eth_api.provider().clone(); @@ -211,7 +217,7 @@ where evm_config: this.evm_config.clone(), chain_spec: this.provider.chain_spec(), config, - cancel: Default::default(), + cancel: task_cancel, best_payload: Default::default(), builder_config: Default::default(), }; diff --git a/crates/execution/rpc/src/witness.rs b/crates/execution/rpc/src/witness.rs index ca15c0f46d..5e0f26e6aa 100644 --- a/crates/execution/rpc/src/witness.rs +++ b/crates/execution/rpc/src/witness.rs @@ -13,6 +13,7 @@ use reth_chainspec::ChainSpecProvider; use reth_evm::ConfigureEvm; use reth_node_api::{BuildNextEnv, NodePrimitives}; use reth_primitives_traits::{SealedHeader, TxTy}; +use reth_revm::cancelled::CancelOnDrop; use reth_rpc_server_types::{ToRpcResult, result::internal_rpc_err}; use reth_storage_api::{ BlockReaderIdExt, NodePrimitivesProvider, StateProviderFactory, @@ -102,10 +103,13 @@ where let parent_header = self.parent_header(parent_block_hash).to_rpc_result()?; + // Cancels the blocking task if this future is dropped (e.g. the client disconnected). + let cancel = CancelOnDrop::default(); + let task_cancel = cancel.clone(); let (tx, rx) = oneshot::channel(); let this = self.clone(); self.inner.task_spawner.spawn_blocking_task(async move { - let res = this.inner.builder.payload_witness(parent_header, attributes); + let res = this.inner.builder.payload_witness(parent_header, attributes, task_cancel); let _ = tx.send(res); }); From e45a8f12f992340c1c6dbecc1364e5e8cf9b16ee Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Wed, 30 Sep 2026 02:16:38 +0000 Subject: [PATCH 172/299] perf(trie): prewarm witness multiproof in parallel (#5301) * perf(trie): prewarm witness multiproof in parallel Compute the witness initial multiproof in parallel chunks (reth multiproof targets/chunking, chunk size 5) on the shared read tx before the serial TrieWitness walk, to overlap IO-bound trie reads. witness_reads fixture now stores trie branch nodes so walks match production. * perf(trie): memoize witness cursor reads across the parallel prewarm --- Cargo.lock | 2 + crates/execution/trie/Cargo.toml | 2 + .../execution/trie/benches/witness_reads.rs | 54 ++- crates/execution/trie/src/cursor_cache.rs | 400 ++++++++++++++++++ crates/execution/trie/src/lib.rs | 6 + crates/execution/trie/src/proof.rs | 58 ++- .../trie/tests/witness_differential.rs | 211 +++++++++ 7 files changed, 716 insertions(+), 17 deletions(-) create mode 100644 crates/execution/trie/src/cursor_cache.rs create mode 100644 crates/execution/trie/tests/witness_differential.rs diff --git a/Cargo.lock b/Cargo.lock index 486ae1a8d6..2110039278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4636,12 +4636,14 @@ dependencies = [ "bincode 2.0.1", "bytes", "criterion", + "dashmap", "derive_more 2.1.1", "eyre", "metrics", "mockall", "parking_lot", "rand 0.8.7", + "rayon", "reth-chainspec", "reth-codecs", "reth-db", diff --git a/crates/execution/trie/Cargo.toml b/crates/execution/trie/Cargo.toml index ef2637b962..43ee71d7ca 100644 --- a/crates/execution/trie/Cargo.toml +++ b/crates/execution/trie/Cargo.toml @@ -48,6 +48,8 @@ bincode = { workspace = true, features = ["serde"] } # misc tracing.workspace = true +rayon.workspace = true +dashmap.workspace = true thiserror.workspace = true auto_impl.workspace = true parking_lot.workspace = true diff --git a/crates/execution/trie/benches/witness_reads.rs b/crates/execution/trie/benches/witness_reads.rs index 4624479a35..91039f8f97 100644 --- a/crates/execution/trie/benches/witness_reads.rs +++ b/crates/execution/trie/benches/witness_reads.rs @@ -17,11 +17,11 @@ use base_execution_trie::{ use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use rand_08::{RngCore, SeedableRng, rngs::StdRng}; use reth_primitives_traits::Account; -use reth_provider::{AccountReader, StateProvider, noop::NoopProvider}; +use reth_provider::{AccountReader, StateProvider, StateRootProvider, noop::NoopProvider}; use reth_revm::{ Database, State, database::StateProviderDatabase, witness::ExecutionWitnessRecord, }; -use reth_trie_common::ExecutionWitnessMode; +use reth_trie_common::{ExecutionWitnessMode, HashedPostState, updates::TrieUpdates}; use tempfile::TempDir; const BASE_ACCOUNTS: usize = 10_000; @@ -82,11 +82,7 @@ fn missing_addresses() -> Vec

{ (0..MISSING_ACCOUNTS).map(|index| Address::repeat_byte(0x80 | index as u8)).collect() } -fn create_fixture(compact: bool) -> WitnessReadFixture { - let dir = TempDir::new().expect("create temp dir"); - let rocksdb = Arc::new(RocksdbProofsStorage::new(dir.path()).expect("create RocksDB storage")); - let accounts = generate_accounts(BASE_ACCOUNTS, SLOTS_PER_ACCOUNT); - +fn seed_leaves(rocksdb: &RocksdbProofsStorage, accounts: &[SeedAccount]) { rocksdb .store_hashed_accounts( accounts @@ -96,7 +92,7 @@ fn create_fixture(compact: bool) -> WitnessReadFixture { ) .expect("store hashed accounts"); - for account in &accounts { + for account in accounts { let storages = account .slots .iter() @@ -105,6 +101,48 @@ fn create_fixture(compact: bool) -> WitnessReadFixture { .store_hashed_storages(account.hashed_address, storages.collect()) .expect("store hashed storage"); } +} + +/// Computes the branch nodes for the seeded leaves in a scratch store, so the benchmarked store holds +/// complete tries like production proofs history. Without them every trie walk rehashes all leaves. +fn trie_branches(accounts: &[SeedAccount]) -> TrieUpdates { + let dir = TempDir::new().expect("create temp dir"); + let scratch = Arc::new(RocksdbProofsStorage::new(dir.path()).expect("create RocksDB storage")); + seed_leaves(&scratch, accounts); + scratch.set_initial_state_anchor(BlockNumHash::new(0, B256::ZERO)).expect("set anchor"); + scratch.commit_initial_state().expect("commit initial state"); + let storage: BaseProofsStorage> = scratch.into(); + BaseProofsStateProviderRef::new(Box::::default(), &storage, 0) + .state_root_with_updates(HashedPostState::default()) + .expect("compute trie branches") + .1 +} + +fn create_fixture(compact: bool) -> WitnessReadFixture { + let dir = TempDir::new().expect("create temp dir"); + let rocksdb = Arc::new(RocksdbProofsStorage::new(dir.path()).expect("create RocksDB storage")); + let accounts = generate_accounts(BASE_ACCOUNTS, SLOTS_PER_ACCOUNT); + seed_leaves(&rocksdb, &accounts); + + let branches = trie_branches(&accounts); + rocksdb + .store_account_branches( + branches.account_nodes.into_iter().map(|(path, node)| (path, Some(node))).collect(), + ) + .expect("store account branches"); + rocksdb + .store_storage_branches_bulk( + branches + .storage_tries + .into_iter() + .map(|(hashed_address, trie)| { + let nodes = + trie.storage_nodes.into_iter().map(|(path, node)| (path, Some(node))); + (hashed_address, nodes.collect()) + }) + .collect(), + ) + .expect("store storage branches"); rocksdb .set_initial_state_anchor(BlockNumHash::new(0, B256::ZERO)) diff --git a/crates/execution/trie/src/cursor_cache.rs b/crates/execution/trie/src/cursor_cache.rs new file mode 100644 index 0000000000..9a85c8fb1a --- /dev/null +++ b/crates/execution/trie/src/cursor_cache.rs @@ -0,0 +1,400 @@ +//! Request-scoped memoization of trie and hashed cursor results. +//! +//! Every cursor opened against one read snapshot at one max block number returns the same result +//! for the same positioning operation, so cursors opened by concurrent proof jobs can share their +//! results. [`CursorResultCache`] holds those results for a single request, and the +//! [`CachedTrieCursorFactory`] / [`CachedHashedCursorFactory`] wrappers consult it before reading +//! from the wrapped factory's cursors. + +use core::{ + fmt::Debug, + hash::Hash, + sync::atomic::{AtomicU64, Ordering}, +}; + +use alloy_primitives::{B256, U256, map::DefaultHashBuilder}; +use dashmap::DashMap; +use reth_db::DatabaseError; +use reth_primitives_traits::Account; +use reth_trie::{ + hashed_cursor::{HashedCursor, HashedCursorFactory, HashedStorageCursor}, + trie_cursor::{TrieCursor, TrieCursorFactory, TrieStorageCursor}, +}; +use reth_trie_common::{BranchNodeCompact, Nibbles}; + +/// A cursor positioning operation whose result depends only on the snapshot and its input key. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CursorOp { + /// `seek(key)`. + Seek(K), + /// `seek_exact(key)`. + SeekExact(K), + /// `next()` while positioned on `key`. + Next(K), +} + +/// Memoized results for one cursor kind, keyed by storage-trie address (`None` for the account +/// tries) and operation. +pub type CursorResultMap = + DashMap<(Option, CursorOp), Option<(K, V)>, DefaultHashBuilder>; + +/// Cursor results shared by every cursor opened through the cached factories of one request. +/// +/// Entries are never invalidated, so a cache must only span cursors that read the same snapshot +/// at the same max block number. +#[derive(Debug, Default)] +pub struct CursorResultCache { + trie_nodes: CursorResultMap, + hashed_accounts: CursorResultMap, + hashed_storages: CursorResultMap, + empty_storages: DashMap, + hits: AtomicU64, + misses: AtomicU64, +} + +impl CursorResultCache { + /// Returns the number of cursor operations served from the cache by cursors dropped so far. + pub fn hits(&self) -> u64 { + self.hits.load(Ordering::Relaxed) + } + + /// Returns the number of cursor operations that read through to the wrapped cursors, counted + /// like [`Self::hits`]. + pub fn misses(&self) -> u64 { + self.misses.load(Ordering::Relaxed) + } +} + +/// Per-cursor memoization state: the logical position, the cache-served operation, if any, that +/// the wrapped cursor has not applied yet, and hit/miss counts flushed to the cache on drop so +/// concurrent cursors do not contend on the shared counters. +#[derive(Debug)] +pub struct CursorMemo<'c, K: Eq + Hash, V> { + cache: &'c CursorResultCache, + results: &'c CursorResultMap, + hashed_address: Option, + position: Option, + pending: Option>, + hits: u64, + misses: u64, +} + +impl<'c, K, V> CursorMemo<'c, K, V> +where + K: Clone + Eq + Hash, + V: Clone, +{ + /// Creates the state for a fresh, unpositioned cursor. + pub const fn new( + cache: &'c CursorResultCache, + results: &'c CursorResultMap, + hashed_address: Option, + ) -> Self { + Self { cache, results, hashed_address, position: None, pending: None, hits: 0, misses: 0 } + } + + /// Returns the cached result of `op` and moves the logical position to it. + pub fn get(&mut self, op: &CursorOp) -> Option> { + let result = self.results.get(&(self.hashed_address, op.clone()))?.value().clone(); + self.hits += 1; + self.position = result.as_ref().map(|(key, _)| key.clone()); + self.pending = Some(op.clone()); + Some(result) + } + + /// Records `result`, which the wrapped cursor returned for `op` and is now positioned at. + pub fn insert(&mut self, op: CursorOp, result: &Option<(K, V)>) { + self.misses += 1; + self.position = result.as_ref().map(|(key, _)| key.clone()); + self.pending = None; + self.results.insert((self.hashed_address, op), result.clone()); + } + + /// Marks the cursor unpositioned, for example after a reset or address change. + pub fn unposition(&mut self, hashed_address: Option) { + self.hashed_address = hashed_address; + self.position = None; + self.pending = None; + } +} + +impl Drop for CursorMemo<'_, K, V> { + fn drop(&mut self) { + self.cache.hits.fetch_add(self.hits, Ordering::Relaxed); + self.cache.misses.fetch_add(self.misses, Ordering::Relaxed); + } +} + +/// [`TrieCursorFactory`] whose cursors memoize results in a shared [`CursorResultCache`]. +#[derive(Debug, Clone)] +pub struct CachedTrieCursorFactory<'c, F> { + inner: F, + cache: &'c CursorResultCache, +} + +impl<'c, F> CachedTrieCursorFactory<'c, F> { + /// Wraps `inner` so its cursors share `cache`. + pub const fn new(inner: F, cache: &'c CursorResultCache) -> Self { + Self { inner, cache } + } +} + +impl TrieCursorFactory for CachedTrieCursorFactory<'_, F> { + type AccountTrieCursor<'a> + = CachedTrieCursor<'a, F::AccountTrieCursor<'a>> + where + Self: 'a; + type StorageTrieCursor<'a> + = CachedTrieCursor<'a, F::StorageTrieCursor<'a>> + where + Self: 'a; + + fn account_trie_cursor(&self) -> Result, DatabaseError> { + Ok(CachedTrieCursor { + inner: self.inner.account_trie_cursor()?, + memo: CursorMemo::new(self.cache, &self.cache.trie_nodes, None), + }) + } + + fn storage_trie_cursor( + &self, + hashed_address: B256, + ) -> Result, DatabaseError> { + Ok(CachedTrieCursor { + inner: self.inner.storage_trie_cursor(hashed_address)?, + memo: CursorMemo::new(self.cache, &self.cache.trie_nodes, Some(hashed_address)), + }) + } +} + +/// [`TrieCursor`] that serves repeated operations from a [`CursorResultCache`]. +#[derive(Debug)] +pub struct CachedTrieCursor<'c, C> { + inner: C, + memo: CursorMemo<'c, Nibbles, BranchNodeCompact>, +} + +impl CachedTrieCursor<'_, C> { + /// Applies the last cache-served operation to the wrapped cursor. + fn sync(&mut self) -> Result<(), DatabaseError> { + match self.memo.pending.take() { + Some(CursorOp::Seek(key)) => { + self.inner.seek(key)?; + } + Some(CursorOp::SeekExact(key)) => { + self.inner.seek_exact(key)?; + } + Some(CursorOp::Next(key)) => { + self.inner.seek(key)?; + self.inner.next()?; + } + None => {} + } + Ok(()) + } +} + +impl TrieCursor for CachedTrieCursor<'_, C> { + fn seek_exact( + &mut self, + key: Nibbles, + ) -> Result, DatabaseError> { + let op = CursorOp::SeekExact(key); + if let Some(result) = self.memo.get(&op) { + return Ok(result); + } + let result = self.inner.seek_exact(key)?; + self.memo.insert(op, &result); + Ok(result) + } + + fn seek( + &mut self, + key: Nibbles, + ) -> Result, DatabaseError> { + let op = CursorOp::Seek(key); + if let Some(result) = self.memo.get(&op) { + return Ok(result); + } + let result = self.inner.seek(key)?; + self.memo.insert(op, &result); + Ok(result) + } + + fn next(&mut self) -> Result, DatabaseError> { + let Some(key) = self.memo.position else { + // Unpositioned `next` semantics are backend-specific, so read through uncached. + self.sync()?; + let result = self.inner.next()?; + self.memo.position = result.as_ref().map(|(key, _)| *key); + return Ok(result); + }; + let op = CursorOp::Next(key); + if let Some(result) = self.memo.get(&op) { + return Ok(result); + } + // `key` is live, so seeking it lands the wrapped cursor exactly on the logical position. + if self.memo.pending.is_some() { + self.inner.seek(key)?; + } + let result = self.inner.next()?; + self.memo.insert(op, &result); + Ok(result) + } + + fn current(&mut self) -> Result, DatabaseError> { + self.sync()?; + self.inner.current() + } + + fn reset(&mut self) { + self.inner.reset(); + self.memo.unposition(self.memo.hashed_address); + } +} + +impl TrieStorageCursor for CachedTrieCursor<'_, C> { + fn set_hashed_address(&mut self, hashed_address: B256) { + self.inner.set_hashed_address(hashed_address); + self.memo.unposition(Some(hashed_address)); + } +} + +/// [`HashedCursorFactory`] whose cursors memoize results in a shared [`CursorResultCache`]. +#[derive(Debug, Clone)] +pub struct CachedHashedCursorFactory<'c, F> { + inner: F, + cache: &'c CursorResultCache, +} + +impl<'c, F> CachedHashedCursorFactory<'c, F> { + /// Wraps `inner` so its cursors share `cache`. + pub const fn new(inner: F, cache: &'c CursorResultCache) -> Self { + Self { inner, cache } + } +} + +impl HashedCursorFactory for CachedHashedCursorFactory<'_, F> { + type AccountCursor<'a> + = CachedHashedCursor<'a, F::AccountCursor<'a>, Account> + where + Self: 'a; + type StorageCursor<'a> + = CachedHashedCursor<'a, F::StorageCursor<'a>, U256> + where + Self: 'a; + + fn hashed_account_cursor(&self) -> Result, DatabaseError> { + Ok(CachedHashedCursor { + inner: self.inner.hashed_account_cursor()?, + memo: CursorMemo::new(self.cache, &self.cache.hashed_accounts, None), + }) + } + + fn hashed_storage_cursor( + &self, + hashed_address: B256, + ) -> Result, DatabaseError> { + Ok(CachedHashedCursor { + inner: self.inner.hashed_storage_cursor(hashed_address)?, + memo: CursorMemo::new(self.cache, &self.cache.hashed_storages, Some(hashed_address)), + }) + } +} + +/// [`HashedCursor`] that serves repeated operations from a [`CursorResultCache`]. +#[derive(Debug)] +pub struct CachedHashedCursor<'c, C, V> { + inner: C, + memo: CursorMemo<'c, B256, V>, +} + +impl CachedHashedCursor<'_, C, V> +where + C: HashedCursor, + V: Clone, +{ + /// Applies the last cache-served operation to the wrapped cursor. Hashed cursors only issue + /// [`CursorOp::Seek`] and [`CursorOp::Next`]. + fn sync(&mut self) -> Result<(), DatabaseError> { + if let Some(op) = self.memo.pending.take() { + let (CursorOp::Seek(key) | CursorOp::SeekExact(key) | CursorOp::Next(key)) = op; + self.inner.seek(key)?; + if matches!(op, CursorOp::Next(_)) { + self.inner.next()?; + } + } + Ok(()) + } +} + +impl HashedCursor for CachedHashedCursor<'_, C, V> +where + C: HashedCursor, + V: Clone + Debug, +{ + type Value = V; + + fn seek(&mut self, key: B256) -> Result, DatabaseError> { + let op = CursorOp::Seek(key); + if let Some(result) = self.memo.get(&op) { + return Ok(result); + } + let result = self.inner.seek(key)?; + self.memo.insert(op, &result); + Ok(result) + } + + fn next(&mut self) -> Result, DatabaseError> { + let Some(key) = self.memo.position else { + // Unpositioned `next` semantics are backend-specific, so read through uncached. + self.sync()?; + let result = self.inner.next()?; + self.memo.position = result.as_ref().map(|(key, _)| *key); + return Ok(result); + }; + let op = CursorOp::Next(key); + if let Some(result) = self.memo.get(&op) { + return Ok(result); + } + // `key` is live, so seeking it lands the wrapped cursor exactly on the logical position. + if self.memo.pending.is_some() { + self.inner.seek(key)?; + } + let result = self.inner.next()?; + self.memo.insert(op, &result); + Ok(result) + } + + fn reset(&mut self) { + self.inner.reset(); + self.memo.unposition(self.memo.hashed_address); + } +} + +impl HashedStorageCursor for CachedHashedCursor<'_, C, U256> +where + C: HashedStorageCursor, +{ + fn is_storage_empty(&mut self) -> Result { + let hashed_address = self.memo.hashed_address.unwrap_or_default(); + if let Some(empty) = self.memo.cache.empty_storages.get(&hashed_address) { + self.memo.hits += 1; + return Ok(*empty); + } + let empty = self.inner.is_storage_empty()?; + // Some backends move the wrapped cursor here; re-seek the logical position before the + // next read-through `next`. + if let Some(key) = self.memo.position { + self.memo.pending = Some(CursorOp::Seek(key)); + } + self.memo.misses += 1; + self.memo.cache.empty_storages.insert(hashed_address, empty); + Ok(empty) + } + + fn set_hashed_address(&mut self, hashed_address: B256) { + self.inner.set_hashed_address(hashed_address); + self.memo.unposition(Some(hashed_address)); + } +} diff --git a/crates/execution/trie/src/lib.rs b/crates/execution/trie/src/lib.rs index af65ff1203..bb4b75eb9d 100644 --- a/crates/execution/trie/src/lib.rs +++ b/crates/execution/trie/src/lib.rs @@ -69,6 +69,12 @@ pub use cursor_factory::{ BaseProofsHashedAccountCursorFactory, BaseProofsTrieCursorFactory, }; +mod cursor_cache; +pub use cursor_cache::{ + CachedHashedCursor, CachedHashedCursorFactory, CachedTrieCursor, CachedTrieCursorFactory, + CursorMemo, CursorOp, CursorResultCache, CursorResultMap, +}; + pub mod error; pub use error::{BaseProofsStorageError, BaseProofsStorageResult}; diff --git a/crates/execution/trie/src/proof.rs b/crates/execution/trie/src/proof.rs index 212751dcaa..6447cd69a0 100644 --- a/crates/execution/trie/src/proof.rs +++ b/crates/execution/trie/src/proof.rs @@ -4,6 +4,7 @@ use alloy_primitives::{ Address, B256, Bytes, keccak256, map::{B256Map, HashMap}, }; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; use reth_db::DatabaseError; use reth_execution_errors::{StateProofError, StateRootError, StorageRootError, TrieWitnessError}; use reth_trie::{ @@ -16,13 +17,14 @@ use reth_trie::{ }; use reth_trie_common::{ AccountProof, ExecutionWitnessMode, HashedPostState, HashedPostStateSorted, HashedStorage, - MultiProof, MultiProofTargets, StorageMultiProof, StorageProof, TrieInput, + MultiProof, MultiProofTargets, MultiProofTargetsV2, StorageMultiProof, StorageProof, TrieInput, updates::TrieUpdates, }; use crate::{ BaseProofsHashedAccountCursorFactory, BaseProofsStorage, BaseProofsStore, - BaseProofsTrieCursorFactory, + BaseProofsTrieCursorFactory, CachedHashedCursorFactory, CachedTrieCursorFactory, + CursorResultCache, }; /// Build the trie + hashed cursor factories sharing one read transaction at the given block. @@ -408,15 +410,53 @@ where let nodes_sorted = input.nodes.into_sorted(); let state_sorted = input.state.into_sorted(); let (trie_factory, hashed_factory) = from_tx(storage, tx, block_number); - TrieWitness::new(trie_factory.clone(), hashed_factory.clone()) - .with_trie_cursor_factory(InMemoryTrieCursorFactory::new(trie_factory, &nodes_sorted)) - .with_hashed_cursor_factory(HashedPostStateCursorFactory::new( - hashed_factory, - &state_sorted, - )) + // Every cursor below reads the same snapshot at the same block, so their results are + // shared through one cache for the whole request. + let cache = CursorResultCache::default(); + let trie_factory = CachedTrieCursorFactory::new(trie_factory, &cache); + let hashed_factory = CachedHashedCursorFactory::new(hashed_factory, &cache); + let overlay_trie_factory = + InMemoryTrieCursorFactory::new(trie_factory.clone(), &nodes_sorted); + let overlay_hashed_factory = + HashedPostStateCursorFactory::new(hashed_factory.clone(), &state_sorted); + // The witness walk below reads the trie serially. Compute each target account's share of + // its initial multiproof in parallel first, with the same proof algorithm, overlay, and + // prefix sets, so the serial walk issues the same cursor operations and replays cached + // results; proofs are discarded and any error resurfaces from the witness itself. + tracing::info_span!("witness_prewarm").in_scope(|| { + target.multi_proof_targets().into_iter().collect::>().into_par_iter().for_each( + |(hashed_address, slots)| { + let mut targets = MultiProofTargetsV2 { + account_targets: vec![hashed_address.into()], + ..Default::default() + }; + if !slots.is_empty() { + targets + .storage_targets + .insert(hashed_address, slots.into_iter().map(Into::into).collect()); + } + let _ = + Proof::new(overlay_trie_factory.clone(), overlay_hashed_factory.clone()) + .with_prefix_sets_mut(input.prefix_sets.clone()) + .multiproof_v2(targets); + }, + ); + }); + let (prewarm_hits, prewarm_misses) = (cache.hits(), cache.misses()); + let witness = TrieWitness::new(trie_factory, hashed_factory) + .with_trie_cursor_factory(overlay_trie_factory) + .with_hashed_cursor_factory(overlay_hashed_factory) .with_prefix_sets_mut(input.prefix_sets) .always_include_root_node() .with_execution_witness_mode(mode) - .compute(target) + .compute(target); + tracing::debug!( + prewarm_hits, + prewarm_misses, + witness_hits = cache.hits() - prewarm_hits, + witness_misses = cache.misses() - prewarm_misses, + "witness cursor cache" + ); + witness } } diff --git a/crates/execution/trie/tests/witness_differential.rs b/crates/execution/trie/tests/witness_differential.rs new file mode 100644 index 0000000000..0454dc8aa7 --- /dev/null +++ b/crates/execution/trie/tests/witness_differential.rs @@ -0,0 +1,211 @@ +//! Differential witness test: `TrieWitness::overlay_witness`, which prewarms a shared cursor-result +//! cache in parallel, must return exactly the nodes of reth's serial `TrieWitness` over uncached +//! cursors, for random multi-block histories and random targets with deletions. + +use std::{collections::BTreeMap, path::Path, sync::Arc}; + +use alloy_eips::{BlockNumHash, NumHash, eip1898::BlockWithParent}; +use alloy_primitives::{B256, Bytes, U256, map::B256Map}; +use base_execution_trie::{ + BaseProofsHashedAccountCursorFactory, BaseProofsInitialStateStore, BaseProofsStorage, + BaseProofsStore, BaseProofsTrieCursorFactory, BlockStateDiff, MdbxProofsStorage, + RocksdbProofsStorage, proof::DatabaseTrieWitness, provider::BaseProofsStateProviderRef, +}; +use rand_08::{Rng, SeedableRng, rngs::StdRng}; +use reth_primitives_traits::Account; +use reth_provider::{StateRootProvider, noop::NoopProvider}; +use reth_trie::{ + hashed_cursor::HashedPostStateCursorFactory, trie_cursor::InMemoryTrieCursorFactory, + witness::TrieWitness, +}; +use reth_trie_common::{ExecutionWitnessMode, HashedPostState, HashedStorage, TrieInput}; +use tempfile::TempDir; + +/// Number of independent random histories checked per backend. +const SEEDS: u64 = 6; +/// Blocks written after the initial state in each history. +const BLOCKS: u64 = 6; +/// Accounts in the initial state. +const ACCOUNTS: usize = 48; +/// Maximum storage slots per initial account. +const MAX_SLOTS: usize = 8; +/// Random witness requests checked per history. +const REQUESTS: usize = 8; + +/// Keys of the initial state, reused to build block diffs and witness targets. +struct Keyspace { + accounts: Vec<(B256, Vec)>, +} + +impl Keyspace { + fn random(rng: &mut StdRng) -> Self { + let accounts = (0..ACCOUNTS) + .map(|_| { + let slots = (0..rng.gen_range(0..=MAX_SLOTS)).map(|_| random_hash(rng)).collect(); + (random_hash(rng), slots) + }) + .collect(); + Self { accounts } + } + + /// Random post state touching a fraction of the known accounts and slots, with account + /// deletions, zeroed (deleted) slots, new keys, and occasional storage wipes. + fn random_state(&self, rng: &mut StdRng, touch: f64) -> HashedPostState { + let mut state = HashedPostState::default(); + for (address, slots) in &self.accounts { + if !rng.gen_bool(touch) { + continue; + } + let account = (!rng.gen_bool(0.15)).then(|| random_account(rng)); + state.accounts.insert(*address, account); + let mut storage = HashedStorage::new(rng.gen_bool(0.05)); + for slot in slots.iter().chain([&random_hash(rng)]) { + if rng.gen_bool(0.6) { + let value = if rng.gen_bool(0.3) { + U256::ZERO + } else { + U256::from(rng.gen_range(1..u64::MAX)) + }; + storage.storage.insert(*slot, value); + } + } + state.storages.insert(*address, storage); + } + for _ in 0..rng.gen_range(0..3) { + state.accounts.insert(random_hash(rng), Some(random_account(rng))); + } + state + } +} + +fn random_hash(rng: &mut StdRng) -> B256 { + B256::from(rng.r#gen::<[u8; 32]>()) +} + +fn random_account(rng: &mut StdRng) -> Account { + Account { + nonce: rng.gen_range(0..16), + balance: U256::from(rng.r#gen::()), + bytecode_hash: None, + } +} + +fn block_hash(number: u64) -> B256 { + B256::left_padding_from(&number.to_be_bytes()) +} + +/// Writes the initial leaves, then `BLOCKS` random diffs whose trie updates are computed from +/// the history itself, so stored branch nodes are consistent with the leaves. +fn build_history( + rng: &mut StdRng, + keys: &Keyspace, + store: &S, + storage: &BaseProofsStorage>, +) where + S: BaseProofsStore + BaseProofsInitialStateStore, +{ + store.set_initial_state_anchor(BlockNumHash::new(0, block_hash(0))).expect("anchor"); + store + .store_hashed_accounts( + keys.accounts + .iter() + .map(|(address, _)| (*address, Some(random_account(rng)))) + .collect(), + ) + .expect("store accounts"); + for (address, slots) in &keys.accounts { + let values = slots.iter().map(|slot| (*slot, U256::from(rng.gen_range(1..u64::MAX)))); + store.store_hashed_storages(*address, values.collect()).expect("store storages"); + } + store.commit_initial_state().expect("commit initial state"); + + for number in 1..=BLOCKS { + let post_state = keys.random_state(rng, 0.3); + let (_, trie_updates) = + BaseProofsStateProviderRef::new(Box::::default(), storage, number - 1) + .state_root_with_updates(post_state.clone()) + .expect("state root"); + let block_ref = + BlockWithParent::new(block_hash(number - 1), NumHash::new(number, block_hash(number))); + let diff = BlockStateDiff { + sorted_trie_updates: trie_updates.into_sorted(), + sorted_post_state: post_state.into_sorted(), + }; + storage.store_trie_updates(block_ref, diff).expect("store block"); + } +} + +/// Reth's serial witness over uncached cursors sharing one read transaction. +fn reference_witness( + storage: &BaseProofsStorage>, + block_number: u64, + input: TrieInput, + target: HashedPostState, + mode: ExecutionWitnessMode, +) -> Result, String> +where + S: BaseProofsStore, +{ + let tx = storage.ro_tx().expect("read tx"); + let trie_factory = BaseProofsTrieCursorFactory::new(storage, &tx, block_number); + let hashed_factory = BaseProofsHashedAccountCursorFactory::new(storage, &tx, block_number); + let nodes_sorted = input.nodes.into_sorted(); + let state_sorted = input.state.into_sorted(); + TrieWitness::new(trie_factory.clone(), hashed_factory.clone()) + .with_trie_cursor_factory(InMemoryTrieCursorFactory::new(trie_factory, &nodes_sorted)) + .with_hashed_cursor_factory(HashedPostStateCursorFactory::new( + hashed_factory, + &state_sorted, + )) + .with_prefix_sets_mut(input.prefix_sets) + .always_include_root_node() + .with_execution_witness_mode(mode) + .compute(target) + .map_err(|error| error.to_string()) +} + +fn check_backend(open: impl Fn(&Path) -> S) +where + S: BaseProofsStore + BaseProofsInitialStateStore + 'static, +{ + for seed in 0..SEEDS { + let dir = TempDir::new().expect("temp dir"); + let store = Arc::new(open(dir.path())); + let storage = BaseProofsStorage::from(Arc::clone(&store)); + let mut rng = StdRng::seed_from_u64(seed); + let keys = Keyspace::random(&mut rng); + build_history(&mut rng, &keys, store.as_ref(), &storage); + for request in 0..REQUESTS { + let block_number = rng.gen_range(0..=BLOCKS); + let target = keys.random_state(&mut rng, 0.25); + let input = if rng.gen_bool(0.5) { + TrieInput::default() + } else { + TrieInput::from_state(keys.random_state(&mut rng, 0.1)) + }; + let mode = if rng.gen_bool(0.5) { + ExecutionWitnessMode::Legacy + } else { + ExecutionWitnessMode::Canonical + }; + let expected = + reference_witness(&storage, block_number, input.clone(), target.clone(), mode) + .map(BTreeMap::from_iter); + let actual = TrieWitness::overlay_witness(&storage, block_number, input, target, mode) + .map(BTreeMap::from_iter) + .map_err(|error| error.to_string()); + assert!(expected.as_ref().is_ok_and(|nodes| !nodes.is_empty()), "seed {seed}"); + assert_eq!(actual, expected, "seed {seed} request {request} block {block_number}"); + } + } +} + +#[test] +fn cached_witness_matches_uncached_rocksdb() { + check_backend(|path| RocksdbProofsStorage::new(path).expect("rocksdb")); +} + +#[test] +fn cached_witness_matches_uncached_mdbx() { + check_backend(|path| MdbxProofsStorage::new(path).expect("mdbx")); +} From e30c533759b54e46a72fd22f3bba4cfcc1eaf267 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 11:21:11 +0000 Subject: [PATCH 173/299] test(batcher): drop redundant driver tests and pin the remaining ones to their contract (#5342) * test(batcher): drop redundant driver tests and pin the remaining ones to their contract * test(batcher): tighten the driver test comments and derive the backlog from the threshold * test(batcher): assert the L1 heads of a throttle test and rename a heads test * test(batcher): tighten the driver tests and cover the driver contracts left untested * test(batcher): drop the core tests others cover and pin the drain time and throttle limits * test(batcher): fix two throttle test comments and group the fatal step test with the priority tests * test(batcher): pin the admin throttle re-push and the blob forcing flag, and fix two throttle docs * test(batcher): document every core test and check the order of all driver arms in one test * test(batcher): keep the driver arm and admin tests on what callers rely on --- crates/batcher/core/README.md | 9 +- crates/batcher/core/src/driver.rs | 512 +++++------------- crates/batcher/core/src/test_utils/builder.rs | 27 +- crates/batcher/core/src/test_utils/mod.rs | 2 +- .../batcher/core/src/test_utils/pipeline.rs | 46 +- crates/batcher/core/src/test_utils/source.rs | 24 +- .../batcher/core/src/test_utils/throttle.rs | 39 +- crates/batcher/core/src/throttle.rs | 74 ++- crates/batcher/core/tests/heads.rs | 94 ++-- crates/batcher/core/tests/lifecycle.rs | 55 +- crates/batcher/core/tests/reorg.rs | 51 +- crates/batcher/core/tests/stop_start.rs | 52 +- crates/batcher/core/tests/throttle.rs | 193 +++---- 13 files changed, 462 insertions(+), 716 deletions(-) diff --git a/crates/batcher/core/README.md b/crates/batcher/core/README.md index 853ffc93df..ce1eae2df2 100644 --- a/crates/batcher/core/README.md +++ b/crates/batcher/core/README.md @@ -32,14 +32,15 @@ and `Failed`. The throttle subsystem controls how much DA data the sequencer 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::Off` disables throttling entirely. `ThrottleStrategy::Step` applies full -intensity when the backlog exceeds the configured threshold. `ThrottleStrategy::Linear` grows +`ThrottleStrategy::Off` disables throttling entirely. `ThrottleStrategy::Step` applies +`max_intensity` once the backlog reaches the configured threshold. `ThrottleStrategy::Linear` grows intensity linearly from zero at the threshold to `max_intensity` at twice the threshold. `ThrottleParams` carries a fractional `intensity` value and the corresponding `max_block_size` and `max_tx_size` byte limits computed by interpolating between the upper and lower limits in `ThrottleConfig`. `DaThrottle` wraps a -`ThrottleController` and a `ThrottleClient` with a last-applied dedup cache so that the -`miner_setMaxDASize` RPC call is only issued when the computed limits actually change between ticks. +`ThrottleController` and a `ThrottleClient` with a last-applied dedup cache. The +`miner_setMaxDASize` RPC call is issued at startup, whenever the computed limits change, after an +admin command replaces or resets the controller, and again after a push the block builder refused. `ThrottleClient` is the async trait that connects the throttle controller to the block builder. Its single method, `set_max_da_size`, forwards the per-transaction and per-block byte limits to diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 566b7837a1..f9d048baf1 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -423,14 +423,15 @@ where mod tests { use std::{sync::Arc, time::Duration}; - use alloy_primitives::B256; - use base_batcher_encoder::{BlobPayload, FrameEncoder, SubmissionId, SubmissionPayload}; + use base_batcher_encoder::{ + BlobPayload, ChannelLimit, FrameEncoder, StepError, SubmissionId, SubmissionPayload, + }; use base_batcher_source::{ L2BlockEvent, test_utils::{ChannelBlockSource, ChannelL1HeadSource}, }; use base_blobs::{BlobDecoder, BlobEncoder}; - use base_protocol::{BlockInfo, Frame}; + use base_protocol::Frame; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, @@ -438,17 +439,13 @@ mod tests { use super::STEP_BUDGET; use crate::{ - BatchDriverError, DerivationStatus, + BatchDriverError, BatchTxCandidateError, DerivationStatus, test_utils::{ BlockStub, DriverFixture, PipelineCall, ScriptedTxManager, SendOutcome, SubmissionStub, TrackingPipeline, }, }; - fn safe_head(number: u64) -> BlockInfo { - BlockInfo { hash: B256::with_last_byte(number as u8), number, ..Default::default() } - } - /// Sources that deliver the given blocks and L1 heads, then park. fn queued_sources( blocks: impl IntoIterator, @@ -475,40 +472,65 @@ mod tests { let (_driver, _handles) = DriverFixture::new(ctx, pipeline, ScriptedTxManager::new([])) .initial_l1_head(50) - .safe_head(safe_head(10)) .build(); assert_eq!(recorded.lock().unwrap().l1_heads(), [50]); }); } - /// Build a [`SubmissionPayload`] whose single frame exactly fills one blob payload, - /// leaving no room for any additional frame alongside it. - /// - /// `payload = 1 (DERIVATION_VERSION_0) + FRAME_OVERHEAD + data.len() = BLOB_MAX_DATA_SIZE` - fn blob_filling_payload() -> SubmissionPayload { - blob_filling_payload_with_frames(1) + /// Blocks at or below the safe head are already derived, so the driver drops them instead + /// of batching them again. + #[test] + fn run_drops_blocks_at_or_below_the_safe_head() { + Runner::start(Config::seeded(0), |ctx| async move { + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + let (source, l1_head_source) = queued_sources([4, 5, 6], []); + let (driver, _handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) + .l1_head_source(l1_head_source) + .safe_head(BlockStub::info(5)) + .build(); + + let handle = ctx.spawn(driver.run()); + ctx.sleep(Duration::from_millis(10)).await; + ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); + + assert_eq!( + recorded.lock().unwrap().calls, + [PipelineCall::AddBlock(6), PipelineCall::Flush] + ); + }); } - fn blob_filling_payload_with_frames(frame_count: usize) -> SubmissionPayload { - let data_len = BlobEncoder::BLOB_MAX_DATA_SIZE - 1 - BlobEncoder::FRAME_OVERHEAD; - SubmissionPayload::Blobs( - (0..frame_count) - .map(|number| { - BlobPayload::new(vec![Arc::new(Frame { - number: number.try_into().expect("frame number fits in u16"), - data: vec![0u8; data_len], - ..Frame::default() - })]) - }) - .collect(), - ) + /// A fatal encoding error stops the driver with that error, instead of dropping the block and + /// leaving a gap in the L2 chain posted to L1. + #[test] + fn run_halts_on_a_fatal_step_error() { + Runner::start(Config::seeded(0), |ctx| async move { + let pipeline = + TrackingPipeline::new().with_step_error(StepError::BlockExceedsChannelLimit { + cursor: 0, + limit: ChannelLimit::RlpBytes { required: 1, maximum: 0 }, + }); + let (driver, _handles) = + DriverFixture::new(ctx, pipeline, ScriptedTxManager::confirming_at(1)).build(); + + assert!(matches!( + driver.run().await, + Err(BatchDriverError::Step(StepError::BlockExceedsChannelLimit { .. })) + )); + }); } - // The loop polls its arms in priority order. Each test below makes several arms ready at - // once and checks which one the driver serves first. Each test checks only the start of the - // call log, because the shutdown flush always ends it. + // The loop polls its arms in priority order. Each test in this group makes several arms + // ready at once and checks which one the driver serves first. Every call log ends with the + // shutdown flush. + /// A cancelled driver shuts down without serving the admin flush, block and L1 head already + /// waiting, and the flush call fails. #[test] fn run_prioritizes_cancellation_over_ready_admin() { Runner::start(Config::seeded(0), |ctx| async move { @@ -535,6 +557,27 @@ mod tests { }); } + /// On cancellation the derivation-status poller exits too and closes its channel, so the + /// driver can see both at once. It must shut down cleanly, not fail with + /// `DerivationStatusSourceClosed`. + #[test] + fn run_prioritizes_cancellation_over_closed_derivation_status() { + Runner::start(Config::seeded(0), |ctx| async move { + let (driver, handles) = DriverFixture::new( + ctx.clone(), + TrackingPipeline::new(), + ScriptedTxManager::confirming_at(1), + ) + .build(); + + ctx.cancel(); + drop(handles); + assert!(driver.run().await.is_ok()); + }); + } + + /// A ready admin command is served before a ready L2 block, so a steady block stream cannot + /// hold back a stop, start or flush. #[test] fn run_prioritizes_admin_before_source() { Runner::start(Config::seeded(0), |ctx| async move { @@ -558,66 +601,32 @@ mod tests { assert!(handle.await.unwrap().is_ok()); assert!(flush.await.unwrap().is_ok()); - let recorded = recorded.lock().unwrap(); - assert!( - recorded.calls.starts_with(&[PipelineCall::Flush, PipelineCall::AddBlock(1)]), - "{:?}", - recorded.calls - ); - }); - } - - #[test] - fn run_prioritizes_receipts_before_source_and_heads() { - Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - // Submitted by the first CPU phase, so its receipt is ready at the first wait. - pipeline.submissions.push_back(SubmissionStub::stub()); - let (source, l1_head_source) = queued_sources([1], [9]); - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) - .source(source) - .l1_head_source(l1_head_source) - .build(); - - let handle = ctx.spawn(driver.run()); - ctx.sleep(Duration::from_millis(10)).await; - ctx.cancel(); - assert!(handle.await.unwrap().is_ok()); - - // The receipt confirms at L1 block 1 before the L1 head source's head 9 arrives. The - // other way round, head 1 would not advance past 9. - let recorded = recorded.lock().unwrap(); - assert!( - recorded.calls.starts_with(&[ - PipelineCall::Dequeue(SubmissionId(0)), - PipelineCall::Confirm(SubmissionId(0), 1), - PipelineCall::AdvanceL1Head(1), - PipelineCall::AddBlock(1), - PipelineCall::AdvanceL1Head(9), - ]), - "{:?}", - recorded.calls + assert_eq!( + recorded.lock().unwrap().calls, + [PipelineCall::Flush, PipelineCall::AddBlock(1), PipelineCall::Flush] ); }); } + /// A new safe head is reconciled before a ready L2 block, so pruning and reorg recovery are + /// not held back by a stream of blocks to encode. #[test] - fn run_prioritizes_derivation_status_before_source_and_receipts() { + fn run_prioritizes_derivation_status_before_source() { Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(); + let pipeline = TrackingPipeline::new(); let recorded = pipeline.recorded(); - pipeline.submissions.push_back(SubmissionStub::stub()); let (source, l1_head_source) = queued_sources([6], []); let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(42)) + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) .l1_head_source(l1_head_source) .build(); handles .derivation_status_tx - .send(DerivationStatus { safe_l2: safe_head(5), current_l1: safe_head(1) }) + .send(DerivationStatus { + safe_l2: BlockStub::info(5), + current_l1: BlockStub::info(1), + }) .await .unwrap(); @@ -626,55 +635,58 @@ mod tests { ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let recorded = recorded.lock().unwrap(); - assert!( - recorded.calls.starts_with(&[ - PipelineCall::Dequeue(SubmissionId(0)), + assert_eq!( + recorded.lock().unwrap().calls, + [ PipelineCall::ReconcileDerivation { safe_l2: 5, current_l1: 1 }, - PipelineCall::Confirm(SubmissionId(0), 42), - PipelineCall::AdvanceL1Head(42), PipelineCall::AddBlock(6), - ]), - "{:?}", - recorded.calls + PipelineCall::Flush, + ] ); }); } + /// A failed submission is resent before any submission a new block releases, even when the + /// failure and the block arrive together. #[test] - fn run_prioritizes_derivation_status_before_l1_head() { + fn run_resends_a_failed_submission_before_a_block_releases_newer_ones() { Runner::start(Config::seeded(0), |ctx| async move { - let pipeline = TrackingPipeline::new(); + let mut pipeline = TrackingPipeline::new(); let recorded = pipeline.recorded(); - let (source, l1_head_source) = queued_sources([], [9]); - let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) - .source(source) - .l1_head_source(l1_head_source) - .build(); - handles - .derivation_status_tx - .send(DerivationStatus { safe_l2: safe_head(5), current_l1: safe_head(1) }) - .await - .unwrap(); + // Submitted by the first CPU phase, so its failure is ready at the first wait, + // along with the block. + pipeline.submissions.push_back(SubmissionStub::stub()); + let (source, l1_head_source) = queued_sources([1], []); + let (driver, _handles) = DriverFixture::new( + ctx.clone(), + pipeline, + ScriptedTxManager::new([SendOutcome::Failed]), + ) + .source(source) + .l1_head_source(l1_head_source) + .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let recorded = recorded.lock().unwrap(); - assert!( - recorded.calls.starts_with(&[ - PipelineCall::ReconcileDerivation { safe_l2: 5, current_l1: 1 }, - PipelineCall::AdvanceL1Head(9), - ]), - "{:?}", - recorded.calls + assert_eq!( + recorded.lock().unwrap().calls, + [ + PipelineCall::Dequeue(SubmissionId(0)), + PipelineCall::Requeue(SubmissionId(0)), + PipelineCall::Dequeue(SubmissionId(1)), + PipelineCall::AddBlock(1), + PipelineCall::Flush, + ] ); }); } + // Encoding runs in slices of `STEP_BUDGET` steps. The tests below check what happens + // between two slices. + /// A backlog larger than one encoding slice is finished without any external event. #[test] fn run_finishes_a_backlog_larger_than_one_slice_without_events() { @@ -694,27 +706,7 @@ mod tests { }); } - /// A ready admin command is served between two encoding slices, not after the whole - /// backlog. - #[test] - fn run_serves_admin_between_encoding_slices() { - Runner::start(Config::seeded(0), |ctx| async move { - let pipeline = TrackingPipeline::new().with_encoding_steps(2 * STEP_BUDGET + 5); - let recorded = pipeline.recorded(); - let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::new([])).build(); - - let handle = ctx.spawn(driver.run()); - // Stop resets the pipeline, which drops whatever was still to encode. - handles.admin.stop().await.unwrap(); - ctx.cancel(); - assert!(handle.await.unwrap().is_ok()); - - assert_eq!(recorded.lock().unwrap().encoded_steps(), STEP_BUDGET); - }); - } - - /// The driver yields to other tasks between encoding slices: a task can send an admin + /// The driver yields to other tasks between encoding slices, so a task can send an admin /// command in the middle of a backlog and have it served before the backlog is done. #[test] fn run_yields_to_other_tasks_between_encoding_slices() { @@ -726,8 +718,8 @@ mod tests { DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::new([])).build(); let handle = ctx.spawn(driver.run()); - // Send the stop once the first slice is done: the driver must yield for this task - // to observe that. + // Send the stop once the first slice is done, which this task only sees if the driver + // yields. while recorded.lock().unwrap().encoded_steps() < STEP_BUDGET { tokio::task::yield_now().await; } @@ -740,63 +732,10 @@ mod tests { }); } - /// `advance_l1_head` must be called with the confirmed L1 block on every - /// confirmation so the encoder can detect channel timeouts. - #[test] - fn test_advance_l1_head_called_on_confirmation() { - Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - pipeline.submissions.push_back(SubmissionStub::stub()); + // The tests below check how ready submissions become L1 transactions. - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(42)) - .build(); - let handle = ctx.spawn(driver.run()); - - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - assert_eq!( - recorded.lock().unwrap().l1_heads(), - [42], - "advance_l1_head must be called with the confirmed L1 block" - ); - }); - } - - /// A failed submission is requeued and resent, and `advance_l1_head` is not called - /// because there is no confirmed L1 block to report. - #[test] - fn test_failed_submission_is_resent_without_advancing_l1_head() { - Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - pipeline.submissions.push_back(SubmissionStub::stub()); - - let tx_manager = ScriptedTxManager::new([SendOutcome::Failed]); - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).build(); - let handle = ctx.spawn(driver.run()); - - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - let recorded = recorded.lock().unwrap(); - assert!( - recorded.l1_heads().is_empty(), - "advance_l1_head must NOT be called on submission failure" - ); - // The failure requeues the submission and the next CPU phase resends it. - assert_eq!(recorded.requeued(), [SubmissionId(0)]); - assert_eq!(tx_manager.candidates().len(), 2, "the failed submission must be resent"); - }); - } - - /// A blob submission that cannot be built into a transaction is fatal: a retry would fail - /// the same way and hold back every submission behind it. + /// A blob submission that cannot be built into a transaction is fatal, because a retry would + /// fail the same way and hold back every submission behind it. #[test] fn test_blob_encoding_failure_is_fatal() { Runner::start(Config::seeded(0), |ctx| async move { @@ -810,201 +749,50 @@ mod tests { ])])); let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) - .build(); - - let result = ctx.spawn(driver.run()).await.unwrap(); - assert!(matches!(result, Err(BatchDriverError::Blob(_))), "got {result:?}"); - }); - } - - /// The submission loop must submit each pipeline submission as one L1 tx. The - /// pipeline is responsible for choosing the frames that belong in a transaction. - #[test] - fn test_submission_loop_submits_each_pipeline_submission_as_one_tx() { - Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - pipeline.submissions.push_back(SubmissionStub::stub()); - pipeline.submissions.push_back(SubmissionStub::stub()); - let tx_manager = ScriptedTxManager::confirming_at(10); - - let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()) - .max_pending(2) - .build(); - let handle = ctx.spawn(driver.run()); - - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - let recorded = recorded.lock().unwrap(); - assert_eq!( - recorded.dequeued(), - [SubmissionId(0), SubmissionId(1)], - "both submissions must be dequeued" - ); - assert_eq!( - recorded.confirmed(), - [SubmissionId(0), SubmissionId(1)], - "each pipeline submission should produce its own confirmation" - ); - assert_eq!( - tx_manager.candidates().len(), - 2, - "separate pipeline submissions must not be coalesced into one L1 tx" - ); - }); - } - - /// A failed submission is resent before anything a block releases, even when both are - /// ready at the same wait. - #[test] - fn run_resends_a_failed_submission_before_a_block_releases_newer_ones() { - Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - // Submitted by the first CPU phase, so its failure is ready at the first wait, - // along with the block. - pipeline.submissions.push_back(SubmissionStub::stub()); - let (source, l1_head_source) = queued_sources([1], []); - let (driver, _handles) = DriverFixture::new( - ctx.clone(), - pipeline, - ScriptedTxManager::new([SendOutcome::Failed]), - ) - .source(source) - .l1_head_source(l1_head_source) - .build(); + DriverFixture::new(ctx, pipeline, ScriptedTxManager::confirming_at(1)).build(); - let handle = ctx.spawn(driver.run()); - ctx.sleep(Duration::from_millis(10)).await; - ctx.cancel(); - assert!(handle.await.unwrap().is_ok()); - - let recorded = recorded.lock().unwrap(); + let result = driver.run().await; assert!( - recorded.calls.starts_with(&[ - PipelineCall::Dequeue(SubmissionId(0)), - PipelineCall::Requeue(SubmissionId(0)), - PipelineCall::Dequeue(SubmissionId(1)), - PipelineCall::AddBlock(1), - ]), - "{:?}", - recorded.calls + matches!( + result, + Err(BatchDriverError::Blob(BatchTxCandidateError::BlobEncoding(_))) + ), + "got {result:?}" ); }); } - /// A single submission may contain multiple blob-filling frames when - /// `max_blobs_per_tx > 1`. Each frame becomes its own blob in the same L1 - /// transaction. + /// Each blob payload of a submission becomes its own blob of one L1 transaction, in order, + /// even when all their frames would fit in one blob. #[test] - fn test_multi_frame_blob_submission_maps_frames_to_blobs() { + fn run_sends_each_blob_payload_as_its_own_blob() { Runner::start(Config::seeded(0), |ctx| async move { let mut pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - let payload = blob_filling_payload_with_frames(3); - let SubmissionPayload::Blobs(payloads) = &payload else { - panic!("helper must create blob payloads"); - }; - let expected_blob_payloads: Vec<_> = payloads - .iter() - .map(|payload| FrameEncoder::to_calldata(&payload.frames()[0])) - .collect(); - pipeline.submissions.push_back(payload); + let frames: Vec<_> = + (0..3).map(|number| Arc::new(Frame { number, ..Frame::default() })).collect(); + pipeline.submissions.push_back(SubmissionPayload::Blobs( + frames.iter().map(|frame| BlobPayload::new(vec![Arc::clone(frame)])).collect(), + )); let tx_manager = ScriptedTxManager::confirming_at(10); - let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).build(); - let handle = ctx.spawn(driver.run()); - ctx.sleep(Duration::from_millis(50)).await; + let handle = ctx.spawn(driver.run()); + ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); + assert!(handle.await.unwrap().is_ok()); - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.dequeued(), [SubmissionId(0)], "submission must be dequeued"); - assert!( - recorded.requeued().is_empty(), - "multi-frame blob submission must not be requeued by blob encoding" - ); - assert_eq!( - recorded.confirmed(), - [SubmissionId(0)], - "multi-frame blob submission should confirm once" - ); let candidates = tx_manager.candidates(); - assert_eq!(candidates.len(), 1, "multi-frame submission should use one L1 tx"); - assert!( - candidates[0].tx_data.is_empty(), - "blob transactions must not also carry calldata" - ); - let decoded_blob_payloads: Vec<_> = candidates[0] + assert_eq!(candidates.len(), 1); + assert!(candidates[0].tx_data.is_empty(), "a blob transaction carries no calldata"); + let blobs: Vec<_> = candidates[0] .blobs .iter() - .map(|blob| BlobDecoder::decode(blob).expect("blob payload should decode")) + .map(|blob| BlobDecoder::decode(blob).expect("the blob decodes")) .collect(); - assert_eq!( - decoded_blob_payloads, expected_blob_payloads, - "each frame in the submission must become its own blob payload" - ); - }); - } - - /// No more than `max_pending_transactions` L1 txs are in flight. With max=1 and two - /// submissions, the second submission must not be dequeued while the first tx is pending. - #[test] - fn test_in_flight_limit_holds_back_further_submissions() { - Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - pipeline.submissions.push_back(blob_filling_payload()); - pipeline.submissions.push_back(blob_filling_payload()); - - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::new([])) - .max_pending(1) - .build(); - let handle = ctx.spawn(driver.run()); - - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.dequeued(), [SubmissionId(0)], "only one tx may be in flight"); - assert!(recorded.requeued().is_empty(), "a held-back submission is not requeued"); - assert!(recorded.l1_heads().is_empty(), "the tx in flight never confirms"); - }); - } - - /// With `max_pending_transactions`=1 and blob-filling submissions, the second - /// blob tx is only submitted once the first is confirmed. - #[test] - fn test_next_blob_tx_submitted_once_the_previous_settles() { - Runner::start(Config::seeded(0), |ctx| async move { - let mut pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - pipeline.submissions.push_back(blob_filling_payload()); - pipeline.submissions.push_back(blob_filling_payload()); - pipeline.submissions.push_back(blob_filling_payload()); - - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(7)) - .max_pending(1) - .build(); - let handle = ctx.spawn(driver.run()); - - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok(), "driver should exit cleanly on cancellation"); - assert_eq!( - recorded.lock().unwrap().confirmed(), - [SubmissionId(0), SubmissionId(1), SubmissionId(2)], - "each queued submission must confirm as the tx before it settles" - ); + let frames: Vec<_> = + frames.iter().map(|frame| FrameEncoder::to_calldata(frame)).collect(); + assert_eq!(blobs, frames); }); } } diff --git a/crates/batcher/core/src/test_utils/builder.rs b/crates/batcher/core/src/test_utils/builder.rs index ede19447a0..db424874b6 100644 --- a/crates/batcher/core/src/test_utils/builder.rs +++ b/crates/batcher/core/src/test_utils/builder.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use alloy_consensus::Header; -use alloy_primitives::Address; +use alloy_primitives::{Address, B256}; use base_batcher_encoder::{BatchPipeline, BlobPayload, SubmissionPayload}; use base_batcher_source::{L1HeadSource, UnsafeBlockSource}; use base_common_consensus::BaseBlock; @@ -18,7 +18,7 @@ use crate::{ test_utils::{PendingL1HeadSource, PendingSource}, }; -/// Factory for empty L2 block stubs used in driver tests. +/// Factory for the empty L2 blocks and the block references used in driver tests. #[derive(Debug)] pub struct BlockStub; @@ -28,6 +28,11 @@ impl BlockStub { pub fn with_number(number: u64) -> BaseBlock { BaseBlock { header: Header { number, ..Default::default() }, body: Default::default() } } + + /// Returns a [`BlockInfo`] numbered `number`, with a hash derived from that number. + pub fn info(number: u64) -> BlockInfo { + BlockInfo { hash: B256::with_last_byte(number as u8), number, ..Default::default() } + } } /// Factory methods for [`SubmissionPayload`] stubs used in driver tests. @@ -71,8 +76,12 @@ pub struct DriverFixture< max_pending: usize, initial_l1_head: u64, safe_head: BlockInfo, + force_blobs_when_throttling: bool, } +/// How long a fixture-built driver waits for its in-flight submissions on cancellation. +pub const DRAIN_TIMEOUT: Duration = Duration::from_millis(10); + /// The sending sides of a fixture-built driver's channels. #[derive(Debug)] pub struct DriverHandles { @@ -95,6 +104,7 @@ impl DriverFixture { max_pending: 1, initial_l1_head: 0, safe_head: BlockInfo::default(), + force_blobs_when_throttling: true, } } } @@ -120,6 +130,7 @@ where max_pending: self.max_pending, initial_l1_head: self.initial_l1_head, safe_head: self.safe_head, + force_blobs_when_throttling: self.force_blobs_when_throttling, } } @@ -138,6 +149,7 @@ where max_pending: self.max_pending, initial_l1_head: self.initial_l1_head, safe_head: self.safe_head, + force_blobs_when_throttling: self.force_blobs_when_throttling, } } @@ -156,6 +168,7 @@ where max_pending: self.max_pending, initial_l1_head: self.initial_l1_head, safe_head: self.safe_head, + force_blobs_when_throttling: self.force_blobs_when_throttling, } } @@ -177,6 +190,12 @@ where self } + /// Set whether throttling forces blob submissions. + pub const fn force_blobs_when_throttling(mut self, force_blobs_when_throttling: bool) -> Self { + self.force_blobs_when_throttling = force_blobs_when_throttling; + self + } + /// Build the driver and the handles that feed it. pub fn build(self) -> (BatchDriver, DriverHandles) { let (admin, admin_rx) = AdminHandle::channel(); @@ -188,8 +207,8 @@ where BatchDriverConfig { inbox: Address::ZERO, max_pending_transactions: self.max_pending, - drain_timeout: Duration::from_millis(10), - force_blobs_when_throttling: true, + drain_timeout: DRAIN_TIMEOUT, + force_blobs_when_throttling: self.force_blobs_when_throttling, stopped: false, }, self.throttle, diff --git a/crates/batcher/core/src/test_utils/mod.rs b/crates/batcher/core/src/test_utils/mod.rs index ca56de3acd..37dfce22a9 100644 --- a/crates/batcher/core/src/test_utils/mod.rs +++ b/crates/batcher/core/src/test_utils/mod.rs @@ -10,7 +10,7 @@ mod source; pub use source::{PendingL1HeadSource, PendingSource, TrackingSource}; mod builder; -pub use builder::{BlockStub, DriverFixture, DriverHandles, SubmissionStub}; +pub use builder::{BlockStub, DRAIN_TIMEOUT, DriverFixture, DriverHandles, SubmissionStub}; mod tx_manager; pub use tx_manager::{Script, ScriptedTxManager, SendOutcome}; diff --git a/crates/batcher/core/src/test_utils/pipeline.rs b/crates/batcher/core/src/test_utils/pipeline.rs index f53be589eb..d01439eeaa 100644 --- a/crates/batcher/core/src/test_utils/pipeline.rs +++ b/crates/batcher/core/src/test_utils/pipeline.rs @@ -8,7 +8,7 @@ use std::{ collections::VecDeque, sync::{ Arc, Mutex, - atomic::{AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, }, }; @@ -74,22 +74,6 @@ impl Recorded { }) } - /// The submission ids passed to `confirm`, in order. - pub fn confirmed(&self) -> Vec { - self.pick(|call| match call { - PipelineCall::Confirm(id, _) => Some(*id), - _ => None, - }) - } - - /// The submission ids passed to `requeue`, in order. - pub fn requeued(&self) -> Vec { - self.pick(|call| match call { - PipelineCall::Requeue(id) => Some(*id), - _ => None, - }) - } - /// The L1 heads the pipeline advanced to, in order. pub fn l1_heads(&self) -> Vec { self.pick(|call| match call { @@ -98,14 +82,6 @@ impl Recorded { }) } - /// The safe L2 block numbers passed to `reconcile_derivation`, in order. - pub fn reconciled(&self) -> Vec { - self.pick(|call| match call { - PipelineCall::ReconcileDerivation { safe_l2, .. } => Some(*safe_l2), - _ => None, - }) - } - /// The number of `reset` calls. pub fn resets(&self) -> usize { self.count(PipelineCall::Reset) @@ -145,6 +121,9 @@ pub struct TrackingPipeline { /// Value returned by `da_backlog_bytes`, shared so a test can change it while the driver /// runs. pub da_backlog_bytes: Arc, + /// The value last passed to `set_blob_override`, shared so a test can read it while the + /// driver runs. + pub blob_override: Arc, /// Submissions handed out and neither confirmed nor requeued since. in_flight: Vec<(SubmissionId, SubmissionPayload)>, /// The id of the next submission handed out. @@ -158,6 +137,8 @@ pub struct TrackingPipeline { add_block_reorgs: bool, /// When set, `flush` records the call then returns this error. flush_error: Option, + /// When set, the next `step` returns this error. + step_error: Option, /// Blocks left to encode. `step` reports one encoded block per call until it reaches zero. encoding_steps: usize, } @@ -168,12 +149,14 @@ impl Default for TrackingPipeline { recorded: Arc::default(), submissions: VecDeque::new(), da_backlog_bytes: Arc::default(), + blob_override: Arc::default(), in_flight: Vec::new(), next_id: 0, l1_head: 0, reconciliation: DerivationReconciliation::Consistent, add_block_reorgs: false, flush_error: None, + step_error: None, encoding_steps: 0, } } @@ -220,6 +203,12 @@ impl TrackingPipeline { self } + /// Make the next `step` fail. + pub fn with_step_error(mut self, error: StepError) -> Self { + self.step_error = Some(error); + self + } + fn record(&self, call: PipelineCall) { self.recorded.lock().unwrap().calls.push(call); } @@ -246,6 +235,9 @@ impl BatchPipeline for TrackingPipeline { } fn step(&mut self) -> Result { + if let Some(error) = self.step_error.take() { + return Err(error); + } if self.encoding_steps == 0 { return Ok(StepResult::Idle); } @@ -307,4 +299,8 @@ impl BatchPipeline for TrackingPipeline { fn da_backlog_bytes(&self) -> u64 { self.da_backlog_bytes.load(Ordering::SeqCst) } + + fn set_blob_override(&mut self, active: bool) { + self.blob_override.store(active, Ordering::SeqCst); + } } diff --git a/crates/batcher/core/src/test_utils/source.rs b/crates/batcher/core/src/test_utils/source.rs index da00e9fad1..3fc4492748 100644 --- a/crates/batcher/core/src/test_utils/source.rs +++ b/crates/batcher/core/src/test_utils/source.rs @@ -3,7 +3,10 @@ //! Hand-rolled rather than mocked because `next` parks forever, which `mockall` expectations //! cannot express. -use std::sync::{Arc, Mutex}; +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; use async_trait::async_trait; use base_batcher_source::{L1HeadSource, L2BlockEvent, UnsafeBlockSource}; @@ -24,24 +27,35 @@ impl UnsafeBlockSource for PendingSource { } } -/// [`UnsafeBlockSource`] that records sequential catchup requests and otherwise parks. +/// [`UnsafeBlockSource`] that delivers its queued events, then parks, and records sequential +/// catchup requests. #[derive(Debug)] pub struct TrackingSource { + events: VecDeque, catchup_heads: Arc>>, } impl TrackingSource { - /// Create a source and its shared catchup call log. + /// Create a source with no events and its shared catchup call log. pub fn new() -> (Self, Arc>>) { let catchup_heads = Arc::new(Mutex::new(Vec::new())); - (Self { catchup_heads: Arc::clone(&catchup_heads) }, catchup_heads) + (Self { events: VecDeque::new(), catchup_heads: Arc::clone(&catchup_heads) }, catchup_heads) + } + + /// Queue `events`, delivered in order before the source parks. + pub fn with_events(mut self, events: impl IntoIterator) -> Self { + self.events.extend(events); + self } } #[async_trait] impl UnsafeBlockSource for TrackingSource { async fn next(&mut self) -> L2BlockEvent { - std::future::pending().await + match self.events.pop_front() { + Some(event) => event, + None => std::future::pending().await, + } } fn reset_catchup(&mut self, safe_head: BlockInfo) { diff --git a/crates/batcher/core/src/test_utils/throttle.rs b/crates/batcher/core/src/test_utils/throttle.rs index 8fbd26298c..a0730d3d1a 100644 --- a/crates/batcher/core/src/test_utils/throttle.rs +++ b/crates/batcher/core/src/test_utils/throttle.rs @@ -1,4 +1,7 @@ -//! Test [`ThrottleClient`] implementations. +//! Test [`ThrottleClient`] implementation. +//! +//! Hand-rolled rather than mocked because the integration tests under `tests/` use it, and an +//! `automock` mock is only built under `cfg(test)`, which they cannot see. use std::sync::{Arc, Mutex}; @@ -9,21 +12,23 @@ use crate::ThrottleClient; /// Shared handle to the call log of a [`TrackingThrottleClient`]. pub type ThrottleCallLog = Arc>>; -/// [`ThrottleClient`] that records every `set_max_da_size` call in order. -/// -/// Use [`TrackingThrottleClient::new`] to obtain both the client and a shared -/// handle to the recorded calls. -#[derive(Debug, Default, Clone)] +/// [`ThrottleClient`] that records every `set_max_da_size` call in order, failed ones included. +#[derive(Debug)] pub struct TrackingThrottleClient { - /// Recorded `(max_tx_size, max_block_size)` calls in order. - pub calls: ThrottleCallLog, + calls: ThrottleCallLog, + failures: Mutex, } impl TrackingThrottleClient { - /// Create a new client and return a shared reference to the recorded calls. + /// Create a client whose calls all succeed, and its shared call log. pub fn new() -> (Self, ThrottleCallLog) { - let calls = Arc::new(Mutex::new(Vec::new())); - (Self { calls: Arc::clone(&calls) }, calls) + let calls = ThrottleCallLog::default(); + (Self { calls: Arc::clone(&calls), failures: Mutex::new(0) }, calls) + } + + /// Make the first `failures` calls fail. + pub fn with_failures(self, failures: usize) -> Self { + Self { failures: Mutex::new(failures), ..self } } } @@ -33,10 +38,14 @@ impl ThrottleClient for TrackingThrottleClient { max_tx_size: u64, max_block_size: u64, ) -> BoxFuture<'_, Result<(), Box>> { - let calls = Arc::clone(&self.calls); - Box::pin(async move { - calls.lock().unwrap().push((max_tx_size, max_block_size)); + self.calls.lock().unwrap().push((max_tx_size, max_block_size)); + let mut failures = self.failures.lock().unwrap(); + let result = if *failures > 0 { + *failures -= 1; + Err("the block builder refused the limits".into()) + } else { Ok(()) - }) + }; + Box::pin(async move { result }) } } diff --git a/crates/batcher/core/src/throttle.rs b/crates/batcher/core/src/throttle.rs index dc7f87833c..7b94821623 100644 --- a/crates/batcher/core/src/throttle.rs +++ b/crates/batcher/core/src/throttle.rs @@ -124,7 +124,7 @@ impl ThrottleParams { pub enum ThrottleStrategy { /// No throttling. Off, - /// Step function: either 0 or `max_intensity` when above threshold. + /// Step function: 0 below the threshold, `max_intensity` from the threshold on. Step, /// Linear interpolation between 0 and `max_intensity` based on backlog. Linear, @@ -182,8 +182,8 @@ impl ThrottleController { /// Update with current DA backlog bytes. /// /// Returns [`ThrottleParams`] if throttling should be applied, or `None` - /// if the backlog is below the threshold or the strategy is - /// [`ThrottleStrategy::Off`]. + /// if the backlog is below the threshold, the linear intensity is zero or the + /// strategy is [`ThrottleStrategy::Off`]. pub fn update(&self, da_backlog_bytes: u64) -> Option { match &self.strategy { ThrottleStrategy::Off => None, @@ -200,15 +200,14 @@ impl ThrottleController { if da_backlog_bytes < self.config.threshold_bytes { return None; } - // Linear interpolation: intensity grows linearly from 0 at threshold - // to max_intensity at 2x threshold (capped at max_intensity). + // Intensity grows linearly from 0 at the threshold to max_intensity at twice + // the threshold, and stays there above it. let excess = da_backlog_bytes - self.config.threshold_bytes; let range = self.config.threshold_bytes.max(1); let ratio = (excess as f64 / range as f64).min(1.0); let intensity = ratio * self.config.max_intensity; - // At exactly the threshold excess is zero, so intensity is 0.0 and - // limits would be the same as unthrottled — return None to avoid a - // spurious "DA throttle deactivated" log entry on startup. + // A zero intensity, at exactly the threshold or with a zero `max_intensity`, is no + // throttling, as below the threshold. if intensity == 0.0 { return None; } @@ -337,45 +336,44 @@ mod tests { use super::*; - fn test_config() -> ThrottleConfig { - ThrottleConfig { threshold_bytes: 1000, max_intensity: 0.8, ..Default::default() } - } - - /// Verifies `ThrottleController::update` returns the correct result for each - /// strategy and backlog combination. - /// - /// `expected_intensity` is `None` when no throttling should be applied, or - /// `Some(intensity)` when throttling must be active with that intensity value. + /// The intensity and DA limits each strategy applies to a backlog, `None` meaning no + /// throttling. #[rstest] - #[case::off_always_none(ThrottleStrategy::Off, 5000, None)] + #[case::off(ThrottleStrategy::Off, 5000, None)] #[case::step_below_threshold(ThrottleStrategy::Step, 999, None)] - #[case::step_at_threshold(ThrottleStrategy::Step, 1000, Some(0.8))] + #[case::step_at_threshold(ThrottleStrategy::Step, 1000, Some((0.8, 27_600, 4_120)))] #[case::linear_below_threshold(ThrottleStrategy::Linear, 500, None)] - // At exactly the threshold, excess = 0 → intensity = 0.0 → must return None, - // not Some with zero intensity (which would trigger a spurious log on startup). #[case::linear_at_threshold(ThrottleStrategy::Linear, 1000, None)] - #[case::linear_at_max(ThrottleStrategy::Linear, 2000, Some(0.8))] - #[case::linear_midpoint(ThrottleStrategy::Linear, 1500, Some(0.4))] - fn test_update( + #[case::linear_midpoint(ThrottleStrategy::Linear, 1500, Some((0.4, 78_800, 12_060)))] + #[case::linear_at_twice_the_threshold( + ThrottleStrategy::Linear, + 2000, + Some((0.8, 27_600, 4_120)) + )] + #[case::linear_above_twice_the_threshold( + ThrottleStrategy::Linear, + 5000, + Some((0.8, 27_600, 4_120)) + )] + fn update_throttles_by_strategy_and_backlog( #[case] strategy: ThrottleStrategy, #[case] da_backlog_bytes: u64, - #[case] expected_intensity: Option, + #[case] expected: Option<(f64, u64, u64)>, ) { - let ctrl = ThrottleController::new(test_config(), strategy); - let result = ctrl.update(da_backlog_bytes); - match expected_intensity { - None => assert!(result.is_none()), - Some(expected) => { - let params = result.expect("expected Some result"); - assert!( - (params.intensity - expected).abs() < 0.01, - "expected intensity {expected}, got {}", - params.intensity - ); - } - } + let config = + ThrottleConfig { threshold_bytes: 1000, max_intensity: 0.8, ..Default::default() }; + let controller = ThrottleController::new(config, strategy); + + let params = controller.update(da_backlog_bytes); + + assert_eq!( + params.map(|params| (params.intensity, params.max_block_size, params.max_tx_size)), + expected + ); } + /// A config is valid when the intensity is within [0, 1], both lower limits are above zero + /// and each lower limit is at most its upper limit. Otherwise the error names the broken rule. #[rstest] #[case::default(ThrottleConfig::default(), Ok(()))] #[case::equal_limits( diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index a5ecb2e9d2..9e8f307f5d 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -1,4 +1,4 @@ -//! Integration tests for L1 and safe L2 head handling in [`BatchDriver`]. +//! Integration tests for safe L2 head handling in [`BatchDriver`]. use std::time::Duration; @@ -6,47 +6,19 @@ use alloy_primitives::B256; use base_batcher_core::{ BatchDriverError, DerivationStatus, test_utils::{ - DriverFixture, PipelineCall, ScriptedTxManager, TrackingPipeline, TrackingSource, + BlockStub, DriverFixture, PipelineCall, ScriptedTxManager, TrackingPipeline, TrackingSource, }, }; use base_batcher_encoder::DerivationReconciliation; -use base_batcher_source::test_utils::ChannelL1HeadSource; use base_protocol::BlockInfo; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, }; -fn safe_head(number: u64) -> BlockInfo { - BlockInfo { hash: B256::with_last_byte(number as u8), number, ..Default::default() } -} - -/// When the L1 head source delivers a new head, the driver must call -/// `advance_l1_head` on the pipeline with the new value. -#[test] -fn test_l1_head_source_advances_pipeline() { - Runner::start(Config::seeded(0), |ctx| async move { - let pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - - let (l1_source, l1_tx) = ChannelL1HeadSource::new(); - - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) - .l1_head_source(l1_source) - .build(); - let handle = ctx.spawn(driver.run()); - - // Send a new L1 head via the channel. - l1_tx.send(42).unwrap(); - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - - assert!(handle.await.unwrap().is_ok()); - assert_eq!(recorded.lock().unwrap().l1_heads(), [42]); - }); -} - +/// 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. #[test] fn test_safe_head_conflicts_reset_pipeline_and_source() { Runner::start(Config::seeded(0), |ctx| async move { @@ -58,36 +30,49 @@ 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(safe_head(10)) + .safe_head(BlockStub::info(10)) .build(); let handle = ctx.spawn(driver.run()); let status_tx = handles.derivation_status_tx; - let regressed = safe_head(5); + let regressed = BlockStub::info(5); let replacement = BlockInfo { hash: B256::repeat_byte(0xff), number: 5, ..Default::default() }; - for safe_l2 in [regressed, replacement, safe_head(10)] { - status_tx.send(DerivationStatus { safe_l2, current_l1: safe_head(1) }).await.unwrap(); + for safe_l2 in [regressed, replacement, BlockStub::info(10)] { + status_tx + .send(DerivationStatus { safe_l2, current_l1: BlockStub::info(1) }) + .await + .unwrap(); } ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let recorded = recorded.lock().unwrap(); - assert_eq!(recorded.resets(), 3); - assert_eq!(recorded.reconciled(), [10]); - assert_eq!(*catchup_heads.lock().unwrap(), [regressed, replacement, safe_head(10)]); + assert_eq!( + recorded.lock().unwrap().calls, + [ + PipelineCall::Reset, + PipelineCall::Reset, + PipelineCall::ReconcileDerivation { safe_l2: 10, current_l1: 1 }, + PipelineCall::Reset, + PipelineCall::Flush, + ] + ); + assert_eq!(*catchup_heads.lock().unwrap(), [regressed, replacement, BlockStub::info(10)]); }); } +/// When derivation moves past a confirmed channel without making its blocks safe, the driver +/// resets the pipeline and restarts the source from the safe head, so those blocks are batched +/// again. #[test] -fn test_derivation_cursor_advance_replays_stalled_channel() { +fn test_stalled_channel_resets_pipeline_and_source() { Runner::start(Config::seeded(0), |ctx| async move { let pipeline = TrackingPipeline::new().with_reconciliation(DerivationReconciliation::StalledChannel); let recorded = pipeline.recorded(); let (source, catchup_heads) = TrackingSource::new(); - let safe_l2 = safe_head(10); + let safe_l2 = BlockStub::info(10); let (driver, handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) @@ -97,7 +82,10 @@ fn test_derivation_cursor_advance_replays_stalled_channel() { let handle = ctx.spawn(driver.run()); let status_tx = handles.derivation_status_tx; - status_tx.send(DerivationStatus { safe_l2, current_l1: safe_head(50) }).await.unwrap(); + status_tx + .send(DerivationStatus { safe_l2, current_l1: BlockStub::info(50) }) + .await + .unwrap(); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -115,6 +103,8 @@ fn test_derivation_cursor_advance_replays_stalled_channel() { }); } +/// The driver fails when the derivation-status source stops, instead of batching on without +/// ever learning the safe head again. #[test] fn test_derivation_status_sender_drop_is_fatal() { Runner::start(Config::seeded(0), |ctx| async move { @@ -126,19 +116,3 @@ fn test_derivation_status_sender_drop_is_fatal() { assert!(matches!(driver.run().await, Err(BatchDriverError::DerivationStatusSourceClosed))); }); } - -#[test] -fn test_derivation_status_sender_drop_during_shutdown_is_clean() { - Runner::start(Config::seeded(0), |ctx| async move { - let (driver, handles) = DriverFixture::new( - ctx.clone(), - TrackingPipeline::new(), - ScriptedTxManager::confirming_at(1), - ) - .build(); - - ctx.cancel(); - drop(handles); - assert!(driver.run().await.is_ok()); - }); -} diff --git a/crates/batcher/core/tests/lifecycle.rs b/crates/batcher/core/tests/lifecycle.rs index 053de5972c..798b3fa17d 100644 --- a/crates/batcher/core/tests/lifecycle.rs +++ b/crates/batcher/core/tests/lifecycle.rs @@ -1,5 +1,5 @@ -//! Integration tests for [`BatchDriver`] lifecycle: drain and the order of work and waiting -//! in the loop. +//! Integration tests for the [`BatchDriver`] shutdown drain and the order of work and waiting +//! in its loop. use std::{ sync::{Arc, Mutex}, @@ -9,7 +9,9 @@ use std::{ use async_trait::async_trait; use base_batcher_core::{ BatchDriverError, - test_utils::{DriverFixture, Recorded, ScriptedTxManager, SubmissionStub, TrackingPipeline}, + test_utils::{ + DRAIN_TIMEOUT, DriverFixture, Recorded, ScriptedTxManager, SubmissionStub, TrackingPipeline, + }, }; use base_batcher_encoder::{ChannelLimit, StepError, SubmissionId}; use base_batcher_source::{L2BlockEvent, UnsafeBlockSource}; @@ -18,8 +20,8 @@ use base_runtime::{ deterministic::{Config, Runner}, }; -/// When cancellation fires while a submission is in flight and never settles, the drain -/// timeout must fire and the driver must exit cleanly. +/// On cancellation, the driver waits exactly the drain timeout for an in-flight submission that +/// never settles, then exits cleanly. #[test] fn test_drain_timeout_exits_with_in_flight_submissions() { Runner::start(Config::seeded(0), |ctx| async move { @@ -32,6 +34,7 @@ fn test_drain_timeout_exits_with_in_flight_submissions() { let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(20)).await; + let cancelled_at = ctx.now(); ctx.cancel(); let result = handle.await.unwrap(); @@ -39,13 +42,18 @@ fn test_drain_timeout_exits_with_in_flight_submissions() { result.is_ok(), "driver must exit after drain timeout even with in-flight submissions" ); + assert_eq!( + ctx.now() - cancelled_at, + DRAIN_TIMEOUT, + "the driver must wait the drain timeout for the in-flight submission" + ); let recorded = recorded.lock().unwrap(); assert_eq!(recorded.dequeued(), [SubmissionId(0)], "submission must have been dequeued"); assert_eq!(recorded.flushes(), 1, "flush must be called on shutdown"); }); } -/// A flush error on shutdown must not skip the in-flight receipt drain. +/// A flush error on shutdown is returned only after the in-flight submissions are drained. #[test] fn test_shutdown_drains_in_flight_before_returning_flush_error() { Runner::start(Config::seeded(0), |ctx| async move { @@ -70,8 +78,9 @@ fn test_shutdown_drains_in_flight_before_returning_flush_error() { matches!(result, Err(BatchDriverError::Step(_))), "flush error must be returned after drain" ); - assert!( - ctx.now().saturating_sub(cancelled_at) >= Duration::from_millis(10), + assert_eq!( + ctx.now() - cancelled_at, + DRAIN_TIMEOUT, "in-flight receipts must be drained before the flush error is returned" ); let recorded = recorded.lock().unwrap(); @@ -83,8 +92,8 @@ fn test_shutdown_drains_in_flight_before_returning_flush_error() { /// Source that never delivers an event and records, each time the driver waits on it, how /// many submissions have been dequeued so far. /// -/// Hand-rolled rather than mocked: the count must be read when the driver polls, not when -/// the mock's scripted response is built. +/// Hand-rolled rather than mocked because the count must be read when the driver polls, not +/// when the mock's scripted response is built. struct PollRecorder { recorded: Arc>, dequeued_at_poll: Arc>>, @@ -99,7 +108,7 @@ impl UnsafeBlockSource for PollRecorder { } } -/// The driver does all the work it can before waiting for the next event: a receipt that +/// The driver does all the work it can before waiting for the next event. A receipt that /// frees an in-flight slot gets the next ready submission sent before any source is waited on /// again. #[test] @@ -113,24 +122,32 @@ fn test_driver_finishes_pending_work_before_waiting_for_events() { let dequeued_at_poll = Arc::new(Mutex::new(Vec::new())); let source = PollRecorder { recorded, dequeued_at_poll: Arc::clone(&dequeued_at_poll) }; - // One tx in flight at most: the second submission can only leave the pipeline once the - // receipt of the first one has been processed. - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()).source(source).build(); + // With one tx in flight at most, the second submission can only leave the pipeline once + // the receipt of the first one has been processed. + let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, tx_manager.clone()) + .source(source) + .max_pending(1) + .build(); let handle = ctx.spawn(driver.run()); // Let the driver submit the first stub and wait on the source, then confirm that stub // so the receipt releases the second one. ctx.sleep(Duration::from_millis(1)).await; + let polls_before_receipt = dequeued_at_poll.lock().unwrap().len(); tx_manager.confirm_next(1); ctx.sleep(Duration::from_millis(1)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - assert_eq!( - *dequeued_at_poll.lock().unwrap(), - [1, 2], - "the submission released by the receipt must be sent before the driver waits again" + let polls = dequeued_at_poll.lock().unwrap(); + let (before_receipt, after_receipt) = polls.split_at(polls_before_receipt); + assert!( + !before_receipt.is_empty() && before_receipt.iter().all(|&dequeued| dequeued == 1), + "the driver must wait on the source with the first submission in flight: {polls:?}" + ); + assert!( + !after_receipt.is_empty() && after_receipt.iter().all(|&dequeued| dequeued == 2), + "the submission released by the receipt must be sent before the driver waits again: {polls:?}" ); }); } diff --git a/crates/batcher/core/tests/reorg.rs b/crates/batcher/core/tests/reorg.rs index 183febe246..defc8bc8b9 100644 --- a/crates/batcher/core/tests/reorg.rs +++ b/crates/batcher/core/tests/reorg.rs @@ -3,7 +3,8 @@ use std::time::Duration; use base_batcher_core::test_utils::{ - BlockStub, DriverFixture, ScriptedTxManager, SubmissionStub, TrackingPipeline, + BlockStub, DriverFixture, PipelineCall, ScriptedTxManager, SubmissionStub, TrackingPipeline, + TrackingSource, }; use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; use base_runtime::{ @@ -11,62 +12,56 @@ use base_runtime::{ deterministic::{Config, Runner}, }; -/// When `add_block` returns a `ReorgError`, the driver must reset the pipeline -/// instead of propagating a fatal error. This mirrors the `L2BlockEvent::Reorg` -/// handling path. +/// A block that does not build on the buffered chain resets the pipeline, and the source +/// starts again from the safe head. #[test] -fn test_add_block_reorg_resets_pipeline_instead_of_fatal_error() { +fn test_add_block_reorg_resets_pipeline_and_source() { Runner::start(Config::seeded(0), |ctx| async move { let pipeline = TrackingPipeline::new().with_add_block_reorg(); let recorded = pipeline.recorded(); - let (source, source_tx) = ChannelBlockSource::new(); + let (source, catchup_heads) = TrackingSource::new(); + let source = + source.with_events([L2BlockEvent::Block(Box::new(BlockStub::with_number(11)))]); let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) + .safe_head(BlockStub::info(10)) .build(); let handle = ctx.spawn(driver.run()); - - source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); - ctx.sleep(Duration::from_millis(50)).await; + ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); - let result = handle.await.unwrap(); - assert!(result.is_ok(), "driver must not return a fatal error on add_block reorg"); + assert!(handle.await.unwrap().is_ok()); assert_eq!( - recorded.lock().unwrap().resets(), - 1, - "pipeline.reset() must be called when add_block returns ReorgError" + recorded.lock().unwrap().calls, + [PipelineCall::AddBlock(11), PipelineCall::Reset, PipelineCall::Flush] ); + assert_eq!(*catchup_heads.lock().unwrap(), [BlockStub::info(10)]); }); } -/// When the source delivers `L2BlockEvent::Reorg`, the driver must reset the -/// pipeline. This is distinct from the `add_block`-triggered reorg path tested -/// above. +/// A reorg the source reports resets the pipeline, and the source starts again from the safe +/// head. #[test] -fn test_l2_reorg_event_resets_pipeline() { +fn test_l2_reorg_event_resets_pipeline_and_source() { Runner::start(Config::seeded(0), |ctx| async move { let pipeline = TrackingPipeline::new(); let recorded = pipeline.recorded(); - let (source, source_tx) = ChannelBlockSource::new(); + let (source, catchup_heads) = TrackingSource::new(); let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) - .source(source) + .source(source.with_events([L2BlockEvent::Reorg])) + .safe_head(BlockStub::info(10)) .build(); let handle = ctx.spawn(driver.run()); - - source_tx.send(L2BlockEvent::Reorg).unwrap(); - ctx.sleep(Duration::from_millis(50)).await; + ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - assert_eq!( - recorded.lock().unwrap().resets(), - 1, - "pipeline must be reset when source delivers a Reorg event" - ); + assert_eq!(recorded.lock().unwrap().calls, [PipelineCall::Reset, PipelineCall::Flush]); + assert_eq!(*catchup_heads.lock().unwrap(), [BlockStub::info(10)]); }); } diff --git a/crates/batcher/core/tests/stop_start.rs b/crates/batcher/core/tests/stop_start.rs index e0a755415f..59951ba3ab 100644 --- a/crates/batcher/core/tests/stop_start.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -16,8 +16,7 @@ use base_runtime::{ deterministic::{Config, Runner}, }; -/// `AdminCommand::Stop` must immediately reset the pipeline. Stopping a batcher -/// that is already stopped succeeds without resetting it again. +/// A stop resets the pipeline, and a second stop succeeds. #[test] fn test_stop_resets_pipeline() { Runner::start(Config::seeded(0), |ctx| async move { @@ -28,22 +27,17 @@ fn test_stop_resets_pipeline() { let handle = ctx.spawn(driver.run()); handles.admin.stop().await.unwrap(); + assert_eq!(recorded.lock().unwrap().resets(), 1, "the stop must reset the pipeline"); handles.admin.stop().await.unwrap(); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - assert_eq!( - recorded.lock().unwrap().resets(), - 1, - "pipeline must be reset exactly once when stopped" - ); }); } -/// `AdminCommand::Start` must reanchor the source at the safe head so it -/// delivers missed blocks sequentially after that head. Starting a batcher that -/// is already running must not reanchor it again: that would replay blocks the -/// pipeline already holds. +/// A start restarts the source from the safe head, so the blocks missed while stopped arrive in +/// order. A start on a running batcher does nothing, since restarting the source would replay +/// blocks the pipeline already holds. #[test] fn test_start_triggers_catchup_from_safe_head() { Runner::start(Config::seeded(0), |ctx| async move { @@ -75,7 +69,7 @@ fn test_start_triggers_catchup_from_safe_head() { }); } -/// While stopped, the batcher does not read its source: the pipeline receives no blocks. +/// While stopped, the batcher does not read its source, so the pipeline receives no blocks. /// Once started again, the blocks queued meanwhile reach the pipeline. #[test] fn test_stopped_leaves_the_source_unread() { @@ -89,7 +83,7 @@ fn test_stopped_leaves_the_source_unread() { .build(); let handle = ctx.spawn(driver.run()); - // Stop, then send a block: it stays in the source. + // A block sent after the stop stays in the source. handles.admin.stop().await.unwrap(); source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); ctx.sleep(Duration::from_millis(10)).await; @@ -98,7 +92,7 @@ fn test_stopped_leaves_the_source_unread() { "a stopped batcher must not ingest blocks" ); - // Start: the queued block and the next one reach the pipeline. + // After the start, the queued block and the next one reach the pipeline. handles.admin.start().await.unwrap(); source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(2)))).unwrap(); ctx.sleep(Duration::from_millis(10)).await; @@ -141,40 +135,22 @@ fn test_stop_leaves_in_flight_submissions_to_settle() { }); } -/// A flush on a running batcher closes the current channel and reports success. -#[test] -fn test_flush_closes_the_channel_on_a_running_batcher() { - Runner::start(Config::seeded(0), |ctx| async move { - let pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)).build(); - let handle = ctx.spawn(driver.run()); - - handles.admin.flush().await.unwrap(); - - assert_eq!(recorded.lock().unwrap().flushes(), 1); - - ctx.cancel(); - assert!(handle.await.unwrap().is_ok()); - }); -} - /// A stopped batcher refuses to flush instead of reporting a flush that produces nothing. #[test] fn test_flush_is_rejected_while_stopped() { Runner::start(Config::seeded(0), |ctx| async move { - let pipeline = TrackingPipeline::new(); - let recorded = pipeline.recorded(); - let (driver, handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)).build(); + let (driver, handles) = DriverFixture::new( + ctx.clone(), + TrackingPipeline::new(), + ScriptedTxManager::confirming_at(1), + ) + .build(); let handle = ctx.spawn(driver.run()); handles.admin.stop().await.unwrap(); let result = handles.admin.flush().await; assert!(matches!(result, Err(AdminError::Stopped))); - assert_eq!(recorded.lock().unwrap().flushes(), 0); ctx.cancel(); assert!(handle.await.unwrap().is_ok()); diff --git a/crates/batcher/core/tests/throttle.rs b/crates/batcher/core/tests/throttle.rs index 67a796ae49..4751f23eb5 100644 --- a/crates/batcher/core/tests/throttle.rs +++ b/crates/batcher/core/tests/throttle.rs @@ -11,186 +11,145 @@ use base_batcher_core::{ BlockStub, DriverFixture, ScriptedTxManager, TrackingPipeline, TrackingThrottleClient, }, }; -use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; +use base_batcher_source::{ + L2BlockEvent, + test_utils::{ChannelBlockSource, ChannelL1HeadSource}, +}; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, }; -/// When the DA backlog exceeds the threshold, the driver must call -/// `set_max_da_size` on the throttle client with reduced limits. +/// A backlog at twice the threshold pushes the lower limits and forces blob submissions. Once the +/// backlog is gone, the upper limits are pushed back and blobs are no longer forced. #[test] -fn test_throttle_client_called_on_high_backlog() { +fn test_throttle_transitions_from_active_to_inactive() { Runner::start(Config::seeded(0), |ctx| async move { - // 2 MB backlog — above the default 1 MB threshold. - let pipeline = TrackingPipeline::new().with_da_backlog(2_000_000); + let (source, source_tx) = ChannelBlockSource::new(); - let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); + let config = ThrottleConfig::default(); + let pipeline = TrackingPipeline::new().with_da_backlog(2 * config.threshold_bytes); + let backlog = Arc::clone(&pipeline.da_backlog_bytes); + let blob_override = Arc::clone(&pipeline.blob_override); + + let lower_limits = (config.tx_size_lower_limit, config.block_size_lower_limit); + let upper_limits = (config.tx_size_upper_limit, config.block_size_upper_limit); + let throttle = ThrottleController::new(config, ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) .build(); let handle = ctx.spawn(driver.run()); - ctx.sleep(Duration::from_millis(50)).await; - ctx.cancel(); - assert!(handle.await.unwrap().is_ok()); - - let calls = throttle_recorded.lock().unwrap(); - assert!(!calls.is_empty(), "throttle client must be called when backlog is high"); - let (max_tx_size, max_block_size) = calls[0]; - assert!( - max_block_size < 130_000, - "max_block_size should be below upper limit when throttled, got {max_block_size}" - ); - assert!( - max_tx_size < 20_000, - "max_tx_size should be below upper limit when throttled, got {max_tx_size}" - ); - }); -} - -/// When the DA backlog is zero (below threshold), the driver must call -/// `set_max_da_size` with the upper limits to reset any previous throttle. -#[test] -fn test_throttle_client_called_with_upper_limits_on_zero_backlog() { - Runner::start(Config::seeded(0), |ctx| async move { - let pipeline = TrackingPipeline::new().with_da_backlog(0); - - let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); - let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); + // The first iteration runs at startup, so give it time to complete. + ctx.sleep(Duration::from_millis(30)).await; + assert!(blob_override.load(Ordering::SeqCst), "throttling forces blobs"); - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) - .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) - .build(); - let handle = ctx.spawn(driver.run()); + // Drop the backlog to zero, then wake the driver by delivering a dummy + // block so the select! arm fires and the loop re-runs the throttle check. + backlog.store(0, Ordering::SeqCst); + source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); - ctx.sleep(Duration::from_millis(50)).await; + ctx.sleep(Duration::from_millis(30)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let calls = throttle_recorded.lock().unwrap(); - assert!(!calls.is_empty(), "throttle client must be called even with zero backlog"); - let (max_tx_size, max_block_size) = calls[0]; - assert_eq!( - max_block_size, 130_000, - "max_block_size should be the upper limit when not throttling" - ); - assert_eq!( - max_tx_size, 20_000, - "max_tx_size should be the upper limit when not throttling" - ); + // Twice the threshold is full intensity, so the lower limits first, then the upper + // limits once the backlog is gone. + assert_eq!(*throttle_recorded.lock().unwrap(), [lower_limits, upper_limits]); + assert!(!blob_override.load(Ordering::SeqCst), "blobs are no longer forced"); }); } -/// `set_max_da_size` must be called exactly once when limits do not change -/// between driver loop iterations. +/// Setting or resetting the throttle controller over the admin API pushes its limits again, +/// even when they did not change. #[test] -fn test_throttle_not_called_redundantly() { +fn test_admin_set_and_reset_push_the_limits_again() { Runner::start(Config::seeded(0), |ctx| async move { - let pipeline = TrackingPipeline::new().with_da_backlog(0); - - let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); + let config = ThrottleConfig::default(); + let upper_limits = (config.tx_size_upper_limit, config.block_size_upper_limit); + let throttle = ThrottleController::new(config.clone(), ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); - - let (driver, _handles) = - DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) - .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) - .build(); + let (driver, handles) = DriverFixture::new( + ctx.clone(), + TrackingPipeline::new(), + ScriptedTxManager::confirming_at(1), + ) + .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) + .build(); let handle = ctx.spawn(driver.run()); - // Run for 100ms to allow multiple loop iterations. - ctx.sleep(Duration::from_millis(100)).await; + handles.admin.set_throttle(ThrottleStrategy::Linear, config).await.unwrap(); + handles.admin.reset_throttle().await.unwrap(); + ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let calls = throttle_recorded.lock().unwrap(); - assert_eq!( - calls.len(), - 1, - "set_max_da_size must be called exactly once when limits do not change, got {}", - calls.len() - ); + assert_eq!(*throttle_recorded.lock().unwrap(), [upper_limits, upper_limits, upper_limits]); }); } -/// With the Step strategy and full intensity, when backlog is above the -/// threshold, the driver must apply the lower DA limits. +/// With blob forcing off, throttling pushes the lower limits but leaves the DA type alone. #[test] -fn test_step_strategy_full_intensity_applies_lower_limits() { +fn test_throttling_without_blob_forcing_keeps_the_da_type() { Runner::start(Config::seeded(0), |ctx| async move { - // Backlog of 100 — above threshold of 1. - let pipeline = TrackingPipeline::new().with_da_backlog(100); - - let config = - ThrottleConfig { threshold_bytes: 1, max_intensity: 1.0, ..Default::default() }; - let throttle = ThrottleController::new(config, ThrottleStrategy::Step); + let config = ThrottleConfig::default(); + let pipeline = TrackingPipeline::new().with_da_backlog(2 * config.threshold_bytes); + let blob_override = Arc::clone(&pipeline.blob_override); + let lower_limits = (config.tx_size_lower_limit, config.block_size_lower_limit); + let throttle = ThrottleController::new(config, ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) + .force_blobs_when_throttling(false) .build(); let handle = ctx.spawn(driver.run()); - ctx.sleep(Duration::from_millis(50)).await; + ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let calls = throttle_recorded.lock().unwrap(); - assert!(!calls.is_empty(), "throttle client must be called with Step strategy"); - let (max_tx_size, max_block_size) = calls[0]; - assert_eq!( - max_block_size, 2_000, - "Step strategy at full intensity must apply block_size_lower_limit" - ); - assert_eq!( - max_tx_size, 150, - "Step strategy at full intensity must apply tx_size_lower_limit" - ); + assert_eq!(*throttle_recorded.lock().unwrap(), [lower_limits]); + assert!(!blob_override.load(Ordering::SeqCst), "the DA type is left alone"); }); } -/// Verifies that when the DA backlog transitions from above the threshold -/// (throttle active) to zero (throttle inactive), the driver makes exactly -/// two RPC calls: one with the lower limits and one resetting to the upper limits. +/// Without a backlog, the upper limits are pushed at startup, lifting any throttle left on the +/// block builder. A push the block builder refuses is made again on the next iteration, and +/// limits it accepted are not pushed again while they stay the same. #[test] -fn test_throttle_transitions_from_active_to_inactive() { +fn test_upper_limits_are_pushed_at_startup_until_accepted() { Runner::start(Config::seeded(0), |ctx| async move { - let (source, source_tx) = ChannelBlockSource::new(); - - // Start with 2 MB backlog — above the default 1 MB threshold. - let pipeline = TrackingPipeline::new().with_da_backlog(2_000_000); - let backlog = Arc::clone(&pipeline.da_backlog_bytes); + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + let (l1_head_source, l1_head_tx) = ChannelL1HeadSource::new(); - let throttle = ThrottleController::new(ThrottleConfig::default(), ThrottleStrategy::Linear); + let config = ThrottleConfig::default(); + let upper_limits = (config.tx_size_upper_limit, config.block_size_upper_limit); + let throttle = ThrottleController::new(config, ThrottleStrategy::Linear); let (throttle_client, throttle_recorded) = TrackingThrottleClient::new(); let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) - .source(source) - .throttle(DaThrottle::new(throttle, Arc::new(throttle_client))) + .l1_head_source(l1_head_source) + .throttle(DaThrottle::new(throttle, Arc::new(throttle_client.with_failures(1)))) .build(); let handle = ctx.spawn(driver.run()); - // First iteration fires immediately on startup; give it time to complete. - ctx.sleep(Duration::from_millis(30)).await; - - // Drop the backlog to zero, then wake the driver by delivering a dummy - // block so the select! arm fires and the loop re-runs the throttle check. - backlog.store(0, Ordering::SeqCst); - source_tx.send(L2BlockEvent::Block(Box::new(BlockStub::with_number(1)))).unwrap(); - - ctx.sleep(Duration::from_millis(30)).await; + // Each L1 head wakes the loop for another iteration with the same backlog. + for l1_head in 1..=3 { + l1_head_tx.send(l1_head).unwrap(); + } + ctx.sleep(Duration::from_millis(10)).await; ctx.cancel(); assert!(handle.await.unwrap().is_ok()); - let calls = throttle_recorded.lock().unwrap(); - // Twice the threshold is full intensity, so the lower limits first, then the upper - // limits once the backlog is gone. - assert_eq!(*calls, [(150, 2_000), (20_000, 130_000)]); + assert_eq!(recorded.lock().unwrap().l1_heads(), [1, 2, 3]); + assert_eq!(*throttle_recorded.lock().unwrap(), [upper_limits, upper_limits]); }); } From fac6b6f95fcddbca26827aff10a2d299131c90d0 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 11:47:34 +0000 Subject: [PATCH 174/299] test(batcher): read encoder submissions back through derivation and pin the round trip (#5343) * test(batcher): read encoder submissions back through derivation and pin the round trip * test(batcher): tighten the encoder round-trip docs * docs(batcher): reword the decode oracle docs * test(batcher): assemble the oracle's channels with the derivation ChannelAssembler * test(batcher): count every channel the oracle sees and tighten its docs --- Cargo.lock | 3 + crates/batcher/encoder/Cargo.toml | 3 + .../batcher/encoder/tests/encoder/common.rs | 228 ++++++++++++++++++ crates/batcher/encoder/tests/encoder/main.rs | 4 + .../encoder/tests/encoder/roundtrip.rs | 66 +++++ 5 files changed, 304 insertions(+) create mode 100644 crates/batcher/encoder/tests/encoder/common.rs create mode 100644 crates/batcher/encoder/tests/encoder/main.rs create mode 100644 crates/batcher/encoder/tests/encoder/roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 2110039278..e2c930ff15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3076,13 +3076,16 @@ dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", "alloy-rlp", + "base-blobs", "base-common-consensus", "base-common-genesis", "base-comp", + "base-consensus-derive", "base-metrics", "base-protocol", "clap", "derive_more 2.1.1", + "futures", "metrics", "rand 0.9.5", "rstest", diff --git a/crates/batcher/encoder/Cargo.toml b/crates/batcher/encoder/Cargo.toml index 0439bc443e..e5c4c9ad58 100644 --- a/crates/batcher/encoder/Cargo.toml +++ b/crates/batcher/encoder/Cargo.toml @@ -29,7 +29,10 @@ clap = { workspace = true, features = ["derive"], optional = true } [dev-dependencies] rstest.workspace = true +base-blobs.workspace = true +futures = { workspace = true, features = ["executor"] } alloy-consensus = { workspace = true, features = ["std"] } +base-consensus-derive = { workspace = true, features = ["test-utils"] } [features] clap = [ "dep:clap" ] diff --git a/crates/batcher/encoder/tests/encoder/common.rs b/crates/batcher/encoder/tests/encoder/common.rs new file mode 100644 index 0000000000..1153e79fe8 --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/common.rs @@ -0,0 +1,228 @@ +//! Fixtures shared by the encoder tests. They provide L2 block chains, the pair of configs a +//! test builds its encoder from, and the derivation-side reader that turns the encoder's +//! submissions back into batches. + +use std::{collections::HashSet, sync::Arc}; + +use alloy_consensus::{BlockBody, Header, SignableTransaction, TxLegacy}; +use alloy_primitives::{B256, Bytes, Sealed, Signature}; +use base_batcher_encoder::{ + BatchComposer, BatchEncoder, BatchSubmission, EncoderConfig, FrameEncoder, SubmissionPayload, +}; +use base_blobs::BlobEncoder; +use base_common_consensus::{BaseBlock, BaseTxEnvelope, TxDeposit}; +use base_common_genesis::{RollupConfig, UpgradeConfig}; +use base_consensus_derive::{ + BlobData, ChannelAssembler, ChannelReaderProvider, PipelineError, PipelineErrorKind, + test_utils::TestNextFrameProvider, +}; +use base_protocol::{Batch, BatchReader, Frame, L1BlockInfoBedrock, L1BlockInfoTx, SingleBatch}; +use futures::executor::block_on; +use rand::{RngCore, SeedableRng, rngs::SmallRng}; + +/// Time of every fixture block and of the Holocene activation. The L1 origin derivation reads at +/// is the default block, also at time 0, so the same forks are active on L2 for the encoder and +/// on L1 for derivation. +pub const GENESIS_TIMESTAMP: u64 = 0; + +/// L2 block fixtures the encoder accepts. +#[derive(Debug)] +pub struct BlockFixture; + +impl BlockFixture { + /// A chain of `len` blocks numbered from 1, the first one built on parent hash zero. + /// + /// Each block carries the L1-info deposit and one user transaction whose input is + /// `payload_len` pseudo-random bytes, so its channel data does not compress away. + pub fn chain(len: u64, payload_len: usize) -> Vec { + let mut parent_hash = B256::ZERO; + (1..=len) + .map(|number| { + let block = Self::block(parent_hash, number, payload_len); + parent_hash = block.header.hash_slow(); + block + }) + .collect() + } + + /// The batches derivation must read back from `blocks`. They come from the encoder's own + /// composer because the round trip checks encoding, not composition, which has its own tests. + pub fn batches(blocks: &[BaseBlock]) -> Vec { + blocks + .iter() + .map(|block| { + BatchComposer::block_to_single_batch(block).expect("fixture blocks should compose") + }) + .collect() + } + + fn block(parent_hash: B256, number: u64, payload_len: usize) -> BaseBlock { + let calldata = L1BlockInfoTx::Bedrock(L1BlockInfoBedrock::default()).encode_calldata(); + let deposit = TxDeposit { input: calldata, ..Default::default() }; + let user_tx = TxLegacy { input: Self::noise(number, payload_len), ..Default::default() } + .into_signed(Signature::test_signature()); + BaseBlock { + header: Header { + parent_hash, + number, + timestamp: GENESIS_TIMESTAMP, + ..Default::default() + }, + body: BlockBody { + transactions: vec![ + BaseTxEnvelope::Deposit(Sealed::new(deposit)), + BaseTxEnvelope::Legacy(user_tx), + ], + ..Default::default() + }, + } + } + + /// `len` pseudo-random bytes determined by `seed`. + fn noise(seed: u64, len: usize) -> Bytes { + let mut bytes = vec![0; len]; + SmallRng::seed_from_u64(seed).fill_bytes(&mut bytes); + bytes.into() + } +} + +/// The pair of configs a test builds its encoder from, kept together so the encoder's output +/// is read back under the same pair. +#[derive(Debug)] +pub struct EncoderFixture { + /// The rollup config, shared with the encoder. + rollup_config: Arc, + /// The encoder config. + config: EncoderConfig, +} + +impl EncoderFixture { + /// A fixture for `config` on the default rollup config, with Holocene active from genesis + /// because `derive` runs its channel stage. Holocene implies Granite and Fjord. Panics if + /// production would reject the pair. + pub fn new(config: EncoderConfig) -> Self { + let rollup_config = RollupConfig { + upgrades: UpgradeConfig { + holocene_time: Some(GENESIS_TIMESTAMP), + ..UpgradeConfig::default() + }, + ..RollupConfig::default() + }; + config + .validate_for_rollup_config(&rollup_config, GENESIS_TIMESTAMP) + .expect("the test config should be one production accepts"); + Self { rollup_config: Arc::new(rollup_config), config } + } + + /// A fresh encoder. + pub fn encoder(&self) -> BatchEncoder { + BatchEncoder::new(Arc::clone(&self.rollup_config), self.config.clone()) + .expect("the config was validated") + } + + /// The batches derivation reads from `submissions`, one list per channel, in the order the + /// channels complete. The submissions are taken as L1 transactions included in this order, + /// all at the genesis L1 origin, so no channel times out. + /// + /// The frames go through the derivation `ChannelAssembler` and the channel data through the + /// `BatchReader` derivation uses. Panics when a payload does not parse, when derivation drops + /// a channel or leaves one without its terminal frame, and when channel data does not + /// decompress or decode. Also panics on output derivation would accept but the encoder never + /// emits, such as a channel id used twice, a channel not compressed with Brotli, a span batch, + /// a frame above `max_frame_size`, a blob payload that does not fit a blob, or a transaction + /// with zero or more than `max_blobs_per_tx` blobs. Batch validity (epoch, timestamp, + /// sequence window) is out of scope. + pub fn derive(&self, submissions: &[BatchSubmission]) -> Vec> { + let frames: Vec = + submissions.iter().flat_map(|submission| self.frames(submission)).collect(); + let mut channel_ids = HashSet::new(); + let mut first_frames = HashSet::new(); + for frame in &frames { + assert!( + frame.encoded_len() <= self.config.max_frame_size, + "frame {} of channel {:?} is {} bytes, above max_frame_size {}", + frame.number, + frame.id, + frame.encoded_len(), + self.config.max_frame_size + ); + channel_ids.insert(frame.id); + if frame.number == 0 { + assert!(first_frames.insert(frame.id), "channel id {:?} used twice", frame.id); + } + } + + // The test provider hands out its frames from the back of the list. + let provider = TestNextFrameProvider::new(frames.into_iter().rev().map(Ok).collect()); + let mut assembler = ChannelAssembler::new(Arc::clone(&self.rollup_config), provider); + let max_rlp_bytes = self.rollup_config.max_rlp_bytes_per_channel(GENESIS_TIMESTAMP); + let mut channels = Vec::new(); + loop { + match block_on(assembler.next_data()) { + Ok(Some(data)) => channels.push(self.read_batches(&data, max_rlp_bytes)), + Err(PipelineErrorKind::Temporary(PipelineError::Eof)) => break, + Ok(None) | Err(PipelineErrorKind::Temporary(PipelineError::NotEnoughData)) => {} + Err(error) => panic!("derivation failed on the frames: {error}"), + } + } + assert!(assembler.channel.is_none(), "a channel has no terminal frame"); + assert_eq!(channels.len(), channel_ids.len(), "derivation dropped a channel"); + channels + } + + /// The frames derivation parses from the L1 transaction built from `submission`, in + /// order. + fn frames(&self, submission: &BatchSubmission) -> Vec { + match submission.payload() { + SubmissionPayload::Calldata(frame) => { + let calldata = FrameEncoder::to_calldata(frame); + Frame::parse_frames(&calldata).expect("calldata should parse") + } + SubmissionPayload::Blobs(blobs) => { + assert!( + (1..=self.config.max_blobs_per_tx).contains(&blobs.len()), + "{} blobs in one transaction, outside 1..={}", + blobs.len(), + self.config.max_blobs_per_tx + ); + blobs + .iter() + .flat_map(|blob| { + let encoded = BlobEncoder::encode_packed(blob.frames()) + .expect("the blob payload should fit a blob"); + let data = BlobData { + data: Some(Bytes::copy_from_slice(encoded.as_slice())), + calldata: None, + } + .decode() + .expect("the blob should decode"); + Frame::parse_frames(&data).expect("blob data should parse") + }) + .collect() + } + } + } + + /// Decode channel `data` with the reader the derivation channel stage uses. + fn read_batches(&self, data: &[u8], max_rlp_bytes: u64) -> Vec { + assert_eq!( + data.first(), + Some(&BatchReader::CHANNEL_VERSION_BROTLI), + "channel data does not start with the Brotli version byte" + ); + let brotli_supported = self.rollup_config.is_fjord_active(GENESIS_TIMESTAMP); + let mut reader = BatchReader::new(data, max_rlp_bytes as usize, brotli_supported); + + let mut batches = Vec::new(); + while let Some(batch) = reader + .next_batch_strict(&self.rollup_config) + .expect("channel data should decompress and decode") + { + let Batch::Single(batch) = batch else { + panic!("the encoder emits single batches, got a span batch"); + }; + batches.push(batch); + } + batches + } +} diff --git a/crates/batcher/encoder/tests/encoder/main.rs b/crates/batcher/encoder/tests/encoder/main.rs new file mode 100644 index 0000000000..e00b9285fd --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/main.rs @@ -0,0 +1,4 @@ +//! Integration tests of the batcher encoder through its public API, one module per subject. + +pub mod common; +mod roundtrip; diff --git a/crates/batcher/encoder/tests/encoder/roundtrip.rs b/crates/batcher/encoder/tests/encoder/roundtrip.rs new file mode 100644 index 0000000000..86fd8a99bc --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/roundtrip.rs @@ -0,0 +1,66 @@ +//! Round trip through the flush path. Derivation reads back the batches of the blocks the +//! encoder was given, in order, whatever the wire shape the encoder config produces. + +use base_batcher_encoder::{BatchPipeline, BatchSubmission, DaType, EncoderConfig}; +use rstest::rstest; + +use crate::common::{BlockFixture, EncoderFixture}; + +/// The number of blocks every case encodes. +const BLOCK_COUNT: u64 = 4; + +/// Derivation reads back one batch per encoded block, in order, whatever the DA type, frame +/// size and channel split. `blob_count` and `channel_count` pin the wire shape each case targets. +#[rstest] +#[case::blobs(EncoderConfig::default(), 1_000, 1, 1)] +#[case::calldata( + EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, + 1_000, + 0, + 1 +)] +#[case::calldata_small_frames( + EncoderConfig { da_type: DaType::Calldata, max_frame_size: 64, ..EncoderConfig::default() }, + 1_000, + 0, + 1 +)] +#[case::blobs_small_frames( + EncoderConfig { max_frame_size: 64, ..EncoderConfig::default() }, + 1_000, + 1, + 1 +)] +#[case::channel_across_blobs_and_transactions( + EncoderConfig { max_blobs_per_tx: 2, ..EncoderConfig::default() }, + 200_000, + 7, + 1 +)] +// A target of one byte closes every channel on its first block. +#[case::channels_packed_in_one_blob( + EncoderConfig { compressed_size_target: Some(1), ..EncoderConfig::default() }, + 1_000, + 1, + BLOCK_COUNT as usize +)] +fn derivation_reads_back_the_encoded_blocks( + #[case] config: EncoderConfig, + #[case] payload_len: usize, + #[case] blob_count: usize, + #[case] channel_count: usize, +) { + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(BLOCK_COUNT, payload_len); + for block in &blocks { + encoder.add_block(block.clone()).expect("fixture blocks should chain from genesis"); + } + + let submissions = encoder.encode_and_drain().expect("blocks should encode"); + assert_eq!(submissions.iter().map(BatchSubmission::blob_count).sum::(), blob_count); + + let derived = fixture.derive(&submissions); + assert_eq!(derived.len(), channel_count); + assert_eq!(derived.concat(), BlockFixture::batches(&blocks)); +} From b5e8f11d8ab0a6af271af8dedca217c799e9e6a5 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 12:12:40 +0000 Subject: [PATCH 175/299] test(batcher): test the encoder through its public API, one module per subject (#5344) * test(batcher): test the encoder through its public API, one module per subject * test(batcher): tighten the encoder test docs and make four assertions exact * docs(batcher): reword the encoder test module docs * test(batcher): drop the encoder tests other tests already cover and fix the shared blob fixture docs * test(batcher): assert a full blob and the frame limit exactly * test(batcher): say the reconcile tests prune up to the safe head * test(batcher): simplify the L1 head test and pin one submission per lease drain * test(batcher): check a block added after a prune is encoded * test(batcher): sharpen the encoder test docs and drop a no-op confirmation * test(batcher): stop pinning the encoder state after a safe head mismatch * test(batcher): fail the open channel fixture with a message past its block budget --- crates/batcher/encoder/src/encoder.rs | 1458 ----------------- crates/batcher/encoder/src/submission.rs | 9 - .../batcher/encoder/tests/encoder/backlog.rs | 30 + .../batcher/encoder/tests/encoder/channels.rs | 119 ++ .../batcher/encoder/tests/encoder/common.rs | 178 +- .../batcher/encoder/tests/encoder/ingest.rs | 121 ++ crates/batcher/encoder/tests/encoder/lease.rs | 110 ++ crates/batcher/encoder/tests/encoder/main.rs | 8 + .../batcher/encoder/tests/encoder/packing.rs | 27 + .../encoder/tests/encoder/reconcile.rs | 275 ++++ .../batcher/encoder/tests/encoder/replay.rs | 198 +++ 11 files changed, 1058 insertions(+), 1475 deletions(-) create mode 100644 crates/batcher/encoder/tests/encoder/backlog.rs create mode 100644 crates/batcher/encoder/tests/encoder/channels.rs create mode 100644 crates/batcher/encoder/tests/encoder/ingest.rs create mode 100644 crates/batcher/encoder/tests/encoder/lease.rs create mode 100644 crates/batcher/encoder/tests/encoder/packing.rs create mode 100644 crates/batcher/encoder/tests/encoder/reconcile.rs create mode 100644 crates/batcher/encoder/tests/encoder/replay.rs diff --git a/crates/batcher/encoder/src/encoder.rs b/crates/batcher/encoder/src/encoder.rs index 37562b70f1..cbbd1e004d 100644 --- a/crates/batcher/encoder/src/encoder.rs +++ b/crates/batcher/encoder/src/encoder.rs @@ -669,1461 +669,3 @@ impl BatchPipeline for BatchEncoder { } } } - -#[cfg(test)] -mod tests { - use alloy_consensus::{BlockBody, Header, SignableTransaction, TxLegacy}; - use alloy_primitives::{Bytes, Sealed, Signature}; - use base_common_consensus::{BaseTxEnvelope, TxDeposit}; - use base_protocol::{Frame, L1BlockInfoBedrock, L1BlockInfoTx}; - use rstest::rstest; - - use super::*; - use crate::{BatchComposeError, ChannelLimit, SubmissionPayload}; - - fn make_deposit_tx() -> BaseTxEnvelope { - let calldata = L1BlockInfoTx::Bedrock(L1BlockInfoBedrock::default()).encode_calldata(); - BaseTxEnvelope::Deposit(Sealed::new(TxDeposit { input: calldata, ..Default::default() })) - } - - fn make_block(parent_hash: B256) -> BaseBlock { - BaseBlock { - header: Header { parent_hash, ..Default::default() }, - body: BlockBody { transactions: vec![make_deposit_tx()], ..Default::default() }, - } - } - - fn make_block_with_user_tx(parent_hash: B256) -> BaseBlock { - let user_tx = { - let signed = TxLegacy::default().into_signed(Signature::test_signature()); - BaseTxEnvelope::Legacy(signed) - }; - - BaseBlock { - header: Header { parent_hash, ..Default::default() }, - body: BlockBody { - transactions: vec![make_deposit_tx(), user_tx], - ..Default::default() - }, - } - } - - fn make_block_with_user_tx_bytes( - parent_hash: B256, - payload_len: usize, - seed: u64, - ) -> BaseBlock { - let mut state = seed; - let input: Vec = (0..payload_len) - .map(|_| { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state as u8 - }) - .collect(); - let signed = TxLegacy { input: Bytes::from(input), ..Default::default() } - .into_signed(Signature::test_signature()); - BaseBlock { - header: Header { parent_hash, ..Default::default() }, - body: BlockBody { - transactions: vec![make_deposit_tx(), BaseTxEnvelope::Legacy(signed)], - ..Default::default() - }, - } - } - - fn make_block_with_large_user_tx(parent_hash: B256, seed: u64) -> BaseBlock { - make_block_with_user_tx_bytes(parent_hash, 200_000, seed) - } - - fn make_user_tx_chain(len: usize) -> Vec { - let mut parent_hash = B256::ZERO; - (0..len) - .map(|number| { - let mut block = make_block_with_user_tx(parent_hash); - block.header.number = number as u64; - parent_hash = block.header.hash_slow(); - block - }) - .collect() - } - - fn default_encoder() -> BatchEncoder { - let rollup_config = Arc::new(RollupConfig::default()); - BatchEncoder::new(rollup_config, EncoderConfig::default()).expect("valid default config") - } - - #[test] - fn new_rejects_invalid_config() { - let config = EncoderConfig { max_blobs_per_tx: 0, ..EncoderConfig::default() }; - - assert!(matches!( - BatchEncoder::new(Arc::new(RollupConfig::default()), config), - Err(EncoderConfigError::MaxBlobsPerTxZero) - )); - } - - fn encoder_with_confirmation_timeout(channel_timeout: u64) -> BatchEncoder { - let rollup_config = Arc::new(RollupConfig { - channel_timeout, - granite_channel_timeout: channel_timeout, - ..RollupConfig::default() - }); - let config = EncoderConfig { - da_type: DaType::Calldata, - max_frame_size: 32, - max_blobs_per_tx: 1, - max_channel_duration: 1000, - ..EncoderConfig::default() - }; - BatchEncoder::new(rollup_config, config).expect("valid test config") - } - - fn drain_submissions(encoder: &mut BatchEncoder) -> Vec { - let mut submissions = Vec::new(); - while let Some(submission) = encoder.next_submission() { - submissions.push(submission); - } - submissions - } - - fn has_open_channel(encoder: &BatchEncoder) -> bool { - encoder.channels.back().is_some_and(Channel::is_open) - } - - #[test] - fn test_add_block_reorg_detection() { - let mut encoder = default_encoder(); - - let block1 = make_block(B256::ZERO); - encoder.add_block(block1).unwrap(); - - // Second block with wrong parent hash should fail. - let wrong_parent = B256::from([0xAB; 32]); - let block2 = make_block(wrong_parent); - let (err, returned_block) = encoder.add_block(block2).unwrap_err(); - assert_eq!(returned_block.header.parent_hash, wrong_parent); - - match err { - ReorgError::ParentMismatch { expected, got } => { - assert_eq!(got, wrong_parent); - assert_ne!(expected, wrong_parent); - } - } - } - - #[test] - fn test_step_encodes_block() { - let mut encoder = default_encoder(); - - let block = make_block(B256::ZERO); - encoder.add_block(block).unwrap(); - - let result = encoder.step().unwrap(); - assert_eq!(result, StepResult::BlockEncoded); - - // No more blocks => idle. - let result = encoder.step().unwrap(); - assert_eq!(result, StepResult::Idle); - } - - fn tiny_frame_encoder() -> BatchEncoder { - let config = EncoderConfig { - max_frame_size: Frame::ENCODED_OVERHEAD + 1, - ..EncoderConfig::default() - }; - BatchEncoder::new(Arc::new(RollupConfig::default()), config) - .expect("valid tiny-frame config") - } - - #[test] - fn test_step_retries_rejected_block_after_protocol_limit_close() { - let mut encoder = tiny_frame_encoder(); - let first = make_block_with_user_tx_bytes(B256::ZERO, 30_000, 1); - let second = make_block_with_user_tx_bytes(first.header.hash_slow(), 30_000, 2); - encoder.add_block(first).unwrap(); - encoder.add_block(second).unwrap(); - - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - assert_eq!(encoder.block_cursor, 1); - assert_eq!(encoder.channels.len(), 1); - assert!(has_open_channel(&encoder)); - - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - assert_eq!(encoder.block_cursor, 1, "rejected block stays at the cursor"); - assert_eq!(encoder.channels.len(), 1); - assert!(!has_open_channel(&encoder)); - assert!(encoder.channels[0].terminal_pending()); - - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - assert_eq!(encoder.block_cursor, 2); - assert_eq!(encoder.channels.len(), 2); - assert!(has_open_channel(&encoder)); - assert_eq!(encoder.channels[1].blocks_added(), 1); - } - - #[test] - fn test_step_discards_block_that_exceeds_empty_channel() { - let mut encoder = tiny_frame_encoder(); - encoder.add_block(make_block_with_user_tx_bytes(B256::ZERO, 100_000, 1)).unwrap(); - - let err = encoder.step().unwrap_err(); - assert!(matches!( - err, - StepError::BlockExceedsChannelLimit { - cursor: 0, - limit: ChannelLimit::FrameCount { .. } - } - )); - assert!(encoder.channels.is_empty()); - assert_eq!(encoder.block_cursor, 0); - } - - #[test] - fn test_safe_head_prunes_fully_confirmed_blocks() { - let mut encoder = default_encoder(); - let blocks = make_user_tx_chain(2); - let safe_l2 = BlockInfo::from(&blocks[0]); - for block in blocks { - encoder.add_block(block).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - } - encoder.flush().unwrap(); - for submission in drain_submissions(&mut encoder) { - encoder.confirm(submission.id, 100); - } - - assert_eq!(encoder.channels.len(), 1); - assert_eq!(encoder.blocks.len(), 2, "confirmation keeps blocks buffered"); - - assert!(encoder.prune_safe(safe_l2)); - assert_eq!(encoder.channels.len(), 1); - assert_eq!(encoder.blocks.len(), 1); - assert_eq!(encoder.blocks[0].header.number, 1); - assert_eq!(encoder.block_cursor, 1); - } - - #[test] - fn test_reset_clears_state() { - let mut encoder = default_encoder(); - - let block = make_block(B256::ZERO); - encoder.add_block(block).unwrap(); - encoder.step().unwrap(); - - assert!(!encoder.blocks.is_empty()); - - encoder.reset(); - - assert!(encoder.blocks.is_empty()); - assert_eq!(encoder.block_cursor, 0); - assert_eq!(encoder.tip, None); - assert!(encoder.channels.is_empty()); - assert_eq!(encoder.egress.pending_submission_count(), 0); - } - - #[test] - fn test_da_backlog_excludes_deposits() { - let mut encoder = default_encoder(); - - let block = make_block_with_user_tx(B256::ZERO); - encoder.add_block(block).unwrap(); - - let backlog = encoder.da_backlog_bytes(); - // The backlog should only count the user tx, not the deposit. - assert!(backlog > 0); - } - - #[test] - fn test_da_backlog_counts_single_blocks_after_encoding_and_closing() { - let mut encoder = default_encoder(); - for block in make_user_tx_chain(3) { - encoder.add_block(block).unwrap(); - } - - let queued_backlog = encoder.da_backlog_bytes(); - assert!(queued_backlog > 0); - - for _ in 0..3 { - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - } - - assert_eq!(encoder.block_cursor, 3); - assert!(has_open_channel(&encoder)); - assert_eq!(encoder.da_backlog_bytes(), queued_backlog); - - encoder.flush().unwrap(); - assert!(!has_open_channel(&encoder)); - assert!(!encoder.channels.is_empty()); - assert!(encoder.da_backlog_bytes() > 0); - - let mut submissions = Vec::new(); - while let Some(submission) = encoder.next_submission() { - submissions.push(submission.id); - } - assert!(!submissions.is_empty()); - assert!(encoder.da_backlog_bytes() > 0, "in-flight submissions still consume DA backlog"); - - for id in submissions { - encoder.confirm(id, 0); - } - assert_eq!(encoder.da_backlog_bytes(), 0); - } - - #[test] - fn test_requeue_marks_frames_ready() { - let mut encoder = default_encoder(); - - let block = make_block(B256::ZERO); - encoder.add_block(block).unwrap(); - encoder.step().unwrap(); - - // Force close the channel. - encoder.advance_l1_head(100); - - let sub = encoder.next_submission().unwrap(); - let sub_id = sub.id; - - // Requeue the submission. - encoder.requeue(sub_id); - - // The frame should be available again. - let resub = encoder.next_submission(); - assert!(resub.is_some()); - } - - #[test] - fn test_step_idle_when_no_blocks() { - let mut encoder = default_encoder(); - assert_eq!(encoder.step().unwrap(), StepResult::Idle); - } - - #[test] - fn test_advance_l1_head_triggers_timeout() { - let mut encoder = default_encoder(); - - let block = make_block(B256::ZERO); - encoder.add_block(block).unwrap(); - encoder.step().unwrap(); - - // Channel should exist. - assert!(has_open_channel(&encoder)); - - // Advance L1 head past max_channel_duration (default 2). - encoder.advance_l1_head(3); - - // Channel should be closed now. - assert!(!has_open_channel(&encoder)); - assert!(!encoder.channels.is_empty()); - } - - /// `advance_l1_head` must be monotonic: a call with a value ≤ the current `l1_head` - /// must be silently ignored. Without this guard, an out-of-order confirmation - /// (possible when `max_pending_transactions` > 1) could decrease `l1_head`, making - /// channel timeout checks produce artificially small deltas and stall timeout closure. - #[test] - fn test_advance_l1_head_ignores_non_monotonic_update() { - let mut encoder = default_encoder(); - - let block = make_block(B256::ZERO); - let block_hash = block.header.hash_slow(); - encoder.add_block(block).unwrap(); - encoder.step().unwrap(); - - // Advance past the timeout threshold so the channel closes. - encoder.advance_l1_head(3); - assert!(!has_open_channel(&encoder), "channel should have timed out at l1_head=3"); - - // Now encode another block so a new channel opens. - // Parent hash must chain from the first block's hash (= current tip). - encoder.add_block(make_block(block_hash)).unwrap(); - encoder.step().unwrap(); - assert!(has_open_channel(&encoder), "new channel should be open"); - - // A non-monotonic (backward) call must not decrease l1_head. - encoder.advance_l1_head(1); - assert!(has_open_channel(&encoder), "backward advance_l1_head must not close the channel"); - } - - // --- Reorg / stale-confirmation tests --- - // - // These tests document the invariant that must hold after a reorg: - // `reset()` clears pending/channels but intentionally does NOT reset next_id, - // keeping submission IDs monotonically increasing across resets. This - // eliminates the class of bugs where a stale in-flight confirmation from - // before the reset could match a fresh post-reset submission with the same ID. - - /// Get a submission into the in-flight state (pending but not yet confirmed), - /// then call `reset()`. A subsequent `confirm()` for the stale ID must be a no-op - /// and the pending map must remain empty. - #[test] - fn test_stale_confirm_after_reset_is_noop() { - let mut encoder = default_encoder(); - - let block = make_block(B256::ZERO); - encoder.add_block(block).unwrap(); - encoder.step().unwrap(); - encoder.advance_l1_head(100); - - let sub = encoder.next_submission().unwrap(); - let stale_id = sub.id; // ID 0, now in-flight - - // Simulate a reorg: driver calls reset() while the submission is still in flight. - encoder.reset(); - assert_eq!(encoder.egress.pending_submission_count(), 0); - // next_id is preserved across reset so post-reset IDs can never collide - // with pre-reset in-flight IDs. - assert_eq!(encoder.next_id, 1); - - // Stale confirm arrives (would have been delivered to the old pipeline). - encoder.confirm(stale_id, 42); - - // Nothing to prune: blocks were already cleared by reset(). - assert!(encoder.blocks.is_empty()); - // pending is still empty — the confirm was a no-op. - assert_eq!(encoder.egress.pending_submission_count(), 0); - } - - /// Same as above but for `requeue()`: a stale requeue after reset must not - /// alter any channel, because the channel no longer exists. - #[test] - fn test_stale_requeue_after_reset_is_noop() { - let mut encoder = default_encoder(); - - let block = make_block(B256::ZERO); - encoder.add_block(block).unwrap(); - encoder.step().unwrap(); - encoder.advance_l1_head(100); - - let sub = encoder.next_submission().unwrap(); - let stale_id = sub.id; - - encoder.reset(); - - // Stale requeue must not panic or corrupt state. - encoder.requeue(stale_id); - - assert!(encoder.channels.is_empty()); - assert_eq!(encoder.egress.pending_submission_count(), 0); - } - - /// `reset()` must not reset `next_id`. Post-reorg submissions must receive IDs - /// that are strictly greater than any pre-reorg in-flight ID, so a stale - /// confirm/requeue can never silently match a fresh submission. - #[test] - fn test_next_id_monotonic_across_reset() { - let mut encoder = default_encoder(); - - // Pre-reorg: encode block 1, get submission ID 0 (in-flight). - encoder.add_block(make_block(B256::ZERO)).unwrap(); - encoder.step().unwrap(); - encoder.advance_l1_head(100); - let pre_reorg_sub = encoder.next_submission().unwrap(); - assert_eq!(pre_reorg_sub.id.0, 0); - - // Reorg: driver resets while pre_reorg_sub.id is still in flight. - encoder.reset(); - - // Post-reorg: next_id must NOT have been reset to 0. - assert_eq!(encoder.next_id, 1, "next_id must be preserved across reset"); - - // Encode a post-reorg block and verify it gets a fresh, non-colliding ID. - encoder.add_block(make_block(B256::ZERO)).unwrap(); - encoder.step().unwrap(); - encoder.advance_l1_head(200); - let post_reorg_sub = encoder.next_submission().unwrap(); - assert_eq!(post_reorg_sub.id.0, 1, "post-reorg ID must not collide with pre-reorg ID 0"); - - // Verify the post-reorg confirmation updates the immutable artifact. - assert!( - encoder - .egress - .artifacts() - .iter() - .all(|artifact| { artifact.state() == crate::ArtifactState::Pending }) - ); - encoder.confirm(post_reorg_sub.id, 201); - assert!( - encoder - .egress - .artifacts() - .iter() - .all(|artifact| artifact.state() == crate::ArtifactState::Confirmed) - ); - assert_eq!(encoder.blocks.len(), 1, "confirmation keeps the post-reorg block buffered"); - } - - // --- sub_safety_margin tests --- - - /// The effective timeout is `max_channel_duration - sub_safety_margin`. A channel - /// opened at L1=0 must stay open until `l1_head` reaches `at_threshold` exactly. - #[rstest] - #[case(10, 4, 5, 6)] // effective = 10-4 = 6 - #[case(5, 0, 4, 5)] // margin=0: effective = full duration - fn test_sub_safety_margin( - #[case] max_channel_duration: u64, - #[case] sub_safety_margin: u64, - #[case] below: u64, - #[case] at_threshold: u64, - ) { - let config = - EncoderConfig { max_channel_duration, sub_safety_margin, ..EncoderConfig::default() }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - - encoder.add_block(make_block(B256::ZERO)).unwrap(); - encoder.step().unwrap(); - assert!(has_open_channel(&encoder)); - - encoder.advance_l1_head(below); - assert!(has_open_channel(&encoder), "channel must stay open before effective timeout"); - - encoder.advance_l1_head(at_threshold); - assert!(!has_open_channel(&encoder), "channel must close at effective timeout"); - assert!(!encoder.channels.is_empty()); - assert!(encoder.next_submission().is_some(), "timeout must release the partial blob"); - } - - // --- max_blobs_per_tx tests --- - - /// Small frames are packed together instead of wasting one blob per frame. - #[test] - fn test_packs_multiple_frames_into_one_blob() { - let config = - EncoderConfig { max_frame_size: 32, max_blobs_per_tx: 2, ..EncoderConfig::default() }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - - encoder.add_block(make_block_with_user_tx(B256::ZERO)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - let submission = encoder.next_submission().expect("multi-frame submission"); - assert!(submission.frame_count() > 2); - let SubmissionPayload::Blobs(blobs) = submission.payload() else { - panic!("expected blob submission"); - }; - assert_eq!(blobs.len(), 1); - encoder.confirm(submission.id, 1); - assert!(encoder.egress.channel_fully_confirmed(&encoder.channels[0])); - } - - #[test] - fn test_flush_releases_tail_from_closed_channel() { - let config = EncoderConfig { compressed_size_target: Some(1), ..EncoderConfig::default() }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - - encoder.add_block(make_block_with_user_tx(B256::ZERO)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - assert!( - encoder.next_submission().is_none(), - "size-closed partial blob must wait even when the input queue is empty" - ); - - encoder.flush().unwrap(); - - assert!( - encoder.next_submission().is_some(), - "explicit flush must release the partial blob" - ); - } - - #[test] - fn test_size_closed_tail_keeps_its_original_timeout() { - let config = EncoderConfig { - compressed_size_target: Some(1), - max_channel_duration: 5, - ..EncoderConfig::default() - }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - encoder.advance_l1_head(10); - - let first = make_block_with_user_tx(B256::ZERO); - let second = make_block_with_user_tx(first.header.hash_slow()); - encoder.add_block(first).unwrap(); - encoder.add_block(second).unwrap(); - - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - assert!(!encoder.channels[0].deadline_due(14)); - assert!(encoder.channels[0].deadline_due(15)); - - encoder.advance_l1_head(12); - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - assert!(encoder.channels[0].deadline_due(15)); - assert!(!encoder.channels[1].deadline_due(16)); - assert!(encoder.channels[1].deadline_due(17)); - - encoder.advance_l1_head(14); - assert!( - encoder.next_submission().is_none(), - "size-closed tails must remain held before the oldest channel timeout" - ); - - encoder.advance_l1_head(15); - let submission = encoder.next_submission().expect("oldest channel timeout releases FIFO"); - encoder.requeue(submission.id); - assert!( - encoder.next_submission().is_some(), - "requeue must preserve an already-reached release deadline" - ); - } - - #[test] - fn test_open_channel_emits_full_blob_without_closing() { - let config = EncoderConfig { - compressed_size_target: None, - max_channel_duration: 100, - ..EncoderConfig::default() - }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - - let mut parent_hash = B256::ZERO; - let mut submission = None; - // Brotli may retain one 4 MiB window before exposing output. Feed - // enough incompressible input to exercise emission without flushes. - for seed in 1..=32 { - let block = make_block_with_large_user_tx(parent_hash, seed); - parent_hash = block.header.hash_slow(); - encoder.add_block(block).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - submission = encoder.next_submission(); - if submission.is_some() { - break; - } - } - let submission = submission.unwrap_or_else(|| { - panic!( - "Brotli emitted {} compressed bytes with {} bytes available", - encoder.channels[0].compressed_bytes(), - encoder.channels[0].available_output() - ) - }); - assert!(has_open_channel(&encoder), "full blob emission must not close the channel"); - let SubmissionPayload::Blobs(blobs) = submission.payload() else { - panic!("expected blob submission"); - }; - assert!(!blobs.is_empty()); - for blob in blobs { - assert!(blob.frames().iter().all(|frame| !frame.is_last)); - assert_eq!( - blob.frames() - .iter() - .map(|frame| Frame::ENCODED_OVERHEAD + frame.data.len()) - .sum::() - + EncoderConfig::BLOB_DERIVATION_PREFIX_SIZE, - EncoderConfig::BLOB_MAX_DATA_SIZE - ); - } - } - - #[test] - fn test_packs_adjacent_channels_across_blob_boundary() { - let config = EncoderConfig { - compressed_size_target: Some(1), - max_blobs_per_tx: 6, - ..EncoderConfig::default() - }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - let first = make_block_with_large_user_tx(B256::ZERO, 1); - let second = make_block_with_large_user_tx(first.header.hash_slow(), 2); - encoder.add_block(first).unwrap(); - encoder.add_block(second).unwrap(); - - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - let first_channel_id = encoder.channels[0].id(); - let first_submission = encoder.next_submission().expect("full first blob"); - let SubmissionPayload::Blobs(first_blobs) = first_submission.payload() else { - panic!("expected blob submission"); - }; - assert_eq!(first_blobs.len(), 1); - assert_eq!( - first_blobs[0] - .frames() - .iter() - .map(|frame| Frame::ENCODED_OVERHEAD + frame.data.len()) - .sum::() - + EncoderConfig::BLOB_DERIVATION_PREFIX_SIZE, - EncoderConfig::BLOB_MAX_DATA_SIZE - ); - assert!(encoder.next_submission().is_none(), "partial tail must wait for more data"); - - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - let second_channel_id = encoder.channels[1].id(); - let second_submission = encoder.next_submission().expect("cross-channel packed blobs"); - let SubmissionPayload::Blobs(blobs) = second_submission.payload() else { - panic!("expected blob submission"); - }; - let first_blob_channel_ids: Vec<_> = - blobs[0].frames().iter().map(|frame| frame.id).collect(); - assert!(first_blob_channel_ids.contains(&first_channel_id)); - assert!(first_blob_channel_ids.contains(&second_channel_id)); - assert!( - encoder - .egress - .pending_artifacts(second_submission.id) - .expect("pending submission") - .iter() - .filter_map(|id| { - encoder.egress.artifacts().iter().find(|artifact| artifact.id() == *id) - }) - .any(|artifact| { - artifact.channel_ids().contains(&first_channel_id) - && artifact.channel_ids().contains(&second_channel_id) - }), - "one immutable artifact must track both channels" - ); - - encoder.requeue(second_submission.id); - assert!( - encoder - .egress - .artifacts() - .iter() - .any(|artifact| artifact.state() == crate::ArtifactState::Ready), - "requeue must restore the immutable cross-channel artifact" - ); - let retry = encoder.next_submission().expect("cross-channel retry"); - - encoder.confirm(first_submission.id, 1); - encoder.confirm(retry.id, 1); - assert!(encoder.egress.channel_fully_confirmed(&encoder.channels[0])); - assert!(!encoder.egress.channel_fully_confirmed(&encoder.channels[1])); - - encoder.flush().unwrap(); - let tail = encoder.next_submission().expect("flushed second-channel tail"); - encoder.confirm(tail.id, 1); - - assert!( - encoder.channels.iter().all(|channel| encoder.egress.channel_fully_confirmed(channel)) - ); - } - - #[test] - fn test_safe_prune_preserves_pending_range_from_packed_next_channel() { - let config = EncoderConfig { - compressed_size_target: Some(1), - max_blobs_per_tx: 6, - ..EncoderConfig::default() - }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - let mut first = make_block_with_large_user_tx(B256::ZERO, 1); - first.header.number = 0; - let safe_l2 = BlockInfo::from(&first); - let mut second = make_block_with_large_user_tx(first.header.hash_slow(), 2); - second.header.number = 1; - encoder.add_block(first).unwrap(); - encoder.add_block(second).unwrap(); - - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - let first_submission = encoder.next_submission().expect("first full blob"); - assert!(encoder.next_submission().is_none()); - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - let packed_submission = encoder.next_submission().expect("cross-channel submission"); - let packed_artifact_id = - encoder.egress.pending_artifacts(packed_submission.id).expect("pending submission")[0]; - let packed_artifact = encoder - .egress - .artifacts() - .iter() - .find(|artifact| artifact.id() == packed_artifact_id) - .expect("packed artifact"); - assert!( - packed_artifact.channel_ids().contains(&encoder.channels[0].id()) - && packed_artifact.channel_ids().contains(&encoder.channels[1].id()) - ); - - // The safe head proves that the shared L1 blob landed even if its - // submission receipt has not reached the batcher yet. - assert!(encoder.prune_safe(safe_l2)); - assert_eq!(encoder.channels.len(), 1); - assert!(encoder.egress.pending_artifacts(first_submission.id).is_none()); - let remaining_id = encoder.channels[0].id(); - let packed_artifact = encoder - .egress - .artifacts() - .iter() - .find(|artifact| artifact.id() == packed_artifact_id) - .expect("mixed artifact retained for remaining channel"); - assert_eq!(packed_artifact.channel_ids(), &[remaining_id]); - - encoder.confirm(packed_submission.id, 10); - assert!(!encoder.egress.channel_fully_confirmed(&encoder.channels[0])); - - encoder.flush().unwrap(); - let tail = encoder.next_submission().expect("flushed remaining channel tail"); - encoder.confirm(tail.id, 10); - - assert!(encoder.egress.channel_fully_confirmed(&encoder.channels[0])); - } - - #[test] - fn test_timeout_replay_includes_earlier_channel_from_packed_blob() { - let rollup_config = Arc::new(RollupConfig { - channel_timeout: 2, - granite_channel_timeout: 2, - ..RollupConfig::default() - }); - let config = EncoderConfig { - compressed_size_target: Some(1), - max_blobs_per_tx: 1, - max_channel_duration: 1000, - ..EncoderConfig::default() - }; - let mut encoder = BatchEncoder::new(rollup_config, config).expect("valid config"); - let mut first = make_block_with_large_user_tx(B256::ZERO, 1); - first.header.number = 0; - let mut second = make_block_with_large_user_tx(first.header.hash_slow(), 2); - second.header.number = 1; - encoder.add_block(first).unwrap(); - encoder.add_block(second).unwrap(); - - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - let _first_submission = encoder.next_submission().expect("first full blob"); - assert!(encoder.next_submission().is_none()); - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - let packed_submission = encoder.next_submission().expect("cross-channel blob"); - let packed_artifact = encoder - .egress - .pending_artifacts(packed_submission.id) - .expect("pending submission") - .iter() - .filter_map(|id| { - encoder.egress.artifacts().iter().find(|artifact| artifact.id() == *id) - }) - .find(|artifact| artifact.channel_ids().len() == 2) - .expect("cross-channel artifact"); - assert!( - packed_artifact.channel_ids().contains(&encoder.channels[0].id()) - && packed_artifact.channel_ids().contains(&encoder.channels[1].id()) - ); - let final_submission = encoder.next_submission().expect("second-channel tail"); - - encoder.confirm(final_submission.id, 1); - encoder.advance_l1_head(1); - encoder.advance_l1_head(4); - - assert!(encoder.channels.is_empty()); - assert_eq!(encoder.egress.pending_submission_count(), 0); - assert_eq!(encoder.block_cursor, 0); - assert_eq!(encoder.blocks.len(), 2); - } - - #[test] - fn test_timeout_replay_discards_entire_channel_suffix() { - let rollup_config = Arc::new(RollupConfig { - channel_timeout: 2, - granite_channel_timeout: 2, - ..RollupConfig::default() - }); - let config = EncoderConfig { - compressed_size_target: Some(1), - max_blobs_per_tx: 6, - max_channel_duration: 1000, - ..EncoderConfig::default() - }; - let mut encoder = BatchEncoder::new(rollup_config, config).expect("valid config"); - - let mut first = make_block_with_large_user_tx(B256::ZERO, 1); - first.header.number = 0; - let mut second = make_block_with_large_user_tx(first.header.hash_slow(), 2); - second.header.number = 1; - let mut third = make_block_with_large_user_tx(second.header.hash_slow(), 3); - third.header.number = 2; - - encoder.add_block(first).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - encoder.flush().unwrap(); - let first_channel_id = encoder.channels[0].id(); - for submission in drain_submissions(&mut encoder) { - encoder.confirm(submission.id, 1); - } - assert!(encoder.egress.channel_fully_confirmed(&encoder.channels[0])); - - encoder.add_block(second).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - let second_channel_id = encoder.channels[1].id(); - let second_submission = encoder.next_submission().expect("second channel blob"); - encoder.confirm(second_submission.id, 1); - assert!(!encoder.egress.channel_fully_confirmed(&encoder.channels[1])); - - encoder.add_block(third).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); - assert_eq!(encoder.channels.len(), 3); - - encoder.advance_l1_head(4); - - assert_eq!(encoder.channels.len(), 1); - assert_eq!(encoder.channels[0].id(), first_channel_id); - assert!( - encoder - .egress - .artifacts() - .iter() - .all(|artifact| { !artifact.channel_ids().contains(&second_channel_id) }) - ); - assert_eq!(encoder.block_cursor, 1); - } - - #[test] - fn test_max_blobs_per_tx_limits_transaction_not_channel() { - let config = EncoderConfig { max_blobs_per_tx: 1, ..EncoderConfig::default() }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - encoder.add_block(make_block_with_large_user_tx(B256::ZERO, 1)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - let submissions = drain_submissions(&mut encoder); - assert!(submissions.len() >= 2, "one channel should span multiple transactions"); - assert!(submissions.iter().all(|submission| submission.blob_count() == 1)); - assert_eq!(encoder.channels.len(), 1, "transaction cuts must not split channels"); - } - - /// A requeue makes every frame in the submission ready again. - #[test] - fn test_requeue_multi_frame_marks_submission_ready() { - let config = - EncoderConfig { max_frame_size: 32, max_blobs_per_tx: 3, ..EncoderConfig::default() }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - - encoder.add_block(make_block_with_user_tx(B256::ZERO)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - let sub = encoder.next_submission().expect("multi-frame submission"); - let id = sub.id; - let submitted_frame_count = sub.frame_count(); - assert!(submitted_frame_count > 1); - - encoder.requeue(id); - - let resub = encoder.next_submission().expect("requeued submission"); - assert_eq!( - resub.frame_count(), - submitted_frame_count, - "requeued submission must contain the same number of frames" - ); - } - - #[test] - fn test_requeue_does_not_resubmit_confirmed_frames() { - let mut encoder = encoder_with_confirmation_timeout(100); - encoder.add_block(make_block_with_user_tx(B256::ZERO)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - let first = encoder.next_submission().unwrap(); - let second = encoder.next_submission().unwrap(); - - encoder.requeue(first.id); - encoder.confirm(second.id, 1); - assert_eq!( - encoder.egress.artifacts().iter().map(crate::DaArtifact::state).collect::>(), - vec![crate::ArtifactState::Ready, crate::ArtifactState::Confirmed] - ); - - let retry = encoder.next_submission().unwrap(); - assert!(Arc::ptr_eq( - retry.first_frame().expect("retry frame"), - first.first_frame().expect("original frame") - )); - assert_eq!( - encoder.egress.artifacts().iter().map(crate::DaArtifact::state).collect::>(), - vec![crate::ArtifactState::Pending, crate::ArtifactState::Confirmed] - ); - } - - #[test] - fn test_late_confirmation_replays_timed_out_channel() { - let mut encoder = encoder_with_confirmation_timeout(2); - - encoder.add_block(make_block_with_user_tx(B256::ZERO)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - let original_channel_id = encoder.channels[0].id(); - let submissions = drain_submissions(&mut encoder); - assert!( - submissions.len() >= 2, - "test requires a multi-frame channel, got {} submission(s)", - submissions.len() - ); - - encoder.confirm(submissions[0].id, 1); - encoder.advance_l1_head(1); - assert_eq!(encoder.blocks.len(), 1, "partial confirmation must not prune blocks"); - - encoder.confirm(submissions[1].id, 4); - encoder.advance_l1_head(4); - assert_eq!(encoder.blocks.len(), 1, "timed-out confirmation must preserve blocks"); - assert_eq!(encoder.block_cursor, 0, "encoder must rewind to replay the block"); - assert!(encoder.channels.is_empty(), "old channel must be discarded"); - assert_eq!( - encoder.egress.pending_submission_count(), - 0, - "stale in-flight tail submissions must be forgotten" - ); - - for submission in submissions.iter().skip(2) { - encoder.confirm(submission.id, 3); - } - assert_eq!(encoder.blocks.len(), 1, "stale late confirmations must be no-ops"); - - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - let replay_submissions = drain_submissions(&mut encoder); - assert!(!replay_submissions.is_empty(), "replay must emit a fresh channel"); - assert_ne!( - replay_submissions[0].first_frame().expect("replay frame").id, - original_channel_id, - "replay must use a fresh channel id" - ); - - for submission in replay_submissions { - encoder.confirm(submission.id, 5); - } - assert_eq!( - encoder.blocks.len(), - 1, - "timely confirmation keeps the replayed block buffered" - ); - let safe_l2 = BlockInfo::from(&encoder.blocks[0]); - assert!(encoder.prune_safe(safe_l2)); - assert!(encoder.blocks.is_empty()); - } - - #[test] - fn test_timely_confirmed_channel_waits_for_safe_head() { - let mut encoder = encoder_with_confirmation_timeout(2); - encoder.add_block(make_block_with_user_tx(B256::ZERO)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - for submission in drain_submissions(&mut encoder) { - encoder.confirm(submission.id, 1); - encoder.advance_l1_head(1); - } - encoder.advance_l1_head(100); - - assert_eq!(encoder.channels.len(), 1); - assert!(encoder.egress.channel_fully_confirmed(&encoder.channels[0])); - assert_eq!(encoder.block_cursor, 1); - } - - #[test] - fn test_fully_confirmed_channel_requires_replay_after_derivation_passes_inclusion() { - let mut encoder = encoder_with_confirmation_timeout(2); - let mut block = make_block_with_user_tx(B256::ZERO); - block.header.number = 101; - let block_hash = block.header.hash_slow(); - encoder.add_block(block).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - for submission in drain_submissions(&mut encoder) { - encoder.confirm(submission.id, 1_000); - } - - let previous_safe_l2 = BlockInfo { number: 100, ..Default::default() }; - assert_eq!( - encoder.reconcile_derivation(previous_safe_l2, 1_000), - DerivationReconciliation::Consistent, - "the current L1 block may still be processing", - ); - assert_eq!( - encoder.reconcile_derivation(previous_safe_l2, 1_001), - DerivationReconciliation::StalledChannel, - "passing the last inclusion without making the channel safe requires replay", - ); - assert_eq!( - encoder.reconcile_derivation( - BlockInfo { hash: block_hash, number: 101, ..Default::default() }, - 1_001, - ), - DerivationReconciliation::Consistent, - "a safe head covering the channel does not require replay", - ); - } - - #[test] - fn test_descending_confirmations_replay_expired_channel() { - let mut encoder = encoder_with_confirmation_timeout(2); - encoder.add_block(make_block_with_user_tx(B256::ZERO)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - let submissions = drain_submissions(&mut encoder); - assert!(submissions.len() >= 2); - - encoder.confirm(submissions[0].id, 100); - encoder.advance_l1_head(100); - encoder.confirm(submissions[1].id, 90); - encoder.advance_l1_head(90); - - assert!(encoder.channels.is_empty()); - assert_eq!(encoder.egress.pending_submission_count(), 0); - assert_eq!(encoder.block_cursor, 0); - } - - #[test] - fn test_l1_head_expiry_replays_before_tail_confirms() { - let mut encoder = encoder_with_confirmation_timeout(2); - - encoder.add_block(make_block_with_user_tx(B256::ZERO)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); - - let submissions = drain_submissions(&mut encoder); - assert!( - submissions.len() >= 2, - "test requires a multi-frame channel, got {} submission(s)", - submissions.len() - ); - - encoder.confirm(submissions[0].id, 1); - encoder.advance_l1_head(4); - - assert_eq!(encoder.blocks.len(), 1, "expired channel must preserve blocks"); - assert_eq!(encoder.block_cursor, 0, "expired channel must rewind for replay"); - assert!(encoder.channels.is_empty(), "expired channel must be discarded"); - assert_eq!( - encoder.egress.pending_submission_count(), - 0, - "stale tail submissions must be forgotten" - ); - - for submission in submissions.iter().skip(1) { - encoder.confirm(submission.id, 4); - } - assert_eq!(encoder.blocks.len(), 1, "late tail confirmations must stay stale"); - } - - // --- step() fatal error tests --- - // - // These tests document the invariant that batch composition failure is fatal. - // A block that cannot be converted to a SingleBatch must not be silently - // skipped: skipping would produce a gap in the L2 block sequence submitted - // to L1, which the derivation spec prohibits. - - fn make_empty_block(parent_hash: B256) -> BaseBlock { - BaseBlock { - header: Header { parent_hash, ..Default::default() }, - body: BlockBody { transactions: vec![], ..Default::default() }, - } - } - - fn make_non_deposit_block(parent_hash: B256) -> BaseBlock { - let user_tx = { - let signed = TxLegacy::default().into_signed(Signature::test_signature()); - BaseTxEnvelope::Legacy(signed) - }; - BaseBlock { - header: Header { parent_hash, ..Default::default() }, - body: BlockBody { transactions: vec![user_tx], ..Default::default() }, - } - } - - fn make_bad_calldata_block(parent_hash: B256) -> BaseBlock { - let deposit = BaseTxEnvelope::Deposit(Sealed::new(TxDeposit { - input: Bytes::new(), - ..Default::default() - })); - BaseBlock { - header: Header { parent_hash, ..Default::default() }, - body: BlockBody { transactions: vec![deposit], ..Default::default() }, - } - } - - /// `step()` must return a fatal `CompositionFailed` error — not silently skip — - /// for any block that cannot be encoded into a `SingleBatch`. - #[rstest] - #[case::empty_block(make_empty_block(B256::ZERO), BatchComposeError::EmptyBlock)] - #[case::not_deposit(make_non_deposit_block(B256::ZERO), BatchComposeError::NotDepositTx)] - #[case::bad_calldata(make_bad_calldata_block(B256::ZERO), BatchComposeError::L1InfoDecode)] - fn test_step_fatal(#[case] block: BaseBlock, #[case] expected_source: BatchComposeError) { - let mut encoder = default_encoder(); - encoder.add_block(block).unwrap(); - let err = encoder.step().unwrap_err(); - assert!( - matches!(err, StepError::CompositionFailed { cursor: 0, source } if source == expected_source) - ); - } - - /// On composition failure the block cursor must not advance: the block stays - /// at its position so the caller can observe the error and halt. - #[test] - fn test_step_fatal_leaves_cursor_unchanged() { - let mut encoder = default_encoder(); - - // Add a valid block first so block_cursor starts at 0 with 1 block queued. - encoder.add_block(make_empty_block(B256::ZERO)).unwrap(); - assert_eq!(encoder.block_cursor, 0); - - let _ = encoder.step(); // returns Err - - // Cursor must still be 0 — the block was not consumed. - assert_eq!(encoder.block_cursor, 0); - assert_eq!(encoder.blocks.len(), 1); - } - - // --- prune_safe tests --- - - fn make_numbered_block(parent_hash: B256, number: u64) -> BaseBlock { - let calldata = L1BlockInfoTx::Bedrock(L1BlockInfoBedrock::default()).encode_calldata(); - let deposit = BaseTxEnvelope::Deposit(Sealed::new(TxDeposit { - input: calldata, - ..Default::default() - })); - BaseBlock { - header: Header { parent_hash, number, ..Default::default() }, - body: BlockBody { transactions: vec![deposit], ..Default::default() }, - } - } - - /// `prune_safe` drains the buffered prefix through the matching safe head. - #[test] - fn test_prune_safe_drains_encoded_blocks() { - let mut encoder = default_encoder(); - - let b1 = make_numbered_block(B256::ZERO, 1); - let b1_hash = b1.header.hash_slow(); - encoder.add_block(b1).unwrap(); - - let b2 = make_numbered_block(b1_hash, 2); - let b2_hash = b2.header.hash_slow(); - encoder.add_block(b2).unwrap(); - - let b3 = make_numbered_block(b2_hash, 3); - encoder.add_block(b3).unwrap(); - - // Encode all three blocks. - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - assert_eq!(encoder.block_cursor, 3); - - assert!(encoder.prune_safe(BlockInfo { hash: b2_hash, number: 2, ..Default::default() })); - - assert_eq!(encoder.blocks.len(), 1, "only block 3 should remain"); - assert_eq!(encoder.blocks[0].header.number, 3); - assert_eq!(encoder.block_cursor, 1, "cursor must be adjusted by prune count"); - } - - #[test] - fn test_prune_safe_rebases_open_channel() { - let mut encoder = default_encoder(); - for block in make_user_tx_chain(2) { - encoder.add_block(block).unwrap(); - } - - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - - let safe_l2 = BlockInfo::from(&encoder.blocks[0]); - assert!(encoder.prune_safe(safe_l2)); - - let remaining_safe_l2 = BlockInfo::from(&encoder.blocks[0]); - let open = encoder.channels.back().unwrap(); - assert_eq!(open.block_range(), 0..1); - assert_eq!(open.blocks_added(), 1); - assert_eq!(encoder.blocks.len(), 1); - - encoder.flush().unwrap(); - for submission in drain_submissions(&mut encoder) { - encoder.confirm(submission.id, 1); - } - assert_eq!(encoder.blocks.len(), 1, "confirmation keeps the remaining block buffered"); - assert!(encoder.prune_safe(remaining_safe_l2)); - assert!(encoder.blocks.is_empty()); - } - - /// Safe-head pruning includes unencoded blocks and clamps the cursor to zero. - #[test] - fn test_prune_safe_prunes_unencoded_blocks() { - let mut encoder = default_encoder(); - - let b1 = make_numbered_block(B256::ZERO, 1); - let b1_hash = b1.header.hash_slow(); - encoder.add_block(b1).unwrap(); - - let b2 = make_numbered_block(b1_hash, 2); - let b2_hash = b2.header.hash_slow(); - encoder.add_block(b2).unwrap(); - - // Encode only block 1 (cursor = 1). - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - assert_eq!(encoder.block_cursor, 1); - - assert!(encoder.prune_safe(BlockInfo { hash: b2_hash, number: 2, ..Default::default() })); - - assert!(encoder.blocks.is_empty()); - assert_eq!(encoder.block_cursor, 0); - assert!(encoder.channels.is_empty()); - } - - #[test] - fn test_prune_safe_rejects_inconsistent_chain() { - let mut encoder = default_encoder(); - assert!(encoder.prune_safe(BlockInfo { number: 1, ..Default::default() })); - - encoder.add_block(make_numbered_block(B256::ZERO, 3)).unwrap(); - encoder.step().unwrap(); - - assert!(!encoder.prune_safe(BlockInfo { - hash: B256::repeat_byte(1), - number: 2, - ..Default::default() - })); - assert!(encoder.prune_safe(BlockInfo { number: 2, ..Default::default() })); - assert!(!encoder.prune_safe(BlockInfo { - hash: B256::repeat_byte(1), - number: 3, - ..Default::default() - })); - assert!(!encoder.prune_safe(BlockInfo { number: 4, ..Default::default() })); - assert!(!encoder.prune_safe(BlockInfo { number: 1, ..Default::default() })); - assert_eq!(encoder.blocks.len(), 1); - } - - /// `encode_and_drain` steps until idle, flushes, and returns all submissions. - #[test] - fn test_encode_and_drain_returns_submissions() { - let mut encoder = default_encoder(); - encoder.add_block(make_block_with_user_tx(B256::ZERO)).expect("add block"); - let submissions = encoder.encode_and_drain().expect("encode_and_drain"); - assert!(!submissions.is_empty(), "encode_and_drain must return a submission"); - assert!(submissions.iter().all(|submission| submission.frame_count() > 0)); - } - - #[test] - fn test_encode_and_drain_releases_already_closed_tails() { - let config = EncoderConfig { compressed_size_target: Some(1), ..EncoderConfig::default() }; - let mut encoder = - BatchEncoder::new(Arc::new(RollupConfig::default()), config).expect("valid config"); - let first = make_block_with_user_tx(B256::ZERO); - let second = make_block_with_user_tx(first.header.hash_slow()); - encoder.add_block(first).expect("add first block"); - encoder.add_block(second).expect("add second block"); - - let submissions = encoder.encode_and_drain().expect("encode_and_drain"); - - assert!(!submissions.is_empty(), "drain must release size-closed channel tails"); - assert!(encoder.next_submission().is_none(), "drain must consume every released tail"); - } - - /// `encode_and_drain` with no blocks added returns empty (Idle immediately). - #[test] - fn test_encode_and_drain_no_blocks_returns_empty() { - let mut encoder = default_encoder(); - let submissions = encoder.encode_and_drain().expect("encode_and_drain"); - assert!(submissions.is_empty(), "no blocks must produce no submissions"); - } - - /// Encoding with a small `max_frame_size` fragments a multi-block channel - /// into multiple frames, proving the encoder respects the frame-size limit. - #[test] - fn frame_fragmentation_with_small_frame_size() { - let rollup_config = Arc::new(RollupConfig::default()); - let config = EncoderConfig { max_frame_size: 80, ..EncoderConfig::default() }; - let mut encoder = BatchEncoder::new(rollup_config, config).expect("valid config"); - - // Add 5 L2 blocks with a user tx in each to produce non-trivial payload. - let mut parent = B256::ZERO; - for _ in 0..5 { - let block = make_block_with_user_tx(parent); - parent = block.header.hash_slow(); - encoder.add_block(block).expect("add block"); - } - - let submissions = encoder.encode_and_drain().expect("encode_and_drain"); - let frame_count = submissions.iter().map(BatchSubmission::frame_count).sum::(); - assert!( - frame_count >= 3, - "expected at least 3 frames with max_frame_size=80, got {frame_count}" - ); - } - - /// Calldata submissions carry exactly one derivation frame. - #[test] - fn calldata_submits_one_frame_per_transaction() { - let rollup_config = Arc::new(RollupConfig::default()); - let config = EncoderConfig { - da_type: DaType::Calldata, - max_frame_size: 100, - ..EncoderConfig::default() - }; - let mut encoder = BatchEncoder::new(rollup_config, config).expect("valid config"); - - let block = make_block_with_user_tx(B256::ZERO); - encoder.add_block(block).expect("add block"); - - // Run until idle, flush, and drain submissions. - loop { - if encoder.step().expect("step") == StepResult::Idle { - break; - } - } - encoder.flush().unwrap(); - - // The calldata transaction format carries one derivation frame. - let mut count = 0; - while let Some(sub) = encoder.next_submission() { - assert_eq!(sub.frame_count(), 1, "calldata submission must have exactly 1 frame"); - count += 1; - } - assert!(count >= 1, "expected at least one submission"); - } - - /// A retry preserves the exact artifact produced while blob override was active. - #[test] - fn blob_override_retry_preserves_immutable_blob_artifact() { - let rollup_config = Arc::new(RollupConfig::default()); - let config = EncoderConfig { - da_type: DaType::Calldata, - max_frame_size: 200, - ..EncoderConfig::default() - }; - let mut encoder = BatchEncoder::new(rollup_config, config).expect("valid config"); - - encoder.add_block(make_block_with_user_tx(B256::ZERO)).expect("add block"); - loop { - if encoder.step().expect("step") == StepResult::Idle { - break; - } - } - encoder.flush().unwrap(); - - encoder.set_blob_override(true); - let sub = encoder.next_submission().expect("submission while override active"); - assert_eq!(sub.da_type(), DaType::Blob, "override must flip da_type to Blob"); - encoder.requeue(sub.id); - - encoder.set_blob_override(false); - let sub = encoder.next_submission().expect("submission after override cleared"); - assert_eq!(sub.da_type(), DaType::Blob, "retry must preserve the original DA artifact"); - } - - /// `set_blob_override(true)` is a no-op for blob-configured encoders — - /// submissions are blob-typed regardless of the override. - #[test] - fn blob_override_is_noop_for_blob_configured_encoder() { - let rollup_config = Arc::new(RollupConfig::default()); - let config = EncoderConfig { da_type: DaType::Blob, ..EncoderConfig::default() }; - let mut encoder = BatchEncoder::new(rollup_config, config).expect("valid config"); - - encoder.add_block(make_block_with_user_tx(B256::ZERO)).expect("add block"); - while encoder.step().expect("step") != StepResult::Idle {} - encoder.flush().unwrap(); - encoder.set_blob_override(true); - let submission = encoder.next_submission().expect("blob submission"); - assert_eq!(submission.da_type(), DaType::Blob); - } -} diff --git a/crates/batcher/encoder/src/submission.rs b/crates/batcher/encoder/src/submission.rs index af6c77144b..fb4dc12023 100644 --- a/crates/batcher/encoder/src/submission.rs +++ b/crates/batcher/encoder/src/submission.rs @@ -118,15 +118,6 @@ impl BatchSubmission { SubmissionPayload::Calldata(frame) => frame.data.len(), } } - - /// Returns the first frame in this submission, if it contains one. - #[cfg(test)] - pub fn first_frame(&self) -> Option<&Arc> { - match &self.payload { - SubmissionPayload::Blobs(blobs) => blobs.first()?.frames.first(), - SubmissionPayload::Calldata(frame) => Some(frame), - } - } } /// Encodes batch frames for L1 submission. diff --git a/crates/batcher/encoder/tests/encoder/backlog.rs b/crates/batcher/encoder/tests/encoder/backlog.rs new file mode 100644 index 0000000000..f5a60c9881 --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/backlog.rs @@ -0,0 +1,30 @@ +//! The DA backlog reported by `BatchEncoder`. + +use alloy_eips::eip2718::Encodable2718; +use alloy_primitives::B256; +use base_batcher_encoder::{BatchPipeline, EncoderConfig, StepResult}; + +use crate::common::{BlockFixture, EncoderFixture}; + +/// The backlog is the bytes of the user transactions, deposits excluded, from the moment a +/// block is queued until its channel is fully confirmed. +#[test] +fn backlog_counts_user_transaction_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; + encoder.add_block(block).unwrap(); + assert_eq!(encoder.da_backlog_bytes(), user_tx_bytes); + + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + assert_eq!(encoder.da_backlog_bytes(), user_tx_bytes, "encoding changes nothing"); + + let submissions = encoder.encode_and_drain().unwrap(); + assert_eq!(encoder.da_backlog_bytes(), user_tx_bytes, "in flight changes nothing"); + + for submission in submissions { + encoder.confirm(submission.id, 1); + } + assert_eq!(encoder.da_backlog_bytes(), 0); +} diff --git a/crates/batcher/encoder/tests/encoder/channels.rs b/crates/batcher/encoder/tests/encoder/channels.rs new file mode 100644 index 0000000000..292e834078 --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/channels.rs @@ -0,0 +1,119 @@ +//! When a channel of `BatchEncoder` closes, when its partial output is released, and what a +//! flush does. + +use alloy_primitives::B256; +use base_batcher_encoder::{BatchPipeline, DaEgress, EncoderConfig, StepResult, SubmissionPayload}; +use rstest::rstest; + +use crate::common::{BlockFixture, EncoderFixture, OpenChannel}; + +/// A channel opened at L1 head 0 closes when the head reaches +/// `max_channel_duration - sub_safety_margin`, and the close releases its output. +#[rstest] +#[case::with_margin(10, 4, 6)] +#[case::without_margin(5, 0, 5)] +fn channel_closes_at_its_effective_duration( + #[case] max_channel_duration: u64, + #[case] sub_safety_margin: u64, + #[case] closes_at: u64, +) { + let config = + EncoderConfig { max_channel_duration, sub_safety_margin, ..EncoderConfig::default() }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, 0)).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + + encoder.advance_l1_head(closes_at - 1); + assert!(encoder.next_submission().is_none(), "the channel is still open"); + + encoder.advance_l1_head(closes_at); + assert!(encoder.next_submission().is_some(), "the close releases the channel"); +} + +/// An L1 head below the current one is ignored, so a channel opened afterwards still measures +/// its duration from the highest head seen. +#[test] +fn l1_head_never_moves_backwards() { + let fixture = + EncoderFixture::new(EncoderConfig { max_channel_duration: 2, ..EncoderConfig::default() }); + let mut encoder = fixture.encoder(); + encoder.advance_l1_head(10); + encoder.advance_l1_head(3); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, 0)).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + + encoder.advance_l1_head(11); + assert!(encoder.next_submission().is_none(), "the channel opened at head 10 is due at 12"); + encoder.advance_l1_head(12); + assert!(encoder.next_submission().is_some()); +} + +/// The partial output of a channel closed on its size target is held back, since more data may +/// fill the blob, until a flush releases it. +#[test] +fn flush_releases_the_tail_of_a_size_closed_channel() { + let config = EncoderConfig { compressed_size_target: Some(1), ..EncoderConfig::default() }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, 0)).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); + assert!(encoder.next_submission().is_none()); + + encoder.flush().unwrap(); + + assert!(encoder.next_submission().is_some()); +} + +/// A held tail is released at the deadline of its own channel, not at that of a later one. +#[test] +fn size_closed_tail_keeps_its_original_deadline() { + let config = EncoderConfig { + compressed_size_target: Some(1), + max_channel_duration: 5, + ..EncoderConfig::default() + }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(2, 0); + for block in &blocks { + encoder.add_block(block.clone()).unwrap(); + } + + // The first channel opens at head 10 and is due at 15, the second at 12 and 17. + encoder.advance_l1_head(10); + assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); + encoder.advance_l1_head(12); + assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); + + encoder.advance_l1_head(14); + assert!(encoder.next_submission().is_none(), "no tail is due yet"); + + encoder.advance_l1_head(15); + assert!(encoder.next_submission().is_some(), "the first tail is due"); +} + +/// An open channel emits every blob it fills without closing. It takes more blocks after the +/// blob went out, and the frames emitted early and the rest decode as one channel. +#[test] +fn open_channel_emits_full_blobs_without_closing() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let mut open = OpenChannel::encode(&mut encoder); + let SubmissionPayload::Blobs(blobs) = open.first.payload() else { + panic!("expected a blob submission"); + }; + for blob in blobs { + assert!(blob.frames().iter().all(|frame| !frame.is_last), "the channel is still open"); + assert_eq!(blob.frame_bytes(), DaEgress::BLOB_CAPACITY, "the blob is full"); + } + + open.add_next_block(&mut encoder); + let batches = open.batches(); + + let mut submissions = vec![open.first]; + submissions.extend(encoder.encode_and_drain().unwrap()); + let derived = fixture.derive(&submissions); + assert_eq!(derived.len(), 1, "the early blob and the rest are one channel"); + assert_eq!(derived.concat(), batches); +} diff --git a/crates/batcher/encoder/tests/encoder/common.rs b/crates/batcher/encoder/tests/encoder/common.rs index 1153e79fe8..afbc67040b 100644 --- a/crates/batcher/encoder/tests/encoder/common.rs +++ b/crates/batcher/encoder/tests/encoder/common.rs @@ -1,13 +1,15 @@ -//! Fixtures shared by the encoder tests. They provide L2 block chains, the pair of configs a -//! test builds its encoder from, and the derivation-side reader that turns the encoder's -//! submissions back into batches. +//! Fixtures shared by the encoder tests. They provide the payload sizes the tests rely on, L2 +//! block chains, the pair of configs a test builds its encoder from, the derivation-side reader +//! that turns the encoder's submissions back into batches, and the encoder states several tests +//! start from. use std::{collections::HashSet, sync::Arc}; use alloy_consensus::{BlockBody, Header, SignableTransaction, TxLegacy}; use alloy_primitives::{B256, Bytes, Sealed, Signature}; use base_batcher_encoder::{ - BatchComposer, BatchEncoder, BatchSubmission, EncoderConfig, FrameEncoder, SubmissionPayload, + BatchComposer, BatchEncoder, BatchPipeline, BatchSubmission, BlobPayload, DaType, + EncoderConfig, FrameEncoder, StepResult, SubmissionPayload, }; use base_blobs::BlobEncoder; use base_common_consensus::{BaseBlock, BaseTxEnvelope, TxDeposit}; @@ -25,6 +27,126 @@ use rand::{RngCore, SeedableRng, rngs::SmallRng}; /// on L1 for derivation. pub const GENESIS_TIMESTAMP: u64 = 0; +/// The derivation channel timeout, in L1 blocks, of the tests that exercise it. +pub const CHANNEL_TIMEOUT: u64 = 2; + +/// A frame size, in bytes, small enough that a block of [`MULTI_FRAME_PAYLOAD`] spans several +/// frames. +pub const SMALL_FRAME_SIZE: usize = 32; + +/// A block payload, in bytes, that spans several frames of [`SMALL_FRAME_SIZE`]. +pub const MULTI_FRAME_PAYLOAD: usize = 200; + +/// A block payload, in bytes, larger than one blob. +pub const MULTI_BLOB_PAYLOAD: usize = 200_000; + +/// A block payload, in bytes, that fills two blobs and part of a third. +pub const THREE_BLOB_PAYLOAD: usize = 300_000; + +/// Reads what an encoder hands out. +#[derive(Debug)] +pub struct SubmissionFixture; + +impl SubmissionFixture { + /// Every submission `encoder` has ready, in order. + pub fn drain(encoder: &mut BatchEncoder) -> Vec { + std::iter::from_fn(|| encoder.next_submission()).collect() + } + + /// The frames `submission` carries, in order. + pub fn frames(submission: &BatchSubmission) -> Vec> { + match submission.payload() { + SubmissionPayload::Blobs(blobs) => { + blobs.iter().flat_map(BlobPayload::frames).cloned().collect() + } + SubmissionPayload::Calldata(frame) => vec![Arc::clone(frame)], + } + } +} + +/// Two channels sharing a blob, on an encoder that closes each channel on its first block. +/// The first channel fills a blob on its own, then its tail and the second channel share the +/// next one. +#[derive(Debug)] +pub struct SharedBlob { + /// The two blocks, one per channel. + pub blocks: Vec, + /// The blob the first channel fills. + pub first: BatchSubmission, + /// The transaction whose first blob carries the first channel's tail and the start of the + /// second channel. + pub packed: BatchSubmission, +} + +impl SharedBlob { + /// Feed `encoder` the two blocks and take the two submissions, checking that the first tail + /// is held back until the second channel fills the blob, and that the blob carries frames of + /// both channels. + pub fn encode(encoder: &mut BatchEncoder) -> Self { + let blocks = BlockFixture::chain(2, MULTI_BLOB_PAYLOAD); + for block in &blocks { + encoder.add_block(block.clone()).unwrap(); + } + assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); + let first = encoder.next_submission().expect("the first channel fills a blob"); + assert!(encoder.next_submission().is_none(), "its tail waits for more data"); + assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); + let packed = encoder.next_submission().expect("the tail and the next channel fill a blob"); + let SubmissionPayload::Blobs(blobs) = packed.payload() else { + panic!("expected a blob submission"); + }; + let channels: HashSet<_> = blobs[0].frames().iter().map(|frame| frame.id).collect(); + assert_eq!(channels.len(), 2, "the blob carries frames of both channels"); + Self { blocks, first, packed } + } +} + +/// An open channel that has handed out its first submission, fed blocks of +/// [`MULTI_BLOB_PAYLOAD`] one at a time until the encoder released output. +#[derive(Debug)] +pub struct OpenChannel { + /// The chain the blocks come from. + pub blocks: Vec, + /// How many of them the encoder took. + pub added: usize, + /// The first submission the channel released. + pub first: BatchSubmission, +} + +impl OpenChannel { + /// More blocks than any configuration needs before the channel releases output. + const MAX_BLOCKS: u64 = 32; + + /// Feed `encoder` blocks until it releases a submission. Brotli may hold back output + /// until it has seen enough input, so the number of blocks it takes is not fixed. + pub fn encode(encoder: &mut BatchEncoder) -> Self { + let blocks = BlockFixture::chain(Self::MAX_BLOCKS, MULTI_BLOB_PAYLOAD); + let mut added = 0; + let first = loop { + let block = blocks.get(added).expect("the channel releases output within MAX_BLOCKS"); + encoder.add_block(block.clone()).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + added += 1; + if let Some(first) = encoder.next_submission() { + break first; + } + }; + Self { blocks, added, first } + } + + /// Feed `encoder` the next block, which the open channel takes. + pub fn add_next_block(&mut self, encoder: &mut BatchEncoder) { + encoder.add_block(self.blocks[self.added].clone()).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + self.added += 1; + } + + /// The batches of every block the encoder took. + pub fn batches(&self) -> Vec { + BlockFixture::batches(&self.blocks[..self.added]) + } +} + /// L2 block fixtures the encoder accepts. #[derive(Debug)] pub struct BlockFixture; @@ -56,7 +178,9 @@ impl BlockFixture { .collect() } - fn block(parent_hash: B256, number: u64, payload_len: usize) -> BaseBlock { + /// A block numbered `number` on `parent_hash`, built like the blocks of + /// [`chain`](Self::chain). + pub fn block(parent_hash: B256, number: u64, payload_len: usize) -> BaseBlock { let calldata = L1BlockInfoTx::Bedrock(L1BlockInfoBedrock::default()).encode_calldata(); let deposit = TxDeposit { input: calldata, ..Default::default() }; let user_tx = TxLegacy { input: Self::noise(number, payload_len), ..Default::default() } @@ -101,12 +225,50 @@ impl EncoderFixture { /// because `derive` runs its channel stage. Holocene implies Granite and Fjord. Panics if /// production would reject the pair. pub fn new(config: EncoderConfig) -> Self { + Self::with_rollup_config(config, RollupConfig::default()) + } + + /// Like [`new`](Self::new), with both channel timeouts set to [`CHANNEL_TIMEOUT`]. + fn with_channel_timeout(config: EncoderConfig) -> Self { + let rollup_config = RollupConfig { + channel_timeout: CHANNEL_TIMEOUT, + granite_channel_timeout: CHANNEL_TIMEOUT, + ..RollupConfig::default() + }; + Self::with_rollup_config(config, rollup_config) + } + + /// Calldata frames of [`SMALL_FRAME_SIZE`], so a block of [`MULTI_FRAME_PAYLOAD`] spans + /// several transactions, and a channel duration of one L1 block under [`CHANNEL_TIMEOUT`]. + pub fn small_calldata_frames() -> Self { + let config = EncoderConfig { + da_type: DaType::Calldata, + max_frame_size: SMALL_FRAME_SIZE, + max_channel_duration: 1, + ..EncoderConfig::default() + }; + Self::with_channel_timeout(config) + } + + /// Blobs with one channel per block, `max_blobs_per_tx` blobs per transaction, and a + /// channel duration of one L1 block under [`CHANNEL_TIMEOUT`]. + pub fn one_channel_per_block(max_blobs_per_tx: usize) -> Self { + let config = EncoderConfig { + compressed_size_target: Some(1), + max_blobs_per_tx, + max_channel_duration: 1, + ..EncoderConfig::default() + }; + Self::with_channel_timeout(config) + } + + fn with_rollup_config(config: EncoderConfig, rollup_config: RollupConfig) -> Self { let rollup_config = RollupConfig { upgrades: UpgradeConfig { holocene_time: Some(GENESIS_TIMESTAMP), ..UpgradeConfig::default() }, - ..RollupConfig::default() + ..rollup_config }; config .validate_for_rollup_config(&rollup_config, GENESIS_TIMESTAMP) @@ -134,7 +296,7 @@ impl EncoderFixture { /// sequence window) is out of scope. pub fn derive(&self, submissions: &[BatchSubmission]) -> Vec> { let frames: Vec = - submissions.iter().flat_map(|submission| self.frames(submission)).collect(); + submissions.iter().flat_map(|submission| self.wire_frames(submission)).collect(); let mut channel_ids = HashSet::new(); let mut first_frames = HashSet::new(); for frame in &frames { @@ -172,7 +334,7 @@ impl EncoderFixture { /// The frames derivation parses from the L1 transaction built from `submission`, in /// order. - fn frames(&self, submission: &BatchSubmission) -> Vec { + fn wire_frames(&self, submission: &BatchSubmission) -> Vec { match submission.payload() { SubmissionPayload::Calldata(frame) => { let calldata = FrameEncoder::to_calldata(frame); diff --git a/crates/batcher/encoder/tests/encoder/ingest.rs b/crates/batcher/encoder/tests/encoder/ingest.rs new file mode 100644 index 0000000000..542fac3edf --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/ingest.rs @@ -0,0 +1,121 @@ +//! Construction and block intake of [`BatchEncoder`], covering the config check, the parent +//! check, one transition per step, the retry of a block the open channel cannot take, and the +//! fatal errors that stop the batcher rather than skip a block. + +use std::sync::Arc; + +use alloy_primitives::B256; +use base_batcher_encoder::{ + BatchComposeError, BatchEncoder, BatchPipeline, Channel, ChannelLimit, EncoderConfig, + EncoderConfigError, ReorgError, StepError, StepResult, +}; +use base_common_genesis::RollupConfig; +use base_protocol::Frame; + +use crate::common::{BlockFixture, EncoderFixture}; + +/// Frames of one data byte, so a channel carries at most `Channel::MAX_FRAMES` bytes. +const ONE_BYTE_FRAMES: usize = Frame::ENCODED_OVERHEAD + 1; + +/// The encoder validates its config on construction. +#[test] +fn new_rejects_an_invalid_config() { + let config = EncoderConfig { max_blobs_per_tx: 0, ..EncoderConfig::default() }; + + assert!(matches!( + BatchEncoder::new(Arc::new(RollupConfig::default()), config), + Err(EncoderConfigError::MaxBlobsPerTxZero) + )); +} + +/// A block whose parent is not the last accepted block is handed back with the parent hash the +/// encoder expected, and the chain still extends from the last accepted block. +#[test] +fn add_block_rejects_a_block_off_the_buffered_chain() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(2, 0); + encoder.add_block(blocks[0].clone()).unwrap(); + + let stray = BlockFixture::block(B256::repeat_byte(0xab), 2, 0); + let (error, returned) = encoder.add_block(stray.clone()).unwrap_err(); + + let ReorgError::ParentMismatch { expected, got } = error; + assert_eq!(expected, blocks[0].header.hash_slow()); + assert_eq!(got, stray.header.parent_hash); + assert_eq!(*returned, stray); + encoder.add_block(blocks[1].clone()).unwrap(); +} + +/// A block that does not compose into a batch is fatal and stays queued, so the next step +/// fails the same way instead of skipping it and leaving a gap in the L2 chain on L1. +#[test] +fn step_fails_on_a_block_that_does_not_compose() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let mut block = BlockFixture::block(B256::ZERO, 1, 0); + block.body.transactions.clear(); + encoder.add_block(block).unwrap(); + + for _ in 0..2 { + let error = encoder.step().unwrap_err(); + assert!( + matches!( + &error, + StepError::CompositionFailed { cursor: 0, source: BatchComposeError::EmptyBlock } + ), + "{error}" + ); + } +} + +/// A batch the open channel cannot take closes that channel on its protocol limit, and the +/// block is retried in a new one. Derivation reads both blocks, in order. +#[test] +fn step_retries_a_rejected_block_in_a_new_channel() { + let config = EncoderConfig { max_frame_size: ONE_BYTE_FRAMES, ..EncoderConfig::default() }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + // One block fits a channel, two do not. + let blocks = BlockFixture::chain(2, Channel::MAX_FRAMES * 2 / 3); + for block in &blocks { + encoder.add_block(block.clone()).unwrap(); + } + + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + assert_eq!(encoder.step().unwrap(), StepResult::Idle); + + let submissions = encoder.encode_and_drain().unwrap(); + let derived = fixture.derive(&submissions); + assert_eq!(derived.len(), 2); + assert_eq!(derived.concat(), BlockFixture::batches(&blocks)); +} + +/// A block that does not fit an empty channel is fatal, since no channel could carry it. It is +/// not skipped, and nothing is emitted for it. +#[test] +fn step_fails_on_a_block_no_channel_can_carry() { + let config = EncoderConfig { max_frame_size: ONE_BYTE_FRAMES, ..EncoderConfig::default() }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + let block = BlockFixture::block(B256::ZERO, 1, Channel::MAX_FRAMES * 3 / 2); + encoder.add_block(block).unwrap(); + + for _ in 0..2 { + let error = encoder.step().unwrap_err(); + assert!( + matches!( + error, + StepError::BlockExceedsChannelLimit { + cursor: 0, + limit: ChannelLimit::FrameCount { maximum: Channel::MAX_FRAMES, .. } + } + ), + "{error}" + ); + } + encoder.flush().unwrap(); + assert!(encoder.next_submission().is_none()); +} diff --git a/crates/batcher/encoder/tests/encoder/lease.rs b/crates/batcher/encoder/tests/encoder/lease.rs new file mode 100644 index 0000000000..e7362487a0 --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/lease.rs @@ -0,0 +1,110 @@ +//! The submission lease in `BatchEncoder`, its ids, its requeue, and what a reset forgets. + +use alloy_primitives::B256; +use base_batcher_encoder::{BatchPipeline, DaType, EncoderConfig, StepResult}; + +use crate::common::{ + BlockFixture, EncoderFixture, MULTI_FRAME_PAYLOAD, SMALL_FRAME_SIZE, SubmissionFixture, +}; + +/// Two submissions requeued newest first come back oldest first, each with exactly its own +/// frames, the older one under a new id. +#[test] +fn requeued_submissions_resend_their_frames_in_production_order() { + let config = EncoderConfig { + max_frame_size: SMALL_FRAME_SIZE, + max_blobs_per_tx: 1, + ..EncoderConfig::default() + }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(2, MULTI_FRAME_PAYLOAD); + encoder.add_block(blocks[0].clone()).unwrap(); + let [older] = <[_; 1]>::try_from(encoder.encode_and_drain().unwrap()).unwrap(); + assert!(older.frame_count() > 1, "{} frames", older.frame_count()); + encoder.add_block(blocks[1].clone()).unwrap(); + let [newer] = <[_; 1]>::try_from(encoder.encode_and_drain().unwrap()).unwrap(); + + encoder.requeue(newer.id); + encoder.requeue(older.id); + + let retry = encoder.next_submission().expect("retry"); + assert_ne!(retry.id, older.id); + assert_eq!(SubmissionFixture::frames(&retry), SubmissionFixture::frames(&older)); + assert_eq!( + SubmissionFixture::frames(&encoder.next_submission().expect("newer output")), + SubmissionFixture::frames(&newer) + ); + assert!(encoder.next_submission().is_none()); +} + +/// Requeuing one submission resends exactly its frames, and none from the other submissions of +/// its channel still in flight. +#[test] +fn requeue_resends_only_that_submission() { + let config = EncoderConfig { + da_type: DaType::Calldata, + max_frame_size: SMALL_FRAME_SIZE, + ..EncoderConfig::default() + }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, MULTI_FRAME_PAYLOAD)).unwrap(); + let submissions = encoder.encode_and_drain().unwrap(); + assert!(submissions.len() > 1, "{} submissions", submissions.len()); + + encoder.requeue(submissions[0].id); + + let retry = encoder.next_submission().expect("retry"); + assert_eq!(SubmissionFixture::frames(&retry), SubmissionFixture::frames(&submissions[0])); + assert!(encoder.next_submission().is_none(), "no other submission is resent"); +} + +/// A reset does not reuse ids, so a confirmation or requeue for a submission issued before the +/// reset leaves the submissions issued after it alone. +#[test] +fn stale_ids_do_not_touch_submissions_issued_after_a_reset() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, 0)).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + encoder.flush().unwrap(); + let stale = encoder.next_submission().expect("submission before the reset"); + + encoder.reset(); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, 0)).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + encoder.flush().unwrap(); + let fresh = encoder.next_submission().expect("submission after the reset"); + assert_ne!(fresh.id, stale.id); + + let backlog = encoder.da_backlog_bytes(); + encoder.confirm(stale.id, 42); + assert_eq!(encoder.da_backlog_bytes(), backlog, "the fresh submission is not confirmed"); + encoder.requeue(stale.id); + assert!(encoder.next_submission().is_none(), "nothing is resent"); + + encoder.confirm(fresh.id, 43); + assert_eq!(encoder.da_backlog_bytes(), 0); +} + +/// A reset drops every buffered block and channel, and the next block is accepted whatever +/// its parent. +#[test] +fn reset_drops_buffered_state_and_accepts_any_parent() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(2, 0); + for block in &blocks { + encoder.add_block(block.clone()).unwrap(); + } + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + + encoder.reset(); + + assert_eq!(encoder.step().unwrap(), StepResult::Idle); + encoder.flush().unwrap(); + assert!(encoder.next_submission().is_none()); + assert_eq!(encoder.da_backlog_bytes(), 0); + encoder.add_block(BlockFixture::block(B256::repeat_byte(0xab), 7, 0)).unwrap(); +} diff --git a/crates/batcher/encoder/tests/encoder/main.rs b/crates/batcher/encoder/tests/encoder/main.rs index e00b9285fd..ac5820d658 100644 --- a/crates/batcher/encoder/tests/encoder/main.rs +++ b/crates/batcher/encoder/tests/encoder/main.rs @@ -1,4 +1,12 @@ //! Integration tests of the batcher encoder through its public API, one module per subject. pub mod common; + +mod backlog; +mod channels; +mod ingest; +mod lease; +mod packing; +mod reconcile; +mod replay; mod roundtrip; diff --git a/crates/batcher/encoder/tests/encoder/packing.rs b/crates/batcher/encoder/tests/encoder/packing.rs new file mode 100644 index 0000000000..f3c55e81c9 --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/packing.rs @@ -0,0 +1,27 @@ +//! The blob override of `BatchEncoder`. + +use alloy_primitives::B256; +use base_batcher_encoder::{BatchPipeline, DaType, EncoderConfig, StepResult}; + +use crate::common::{BlockFixture, EncoderFixture}; + +/// While the blob override is active, a calldata encoder emits blobs, and a retry keeps the +/// DA type its submission was built with. +#[test] +fn blob_override_switches_a_calldata_encoder_to_blobs() { + let config = EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, 0)).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + encoder.flush().unwrap(); + + encoder.set_blob_override(true); + let submission = encoder.next_submission().expect("submission under the override"); + assert_eq!(submission.da_type(), DaType::Blob); + + encoder.requeue(submission.id); + encoder.set_blob_override(false); + let retry = encoder.next_submission().expect("retry after the override"); + assert_eq!(retry.da_type(), DaType::Blob); +} diff --git a/crates/batcher/encoder/tests/encoder/reconcile.rs b/crates/batcher/encoder/tests/encoder/reconcile.rs new file mode 100644 index 0000000000..22f3ce2e80 --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/reconcile.rs @@ -0,0 +1,275 @@ +//! Reconciliation of `BatchEncoder` with derivation progress, covering the pruning up to the +//! safe L2 head, a safe head off the buffered chain, and a fully confirmed channel derivation +//! skipped. + +use alloy_primitives::B256; +use base_batcher_encoder::{ + BatchPipeline, DerivationReconciliation, EncoderConfig, ReorgError, StepResult, +}; +use base_protocol::BlockInfo; + +use crate::common::{ + BlockFixture, CHANNEL_TIMEOUT, EncoderFixture, MULTI_FRAME_PAYLOAD, SharedBlob, + SubmissionFixture, +}; + +/// The L1 block derivation is processing, for the reconciliations that only move the safe +/// head. It is never past the inclusion block of a fully confirmed channel above the safe head, +/// so none of them reports a stall. +const DERIVATION_L1: u64 = 1; + +/// Blocks at or below the safe head are pruned whether or not they were encoded yet, and a +/// channel left without blocks goes with them. +#[test] +fn reconcile_prunes_blocks_up_to_the_safe_head() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(2, 0); + for block in &blocks { + encoder.add_block(block.clone()).unwrap(); + } + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + + assert_eq!( + encoder.reconcile_derivation(BlockInfo::from(&blocks[1]), DERIVATION_L1), + DerivationReconciliation::Consistent + ); + + assert_eq!(encoder.step().unwrap(), StepResult::Idle, "the second block is gone"); + encoder.flush().unwrap(); + assert!(encoder.next_submission().is_none(), "the first block's channel is gone"); + assert_eq!(encoder.da_backlog_bytes(), 0); +} + +/// A prune inside a channel shifts the buffered blocks, and the next block added is still +/// encoded, alone, so the L2 chain on L1 has no gap and no repeat. +#[test] +fn a_block_added_after_a_prune_is_encoded() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(3, 0); + for block in &blocks[..2] { + encoder.add_block(block.clone()).unwrap(); + } + for submission in encoder.encode_and_drain().unwrap() { + encoder.confirm(submission.id, DERIVATION_L1); + } + assert_eq!( + encoder.reconcile_derivation(BlockInfo::from(&blocks[0]), DERIVATION_L1), + DerivationReconciliation::Consistent + ); + + encoder.add_block(blocks[2].clone()).unwrap(); + let submissions = encoder.encode_and_drain().unwrap(); + assert_eq!(fixture.derive(&submissions).concat(), BlockFixture::batches(&blocks[2..])); +} + +/// A replay re-encodes only the blocks still above the safe head, so the pruned start of the +/// channel is never sent again. +#[test] +fn pruned_blocks_are_not_replayed() { + let fixture = EncoderFixture::small_calldata_frames(); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(2, MULTI_FRAME_PAYLOAD); + for block in &blocks { + encoder.add_block(block.clone()).unwrap(); + } + let submissions = encoder.encode_and_drain().unwrap(); + assert!(submissions.len() > 1, "{} submissions", submissions.len()); + + encoder.confirm(submissions[0].id, 1); + encoder.advance_l1_head(1); + assert_eq!( + encoder.reconcile_derivation(BlockInfo::from(&blocks[0]), DERIVATION_L1), + DerivationReconciliation::Consistent + ); + encoder.confirm(submissions[1].id, 4); + encoder.advance_l1_head(4); + + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + assert_eq!(encoder.step().unwrap(), StepResult::Idle); + let replay = encoder.encode_and_drain().unwrap(); + assert_eq!(fixture.derive(&replay).concat(), BlockFixture::batches(&blocks[1..])); +} + +/// Pruning a whole channel does not shift the next one, whose replay re-encodes its own +/// blocks rather than the blocks now at its former positions. +#[test] +fn pruned_channel_does_not_shift_the_replay_of_the_next_one() { + let fixture = EncoderFixture::small_calldata_frames(); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(2, MULTI_FRAME_PAYLOAD); + encoder.add_block(blocks[0].clone()).unwrap(); + for submission in encoder.encode_and_drain().unwrap() { + encoder.confirm(submission.id, 1); + } + encoder.add_block(blocks[1].clone()).unwrap(); + let second = encoder.encode_and_drain().unwrap(); + assert!(second.len() > 1, "{} submissions", second.len()); + encoder.confirm(second[0].id, 1); + + assert_eq!( + encoder.reconcile_derivation(BlockInfo::from(&blocks[0]), DERIVATION_L1), + DerivationReconciliation::Consistent + ); + encoder.advance_l1_head(1 + CHANNEL_TIMEOUT + 1); + + let replay = encoder.encode_and_drain().unwrap(); + assert_eq!(fixture.derive(&replay).concat(), BlockFixture::batches(&blocks[1..])); +} + +/// After a prune inside a channel, the channel still maps to the blocks it has left, so a +/// stall on its last block is reported and a safe head covering it is consistent. +#[test] +fn reconcile_follows_a_channel_after_a_prune_inside_it() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(2, 0); + for block in &blocks { + encoder.add_block(block.clone()).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + } + assert_eq!( + encoder.reconcile_derivation(BlockInfo::from(&blocks[0]), DERIVATION_L1), + DerivationReconciliation::Consistent + ); + for submission in encoder.encode_and_drain().unwrap() { + encoder.confirm(submission.id, 10); + } + + assert_eq!( + encoder.reconcile_derivation(BlockInfo::from(&blocks[0]), 11), + DerivationReconciliation::StalledChannel + ); + assert_eq!( + encoder.reconcile_derivation(BlockInfo::from(&blocks[1]), 11), + DerivationReconciliation::Consistent + ); + assert_eq!(encoder.da_backlog_bytes(), 0); +} + +/// With nothing buffered, any safe head anchors the chain, and the next block must build on it. +#[test] +fn reconcile_with_nothing_buffered_anchors_the_chain() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let anchor = BlockInfo { hash: B256::repeat_byte(7), number: 2, ..Default::default() }; + + assert_eq!( + encoder.reconcile_derivation(anchor, DERIVATION_L1), + DerivationReconciliation::Consistent + ); + + let (ReorgError::ParentMismatch { expected, .. }, _) = + encoder.add_block(BlockFixture::block(B256::ZERO, 3, 0)).unwrap_err(); + assert_eq!(expected, anchor.hash); + encoder.add_block(BlockFixture::block(anchor.hash, 3, 0)).unwrap(); +} + +/// A safe head off the buffered chain is reported as a mismatch. Below the buffered blocks only +/// the parent of the oldest one fits, and an unknown hash or a head above them never does. +#[test] +fn reconcile_reports_a_safe_head_off_the_buffered_chain() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let anchor = BlockInfo { hash: B256::repeat_byte(7), number: 2, ..Default::default() }; + assert_eq!( + encoder.reconcile_derivation(anchor, DERIVATION_L1), + DerivationReconciliation::Consistent + ); + encoder.add_block(BlockFixture::block(anchor.hash, 3, 0)).unwrap(); + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); + + let mismatch = DerivationReconciliation::SafeHeadMismatch; + let unknown = B256::repeat_byte(1); + assert_eq!( + encoder + .reconcile_derivation(BlockInfo { hash: unknown, number: 2, ..anchor }, DERIVATION_L1), + mismatch + ); + assert_eq!( + encoder + .reconcile_derivation(BlockInfo { hash: unknown, number: 3, ..anchor }, DERIVATION_L1), + mismatch + ); + assert_eq!( + encoder.reconcile_derivation(BlockInfo { number: 1, ..anchor }, DERIVATION_L1), + mismatch + ); + assert_eq!( + encoder.reconcile_derivation(BlockInfo { number: 4, ..anchor }, DERIVATION_L1), + mismatch + ); +} + +/// A fully confirmed channel that derivation passed without making its last block safe is +/// reported as stalled. Nothing is concluded while derivation is still processing the +/// inclusion block, or while a frame is still in flight. +#[test] +fn reconcile_reports_a_confirmed_channel_derivation_skipped() { + let fixture = EncoderFixture::small_calldata_frames(); + let mut encoder = fixture.encoder(); + let inclusion = 1_000; + let block = BlockFixture::block(B256::ZERO, 101, MULTI_FRAME_PAYLOAD); + encoder.add_block(block.clone()).unwrap(); + let submissions = encoder.encode_and_drain().unwrap(); + let (last, landed) = submissions.split_last().expect("at least one frame"); + assert!(!landed.is_empty(), "the block must span several frames"); + for submission in landed { + encoder.confirm(submission.id, inclusion); + } + let previous_safe_l2 = BlockInfo { + hash: block.header.parent_hash, + number: block.header.number - 1, + ..Default::default() + }; + + assert_eq!( + encoder.reconcile_derivation(previous_safe_l2, inclusion + 1), + DerivationReconciliation::Consistent, + "the last frame is still in flight" + ); + encoder.confirm(last.id, inclusion); + assert_eq!( + encoder.reconcile_derivation(previous_safe_l2, inclusion), + DerivationReconciliation::Consistent, + "the inclusion block may still be processing" + ); + assert_eq!( + encoder.reconcile_derivation(previous_safe_l2, inclusion + 1), + DerivationReconciliation::StalledChannel + ); + assert_eq!( + encoder.reconcile_derivation(BlockInfo::from(&block), inclusion + 1), + DerivationReconciliation::Consistent, + "the channel was derived after all" + ); +} + +/// A blob shared by a pruned channel and the next one is still retried for the next one, with +/// the same frames, pruned tail included, and derivation reads both channels from what landed. +#[test] +fn shared_blob_keeps_serving_the_remaining_channel_after_a_prune() { + let config = EncoderConfig { compressed_size_target: Some(1), ..EncoderConfig::default() }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + let shared = SharedBlob::encode(&mut encoder); + + // The safe head proves the first channel landed, receipt or not. + assert_eq!( + encoder.reconcile_derivation(BlockInfo::from(&shared.blocks[0]), DERIVATION_L1), + DerivationReconciliation::Consistent + ); + encoder.requeue(shared.packed.id); + let retry = encoder.next_submission().expect("the shared blob is retried"); + assert_eq!(SubmissionFixture::frames(&retry), SubmissionFixture::frames(&shared.packed)); + encoder.confirm(retry.id, 10); + + let mut submissions = vec![shared.first, retry]; + submissions.extend(encoder.encode_and_drain().unwrap()); + for submission in &submissions[2..] { + encoder.confirm(submission.id, 10); + } + assert_eq!(encoder.da_backlog_bytes(), 0); + assert_eq!(fixture.derive(&submissions).concat(), BlockFixture::batches(&shared.blocks)); +} diff --git a/crates/batcher/encoder/tests/encoder/replay.rs b/crates/batcher/encoder/tests/encoder/replay.rs new file mode 100644 index 0000000000..f32f0a6fb1 --- /dev/null +++ b/crates/batcher/encoder/tests/encoder/replay.rs @@ -0,0 +1,198 @@ +//! Confirmation-window replay in `BatchEncoder`. A channel whose frames cannot all land +//! within the derivation channel timeout is re-encoded under a fresh id, together with every +//! channel that shares a blob or an in-flight transaction with it and every later one. + +use alloy_primitives::B256; +use base_batcher_encoder::{BatchPipeline, StepResult}; +use rstest::rstest; + +use crate::common::{ + BlockFixture, CHANNEL_TIMEOUT, EncoderFixture, MULTI_BLOB_PAYLOAD, MULTI_FRAME_PAYLOAD, + SharedBlob, SubmissionFixture, THREE_BLOB_PAYLOAD, +}; + +/// A complete channel whose confirmations are more than the timeout apart, in either order, is +/// replayed under a fresh channel id, and confirming the replay clears the backlog. +#[rstest] +#[case::ascending(1, 4)] +#[case::descending(100, 90)] +fn confirmations_spanning_more_than_the_timeout_replay_the_channel( + #[case] first_l1_block: u64, + #[case] other_l1_block: u64, +) { + let fixture = EncoderFixture::small_calldata_frames(); + let mut encoder = fixture.encoder(); + let block = BlockFixture::block(B256::ZERO, 1, MULTI_FRAME_PAYLOAD); + encoder.add_block(block.clone()).unwrap(); + let submissions = encoder.encode_and_drain().unwrap(); + assert!(submissions.len() > 1, "{} submissions", submissions.len()); + + encoder.confirm(submissions[0].id, first_l1_block); + encoder.advance_l1_head(first_l1_block); + for submission in &submissions[1..] { + encoder.confirm(submission.id, other_l1_block); + } + encoder.advance_l1_head(other_l1_block); + + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded, "the block is re-encoded"); + let replay = encoder.encode_and_drain().unwrap(); + assert_eq!( + fixture.derive(&replay).concat(), + BlockFixture::batches(std::slice::from_ref(&block)) + ); + assert_ne!( + SubmissionFixture::frames(&replay[0])[0].id, + SubmissionFixture::frames(&submissions[0])[0].id, + "a fresh channel" + ); + + for submission in &replay { + encoder.confirm(submission.id, other_l1_block + 1); + } + assert_eq!(encoder.da_backlog_bytes(), 0); +} + +/// A channel whose first frame landed more than the timeout ago while later frames are still +/// unconfirmed is replayed, and confirming the replay clears the backlog. +#[test] +fn expiry_before_the_last_frame_lands_replays_the_channel() { + let fixture = EncoderFixture::small_calldata_frames(); + let mut encoder = fixture.encoder(); + let block = BlockFixture::block(B256::ZERO, 1, MULTI_FRAME_PAYLOAD); + encoder.add_block(block.clone()).unwrap(); + let submissions = encoder.encode_and_drain().unwrap(); + assert!(submissions.len() > 1, "{} submissions", submissions.len()); + + encoder.confirm(submissions[0].id, 1); + encoder.advance_l1_head(1 + CHANNEL_TIMEOUT + 1); + + assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded, "the block is re-encoded"); + let replay = encoder.encode_and_drain().unwrap(); + assert_eq!(fixture.derive(&replay).concat(), BlockFixture::batches(&[block])); + + for submission in &replay { + encoder.confirm(submission.id, 5); + } + assert_eq!(encoder.da_backlog_bytes(), 0); +} + +/// A channel is not replayed while derivation can still read it, whether all its frames +/// landed in one block, its confirmations are exactly the timeout apart, or it is incomplete +/// with the L1 head exactly at its deadline, where derivation still accepts a frame. +#[rstest] +#[case::all_in_one_block(Some(1), 100)] +#[case::span_of_the_timeout(Some(1 + CHANNEL_TIMEOUT), 1 + CHANNEL_TIMEOUT)] +#[case::incomplete_at_the_deadline(None, 1 + CHANNEL_TIMEOUT)] +fn channel_within_its_window_is_not_replayed( + #[case] rest_l1_block: Option, + #[case] l1_head: u64, +) { + let fixture = EncoderFixture::small_calldata_frames(); + let mut encoder = fixture.encoder(); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, MULTI_FRAME_PAYLOAD)).unwrap(); + let submissions = encoder.encode_and_drain().unwrap(); + assert!(submissions.len() > 1, "{} submissions", submissions.len()); + + encoder.confirm(submissions[0].id, 1); + if let Some(rest_l1_block) = rest_l1_block { + for submission in &submissions[1..] { + encoder.confirm(submission.id, rest_l1_block); + } + } + encoder.advance_l1_head(l1_head); + + assert_eq!(encoder.step().unwrap(), StepResult::Idle); + assert!(encoder.next_submission().is_none()); +} + +/// Replaying a channel also replays the channel whose tail shares a blob with its first +/// frames, even though that earlier channel was confirmed in time, because a blob lands whole. +#[test] +fn replay_includes_the_channel_sharing_a_blob() { + let fixture = EncoderFixture::one_channel_per_block(1); + let mut encoder = fixture.encoder(); + let shared = SharedBlob::encode(&mut encoder); + + // The first channel lands in time. The second one still has its tail held back when its + // window closes. + encoder.confirm(shared.first.id, 1); + encoder.confirm(shared.packed.id, 1); + encoder.advance_l1_head(1 + CHANNEL_TIMEOUT + 1); + + assert_eq!(encoder.step().unwrap(), StepResult::ChannelClosed, "the first block is re-encoded"); + assert_eq!( + encoder.step().unwrap(), + StepResult::ChannelClosed, + "the second block is re-encoded" + ); + let replay = encoder.encode_and_drain().unwrap(); + assert_eq!(fixture.derive(&replay).concat(), BlockFixture::batches(&shared.blocks)); +} + +/// Replaying a channel also replays the channel whose retried blob shares a transaction with +/// its retried tail, because a transaction lands whole. Confirming the replay clears the backlog. +#[test] +fn replay_includes_the_channel_sharing_a_transaction() { + let fixture = EncoderFixture::one_channel_per_block(2); + let mut encoder = fixture.encoder(); + let first_block = BlockFixture::block(B256::ZERO, 1, 0); + let second_block = BlockFixture::block(first_block.header.hash_slow(), 2, THREE_BLOB_PAYLOAD); + encoder.add_block(first_block.clone()).unwrap(); + let first = encoder.encode_and_drain().unwrap(); + encoder.add_block(second_block.clone()).unwrap(); + let second = encoder.encode_and_drain().unwrap(); + assert_eq!(second.len(), 2, "two full blobs, then the tail"); + + // The first channel's blob and the second channel's tail are retried in one transaction. + encoder.confirm(second[0].id, 1); + encoder.requeue(first[0].id); + encoder.requeue(second[1].id); + let retry = encoder.next_submission().expect("the retry"); + assert_eq!(retry.blob_count(), 2, "one transaction carries both channels"); + encoder.advance_l1_head(1 + CHANNEL_TIMEOUT + 1); + + let replay = encoder.encode_and_drain().unwrap(); + assert_eq!( + fixture.derive(&replay).concat(), + BlockFixture::batches(&[first_block, second_block]) + ); + for submission in &replay { + encoder.confirm(submission.id, 5); + } + assert_eq!(encoder.da_backlog_bytes(), 0); +} + +/// Replaying a channel discards every later channel too, but not the earlier ones that landed +/// in time, and the submissions of the discarded channels are forgotten, so requeuing them +/// sends nothing. +#[test] +fn replay_discards_every_later_channel() { + let fixture = EncoderFixture::one_channel_per_block(1); + let mut encoder = fixture.encoder(); + let blocks = BlockFixture::chain(3, MULTI_BLOB_PAYLOAD); + + // The first channel lands in time. + encoder.add_block(blocks[0].clone()).unwrap(); + for submission in encoder.encode_and_drain().unwrap() { + encoder.confirm(submission.id, 1); + } + + // The second channel has its tail in flight when its window closes, the third is in flight. + encoder.add_block(blocks[1].clone()).unwrap(); + let second = encoder.encode_and_drain().unwrap(); + assert!(second.len() > 1, "{} submissions", second.len()); + encoder.confirm(second[0].id, 1); + encoder.add_block(blocks[2].clone()).unwrap(); + let third = encoder.encode_and_drain().unwrap(); + encoder.advance_l1_head(1 + CHANNEL_TIMEOUT + 1); + + let replay = encoder.encode_and_drain().unwrap(); + assert_eq!(fixture.derive(&replay).concat(), BlockFixture::batches(&blocks[1..])); + for submission in second[1..].iter().chain(&third) { + encoder.requeue(submission.id); + } + assert!( + SubmissionFixture::drain(&mut encoder).is_empty(), + "the discarded submissions are forgotten" + ); +} From a36bb4d354f2f4a75ef2ef4e6d25a05b6ba8499f Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 13:21:52 +0000 Subject: [PATCH 176/299] test(batcher): replace the encoder's internal ledger tests with public ones (#5345) * test(batcher): replace the encoder's internal ledger tests with public ones * test(batcher): tighten the ledger replacement tests and pin the blob split * test(batcher): fold the encoder tests that repeat others and pin the assembled size and epoch * test(batcher): pin the encoder limit errors exactly and drop an assert that cannot fail * test(batcher): pin the order of retries across DA types and the channel limits exactly * test(batcher): unit test DaEgress through its public API * test(batcher): document every ledger test and drop the egress tests the encoder tests cover * test(batcher): drop the test pinning that a retry is never packed with newer blobs * test(batcher): keep the ledger tests on current-fork contracts and restore the DaEgress rules * test(batcher): cover the replay closure, invalidation and full confirmation rules of DaEgress * test(batcher): keep testing the fork guards the batcher still runs and name the timeout rule --- crates/batcher/encoder/src/artifact.rs | 150 +------ crates/batcher/encoder/src/channel.rs | 70 +-- crates/batcher/encoder/src/composer.rs | 25 +- crates/batcher/encoder/src/config.rs | 97 ++--- crates/batcher/encoder/src/egress.rs | 407 ++++++++++-------- .../batcher/encoder/tests/encoder/backlog.rs | 23 +- .../batcher/encoder/tests/encoder/channels.rs | 36 +- .../batcher/encoder/tests/encoder/common.rs | 36 ++ .../batcher/encoder/tests/encoder/packing.rs | 66 ++- .../batcher/encoder/tests/encoder/replay.rs | 47 +- 10 files changed, 479 insertions(+), 478 deletions(-) diff --git a/crates/batcher/encoder/src/artifact.rs b/crates/batcher/encoder/src/artifact.rs index 73f401ed44..3891c0cd24 100644 --- a/crates/batcher/encoder/src/artifact.rs +++ b/crates/batcher/encoder/src/artifact.rs @@ -72,11 +72,6 @@ impl DaArtifact { pub fn channel_ids(&self) -> &[ChannelId] { &self.channel_ids } - - /// Returns the current submission state. - pub const fn state(&self) -> ArtifactState { - self.state - } } /// Ledger of immutable DA artifacts, in creation order. @@ -107,11 +102,6 @@ impl DaArtifacts { self.artifacts.is_empty() } - /// Returns every retained artifact in creation order. - pub fn iter(&self) -> vec_deque::Iter<'_, DaArtifact> { - self.artifacts.iter() - } - /// Appends one immutable ready artifact. pub fn push(&mut self, payload: DaArtifactPayload, channel_ids: Vec) -> ArtifactId { let id = ArtifactId(self.next_id); @@ -307,147 +297,27 @@ impl<'a> IntoIterator for &'a DaArtifacts { #[cfg(test)] mod tests { use super::*; - use crate::DaType; const FIRST: ChannelId = [1; 16]; const SECOND: ChannelId = [2; 16]; - fn frame(channel_id: ChannelId) -> Arc { - Arc::new(Frame { id: channel_id, number: 0, data: Vec::new(), is_last: true }) - } - - fn blob(channel_id: ChannelId, frames: usize) -> DaArtifactPayload { - DaArtifactPayload::Blob(BlobPayload::new((0..frames).map(|_| frame(channel_id)).collect())) - } - - fn calldata(channel_id: ChannelId) -> DaArtifactPayload { - DaArtifactPayload::Calldata(frame(channel_id)) - } - - #[test] - fn push_appends_ready_artifacts_in_creation_order() { - let mut artifacts = DaArtifacts::new(); - - let first = artifacts.push(blob(FIRST, 1), vec![FIRST]); - let second = artifacts.push(calldata(SECOND), vec![SECOND]); - - assert_eq!(artifacts.len(), 2); - assert_eq!(artifacts.iter().map(DaArtifact::id).collect::>(), [first, second]); - assert!(artifacts.iter().all(|artifact| artifact.state() == ArtifactState::Ready)); - } - - #[test] - fn clear_preserves_monotonic_identifiers() { - let mut artifacts = DaArtifacts::new(); - let first = artifacts.push(calldata(FIRST), vec![FIRST]); - - artifacts.clear(); - let second = artifacts.push(calldata(FIRST), vec![FIRST]); - - assert_eq!(artifacts.len(), 1); - assert_ne!(first, second); - } - - #[test] - fn lease_packs_consecutive_ready_blobs_up_to_the_maximum() { - let mut artifacts = DaArtifacts::new(); - for _ in 0..3 { - artifacts.push(blob(FIRST, 2), vec![FIRST]); - } - - let (submission, leased) = artifacts.lease(SubmissionId(0), 2).expect("blob lease"); - - assert_eq!(leased.len(), 2); - assert_eq!(submission.blob_count(), 2); - assert_eq!(submission.frame_count(), 4); - assert!(artifacts.all_pending(&leased)); - assert_eq!(artifacts.ready_blob_count(), 1); - } - - #[test] - fn lease_stops_at_the_first_calldata_artifact() { - let mut artifacts = DaArtifacts::new(); - artifacts.push(blob(FIRST, 1), vec![FIRST]); - artifacts.push(calldata(FIRST), vec![FIRST]); - artifacts.push(blob(FIRST, 1), vec![FIRST]); - - let (blobs, _) = artifacts.lease(SubmissionId(0), 6).expect("blob lease"); - let (frame, leased) = artifacts.lease(SubmissionId(1), 6).expect("calldata lease"); - - assert_eq!(blobs.blob_count(), 1); - assert_eq!(frame.da_type(), DaType::Calldata); - assert_eq!(leased.len(), 1); - } - - #[test] - fn lease_requires_a_ready_artifact() { - let mut artifacts = DaArtifacts::new(); - assert!(artifacts.lease(SubmissionId(0), 6).is_none()); - - artifacts.push(calldata(FIRST), vec![FIRST]); - artifacts.lease(SubmissionId(0), 6).expect("calldata lease"); - - assert!(artifacts.lease(SubmissionId(1), 6).is_none()); - } - - #[test] - fn confirm_reports_each_contributing_channel_once() { - let mut artifacts = DaArtifacts::new(); - artifacts.push(blob(FIRST, 2), vec![FIRST, SECOND]); - artifacts.push(blob(SECOND, 1), vec![SECOND]); - let (_, leased) = artifacts.lease(SubmissionId(0), 6).expect("blob lease"); - - assert_eq!(artifacts.confirm(&leased), [FIRST, SECOND]); - assert!(!artifacts.all_pending(&leased)); - } - - #[test] - fn requeue_returns_leased_frames_to_ready() { - let mut artifacts = DaArtifacts::new(); - artifacts.push(blob(FIRST, 3), vec![FIRST]); - let (_, leased) = artifacts.lease(SubmissionId(0), 6).expect("blob lease"); - assert_eq!(artifacts.ready_frame_count(), 0); - - assert_eq!(artifacts.requeue(&leased), 3); - assert_eq!(artifacts.ready_frame_count(), 3); - } - - #[test] - fn a_channel_without_artifacts_is_never_fully_confirmed() { - let mut artifacts = DaArtifacts::new(); - assert!(!artifacts.all_confirmed_for(FIRST)); - - artifacts.push(blob(FIRST, 1), vec![FIRST]); - assert!(!artifacts.all_confirmed_for(FIRST)); - - let (_, leased) = artifacts.lease(SubmissionId(0), 6).expect("blob lease"); - artifacts.confirm(&leased); - - assert!(artifacts.all_confirmed_for(FIRST)); + fn blob() -> DaArtifactPayload { + let frame = Frame { id: FIRST, number: 0, data: Vec::new(), is_last: true }; + DaArtifactPayload::Blob(BlobPayload::new(vec![Arc::new(frame)])) } + /// Pruning a channel removes it from every artifact and drops the artifacts left with no + /// channel, so the ledger does not grow forever. An artifact another channel still shares + /// stays, listing only that channel. #[test] fn prune_channels_drops_artifacts_left_without_a_channel() { let mut artifacts = DaArtifacts::new(); - let shared = artifacts.push(blob(FIRST, 1), vec![FIRST, SECOND]); - let single = artifacts.push(blob(FIRST, 1), vec![FIRST]); + artifacts.push(blob(), vec![FIRST, SECOND]); + artifacts.push(blob(), vec![FIRST]); artifacts.prune_channels(&[FIRST]); - assert!(artifacts.contains(shared)); - assert!(!artifacts.contains(single)); - assert_eq!(artifacts.iter().next().expect("retained artifact").channel_ids(), [SECOND]); - } - - #[test] - fn invalidate_removes_only_the_listed_artifacts() { - let mut artifacts = DaArtifacts::new(); - let first = artifacts.push(calldata(FIRST), vec![FIRST]); - let second = artifacts.push(calldata(SECOND), vec![SECOND]); - - artifacts.invalidate(&[first]); - - assert!(!artifacts.contains(first)); - assert!(artifacts.contains(second)); + let retained: Vec<_> = artifacts.into_iter().map(DaArtifact::channel_ids).collect(); + assert_eq!(retained, [[SECOND]]); } } diff --git a/crates/batcher/encoder/src/channel.rs b/crates/batcher/encoder/src/channel.rs index 09be53e6a5..77047f98fd 100644 --- a/crates/batcher/encoder/src/channel.rs +++ b/crates/batcher/encoder/src/channel.rs @@ -463,9 +463,9 @@ impl Channel { #[cfg(test)] mod tests { use alloy_primitives::B256; + use base_common_genesis::UpgradeConfig; use super::*; - use crate::BrotliLevel; fn batch(transaction_len: usize) -> SingleBatch { let mut state = 1u64; @@ -486,67 +486,31 @@ mod tests { } } + /// A channel on a rollup config with Holocene, hence Fjord's channel size limit, active. fn channel(config: EncoderConfig) -> Channel { - Channel::new(ChannelId::default(), Arc::new(RollupConfig::default()), &config, 0, 0) - .unwrap() - } - - #[test] - fn output_fifo_preserves_transferred_stream_order() { - let mut channel = channel(EncoderConfig::default()); - channel.push_output(vec![1, 2]); - channel.push_output(vec![3, 4, 5]); - - assert_eq!(channel.take_output(4), vec![1, 2, 3, 4]); - assert_eq!(channel.take_output(1), vec![5]); - assert_eq!(channel.available_output(), 0); - } - - #[test] - fn soft_target_accepts_complete_batch_before_closing() { - let config = EncoderConfig { - compressed_size_target: Some(1), - brotli_level: BrotliLevel::Brotli0, - ..EncoderConfig::default() + let rollup_config = RollupConfig { + upgrades: UpgradeConfig { holocene_time: Some(0), ..UpgradeConfig::default() }, + ..RollupConfig::default() }; - let mut channel = channel(config); - - assert_eq!( - channel.add_batch(&batch(10_000), 10_000).unwrap(), - ChannelAddOutcome::TargetReached - ); - assert_eq!(channel.blocks_added(), 1); - assert!(channel.input_bytes() > 0); + Channel::new(ChannelId::default(), Arc::new(rollup_config), &config, 0, 0).unwrap() } + /// A batch within the RLP limit whose worst-case assembled channel would exceed it is + /// rejected without touching the stream, because derivation drops such a channel. #[test] - fn cumulative_rlp_limit_rejects_without_mutating_stream() { + fn assembled_size_limit_rejects_without_mutating_stream() { let mut channel = channel(EncoderConfig::default()); let maximum = channel.rollup_config.max_rlp_bytes_per_channel(0); - channel.input_bytes = maximum; - - assert!(matches!( - channel.add_batch(&batch(1), 1).unwrap(), - ChannelAddOutcome::Rejected(ChannelLimit::RlpBytes { .. }) - )); - assert_eq!(channel.input_bytes, maximum); - assert_eq!(channel.blocks_added(), 0); - assert_eq!(channel.compressed_bytes(), 0); - } + channel.input_bytes = maximum - 100_000; - #[test] - fn frame_number_limit_rejects_without_mutating_stream() { - let config = EncoderConfig { - max_frame_size: Frame::ENCODED_OVERHEAD + 1, - ..EncoderConfig::default() + let ChannelAddOutcome::Rejected(ChannelLimit::AssembledBytes { maximum: limit, .. }) = + channel.add_batch(&batch(1), 1).unwrap() + else { + panic!("the batch must be rejected on the assembled size limit"); }; - let mut channel = channel(config); - - assert!(matches!( - channel.add_batch(&batch(Channel::MAX_FRAMES + 1), 1).unwrap(), - ChannelAddOutcome::Rejected(ChannelLimit::FrameCount { .. }) - )); - assert!(channel.is_empty()); + assert_eq!(limit, maximum); + assert_eq!(channel.input_bytes, maximum - 100_000); + assert_eq!(channel.blocks_added(), 0); assert_eq!(channel.compressed_bytes(), 0); } } diff --git a/crates/batcher/encoder/src/composer.rs b/crates/batcher/encoder/src/composer.rs index 08a10bfdad..9be6cf8ffa 100644 --- a/crates/batcher/encoder/src/composer.rs +++ b/crates/batcher/encoder/src/composer.rs @@ -62,11 +62,9 @@ impl BatchComposer { #[cfg(test)] mod tests { - use std::vec; - use alloy_consensus::{BlockBody, Header, SignableTransaction, TxLegacy}; use alloy_eips::eip2718::Encodable2718; - use alloy_primitives::{B256, Bytes, Sealed, Signature}; + use alloy_primitives::{Address, B256, Bytes, Sealed, Signature, U256}; use base_common_consensus::{BaseBlock, BaseTxEnvelope, TxDeposit}; use base_protocol::{L1BlockInfoBedrock, L1BlockInfoTx}; use rstest::rstest; @@ -94,6 +92,8 @@ mod tests { BaseTxEnvelope::Legacy(signed) } + /// A block with no transaction, a first transaction that is not a deposit, or an L1-info + /// deposit whose calldata does not decode cannot become a batch, each with its own error. #[rstest] #[case::empty_block(make_block(vec![]), BatchComposeError::EmptyBlock)] #[case::not_deposit(make_block(vec![non_deposit_tx()]), BatchComposeError::NotDepositTx)] @@ -102,6 +102,7 @@ mod tests { assert_eq!(BatchComposer::block_to_single_batch(&block).unwrap_err(), expected); } + /// Deposits are left out of the batch, since derivation rebuilds them from L1. #[test] fn test_deposits_filtered() { let block = make_block(vec![valid_deposit_tx(), deposit_tx(Bytes::new())]); @@ -109,6 +110,7 @@ mod tests { assert!(batch.transactions.is_empty()); } + /// User transactions go into the batch in their EIP-2718 encoding. #[test] fn test_user_txs_encoded() { let user_tx = non_deposit_tx(); @@ -118,20 +120,25 @@ mod tests { assert_eq!(batch.transactions, vec![expected]); } + /// The batch takes its parent hash and timestamp from the block header, and its epoch from + /// the L1 origin the L1-info deposit names. #[test] fn test_fields_match_block() { - let parent_hash = B256::from([0xAB; 32]); - let timestamp = 1_234_567_u64; - let mut block = make_block(vec![valid_deposit_tx()]); + let parent_hash = B256::repeat_byte(0xab); + let timestamp = 1_234_567; + let epoch_hash = B256::repeat_byte(0xcd); + let info = + L1BlockInfoBedrock::new(42, 0, 0, epoch_hash, 0, Address::ZERO, U256::ZERO, U256::ZERO); + let mut block = + make_block(vec![deposit_tx(L1BlockInfoTx::Bedrock(info).encode_calldata())]); block.header.parent_hash = parent_hash; block.header.timestamp = timestamp; - let info = L1BlockInfoTx::Bedrock(L1BlockInfoBedrock::default()); let batch = BatchComposer::block_to_single_batch(&block).unwrap(); assert_eq!(batch.parent_hash, parent_hash); assert_eq!(batch.timestamp, timestamp); - assert_eq!(batch.epoch_num, info.id().number); - assert_eq!(batch.epoch_hash, info.id().hash); + assert_eq!(batch.epoch_num, 42); + assert_eq!(batch.epoch_hash, epoch_hash); } } diff --git a/crates/batcher/encoder/src/config.rs b/crates/batcher/encoder/src/config.rs index 6ca208800d..7df62db836 100644 --- a/crates/batcher/encoder/src/config.rs +++ b/crates/batcher/encoder/src/config.rs @@ -298,32 +298,21 @@ mod tests { EncoderConfig { sub_safety_margin, max_channel_duration, ..EncoderConfig::default() } } - #[test] - fn default_blob_max_frame_size_reserves_derivation_prefix() { - let cfg = EncoderConfig::default(); - - assert_eq!(cfg.max_frame_size, EncoderConfig::MAX_BLOB_FRAME_SIZE); - assert_eq!(cfg.compressed_size_target, None); - assert_eq!(cfg.max_blobs_per_tx, 6); - assert_eq!(cfg.brotli_level, BrotliLevel::DEFAULT); - assert_eq!( - cfg.max_frame_size + EncoderConfig::BLOB_DERIVATION_PREFIX_SIZE, - EncoderConfig::BLOB_MAX_DATA_SIZE - ); - } - + /// A safety margin below the channel duration is accepted, down to zero. #[rstest] - #[case(0, 2)] // zero margin: always valid - #[case(1, 2)] // one below duration - #[case(4, 10)] // typical production values + #[case::zero_margin(0, 2)] + #[case::margin_just_below_the_duration(1, 2)] + #[case::production_values(4, 10)] fn validate_ok(#[case] sub_safety_margin: u64, #[case] max_channel_duration: u64) { assert!(config_with(sub_safety_margin, max_channel_duration).validate().is_ok()); } + /// A safety margin at or above the channel duration is rejected with both values, since the + /// effective duration would be zero and every channel would close at once. #[rstest] - #[case(2, 2)] // equal: effective timeout saturates to 0 - #[case(5, 2)] // greater: same failure mode - #[case(u64::MAX, 1)] // extreme: maximum possible margin + #[case::margin_equal_to_the_duration(2, 2)] + #[case::margin_above_the_duration(5, 2)] + #[case::maximum_margin(u64::MAX, 1)] fn validate_err(#[case] sub_safety_margin: u64, #[case] max_channel_duration: u64) { let err = config_with(sub_safety_margin, max_channel_duration).validate().unwrap_err(); assert!(matches!( @@ -333,12 +322,10 @@ mod tests { max_channel_duration: d, } if m == sub_safety_margin && d == max_channel_duration )); - // Error message must be human-readable and include both values. - let msg = err.to_string(); - assert!(msg.contains(&sub_safety_margin.to_string())); - assert!(msg.contains(&max_channel_duration.to_string())); } + /// A frame size that only fits the frame overhead is rejected, since such a frame cannot carry + /// a single channel byte. The error gives the smallest usable size. #[test] fn validate_rejects_frame_without_payload_capacity() { let max_frame_size = Frame::ENCODED_OVERHEAD; @@ -356,6 +343,7 @@ mod tests { )); } + /// One byte above the frame overhead is the smallest accepted frame size. #[test] fn validate_accepts_frame_with_payload_capacity() { let max_frame_size = Frame::ENCODED_OVERHEAD + 1; @@ -364,6 +352,7 @@ mod tests { assert!(cfg.validate().is_ok()); } + /// A compressed size target of zero is rejected. `None` is how the target is turned off. #[test] fn validate_rejects_zero_compressed_target() { let cfg = EncoderConfig { compressed_size_target: Some(0), ..EncoderConfig::default() }; @@ -371,6 +360,7 @@ mod tests { assert!(matches!(cfg.validate().unwrap_err(), EncoderConfigError::CompressedTargetZero)); } + /// A limit of zero blobs per transaction is rejected, since no blob submission could go out. #[test] fn validate_rejects_zero_max_blobs_per_tx() { let cfg = EncoderConfig { max_blobs_per_tx: 0, ..EncoderConfig::default() }; @@ -378,6 +368,7 @@ mod tests { assert!(matches!(cfg.validate().unwrap_err(), EncoderConfigError::MaxBlobsPerTxZero)); } + /// More blobs per transaction than the protocol allows is rejected. #[test] fn validate_rejects_transaction_above_blob_limit() { let cfg = EncoderConfig { @@ -391,47 +382,24 @@ mod tests { )); } - #[test] - fn validate_rejects_frame_above_protocol_limit() { - let cfg = EncoderConfig { - da_type: DaType::Calldata, - max_frame_size: Frame::ENCODED_OVERHEAD + Frame::MAX_LEN + 1, - ..EncoderConfig::default() - }; - - assert!(matches!(cfg.validate(), Err(EncoderConfigError::FrameSizeTooLarge { .. }))); - } - - #[test] - fn validate_rejects_blob_frame_size_that_leaves_no_prefix_room() { - let cfg = EncoderConfig { - max_frame_size: EncoderConfig::BLOB_MAX_DATA_SIZE, - ..EncoderConfig::default() - }; - - let err = cfg.validate().unwrap_err(); - assert!(matches!( - err, - EncoderConfigError::FrameExceedsBlobPackingLimit { - max_frame_size, - max_blob_frame_size, - } if max_frame_size == EncoderConfig::BLOB_MAX_DATA_SIZE - && max_blob_frame_size == EncoderConfig::MAX_BLOB_FRAME_SIZE - )); - assert!(err.to_string().contains("derivation-version prefix")); - } - - #[test] - fn validate_reserves_blob_prefix_room_for_calldata_override() { + /// A frame must leave room for the blob derivation prefix even on a calldata batcher, whose + /// submissions the blob override can turn into blobs. + #[rstest] + #[case::blob(DaType::Blob)] + #[case::calldata(DaType::Calldata)] + fn validate_rejects_frame_size_that_leaves_no_blob_prefix_room(#[case] da_type: DaType) { let cfg = EncoderConfig { - da_type: DaType::Calldata, + da_type, max_frame_size: EncoderConfig::BLOB_MAX_DATA_SIZE, ..EncoderConfig::default() }; assert!(matches!( cfg.validate(), - Err(EncoderConfigError::FrameExceedsBlobPackingLimit { .. }) + Err(EncoderConfigError::FrameExceedsBlobPackingLimit { + max_frame_size: EncoderConfig::BLOB_MAX_DATA_SIZE, + max_blob_frame_size: EncoderConfig::MAX_BLOB_FRAME_SIZE, + }) )); } @@ -443,6 +411,8 @@ mod tests { } } + /// Brotli is rejected when the next L2 block to encode is before Fjord, since derivation only + /// accepts Brotli channels from Fjord on. #[test] fn validate_for_rollup_config_rejects_brotli_before_fjord() { let cfg = EncoderConfig::default(); @@ -469,18 +439,25 @@ mod tests { } } + /// The confirmation window is the smaller of the pre- and post-Granite channel timeouts, so + /// with Base's 300 and 50 L1 blocks it is 50 and the batcher replays a channel before + /// derivation drops it. #[test] - fn confirmation_channel_timeout_takes_the_conservative_minimum() { + fn confirmation_channel_timeout_takes_the_smaller_timeout() { let rollup_config = rollup_config_with_channel_timeouts(300, 50, Some(10)); assert_eq!(EncoderConfig::confirmation_channel_timeout(&rollup_config), 50); } + /// A zero channel timeout counts as unset, so the other one is used instead of zero. #[test] fn confirmation_channel_timeout_treats_zero_as_unset() { let rollup_config = rollup_config_with_channel_timeouts(0, 50, Some(10)); assert_eq!(EncoderConfig::confirmation_channel_timeout(&rollup_config), 50); } + /// An effective channel duration (`max_channel_duration - sub_safety_margin`) equal to the + /// channel timeout is rejected, since derivation could time the channel out before the + /// batcher closes it. #[test] fn validate_for_rollup_config_rejects_effective_duration_at_channel_timeout() { let cfg = EncoderConfig { @@ -502,6 +479,7 @@ mod tests { )); } + /// An effective channel duration below the channel timeout is accepted. #[test] fn validate_for_rollup_config_allows_effective_duration_below_channel_timeout() { let cfg = EncoderConfig { @@ -514,6 +492,7 @@ mod tests { assert!(cfg.validate_for_rollup_config(&rollup_config, 0).is_ok()); } + /// With no channel timeout configured, the channel duration is not checked against it. #[test] fn validate_for_rollup_config_skips_when_channel_timeout_is_unset() { let cfg = EncoderConfig { max_channel_duration: 1000, ..EncoderConfig::default() }; diff --git a/crates/batcher/encoder/src/egress.rs b/crates/batcher/encoder/src/egress.rs index 818c3b2d88..a3d818d989 100644 --- a/crates/batcher/encoder/src/egress.rs +++ b/crates/batcher/encoder/src/egress.rs @@ -293,11 +293,6 @@ impl DaEgress { }); } - /// Returns artifacts leased to `submission_id`. - pub fn pending_artifacts(&self, submission_id: SubmissionId) -> Option<&[ArtifactId]> { - self.pending.get(&submission_id).map(Vec::as_slice) - } - /// Returns the number of in-flight submissions. pub fn pending_submission_count(&self) -> usize { self.pending.len() @@ -307,19 +302,28 @@ impl DaEgress { #[cfg(test)] mod tests { use alloy_primitives::{B256, Bytes}; - use base_common_genesis::RollupConfig; + use base_common_genesis::{RollupConfig, UpgradeConfig}; use base_protocol::SingleBatch; use super::*; - use crate::{ChannelAddOutcome, EncoderConfig}; + use crate::{ChannelAddOutcome, DaArtifact, EncoderConfig, SubmissionPayload}; + + const FIRST: ChannelId = [1; 16]; + const SECOND: ChannelId = [2; 16]; - fn channel(id: ChannelId, opened_l1_block: u64, duration: u64) -> Channel { - let config = EncoderConfig { max_channel_duration: duration, ..EncoderConfig::default() }; - Channel::new(id, Arc::new(RollupConfig::default()), &config, 0, opened_l1_block).unwrap() + /// A channel opened at L1 head 0 on a rollup config with Holocene active. + fn channel(id: ChannelId, max_channel_duration: u64) -> Channel { + let config = EncoderConfig { max_channel_duration, ..EncoderConfig::default() }; + let rollup_config = RollupConfig { + upgrades: UpgradeConfig { holocene_time: Some(0), ..UpgradeConfig::default() }, + ..RollupConfig::default() + }; + Channel::new(id, Arc::new(rollup_config), &config, 0, 0).unwrap() } - fn incompressible_batch(transaction_len: usize, seed: u64) -> SingleBatch { - let mut state = seed; + /// Adds a batch of `transaction_len` random bytes, which Brotli cannot shrink. + fn add_incompressible_batch(channel: &mut Channel, transaction_len: usize) { + let mut state = channel.blocks_added() as u64 + 1; let transaction = (0..transaction_len) .map(|_| { state ^= state << 13; @@ -328,255 +332,302 @@ mod tests { state as u8 }) .collect::>(); - SingleBatch { + let batch = SingleBatch { parent_hash: B256::ZERO, epoch_num: 0, epoch_hash: B256::ZERO, timestamp: 0, transactions: vec![Bytes::from(transaction)], - } - } - - fn append_accepted(channel: &mut Channel, transaction_len: usize) { - let seed = channel.blocks_added() as u64 + 1; - assert_eq!( - channel - .add_batch(&incompressible_batch(transaction_len, seed), transaction_len as u64) - .unwrap(), - ChannelAddOutcome::Accepted - ); + }; + let outcome = channel.add_batch(&batch, transaction_len as u64).unwrap(); + assert_eq!(outcome, ChannelAddOutcome::Accepted); } - fn fill_open_channel(channel: &mut Channel, min_output: usize) { + /// An open channel holding at least `min_output` bytes of stable output. + fn open_channel_with_output(id: ChannelId, min_output: usize) -> Channel { + let mut channel = channel(id, 10); let mut transaction_len = 4_096; while channel.available_output() < min_output { - append_accepted(channel, transaction_len); + add_incompressible_batch(&mut channel, transaction_len); transaction_len = (transaction_len * 2).min(200_000); } - assert!(channel.is_open()); + channel } - #[test] - fn full_blob_is_ready_from_open_channel_output() { - let mut channel = channel([1; 16], 0, 10); - fill_open_channel(&mut channel, DaEgress::BLOB_CAPACITY - Frame::ENCODED_OVERHEAD); - let channels = VecDeque::from([channel]); - - let plan = DaEgress::plan_blob(&channels, 0).expect("full blob"); - let (_, _, is_last) = plan[0]; - - assert_eq!(plan.len(), 1); - assert!(!is_last); + /// A closed channel whose tail is shorter than a blob and than a calldata frame. + fn closed_channel_with_a_short_tail(id: ChannelId, max_channel_duration: u64) -> Channel { + let mut channel = channel(id, max_channel_duration); + add_incompressible_batch(&mut channel, 50_000); + channel.close().unwrap(); + assert!(channel.available_output() < channel.max_frame_data()); + channel } - #[test] - fn open_channel_timeout_does_not_release_non_terminal_partial_data() { - let mut channel = channel([1; 16], 0, 1); - append_accepted(&mut channel, 50_000); - assert!(channel.available_output() > 0); - assert!(channel.available_output() < DaEgress::BLOB_CAPACITY - Frame::ENCODED_OVERHEAD); - let channels = VecDeque::from([channel]); - - assert!(DaEgress::plan_blob(&channels, 1).is_none()); + /// Every frame `submission` carries, in order. + fn all_frames(submission: &BatchSubmission) -> Vec> { + match submission.payload() { + SubmissionPayload::Blobs(blobs) => { + blobs.iter().flat_map(BlobPayload::frames).cloned().collect() + } + SubmissionPayload::Calldata(frame) => vec![Arc::clone(frame)], + } } - #[test] - fn closed_channel_timeout_releases_partial_tail() { - let mut channel = channel([1; 16], 0, 2); - append_accepted(&mut channel, 50_000); - channel.close().unwrap(); - assert!(channel.available_output() > 0); - assert!(channel.available_output() < DaEgress::BLOB_CAPACITY - Frame::ENCODED_OVERHEAD); - let channels = VecDeque::from([channel]); - - assert!(DaEgress::plan_blob(&channels, 1).is_none()); - let plan = DaEgress::plan_blob(&channels, 2).expect("timeout-released tail"); - assert!(plan.last().is_some_and(|(_, _, is_last)| *is_last)); + /// The channel and last-frame flag of every frame `submission` carries. + fn frames(submission: &BatchSubmission) -> Vec<(ChannelId, bool)> { + all_frames(submission).iter().map(|frame| (frame.id, frame.is_last)).collect() } + /// A blob filled by an open channel goes out without closing the channel, so its frame is not + /// the last one. #[test] - fn calldata_plan_emits_a_full_frame_before_close() { - let mut channel = channel([1; 16], 0, 10); - let max_frame_data = channel.max_frame_data(); - fill_open_channel(&mut channel, max_frame_data); - let channels = VecDeque::from([channel]); + fn a_full_blob_leaves_an_open_channel() { + let channel = open_channel_with_output(FIRST, DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([channel]); - assert_eq!(DaEgress::plan_calldata(&channels), Some(([1; 16], max_frame_data, false))); - } + let submission = DaEgress::new() + .next_submission(&mut channels, DaType::Blob, 0, 1, SubmissionId(0)) + .expect("a full blob"); - #[test] - fn calldata_plan_buffers_partial_open_output() { - let mut channel = channel([1; 16], 0, 10); - append_accepted(&mut channel, 50_000); - let buffered = channel.available_output(); - assert!(buffered > 0 && buffered < channel.max_frame_data()); - let channels = VecDeque::from([channel]); - - assert!(DaEgress::plan_calldata(&channels).is_none()); - assert_eq!(channels[0].available_output(), buffered); + assert_eq!(submission.blob_count(), 1); + assert_eq!(frames(&submission), [(FIRST, false)]); } + /// Partial output of an open channel stays buffered, as blob or calldata, even past the + /// channel deadline. #[test] - fn calldata_plan_releases_the_closed_tail() { - let mut channel = channel([1; 16], 0, 10); - append_accepted(&mut channel, 50_000); - channel.close().unwrap(); - let tail = channel.available_output(); - assert!(tail > 0 && tail < channel.max_frame_data()); - let channels = VecDeque::from([channel]); - - assert_eq!(DaEgress::plan_calldata(&channels), Some(([1; 16], tail, true))); + fn partial_output_of_an_open_channel_stays_buffered() { + let mut open = channel(FIRST, 1); + add_incompressible_batch(&mut open, 50_000); + let buffered = open.available_output(); + assert!(buffered > 0 && buffered < open.max_frame_data()); + let mut channels = VecDeque::from([open]); + let mut egress = DaEgress::new(); + + assert!( + egress.next_submission(&mut channels, DaType::Blob, 5, 6, SubmissionId(0)).is_none() + ); + assert!( + egress + .next_submission(&mut channels, DaType::Calldata, 5, 6, SubmissionId(1)) + .is_none() + ); + assert_eq!(channels[0].available_output(), buffered); } + /// A closed tail too short to fill a blob goes out once its channel deadline is due. #[test] - fn plan_crosses_closed_channel_boundary_without_mutation() { - let mut first = channel([1; 16], 0, 10); - append_accepted(&mut first, 50_000); - first.close().unwrap(); - let first_bytes = first.available_output(); - assert!(first_bytes > 0); - assert!(first_bytes < DaEgress::BLOB_CAPACITY - Frame::ENCODED_OVERHEAD); + fn a_short_blob_tail_waits_for_its_channel_deadline() { + let mut channels = VecDeque::from([closed_channel_with_a_short_tail(FIRST, 2)]); + let mut egress = DaEgress::new(); - let mut second = channel([2; 16], 0, 10); - fill_open_channel(&mut second, DaEgress::BLOB_CAPACITY); - let second_bytes = second.available_output(); - let channels = VecDeque::from([first, second]); - - let plan = DaEgress::plan_blob(&channels, 0).expect("cross-channel blob"); - let (first_id, _, first_is_last) = plan[0]; - let (second_id, _, second_is_last) = plan[1]; + assert!( + egress.next_submission(&mut channels, DaType::Blob, 1, 6, SubmissionId(0)).is_none() + ); + let submission = egress + .next_submission(&mut channels, DaType::Blob, 2, 6, SubmissionId(1)) + .expect("the tail at the deadline"); - assert_eq!(first_id, [1; 16]); - assert!(first_is_last); - assert_eq!(second_id, [2; 16]); - assert!(!second_is_last); - assert_eq!(channels[0].available_output(), first_bytes); - assert_eq!(channels[1].available_output(), second_bytes); + assert_eq!(frames(&submission), [(FIRST, true)]); } - /// Returns an egress and one channel holding `blobs` worth of output. - fn egress_with_output(blobs: usize) -> (DaEgress, VecDeque) { - let mut channel = channel([1; 16], 0, 10); - fill_open_channel(&mut channel, DaEgress::BLOB_CAPACITY * blobs); - - (DaEgress::new(), VecDeque::from([channel])) + /// Calldata streams full frames from an open channel, then the tail once it closes. + #[test] + fn calldata_streams_full_frames_then_the_closed_tail() { + let mut open = channel(FIRST, 10); + let max_frame_data = open.max_frame_data(); + while open.available_output() < max_frame_data { + add_incompressible_batch(&mut open, 50_000); + } + let mut channels = VecDeque::from([open]); + let mut egress = DaEgress::new(); + + let full = egress + .next_submission(&mut channels, DaType::Calldata, 0, 6, SubmissionId(0)) + .expect("a full frame"); + assert_eq!(frames(&full), [(FIRST, false)]); + + channels[0].close().unwrap(); + let mut last = None; + for id in 1.. { + match egress.next_submission(&mut channels, DaType::Calldata, 0, 6, SubmissionId(id)) { + Some(submission) => last = Some(submission), + None => break, + } + } + assert_eq!(frames(&last.expect("the tail")), [(FIRST, true)]); } + /// A blob carries the tail of a closed channel and fills up with the next channel. #[test] - fn submission_confirms_the_contributing_channel() { - let (mut egress, mut channels) = egress_with_output(1); - - let submission = egress - .next_submission(&mut channels, DaType::Blob, 0, 6, SubmissionId(0)) - .expect("blob submission"); + fn a_blob_packs_a_closed_tail_with_the_next_channel() { + let first = closed_channel_with_a_short_tail(FIRST, 10); + let second = open_channel_with_output(SECOND, DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([first, second]); - assert_eq!(egress.pending_submission_count(), 1); - assert!(!egress.channel_fully_confirmed(&channels[0])); - - assert_eq!(egress.confirm(submission.id), Some(vec![[1; 16]])); - assert_eq!(egress.pending_submission_count(), 0); - assert!(egress.confirm(submission.id).is_none()); + let submission = DaEgress::new() + .next_submission(&mut channels, DaType::Blob, 0, 1, SubmissionId(0)) + .expect("a shared blob"); - // The channel keeps producing frames, so confirmed artifacts are not enough. - assert!(!egress.channel_fully_confirmed(&channels[0])); + assert_eq!(submission.blob_count(), 1); + assert_eq!(frames(&submission), [(FIRST, true), (SECOND, false)]); } + /// A submission carries at most `max_blobs_per_tx` blobs, even when the channel has output + /// for more. #[test] - fn a_requeued_submission_is_leased_again_before_new_output() { - let (mut egress, mut channels) = egress_with_output(2); - let first = egress - .next_submission(&mut channels, DaType::Blob, 0, 1, SubmissionId(0)) - .expect("blob submission"); - let leased = egress.pending_artifacts(first.id).expect("in-flight submission").to_vec(); + fn a_transaction_carries_at_most_max_blobs_per_tx() { + let channel = open_channel_with_output(FIRST, 3 * DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([channel]); - assert_eq!(egress.requeue(first.id), Some(first.frame_count())); - assert_eq!(egress.artifacts().ready_frame_count(), first.frame_count()); - assert_eq!(egress.pending_submission_count(), 0); - assert!(egress.requeue(first.id).is_none()); - - // The channel still holds output, but a retry takes priority over new blobs. - let retry = egress - .next_submission(&mut channels, DaType::Blob, 0, 2, SubmissionId(1)) - .expect("retry submission"); + let submission = DaEgress::new() + .next_submission(&mut channels, DaType::Blob, 0, 2, SubmissionId(0)) + .expect("blob submission"); - assert_eq!(egress.pending_artifacts(retry.id), Some(leased.as_slice())); - assert_eq!(egress.artifacts().len(), 1); + assert_eq!(submission.blob_count(), 2); } + /// Confirming a submission reports the channels it carried and ends its lease, so a second + /// confirmation reports nothing. #[test] - fn blob_submissions_are_capped_by_max_blobs_per_tx() { - let (mut egress, mut channels) = egress_with_output(3); - + fn confirm_reports_the_submitted_channels_once() { + let channel = open_channel_with_output(FIRST, DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([channel]); + let mut egress = DaEgress::new(); let submission = egress - .next_submission(&mut channels, DaType::Blob, 0, 2, SubmissionId(0)) + .next_submission(&mut channels, DaType::Blob, 0, 6, SubmissionId(0)) .expect("blob submission"); - assert_eq!(submission.blob_count(), 2); - assert_eq!(egress.artifacts().len(), 2); + assert_eq!(egress.confirm(submission.id), Some(vec![FIRST])); + assert!(egress.confirm(submission.id).is_none()); } + /// A replay that reaches one artifact of an in-flight submission takes every artifact of that + /// submission, since its transaction lands or fails whole. A confirmed submission ties + /// nothing together. #[test] - fn co_submitted_artifacts_extend_the_replay_closure() { - let (mut egress, mut channels) = egress_with_output(2); + fn an_in_flight_submission_is_replayed_whole() { + let channel = open_channel_with_output(FIRST, 2 * DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([channel]); + let mut egress = DaEgress::new(); let submission = egress .next_submission(&mut channels, DaType::Blob, 0, 2, SubmissionId(0)) - .expect("blob submission"); - let leased = - egress.pending_artifacts(submission.id).expect("in-flight submission").to_vec(); + .expect("two full blobs"); + let leased: Vec<_> = egress.artifacts().into_iter().map(DaArtifact::id).collect(); assert_eq!(leased.len(), 2); let mut affected = vec![leased[0]]; egress.extend_with_submission_artifacts(&mut affected); - assert_eq!(affected, leased); + + egress.confirm(submission.id).expect("confirmed"); + let mut affected = vec![leased[0]]; + egress.extend_with_submission_artifacts(&mut affected); + assert_eq!(affected, [leased[0]]); } + /// Invalidated artifacts leave the ledger with their submission, so a late requeue or + /// confirmation of it does nothing. #[test] - fn invalidating_a_leased_artifact_drops_its_submission() { - let (mut egress, mut channels) = egress_with_output(1); + fn invalidated_artifacts_take_their_submission_with_them() { + let channel = open_channel_with_output(FIRST, DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([channel]); + let mut egress = DaEgress::new(); let submission = egress .next_submission(&mut channels, DaType::Blob, 0, 6, SubmissionId(0)) - .expect("blob submission"); - let leased = - egress.pending_artifacts(submission.id).expect("in-flight submission").to_vec(); + .expect("a full blob"); + let leased: Vec<_> = egress.artifacts().into_iter().map(DaArtifact::id).collect(); egress.invalidate_artifacts(&leased); assert!(egress.artifacts().is_empty()); - assert_eq!(egress.pending_submission_count(), 0); - assert!(egress.pending_artifacts(submission.id).is_none()); + assert!(egress.requeue(submission.id).is_none()); + assert!(egress.confirm(submission.id).is_none()); + } + + /// A channel is fully confirmed only once its last frame went out and every artifact carrying + /// it is confirmed, so confirming the blobs of a channel still open is not enough. + #[test] + fn a_channel_is_fully_confirmed_once_its_last_frame_is() { + let channel = open_channel_with_output(FIRST, DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([channel]); + let mut egress = DaEgress::new(); + let full = egress + .next_submission(&mut channels, DaType::Blob, 0, 6, SubmissionId(0)) + .expect("a full blob"); + egress.confirm(full.id).expect("confirmed"); + assert!(!egress.channel_fully_confirmed(&channels[0])); + + channels[0].close().unwrap(); + let mut rest = Vec::new(); + while let Some(submission) = egress.next_submission( + &mut channels, + DaType::Blob, + 10, + 6, + SubmissionId(rest.len() as u64 + 1), + ) { + rest.push(submission); + } + let last = rest.pop().expect("the tail at the channel deadline"); + for submission in rest { + egress.confirm(submission.id).expect("confirmed"); + } + assert!(!egress.channel_fully_confirmed(&channels[0])); + egress.confirm(last.id).expect("confirmed"); + assert!(egress.channel_fully_confirmed(&channels[0])); + } + + /// Requeuing a submission returns its frame count once, and its frames go out again ahead of + /// the channel's newer output. + #[test] + fn a_requeued_submission_is_leased_again_before_new_output() { + let channel = open_channel_with_output(FIRST, 2 * DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([channel]); + let mut egress = DaEgress::new(); + let first = egress + .next_submission(&mut channels, DaType::Blob, 0, 1, SubmissionId(0)) + .expect("blob submission"); + + assert_eq!(egress.requeue(first.id), Some(first.frame_count())); + assert!(egress.requeue(first.id).is_none()); + let retry = egress + .next_submission(&mut channels, DaType::Blob, 0, 2, SubmissionId(1)) + .expect("the retry"); + + assert!(all_frames(&retry).starts_with(&all_frames(&first))); } + /// Pruning a safe channel drops the confirmed artifacts that carried it. #[test] - fn pruning_a_safe_channel_drops_its_confirmed_artifacts() { - let (mut egress, mut channels) = egress_with_output(1); + fn pruning_a_safe_channel_drops_its_artifacts() { + let channel = open_channel_with_output(FIRST, DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([channel]); + let mut egress = DaEgress::new(); let submission = egress .next_submission(&mut channels, DaType::Blob, 0, 6, SubmissionId(0)) .expect("blob submission"); - egress.confirm(submission.id).expect("confirmed submission"); + egress.confirm(submission.id).expect("confirmed"); - egress.prune_channels(&[[1; 16]]); + egress.prune_channels(&[FIRST]); assert!(egress.artifacts().is_empty()); } + /// A reset drops every artifact and pending submission, so a confirmation for a submission + /// issued before it finds nothing. #[test] - fn reset_clears_artifacts_without_reusing_identifiers() { - let (mut egress, mut channels) = egress_with_output(2); + fn reset_drops_every_artifact_and_submission() { + let channel = open_channel_with_output(FIRST, DaEgress::BLOB_CAPACITY); + let mut channels = VecDeque::from([channel]); + let mut egress = DaEgress::new(); let submission = egress - .next_submission(&mut channels, DaType::Blob, 0, 1, SubmissionId(0)) + .next_submission(&mut channels, DaType::Blob, 0, 6, SubmissionId(0)) .expect("blob submission"); - let leased = - egress.pending_artifacts(submission.id).expect("in-flight submission").to_vec(); egress.reset(); - assert!(egress.artifacts().is_empty()); - assert_eq!(egress.pending_submission_count(), 0); - let rebuilt = egress - .next_submission(&mut channels, DaType::Blob, 0, 1, SubmissionId(1)) - .expect("submission after reset"); - - assert_ne!(egress.pending_artifacts(rebuilt.id), Some(leased.as_slice())); + assert!(egress.artifacts().is_empty()); + assert!(egress.confirm(submission.id).is_none()); } } diff --git a/crates/batcher/encoder/tests/encoder/backlog.rs b/crates/batcher/encoder/tests/encoder/backlog.rs index f5a60c9881..cbf6a01cae 100644 --- a/crates/batcher/encoder/tests/encoder/backlog.rs +++ b/crates/batcher/encoder/tests/encoder/backlog.rs @@ -4,7 +4,7 @@ use alloy_eips::eip2718::Encodable2718; use alloy_primitives::B256; use base_batcher_encoder::{BatchPipeline, EncoderConfig, StepResult}; -use crate::common::{BlockFixture, EncoderFixture}; +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. @@ -28,3 +28,24 @@ fn backlog_counts_user_transaction_bytes_until_confirmation() { } assert_eq!(encoder.da_backlog_bytes(), 0); } + +/// A channel stays in the backlog until its last frame is confirmed, so confirming every blob +/// an open channel emitted so far changes nothing. +#[test] +fn backlog_stays_until_the_last_frame_is_confirmed() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); + let open = OpenChannel::encode(&mut encoder); + let mut emitted = vec![open.first]; + emitted.extend(SubmissionFixture::drain(&mut encoder)); + let backlog = encoder.da_backlog_bytes(); + for submission in &emitted { + encoder.confirm(submission.id, 1); + } + assert_eq!(encoder.da_backlog_bytes(), backlog, "the channel is still open"); + + for submission in encoder.encode_and_drain().unwrap() { + encoder.confirm(submission.id, 1); + } + assert_eq!(encoder.da_backlog_bytes(), 0); +} diff --git a/crates/batcher/encoder/tests/encoder/channels.rs b/crates/batcher/encoder/tests/encoder/channels.rs index 292e834078..115205cc62 100644 --- a/crates/batcher/encoder/tests/encoder/channels.rs +++ b/crates/batcher/encoder/tests/encoder/channels.rs @@ -2,10 +2,12 @@ //! flush does. use alloy_primitives::B256; -use base_batcher_encoder::{BatchPipeline, DaEgress, EncoderConfig, StepResult, SubmissionPayload}; +use base_batcher_encoder::{ + BatchPipeline, DaEgress, DaType, EncoderConfig, StepResult, SubmissionPayload, +}; use rstest::rstest; -use crate::common::{BlockFixture, EncoderFixture, OpenChannel}; +use crate::common::{BlockFixture, EncoderFixture, OpenChannel, SubmissionFixture}; /// A channel opened at L1 head 0 closes when the head reaches /// `max_channel_duration - sub_safety_margin`, and the close releases its output. @@ -93,7 +95,7 @@ fn size_closed_tail_keeps_its_original_deadline() { assert!(encoder.next_submission().is_some(), "the first tail is due"); } -/// An open channel emits every blob it fills without closing. It takes more blocks after the +/// An open channel emits the blobs it fills without closing. It takes more blocks after the /// blob went out, and the frames emitted early and the rest decode as one channel. #[test] fn open_channel_emits_full_blobs_without_closing() { @@ -117,3 +119,31 @@ fn open_channel_emits_full_blobs_without_closing() { assert_eq!(derived.len(), 1, "the early blob and the rest are one channel"); assert_eq!(derived.concat(), batches); } + +/// An open calldata channel emits only full frames, none of them last. The frames emitted +/// early and the rest of the channel decode as one channel. +#[test] +fn open_channel_emits_full_calldata_frames_without_closing() { + let max_frame_size = 1_000; + let config = + EncoderConfig { da_type: DaType::Calldata, max_frame_size, ..EncoderConfig::default() }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + let mut open = OpenChannel::encode(&mut encoder); + let mut submissions = SubmissionFixture::drain(&mut encoder); + for frame in + std::iter::once(&open.first).chain(&submissions).flat_map(SubmissionFixture::frames) + { + assert!(!frame.is_last, "the channel is still open"); + assert_eq!(frame.encoded_len(), max_frame_size, "only full frames leave an open channel"); + } + + open.add_next_block(&mut encoder); + let batches = open.batches(); + + submissions.insert(0, open.first); + submissions.extend(encoder.encode_and_drain().unwrap()); + let derived = fixture.derive(&submissions); + assert_eq!(derived.len(), 1, "the early frames and the rest are one channel"); + assert_eq!(derived.concat(), batches); +} diff --git a/crates/batcher/encoder/tests/encoder/common.rs b/crates/batcher/encoder/tests/encoder/common.rs index afbc67040b..320cbb936d 100644 --- a/crates/batcher/encoder/tests/encoder/common.rs +++ b/crates/batcher/encoder/tests/encoder/common.rs @@ -101,6 +101,42 @@ impl SharedBlob { } } +/// Two channels retried in one transaction. The first channel's only blob and the second +/// channel's tail are requeued together once the second channel's two full blobs went out. +#[derive(Debug)] +pub struct SharedRetry { + /// The two blocks, one per channel. + pub blocks: Vec, + /// The two full blobs of the second channel. + pub full_blobs: BatchSubmission, + /// The transaction carrying the first channel's blob and the second channel's tail. + pub retry: BatchSubmission, +} + +impl SharedRetry { + /// Feed the two blocks to an encoder built with + /// [`one_channel_per_block(2)`](EncoderFixture::one_channel_per_block), and requeue the two + /// submissions that come back in one transaction. + pub fn encode(encoder: &mut BatchEncoder) -> Self { + let first_block = BlockFixture::block(B256::ZERO, 1, 0); + let second_block = + BlockFixture::block(first_block.header.hash_slow(), 2, THREE_BLOB_PAYLOAD); + encoder.add_block(first_block.clone()).unwrap(); + let [first] = <[_; 1]>::try_from(encoder.encode_and_drain().unwrap()).unwrap(); + encoder.add_block(second_block.clone()).unwrap(); + let mut second = encoder.encode_and_drain().unwrap(); + assert_eq!(second.len(), 2, "two full blobs, then the tail"); + let tail = second.pop().expect("the tail"); + let full_blobs = second.pop().expect("the full blobs"); + + encoder.requeue(first.id); + encoder.requeue(tail.id); + let retry = encoder.next_submission().expect("the retry"); + assert_eq!(retry.blob_count(), 2, "one transaction carries both channels"); + Self { blocks: vec![first_block, second_block], full_blobs, retry } + } +} + /// An open channel that has handed out its first submission, fed blocks of /// [`MULTI_BLOB_PAYLOAD`] one at a time until the encoder released output. #[derive(Debug)] diff --git a/crates/batcher/encoder/tests/encoder/packing.rs b/crates/batcher/encoder/tests/encoder/packing.rs index f3c55e81c9..52aa0d2fa9 100644 --- a/crates/batcher/encoder/tests/encoder/packing.rs +++ b/crates/batcher/encoder/tests/encoder/packing.rs @@ -1,27 +1,63 @@ -//! The blob override of `BatchEncoder`. +//! How `BatchEncoder` packs blobs into transactions, and the blob override. use alloy_primitives::B256; -use base_batcher_encoder::{BatchPipeline, DaType, EncoderConfig, StepResult}; +use base_batcher_encoder::{BatchPipeline, BatchSubmission, DaType, EncoderConfig}; -use crate::common::{BlockFixture, EncoderFixture}; +use crate::common::{BlockFixture, EncoderFixture, SubmissionFixture, THREE_BLOB_PAYLOAD}; -/// While the blob override is active, a calldata encoder emits blobs, and a retry keeps the -/// DA type its submission was built with. +/// Blobs go out in transactions of `max_blobs_per_tx`, the remainder in a shorter last one. #[test] -fn blob_override_switches_a_calldata_encoder_to_blobs() { - let config = EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }; +fn transactions_carry_up_to_max_blobs_per_tx() { + let config = EncoderConfig { max_blobs_per_tx: 2, ..EncoderConfig::default() }; let fixture = EncoderFixture::new(config); let mut encoder = fixture.encoder(); - encoder.add_block(BlockFixture::block(B256::ZERO, 1, 0)).unwrap(); - assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - encoder.flush().unwrap(); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, THREE_BLOB_PAYLOAD)).unwrap(); + + let submissions = encoder.encode_and_drain().unwrap(); + + let counts: Vec<_> = submissions.iter().map(BatchSubmission::blob_count).collect(); + assert_eq!(counts, [2, 1]); +} +/// The blob override only moves a calldata encoder to blobs. A blob encoder, the default, +/// keeps sending blobs while the driver holds the override on during throttling. +#[test] +fn blob_override_keeps_a_blob_encoder_on_blobs() { + let fixture = EncoderFixture::new(EncoderConfig::default()); + let mut encoder = fixture.encoder(); encoder.set_blob_override(true); - let submission = encoder.next_submission().expect("submission under the override"); + encoder.add_block(BlockFixture::block(B256::ZERO, 1, 0)).unwrap(); + + let [submission] = <[_; 1]>::try_from(encoder.encode_and_drain().unwrap()).unwrap(); + assert_eq!(submission.da_type(), DaType::Blob); +} + +/// Retries go out in the order their data was built, across DA types. With the blob override +/// on, off and on again, the third block's blob is not packed with the first one ahead of the +/// calldata between them, which would land the third block before the second on L1. +#[test] +fn retries_keep_their_order_across_da_types() { + let config = EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }; + let fixture = EncoderFixture::new(config); + let mut encoder = fixture.encoder(); + let mut built = Vec::new(); + for (block, blob_override) in BlockFixture::chain(3, 0).into_iter().zip([true, false, true]) { + encoder.set_blob_override(blob_override); + encoder.add_block(block).unwrap(); + let [submission] = <[_; 1]>::try_from(encoder.encode_and_drain().unwrap()).unwrap(); + built.push(submission); + } + let da_types: Vec<_> = built.iter().map(BatchSubmission::da_type).collect(); + assert_eq!(da_types, [DaType::Blob, DaType::Calldata, DaType::Blob]); + for submission in &built { + encoder.requeue(submission.id); + } - encoder.requeue(submission.id); - encoder.set_blob_override(false); - let retry = encoder.next_submission().expect("retry after the override"); - assert_eq!(retry.da_type(), DaType::Blob); + for submission in &built { + let retry = encoder.next_submission().expect("a retry"); + assert_eq!(retry.da_type(), submission.da_type()); + assert_eq!(SubmissionFixture::frames(&retry), SubmissionFixture::frames(submission)); + } + assert!(encoder.next_submission().is_none()); } diff --git a/crates/batcher/encoder/tests/encoder/replay.rs b/crates/batcher/encoder/tests/encoder/replay.rs index f32f0a6fb1..6bc8d0a8de 100644 --- a/crates/batcher/encoder/tests/encoder/replay.rs +++ b/crates/batcher/encoder/tests/encoder/replay.rs @@ -8,7 +8,7 @@ use rstest::rstest; use crate::common::{ BlockFixture, CHANNEL_TIMEOUT, EncoderFixture, MULTI_BLOB_PAYLOAD, MULTI_FRAME_PAYLOAD, - SharedBlob, SubmissionFixture, THREE_BLOB_PAYLOAD, + SharedBlob, SharedRetry, SubmissionFixture, }; /// A complete channel whose confirmations are more than the timeout apart, in either order, is @@ -135,36 +135,39 @@ fn replay_includes_the_channel_sharing_a_blob() { fn replay_includes_the_channel_sharing_a_transaction() { let fixture = EncoderFixture::one_channel_per_block(2); let mut encoder = fixture.encoder(); - let first_block = BlockFixture::block(B256::ZERO, 1, 0); - let second_block = BlockFixture::block(first_block.header.hash_slow(), 2, THREE_BLOB_PAYLOAD); - encoder.add_block(first_block.clone()).unwrap(); - let first = encoder.encode_and_drain().unwrap(); - encoder.add_block(second_block.clone()).unwrap(); - let second = encoder.encode_and_drain().unwrap(); - assert_eq!(second.len(), 2, "two full blobs, then the tail"); + let shared = SharedRetry::encode(&mut encoder); - // The first channel's blob and the second channel's tail are retried in one transaction. - encoder.confirm(second[0].id, 1); - encoder.requeue(first[0].id); - encoder.requeue(second[1].id); - let retry = encoder.next_submission().expect("the retry"); - assert_eq!(retry.blob_count(), 2, "one transaction carries both channels"); + // The second channel's window closes with its tail, hence the retry, still in flight. + encoder.confirm(shared.full_blobs.id, 1); encoder.advance_l1_head(1 + CHANNEL_TIMEOUT + 1); let replay = encoder.encode_and_drain().unwrap(); - assert_eq!( - fixture.derive(&replay).concat(), - BlockFixture::batches(&[first_block, second_block]) - ); + assert_eq!(fixture.derive(&replay).concat(), BlockFixture::batches(&shared.blocks)); for submission in &replay { encoder.confirm(submission.id, 5); } assert_eq!(encoder.da_backlog_bytes(), 0); } +/// A transaction that landed no longer ties its channels together, so replaying one of them +/// leaves the other, fully confirmed, alone. +#[test] +fn a_landed_transaction_does_not_pull_its_other_channel_into_a_replay() { + let fixture = EncoderFixture::one_channel_per_block(2); + let mut encoder = fixture.encoder(); + let shared = SharedRetry::encode(&mut encoder); + + // The second channel's window closes with its full blobs still in flight. + encoder.confirm(shared.retry.id, 1); + encoder.advance_l1_head(1 + CHANNEL_TIMEOUT + 1); + + let replay = encoder.encode_and_drain().unwrap(); + assert_eq!(fixture.derive(&replay).concat(), BlockFixture::batches(&shared.blocks[1..])); +} + /// Replaying a channel discards every later channel too, but not the earlier ones that landed -/// in time, and the submissions of the discarded channels are forgotten, so requeuing them -/// sends nothing. +/// in time, which stay confirmed. The submissions of the discarded channels are forgotten, so +/// requeuing them sends nothing. #[test] fn replay_discards_every_later_channel() { let fixture = EncoderFixture::one_channel_per_block(1); @@ -195,4 +198,8 @@ fn replay_discards_every_later_channel() { SubmissionFixture::drain(&mut encoder).is_empty(), "the discarded submissions are forgotten" ); + for submission in &replay { + encoder.confirm(submission.id, 5); + } + assert_eq!(encoder.da_backlog_bytes(), 0, "the first channel is still confirmed"); } From 6e6fc11680f624573fe0da3466869da806a125b6 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Wed, 30 Sep 2026 14:52:13 +0000 Subject: [PATCH 177/299] refactor(base): inline RethCommand and SnapshotCommand run wrappers (#5429) Co-authored-by: Toshi --- bin/base/src/commands/command.rs | 4 ++-- bin/base/src/commands/reth.rs | 6 ------ bin/base/src/commands/snapshot.rs | 6 ------ 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/bin/base/src/commands/command.rs b/bin/base/src/commands/command.rs index 55e8d37878..ea0d53026a 100644 --- a/bin/base/src/commands/command.rs +++ b/bin/base/src/commands/command.rs @@ -66,7 +66,7 @@ impl BaseCommand { Self::Update(update) => (*update).run(), Self::Reth(reth) => { chain_resolver.reject_for_reth_command("base reth")?; - (*reth).run() + reth.command.run() } Self::Proofs(command) => { chain_resolver.reject_for_reth_command("base proofs")?; @@ -76,7 +76,7 @@ impl BaseCommand { } Self::Snapshot(snapshot) => { chain_resolver.reject_for_reth_command("base snapshot")?; - (*snapshot).run() + snapshot.command.run() } } } diff --git a/bin/base/src/commands/reth.rs b/bin/base/src/commands/reth.rs index 4e31b45ca7..b192fbae43 100644 --- a/bin/base/src/commands/reth.rs +++ b/bin/base/src/commands/reth.rs @@ -22,12 +22,6 @@ pub(crate) struct RethCommand { pub(crate) command: RethSubcommand, } -impl RethCommand { - pub(crate) fn run(self) -> eyre::Result<()> { - self.command.run() - } -} - /// Subcommands for `base reth`. #[derive(Subcommand, Debug)] #[non_exhaustive] diff --git a/bin/base/src/commands/snapshot.rs b/bin/base/src/commands/snapshot.rs index b239cf0b9c..5e109d3b3b 100644 --- a/bin/base/src/commands/snapshot.rs +++ b/bin/base/src/commands/snapshot.rs @@ -16,12 +16,6 @@ pub(crate) struct SnapshotCommand { pub(crate) command: SnapshotSubcommand, } -impl SnapshotCommand { - pub(crate) fn run(self) -> eyre::Result<()> { - self.command.run() - } -} - /// Subcommands for `base snapshot`. #[derive(Subcommand, Debug)] #[non_exhaustive] From 30135a4e24ae988152a48747de03d2b928c70aab Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Wed, 30 Sep 2026 14:52:30 +0000 Subject: [PATCH 178/299] refactor(bootnode): inline BootnodeCommand::run_execution (#5428) --- bin/base/src/commands/bootnode.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/bin/base/src/commands/bootnode.rs b/bin/base/src/commands/bootnode.rs index 66ff96d290..a94c1d351b 100644 --- a/bin/base/src/commands/bootnode.rs +++ b/bin/base/src/commands/bootnode.rs @@ -49,7 +49,7 @@ impl BootnodeCommand { let mut consensus_bootnode = tokio::spawn(Self::run_consensus(self.consensus, chain_id)); - let mut execution_bootnode = tokio::spawn(Self::run_execution(self.execution)); + let mut execution_bootnode = tokio::spawn(self.execution.execute()); tokio::select! { result = &mut consensus_bootnode => { @@ -97,10 +97,6 @@ impl BootnodeCommand { Ok(()) } - async fn run_execution(execution: ExecutionBootnodeCommand) -> eyre::Result<()> { - execution.execute().await - } - async fn stop_task( layer: &'static str, task: JoinHandle>, From 44d76883fd0943f5ad0c118f07714d40cdfb0ed9 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 17:50:30 +0000 Subject: [PATCH 179/299] test(harness): rename Batcher::confirm_staged to observe_l1_block (#5346) * test(harness): rename Batcher::confirm_staged to observe_l1_block * docs(harness): describe observe_l1_block in plain sentences * test(harness): drop two em dashes in the renamed reorg test comments * test(harness): document the remaining batcher tests and fix two stale comments --- actions/README.md | 9 +++--- actions/harness/README.md | 2 +- actions/harness/src/batcher/actor.rs | 32 +++++++++---------- actions/harness/tests/batcher/channels.rs | 25 +++++++-------- actions/harness/tests/batcher/submission.rs | 10 ++++-- .../tests/batcher/upgrade_transitions.rs | 4 +-- actions/harness/tests/derivation/main.rs | 10 +++--- actions/harness/tests/upgrade/holocene.rs | 12 +++---- 8 files changed, 54 insertions(+), 50 deletions(-) diff --git a/actions/README.md b/actions/README.md index 537aea3908..ed8234a45f 100644 --- a/actions/README.md +++ b/actions/README.md @@ -168,11 +168,10 @@ turns the driver's transaction candidates into signed L1 transactions and lets tests control when those transactions are staged, mined, confirmed, failed, or reorged. -For the common happy path, call `batcher.advance(&mut h.l1).await`: it drains -the L2 source, flushes the encoder, mines one L1 block, and confirms the -resulting receipts. For more exact scenarios, use `encode_only`, -`stage_n_frames`, `confirm_staged`, `mine_pending`, `fail_next_n_submissions` -and `reorg`. +For the common happy path, call `batcher.advance(&mut h.l1).await`. It drains +the L2 source, flushes the encoder, mines one L1 block, and shows it to the +driver. For more exact scenarios, use `encode_only`, `stage_n_frames`, +`observe_l1_block`, `mine_pending`, `fail_next_n_submissions` and `reorg`. Every `async` method of `Batcher` returns once the driver is idle again, so the test can read the tx manager's queues right after. diff --git a/actions/harness/README.md b/actions/harness/README.md index d6ba845b6c..17593c01a8 100644 --- a/actions/harness/README.md +++ b/actions/harness/README.md @@ -198,7 +198,7 @@ The production-shaped synthetic L1/DA implementation is now the default: signer recovery and inbox filtering. 4. Use the L1 event helpers for system-config, operator-fee, and deposit tests so derivation reads logs from signed transaction receipts. -5. Use `Batcher::stage_n_frames`, `Batcher::confirm_staged`, and +5. Use `Batcher::stage_n_frames`, `Batcher::observe_l1_block`, and `Batcher::staged_count` when a test needs to distinguish submission from L1 inclusion. diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index beccbf784c..42155ec6b7 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -99,9 +99,8 @@ pub enum BatcherError { /// 3. Flush through the admin channel, exactly as an operator would, to close and /// release the current channel. /// 4. Wait for the driver to hand every resulting submission to the tx manager. -/// 5. Stage every pending submission, mine one L1 block, fire the receipts of the -/// submissions it includes and deliver the new L1 head to the driver. -/// 6. Wait for the driver to confirm the receipts and advance its L1 head. +/// 5. Stage every pending submission, mine one L1 block and show it to the driver. +/// 6. Wait for the driver to apply the block's receipts and its new L1 head. /// /// The driver's [`BatchEncoder`] state is persistent across `advance()` calls. /// The driver task continues running between cycles, waiting for new events. @@ -273,20 +272,19 @@ impl Batcher { self.wait_until_idle().await } - /// Fire receipts for the staged items included in `block`, then deliver its number as - /// the new L1 head. - async fn try_confirm_staged(&self, block: &L1Block) -> Result<(), BatcherError> { + /// Fallible variant of [`observe_l1_block`](Self::observe_l1_block). + async fn try_observe_l1_block(&self, block: &L1Block) -> Result<(), BatcherError> { // Receipts first: the driver serves them before L1 heads, so a failed submission is // requeued before the head advances. self.tx_manager.confirm_block(block); self.deliver_l1_head(block.number()).await } - /// Stage every pending submission, mine one L1 block and confirm it. + /// Fallible variant of [`mine_pending`](Self::mine_pending). async fn try_mine_pending(&self, l1: &mut L1Miner) -> Result { self.tx_manager.stage_n_to_l1(l1, usize::MAX); let block = l1.mine_block().clone(); - self.try_confirm_staged(&block).await?; + self.try_observe_l1_block(&block).await?; Ok(block.number()) } @@ -348,17 +346,19 @@ impl Batcher { self.tx_manager.drop_n(n) } - /// Fire receipts for all staged items included in `block`, deliver its number to the - /// driver as the new L1 head, and wait until the driver has applied both. + /// Show a mined L1 block to the driver, as the tx manager's receipt polling and the L1 + /// head source would. Fires the receipts of the staged submissions the block includes, + /// delivers its number as the new L1 head and waits until the driver has applied both. A + /// block without any of the batcher's transactions only advances the L1 head. /// /// # Panics /// /// Panics if the driver task has exited, or did not catch up within /// [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). - pub async fn confirm_staged(&self, block: &L1Block) { - self.try_confirm_staged(block) + pub async fn observe_l1_block(&self, block: &L1Block) { + self.try_observe_l1_block(block) .await - .unwrap_or_else(|e| panic!("Batcher::confirm_staged failed: {e}")); + .unwrap_or_else(|e| panic!("Batcher::observe_l1_block failed: {e}")); } /// Simulate an L1 reorg back to `block_number`. @@ -367,7 +367,7 @@ impl Batcher { /// item in `pending` and `staged`, delivers the new L1 head to the driver, and waits /// until the driver has requeued and resubmitted the failed frames. /// - /// Submissions already confirmed through [`confirm_staged`] are not revisited. + /// Submissions already confirmed through [`observe_l1_block`] are not revisited. /// /// # Panics /// @@ -375,9 +375,9 @@ impl Batcher { /// (`ReorgError::BeyondTip`), or if the driver task has exited or did not catch up /// within [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). /// - /// [`confirm_staged`]: Batcher::confirm_staged + /// [`observe_l1_block`]: Batcher::observe_l1_block pub async fn reorg(&self, block_number: u64, l1: &mut L1Miner) { - // Failure receipts first, for the same reason as in `try_confirm_staged`. + // Failure receipts first, for the same reason as in `try_observe_l1_block`. self.tx_manager.reorg_to(block_number, l1); self.deliver_l1_head(block_number) .await diff --git a/actions/harness/tests/batcher/channels.rs b/actions/harness/tests/batcher/channels.rs index 2e98c82b29..5a188b3d03 100644 --- a/actions/harness/tests/batcher/channels.rs +++ b/actions/harness/tests/batcher/channels.rs @@ -54,7 +54,7 @@ async fn channel_timeout_triggers_channel_invalidation() { batcher.stage_n_frames(&mut h.l1, 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; node.initialize().await; node.run_until_idle().await; @@ -74,7 +74,7 @@ async fn channel_timeout_triggers_channel_invalidation() { batcher.stage_n_frames(&mut h.l1, frame_count - 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; let derived = node.run_until_idle().await; assert_eq!(derived, 0, "late frames after channel timeout must be ignored"); @@ -138,7 +138,7 @@ async fn channel_timeout_recovery_resubmits_successfully() { batcher.stage_n_frames(&mut h.l1, 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; node.initialize().await; @@ -225,8 +225,8 @@ async fn interleaved_channels_correctly_reassembled() { // Mine one L1 block containing all interleaved frames. h.l1.mine_block(); - batcher_a.confirm_staged(h.l1.tip()).await; - batcher_b.confirm_staged(h.l1.tip()).await; + batcher_a.observe_l1_block(h.l1.tip()).await; + batcher_b.observe_l1_block(h.l1.tip()).await; let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( &mut sequencer, @@ -285,7 +285,7 @@ async fn multi_block_channel_assembles_across_l1_blocks() { batcher.stage_n_frames(&mut h.l1, 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; node.initialize().await; node.run_until_idle().await; @@ -300,7 +300,7 @@ async fn multi_block_channel_assembles_across_l1_blocks() { batcher.stage_n_frames(&mut h.l1, frame_count - 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; let derived = node.run_until_idle().await; @@ -362,7 +362,7 @@ async fn multi_frame_channel_with_empty_l1_gap_derives_correctly() { batcher.stage_n_frames(&mut h.l1, 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; node.initialize().await; node.run_until_idle().await; @@ -373,19 +373,18 @@ async fn multi_frame_channel_with_empty_l1_gap_derives_correctly() { "incomplete channel after block 1; safe head must stay at genesis" ); - // Mine an empty L1 block 2 and confirm it: encode_only() already closed the channel - // (admin flush), so this fires no receipt and only advances the driver's L1 head to 2. - // The remaining frames are already in `pending`. + // Mine an empty L1 block 2 and show it to the batcher. It holds no batcher transaction, so + // it only moves the driver's L1 head to 2. The remaining frames wait in `pending`. h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; // L1 block 3: submit the remaining frames. let remaining = batcher.pending_count(); batcher.stage_n_frames(&mut h.l1, remaining); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; // Signal node for all L1 blocks. Track the total L2 blocks derived // to confirm exactly one block was produced across the 3-block span. diff --git a/actions/harness/tests/batcher/submission.rs b/actions/harness/tests/batcher/submission.rs index 131ecb2a1e..a84aa02d63 100644 --- a/actions/harness/tests/batcher/submission.rs +++ b/actions/harness/tests/batcher/submission.rs @@ -10,6 +10,7 @@ use base_batcher_encoder::{DaType, EncoderConfig}; // Batcher: persistent pipeline end-to-end path // --------------------------------------------------------------------------- +/// `advance` on three L2 blocks mines an L1 block that holds the batcher's submissions. #[tokio::test] async fn batcher_mines_block_with_submissions() { let mut h = ActionTestHarness::default(); @@ -27,6 +28,7 @@ async fn batcher_mines_block_with_submissions() { ); } +/// `try_advance` fails with `NoBlocks` when the source has no L2 block to batch. #[tokio::test] async fn batcher_errors_when_no_l2_blocks_async() { let mut h = ActionTestHarness::default(); @@ -38,6 +40,8 @@ async fn batcher_errors_when_no_l2_blocks_async() { assert!(matches!(err, BatcherError::NoBlocks)); } +/// A one-byte compressed size target makes every block close its own calldata channel, and +/// derivation still makes the four blocks safe one by one, in order. #[tokio::test] async fn batcher_soft_channel_target_derives_exact_blocks() { const BLOCK_COUNT: u64 = 4; @@ -93,7 +97,7 @@ async fn batcher_soft_channel_target_derives_exact_blocks() { /// /// Sequence: /// 1. Encode and stage all frames; mine L1 block 1 (original). -/// 2. Reorg to genesis **before** calling `confirm_staged` — frames are still +/// 2. Reorg to genesis **before** calling `observe_l1_block`. The frames are still /// in `staged`, so `reorg_to` fires `Err(TxManagerError::Rpc("reorg"))` on /// each oneshot responder. /// 3. The driver handles each failed receipt: `pipeline.requeue(id)` rewinds the @@ -124,7 +128,7 @@ async fn batcher_reorg_during_submission() { ); // Encode and stage all frames; mine L1 block 1 (original). - // Do NOT call confirm_staged — frames remain in `staged` so the reorg + // Do NOT call observe_l1_block, so the frames remain in `staged` and the reorg // below fires failure receipts for them. let mut source = ActionL2Source::new(); source.push(block); @@ -150,7 +154,7 @@ async fn batcher_reorg_during_submission() { batcher.stage_n_frames(&mut h.l1, usize::MAX); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; // Verify the node re-derives L2 block 1 from the new-fork submission. node.initialize().await; diff --git a/actions/harness/tests/batcher/upgrade_transitions.rs b/actions/harness/tests/batcher/upgrade_transitions.rs index 1276545cf0..6b84ab89ef 100644 --- a/actions/harness/tests/batcher/upgrade_transitions.rs +++ b/actions/harness/tests/batcher/upgrade_transitions.rs @@ -361,7 +361,7 @@ async fn granite_channel_timeout_enforced() { batcher.stage_n_frames(&mut h.l1, 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; node.initialize().await; node.run_until_idle().await; @@ -395,7 +395,7 @@ async fn granite_channel_timeout_enforced() { batcher.stage_n_frames(&mut h.l1, frame_count - 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; let derived = node.run_until_idle().await; assert_eq!( diff --git a/actions/harness/tests/derivation/main.rs b/actions/harness/tests/derivation/main.rs index 311998083d..e0ba0ea6f1 100644 --- a/actions/harness/tests/derivation/main.rs +++ b/actions/harness/tests/derivation/main.rs @@ -390,7 +390,7 @@ async fn orphaned_partial_channel_does_not_combine_with_canonical_remainder() { batcher.stage_n_frames(&mut h.l1, 1); h.l1.mine_block(); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); node.initialize().await; @@ -405,7 +405,7 @@ async fn orphaned_partial_channel_does_not_combine_with_canonical_remainder() { assert!(remaining > 0, "test requires canonical remainder frames"); batcher.stage_n_frames(&mut h.l1, remaining); h.l1.mine_block(); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); node.act_reset(h.l2_genesis()).await; @@ -1348,11 +1348,11 @@ async fn multi_frame_channel_reassembled() { batcher.pending_count() ); - // Stage all frames, mine one L1 block, and confirm. + // Stage all frames, mine one L1 block and show it to the batcher. let n = batcher.pending_count(); batcher.stage_n_frames(&mut h.l1, n); h.l1.mine_block(); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); node.initialize().await; @@ -1921,6 +1921,7 @@ async fn derive_chain_from_near_l1_genesis() { // Blob DA derivation tests // --------------------------------------------------------------------------- +/// One L2 block batched in a blob is derived and becomes the safe head. #[tokio::test] async fn single_l2_block_derived_from_blob() { let batcher_cfg = BatcherConfig::default(); // DaType::Blob by default @@ -1946,6 +1947,7 @@ async fn single_l2_block_derived_from_blob() { assert_eq!(node.l2_safe_number(), 1, "safe head should be L2 block 1"); } +/// Three L2 blocks batched in a blob are all derived, and the last one becomes the safe head. #[tokio::test] async fn multiple_l2_blocks_derived_from_blob() { const L2_BLOCK_COUNT: u64 = 3; diff --git a/actions/harness/tests/upgrade/holocene.rs b/actions/harness/tests/upgrade/holocene.rs index 36d4442fc0..f7bc5dc98f 100644 --- a/actions/harness/tests/upgrade/holocene.rs +++ b/actions/harness/tests/upgrade/holocene.rs @@ -128,7 +128,7 @@ async fn holocene_activation_flushes_buffered_pre_holocene_channel_data() { batcher.stage_n_frames(&mut h.l1, 1); h.l1.mine_block(); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); node.initialize().await; @@ -145,7 +145,7 @@ async fn holocene_activation_flushes_buffered_pre_holocene_channel_data() { assert!(remaining > 0, "test requires post-Holocene remainder frames"); batcher.stage_n_frames(&mut h.l1, remaining); h.l1.mine_block(); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); let derived = node.run_until_idle().await; @@ -304,7 +304,7 @@ async fn holocene_non_sequential_frame_pruned_channel_never_completes() { batcher.drop_n_frames(1); // drop frame 1 batcher.stage_n_frames(&mut h.l1, 1); // frame 2 (non-sequential) h.l1.mine_block(); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); // L1 block 1: frames 0 and 2 node.initialize().await; @@ -411,7 +411,7 @@ async fn holocene_new_channel_abandons_incomplete_old_channel() { // L1 block 1: only frame 0 of channel A (channel is incomplete). batcher_a.stage_n_frames(&mut h.l1, 1); // frame 0 of channel A h.l1.mine_block(); - batcher_a.confirm_staged(h.l1.tip()).await; + batcher_a.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); // L1 block 1: frame 0 of channel A node.initialize().await; @@ -432,7 +432,7 @@ async fn holocene_new_channel_abandons_incomplete_old_channel() { batcher_b.stage_n_frames(&mut h.l1, 1); } h.l1.mine_block(); - batcher_b.confirm_staged(h.l1.tip()).await; + batcher_b.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); // L1 block 2: all frames of channel B node.run_until_idle().await; @@ -514,7 +514,7 @@ async fn holocene_non_sequential_frame_pruned_then_recovery_succeeds() { batcher.drop_n_frames(1); // drop frame 1 batcher.stage_n_frames(&mut h.l1, 1); // frame 2 (non-sequential) h.l1.mine_block(); - batcher.confirm_staged(h.l1.tip()).await; + batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); node.initialize().await; From f7a01510ecc688f6c479065f1da07eedc456bb09 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 18:16:04 +0000 Subject: [PATCH 180/299] test(harness): let the batcher poll the L2 chain the test builds and catch up from the safe head (#5347) * test(harness): let the batcher poll the L2 chain the test builds and catch up from the safe head * test(harness): tighten the L2 chain docs and simplify the DA switching test * refactor(harness): drop the unused ActionL2Source impls and fix the batcher docs * test(harness): drop the reorg test of a chain no node serves * docs(harness): list the batcher L2 chain and L1 head sources in the README tree * docs(harness): point the README tree at the sequencer and common modules * test(harness): release the chain lock before waking the source and document its tests * test(harness): check a fork drops the old blocks above it --- actions/README.md | 38 +-- actions/harness/Cargo.toml | 2 +- actions/harness/README.md | 2 +- actions/harness/src/batcher/actor.rs | 263 +++++++----------- actions/harness/src/batcher/l2_chain.rs | 204 ++++++++++++++ actions/harness/src/batcher/mod.rs | 5 +- actions/harness/src/common/l2_source.rs | 49 +--- actions/harness/src/lib.rs | 4 +- actions/harness/tests/azul/derivation.rs | 2 +- actions/harness/tests/azul/env.rs | 2 +- actions/harness/tests/batcher/blobs.rs | 17 +- actions/harness/tests/batcher/channels.rs | 16 +- actions/harness/tests/batcher/gap_filling.rs | 123 +------- .../harness/tests/batcher/sequencer_drift.rs | 4 +- actions/harness/tests/batcher/submission.rs | 24 +- .../tests/batcher/submission_failure.rs | 4 +- .../tests/batcher/upgrade_transitions.rs | 4 +- actions/harness/tests/beryl/env.rs | 2 +- actions/harness/tests/derivation/main.rs | 29 +- actions/harness/tests/everest/derivation.rs | 2 +- actions/harness/tests/everest/env.rs | 2 +- actions/harness/tests/finalization.rs | 14 +- actions/harness/tests/unsafe_gossip.rs | 2 +- actions/harness/tests/upgrade/activation.rs | 4 +- actions/harness/tests/upgrade/holocene.rs | 14 +- .../harness/tests/upgrade/operator_fees.rs | 8 +- 26 files changed, 417 insertions(+), 423 deletions(-) create mode 100644 actions/harness/src/batcher/l2_chain.rs diff --git a/actions/README.md b/actions/README.md index ed8234a45f..bb1af86939 100644 --- a/actions/README.md +++ b/actions/README.md @@ -51,11 +51,14 @@ actions/ │ ├── provider.rs SharedL1Chain, ActionL1ChainProvider │ ├── block_fetcher.rs ActionL1BlockFetcher │ └── blob.rs ActionBlobProvider - ├── l2.rs L2Sequencer, ActionL2Source, TestAccount + ├── sequencer/ L2Sequencer and its engine, origin and gossip actors + ├── common/ ActionL2Source, TestAccount, SharedBlockHashRegistry ├── p2p.rs SupervisedP2P, TestGossipTransport ├── node.rs TestRollupNode, derivation / verifier pipelines ├── batcher/ │ ├── actor.rs Batcher actor + │ ├── l2_chain.rs SharedL2Chain, HarnessBlockSource + │ ├── source.rs HarnessL1HeadSource │ └── tx_manager.rs L1MinerTxManager (inbox submission) └── providers/ └── l2.rs ActionL2ChainProvider @@ -145,8 +148,8 @@ in-memory. ## ActionL2Source and BaseBlock -The batcher actor needs to read L2 blocks in order to know what to batch. -`ActionL2Source` is a `VecDeque`. Tests usually fill it with blocks +`ActionL2Source` holds the `BaseBlock`s a batcher is created with, its L2 chain +so far. Tests usually fill it with blocks produced by `L2Sequencer`, which uses the production L1 origin selector, attributes builder, and in-process engine client. Each block therefore contains a real L1-info deposit transaction and signed user transactions, @@ -160,18 +163,21 @@ manually. ## Batcher actor -`Batcher` drains `BaseBlock`s from an `ActionL2Source` and forwards them to a -production `BatchDriver` running in a background tokio task. The driver owns a -`BatchEncoder`, channel manager behavior, calldata/blob frame construction, -and submission flow. The harness-owned boundary is `L1MinerTxManager`, which -turns the driver's transaction candidates into signed L1 transactions and -lets tests control when those transactions are staged, mined, confirmed, -failed, or reorged. - -For the common happy path, call `batcher.advance(&mut h.l1).await`. It drains -the L2 source, flushes the encoder, mines one L1 block, and shows it to the -driver. For more exact scenarios, use `encode_only`, `stage_n_frames`, -`observe_l1_block`, `mine_pending`, `fail_next_n_submissions` and `reorg`. +`Batcher` runs a production `BatchDriver` in a background tokio task over the +L2 chain the test builds, made of the blocks of an `ActionL2Source` and those +pushed later with `push_block`. The driver polls that chain by block number +like the production source polls its L2 node, so after a reset it catches up +again from the safe head. The driver owns a `BatchEncoder`, which builds the +channels and their calldata or blob frames, and the submission flow. The +harness-owned boundary is `L1MinerTxManager`, which turns the driver's +transaction candidates into signed L1 transactions and lets tests control when +those transactions are staged, mined, confirmed, failed, or reorged. + +For the common happy path, call `batcher.advance(&mut h.l1).await`. It waits +for the driver to encode every block pushed so far, flushes the encoder, mines +one L1 block, and shows it to the driver. For more exact scenarios, use +`encode_only`, `stage_n_frames`, `observe_l1_block`, `mine_pending`, +`fail_next_n_submissions` and `reorg`. Every `async` method of `Batcher` returns once the driver is idle again, so the test can read the tx manager's queues right after. @@ -192,7 +198,7 @@ async fn example_action_test() { // Step 2: build real L2 blocks and batch them into one L1 block. let source = h.create_l2_source(5).await; - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); batcher.advance(&mut h.l1).await; // Step 3: inspect the signed L1 submissions. diff --git a/actions/harness/Cargo.toml b/actions/harness/Cargo.toml index 6c897be9c2..1449ec079b 100644 --- a/actions/harness/Cargo.toml +++ b/actions/harness/Cargo.toml @@ -55,7 +55,7 @@ base-common-chains.workspace = true base-execution-evm.workspace = true base-consensus-rpc.workspace = true base-consensus-node.workspace = true -base-batcher-source = { workspace = true, features = ["test-utils"] } +base-batcher-source.workspace = true base-batcher-encoder = { workspace = true, features = ["test-utils"] } base-consensus-safedb.workspace = true base-consensus-gossip.workspace = true diff --git a/actions/harness/README.md b/actions/harness/README.md index 17593c01a8..63014a123e 100644 --- a/actions/harness/README.md +++ b/actions/harness/README.md @@ -41,7 +41,7 @@ store, P2P transport, conductor behavior, and finality/reset orchestration. | L1 chain and miner | Alloy `Header`, block hash chaining, signed `TxEnvelope` bodies, consensus receipts consumed by derivation, RPC-shaped transaction receipts and log metadata for batcher confirmations and L1 events | `L1Miner`, `L1Block`, manual reorg/safe/finalized heads | No tx pool, contract execution, full gas accounting, or beacon sidecar service | | L1 calldata DA | Verifier nodes use `EthereumDataSource` and production `CalldataSource` over signed tx bodies | In-memory `ActionL1ChainProvider` backed by `SharedL1Chain` | RPC paging/provider edge cases are not covered by the default action path | | L1 blob DA | Verifier nodes use `EthereumDataSource`, production `BlobSource`, versioned hashes from signed EIP-4844 txs, and `ActionBlobProvider` sidecar lookup | Blob sidecars are stored in `L1Block::blob_sidecars` rather than fetched from a beacon API | Beacon API behavior, blob retention windows, and sidecar transport are not modeled | -| Batcher | `BatchDriver`, `BatchEncoder`, channel manager behavior, Single-batch encoding, signed calldata/blob tx construction | `L1MinerTxManager`, protocol-level Span fixtures for derivation coverage, in-memory L2/L1 event channels, synthetic inclusion receipts | Submission does not use a real RPC tx manager, replacement, fee bumping, or production receipt polling against an RPC provider | +| Batcher | `BatchDriver`, `BatchEncoder`, Single-batch encoding, signed calldata/blob tx construction | `L1MinerTxManager`, protocol-level Span fixtures for derivation coverage, a `SharedL2Chain` polled by `HarnessBlockSource`, an in-memory L1 head channel, synthetic inclusion receipts | Submission does not use a real RPC tx manager, replacement, fee bumping, or production receipt polling against an RPC provider | | Sequencer | L1 origin selection, attributes building, payload construction, real signed L2 user txs | Test actor lifecycle and manual stepping | No real node service loop, txpool/RPC ingress, engine transport, or production unsafe block scheduling | | Engine (default) | `BasePayloadBuilder`, Base EVM config, temporary Reth database, state-root comparison | `ActionEngineClient` implements only the Engine API behavior tests need | Simplified payload statuses, forkchoice handling, transaction pool, networking, persistence lifecycle, and Engine API edge cases | | Builder (opt-in) | Production `FlashblocksServiceBuilder` + real transaction pool driving a real in-process Reth node over auth IPC (`BuilderBackedEngineClient`); real pool selection, DA/gas limits, metering | `L2Sequencer` via `create_l2_sequencer_with_builder`; the production `SequencerActor` drives it, but transactions still arrive via harness injection rather than RPC ingress | Flashblock scheduling is wall-clock based, so pool-dependent tests use the wall-clock timestamp mode (anchor L1+L2 genesis near `now`, within `max_sequencer_drift`) and must land within the inserted-block timeout | diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 42155ec6b7..40f0559f0d 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -5,11 +5,10 @@ use std::{sync::Arc, time::Duration}; use alloy_primitives::B256; use alloy_signer_local::PrivateKeySigner; use base_batcher_core::{ - AdminError, AdminHandle, BatchDriver, BatchDriverConfig, BatchDriverError, BatchDriverInputs, - DaThrottle, NoopThrottleClient, ThrottleController, + AdminHandle, BatchDriver, BatchDriverConfig, BatchDriverError, BatchDriverInputs, DaThrottle, + NoopThrottleClient, ThrottleController, }; use base_batcher_encoder::{BatchEncoder, EncoderConfig}; -use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; use base_common_consensus::BaseBlock; use base_common_genesis::RollupConfig; use base_protocol::BlockInfo; @@ -17,7 +16,10 @@ use base_runtime::TokioRuntime; use base_tx_manager::TxManager; use tokio::sync::{mpsc, oneshot}; -use crate::{ActionL2Source, HarnessL1HeadSource, L1Block, L1HeadItem, L1Miner, L1MinerTxManager}; +use crate::{ + ActionL2Source, HarnessBlockSource, HarnessL1HeadSource, L1Block, L1HeadItem, L1Miner, + L1MinerTxManager, SharedL2Chain, +}; /// Configuration for the [`Batcher`] actor. #[derive(Debug, Clone)] @@ -35,6 +37,11 @@ pub struct BatcherConfig { /// signer address becomes [`batcher_address`](BatcherConfig::batcher_address) /// so production calldata/blob sources can recover the expected sender. pub l1_signer: PrivateKeySigner, + /// The safe L2 head the batcher starts from, as its node would report it. The batcher + /// posts the blocks above it. `None` means the parent of the first block given to + /// [`Batcher::new`], or the L2 genesis of the rollup config when it is given none, so the + /// first block pushed to such a batcher must be block 1. + pub initial_safe_head: Option, } impl Default for BatcherConfig { @@ -45,6 +52,7 @@ impl Default for BatcherConfig { inbox_address: alloy_primitives::Address::repeat_byte(0xCA), encoder: EncoderConfig::default(), l1_signer, + initial_safe_head: None, } } } @@ -63,56 +71,41 @@ impl BatcherConfig { } } -/// Errors returned by [`Batcher`] methods. -#[derive(Debug, thiserror::Error)] -pub enum BatcherError { - /// The L2 source was exhausted before any blocks could be batched. - #[error("no L2 blocks available to batch")] - NoBlocks, - /// A new batch cycle was started before prior submissions were mined. - #[error("cannot start a batch cycle with outstanding frame submissions")] - OutstandingSubmissions, - /// The end-of-cycle flush failed, for example because the driver is stopped. - #[error("flush failed: {0}")] - Flush(#[from] AdminError), - /// The driver exited, or did not catch up with the harness within - /// [`Batcher::IDLE_TIMEOUT`]. - #[error("the batch driver exited or stalled")] - DriverUnavailable, -} - /// Batcher actor that drives a persistent [`BatchDriver`] through [`L1Miner`]. /// /// On construction, `Batcher` spawns a [`BatchDriver`] as a background tokio task backed by -/// a [`ChannelBlockSource`] for L2 block delivery, a [`HarnessL1HeadSource`] for L1 heads, -/// and an admin channel. This mirrors the production batcher architecture: the driver owns -/// its encoding pipeline and transaction manager and runs its own async loop. +/// a [`HarnessBlockSource`] polling the L2 chain the test builds, a [`HarnessL1HeadSource`] +/// for L1 heads, and an admin channel. As in production, the driver owns its encoding +/// pipeline and transaction manager, runs its own async loop and catches up from the safe +/// head again after a reset. The test plays the world around it. It pushes L2 blocks with +/// [`push_block`], and mines L1 blocks and shows them to the driver with +/// [`observe_l1_block`]. /// /// Every `async` method returns once the driver is idle again, that is once it has taken /// what it was given, encoded it, handed the resulting submissions to the tx manager and /// applied every receipt. The harness waits for that with a marker queued in the L1 head -/// source; see [`L1HeadItem::Marker`]. +/// source, see [`L1HeadItem::Marker`]. Each of them panics if the driver task has exited, or +/// did not go idle within [`IDLE_TIMEOUT`](Batcher::IDLE_TIMEOUT). /// /// Each call to [`advance`] drives one complete batch cycle: -/// 1. Drain the L2 source and forward each block to the driver via the block source. -/// 2. Wait for the driver to encode every block. -/// 3. Flush through the admin channel, exactly as an operator would, to close and +/// 1. Wait for the driver to take and encode every block pushed so far. +/// 2. Flush through the admin channel, exactly as an operator would, to close and /// release the current channel. -/// 4. Wait for the driver to hand every resulting submission to the tx manager. -/// 5. Stage every pending submission, mine one L1 block and show it to the driver. -/// 6. Wait for the driver to apply the block's receipts and its new L1 head. +/// 3. Wait for the driver to hand every resulting submission to the tx manager. +/// 4. Stage every pending submission, mine one L1 block and show it to the driver. +/// 5. Wait for the driver to apply the block's receipts and its new L1 head. /// /// The driver's [`BatchEncoder`] state is persistent across `advance()` calls. /// The driver task continues running between cycles, waiting for new events. /// /// [`advance`]: Batcher::advance +/// [`push_block`]: Batcher::push_block +/// [`observe_l1_block`]: Batcher::observe_l1_block /// [`BatchDriver`]: base_batcher_core::BatchDriver #[derive(Debug)] pub struct Batcher { - /// The L2 block source to drain on each [`advance`](Batcher::advance) cycle. - l2_source: ActionL2Source, - /// Feeds the driver's block source with block and reorg events. - source_tx: mpsc::UnboundedSender, + /// The L2 chain the driver's block source polls. + chain: SharedL2Chain, /// Feeds the driver's L1 head source with mined heads, and with markers. l1_head_tx: mpsc::UnboundedSender, /// Admin channel to the driver, used to flush at the end of a cycle. @@ -127,14 +120,17 @@ impl Batcher { /// How long a method waits for the driver to go idle before giving up. pub const IDLE_TIMEOUT: Duration = Duration::from_secs(10); - /// Create a new [`Batcher`] backed by a persistent [`BatchDriver`] task. + /// Create a new [`Batcher`] backed by a persistent [`BatchDriver`] task, with the blocks + /// of `l2_source` as its L2 chain so far, see + /// [`BatcherConfig::initial_safe_head`] for where it starts. /// /// Spawns the driver immediately. /// /// # Panics /// - /// Panics if `config.encoder` is invalid, or if `config.batcher_address` is not the - /// address of `config.l1_signer`. + /// Panics if `config.encoder` is invalid, if `config.batcher_address` is not the address + /// of `config.l1_signer`, or if `config.initial_safe_head` is `None` and the first block is + /// the genesis block, which no batcher posts. pub fn new( l2_source: ActionL2Source, rollup_config: &RollupConfig, @@ -144,7 +140,23 @@ impl Batcher { let pipeline = BatchEncoder::new(Arc::new(rollup_config.clone()), config.encoder.clone()) .expect("valid encoder config"); - let (source, source_tx) = ChannelBlockSource::new(); + let blocks: Vec = l2_source.into_iter().collect(); + // Only the number and hash of the safe head are ever read. + let initial_safe_head = config.initial_safe_head.unwrap_or_else(|| { + blocks.first().map_or_else( + || BlockInfo::from_l2_genesis(&rollup_config.genesis), + |block| BlockInfo { + hash: block.header.parent_hash, + number: block.header.number.checked_sub(1).expect("a block above genesis"), + ..Default::default() + }, + ) + }); + let chain = SharedL2Chain::new(); + for block in blocks { + chain.push(block); + } + let source = HarnessBlockSource::new(&chain, initial_safe_head); let (l1_head_source, l1_head_tx) = HarnessL1HeadSource::new(); let (admin, admin_rx) = AdminHandle::channel(); @@ -178,7 +190,7 @@ impl Batcher { l1_head_source, // The driver learns the L1 head from the blocks the tests mine. initial_l1_head: 0, - initial_safe_head: BlockInfo::from_l2_genesis(&rollup_config.genesis), + initial_safe_head, derivation_status_rx, admin_rx, }, @@ -191,101 +203,56 @@ impl Batcher { driver.run().await }); - Self { l2_source, source_tx, l1_head_tx, admin, tx_manager, driver_task } + Self { chain, l1_head_tx, admin, tx_manager, driver_task } } - /// Push a block into the L2 source for the next [`advance`] call. - /// - /// [`advance`]: Batcher::advance - pub fn push_block(&mut self, block: BaseBlock) { - self.l2_source.push(block); + /// Make `block` the head of the L2 chain, dropping the blocks at or above its number, as + /// a node would. The driver resets to its safe head when the next block it expects does not + /// build on the last one it took. + pub fn push_block(&self, block: BaseBlock) { + self.chain.push(block); } - /// Drain the L2 source and forward all blocks to the driver, then flush. + /// Wait for the driver to encode every block pushed so far, then flush. /// - /// Performs steps 1–4 of [`advance`] without mining. Once it returns, every frame the + /// Performs steps 1–3 of [`advance`] without mining. Once it returns, every frame the /// flush released has been handed to the tx manager, so [`pending_count`] counts them all. /// /// # Panics /// - /// Panics if the L2 source is empty. Use [`try_advance`] if you need to - /// test the empty-source error path. + /// Panics if submissions are still pending or staged, or if the flush is rejected. /// /// [`advance`]: Batcher::advance - /// [`try_advance`]: Batcher::try_advance /// [`pending_count`]: Batcher::pending_count - pub async fn encode_only(&mut self) { - self.try_encode_only().await.unwrap_or_else(|e| panic!("Batcher::encode_only failed: {e}")) - } - - /// Fallible variant of [`encode_only`] that returns an error instead of panicking. - /// - /// [`encode_only`]: Batcher::encode_only - async fn try_encode_only(&mut self) -> Result<(), BatcherError> { - if self.pending_count() > 0 || self.staged_count() > 0 { - return Err(BatcherError::OutstandingSubmissions); - } - - let mut block_count = 0u64; - while let Some(block) = self.l2_source.next_block() { - self.send_event(L2BlockEvent::Block(Box::new(block)))?; - block_count += 1; - } - if block_count == 0 { - return Err(BatcherError::NoBlocks); - } + pub async fn encode_only(&self) { + assert!( + self.pending_count() == 0 && self.staged_count() == 0, + "cannot start a batch cycle with outstanding frame submissions" + ); // Admin commands outrank the block source in the driver's select, so wait until every - // block above is taken and encoded before asking for the flush. - self.wait_until_idle().await?; - self.admin.flush().await?; + // block pushed so far is taken and encoded before asking for the flush. + self.wait_until_idle().await; + self.admin.flush().await.unwrap_or_else(|e| panic!("flush failed: {e}")); // The flush is answered before the driver's next encode-and-submit pass. Wait for that // pass so every frame the flush released has been handed to the tx manager. - self.wait_until_idle().await - } - - /// Queue an event for the driver's block source. Fails if the driver task has exited. - fn send_event(&self, event: L2BlockEvent) -> Result<(), BatcherError> { - self.source_tx.send(event).map_err(|_| BatcherError::DriverUnavailable) - } - - /// Queue an item for the driver's L1 head source. Fails if the driver task has exited. - fn send_l1_head_item(&self, item: L1HeadItem) -> Result<(), BatcherError> { - self.l1_head_tx.send(item).map_err(|_| BatcherError::DriverUnavailable) + self.wait_until_idle().await; } /// Wait until the driver has nothing left to do: everything sent so far is taken, /// encoded and submitted, and every receipt is applied. - async fn wait_until_idle(&self) -> Result<(), BatcherError> { + async fn wait_until_idle(&self) { let (reached_tx, reached_rx) = oneshot::channel(); - self.send_l1_head_item(L1HeadItem::Marker(reached_tx))?; - match tokio::time::timeout(Self::IDLE_TIMEOUT, reached_rx).await { - Ok(Ok(())) => Ok(()), - _ => Err(BatcherError::DriverUnavailable), - } + self.l1_head_tx.send(L1HeadItem::Marker(reached_tx)).expect("the batch driver has exited"); + let reached = tokio::time::timeout(Self::IDLE_TIMEOUT, reached_rx).await; + assert!(matches!(reached, Ok(Ok(()))), "the batch driver exited or stalled"); } /// Deliver `head` to the driver as the new L1 head and wait until it is applied. - async fn deliver_l1_head(&self, head: u64) -> Result<(), BatcherError> { - self.send_l1_head_item(L1HeadItem::Head(head))?; - self.wait_until_idle().await - } - - /// Fallible variant of [`observe_l1_block`](Self::observe_l1_block). - async fn try_observe_l1_block(&self, block: &L1Block) -> Result<(), BatcherError> { - // Receipts first: the driver serves them before L1 heads, so a failed submission is - // requeued before the head advances. - self.tx_manager.confirm_block(block); - self.deliver_l1_head(block.number()).await - } - - /// Fallible variant of [`mine_pending`](Self::mine_pending). - async fn try_mine_pending(&self, l1: &mut L1Miner) -> Result { - self.tx_manager.stage_n_to_l1(l1, usize::MAX); - let block = l1.mine_block().clone(); - self.try_observe_l1_block(&block).await?; - Ok(block.number()) + async fn deliver_l1_head(&self, head: u64) { + self.l1_head_tx.send(L1HeadItem::Head(head)).expect("the batch driver has exited"); + self.wait_until_idle().await; } /// Returns the number of encoded-but-not-yet-staged pending frame submissions. @@ -326,15 +293,11 @@ impl Batcher { /// the driver has confirmed them. Returns the mined block number. /// /// Use this to confirm a requeued batch without encoding new L2 blocks. - /// - /// # Panics - /// - /// Panics if the driver task has exited, or did not catch up within - /// [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). pub async fn mine_pending(&self, l1: &mut L1Miner) -> u64 { - self.try_mine_pending(l1) - .await - .unwrap_or_else(|e| panic!("Batcher::mine_pending failed: {e}")) + self.tx_manager.stage_n_to_l1(l1, usize::MAX); + let block = l1.mine_block().clone(); + self.observe_l1_block(&block).await; + block.number() } /// Drop the first `n` pending frame submissions without staging them to L1. @@ -350,15 +313,11 @@ impl Batcher { /// head source would. Fires the receipts of the staged submissions the block includes, /// delivers its number as the new L1 head and waits until the driver has applied both. A /// block without any of the batcher's transactions only advances the L1 head. - /// - /// # Panics - /// - /// Panics if the driver task has exited, or did not catch up within - /// [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). pub async fn observe_l1_block(&self, block: &L1Block) { - self.try_observe_l1_block(block) - .await - .unwrap_or_else(|e| panic!("Batcher::observe_l1_block failed: {e}")); + // Fire the receipts first because the driver serves them before L1 heads, so a failed + // submission is requeued before the head advances. + self.tx_manager.confirm_block(block); + self.deliver_l1_head(block.number()).await; } /// Simulate an L1 reorg back to `block_number`. @@ -371,58 +330,24 @@ impl Batcher { /// /// # Panics /// - /// Panics if `block_number` exceeds the current L1 chain tip - /// (`ReorgError::BeyondTip`), or if the driver task has exited or did not catch up - /// within [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). + /// Panics if `block_number` exceeds the current L1 chain tip (`ReorgError::BeyondTip`). /// /// [`observe_l1_block`]: Batcher::observe_l1_block pub async fn reorg(&self, block_number: u64, l1: &mut L1Miner) { - // Failure receipts first, for the same reason as in `try_observe_l1_block`. + // Failure receipts first, for the same reason as in `observe_l1_block`. self.tx_manager.reorg_to(block_number, l1); - self.deliver_l1_head(block_number) - .await - .unwrap_or_else(|e| panic!("Batcher::reorg failed: {e}")); - } - - /// Signal that the batcher has been repointed to a different L2 node. - /// - /// Sends an [`L2BlockEvent::Reorg`] to the background [`BatchDriver`] and waits until - /// it has applied it, so the encoder is known to be empty on return. - /// - /// # Panics - /// - /// Panics if the driver task has exited, or did not apply the reorg within - /// [`IDLE_TIMEOUT`](Self::IDLE_TIMEOUT). - /// - /// [`BatchDriver`]: base_batcher_core::BatchDriver - pub async fn signal_reorg(&self) { - self.send_event(L2BlockEvent::Reorg).expect("driver task alive"); - self.wait_until_idle().await.expect("driver applies the reorg"); + self.deliver_l1_head(block_number).await; } /// Run one full batch cycle through the production [`BatchDriver`] path. /// /// # Panics /// - /// Panics if the L2 source is empty. Use [`try_advance`] to test the - /// empty-source error path. - /// - /// [`try_advance`]: Batcher::try_advance - pub async fn advance(&mut self, l1: &mut L1Miner) { - self.try_advance(l1).await.unwrap_or_else(|e| panic!("Batcher::advance failed: {e}")) - } - - /// Fallible variant of [`advance`] — returns an error instead of panicking. - /// - /// Use this when a test needs to assert that `advance` fails (e.g. to - /// verify that [`BatcherError::NoBlocks`] is returned for an empty source). - /// For the common happy-path case prefer [`advance`]. - /// - /// [`advance`]: Batcher::advance - pub async fn try_advance(&mut self, l1: &mut L1Miner) -> Result<(), BatcherError> { - self.try_encode_only().await?; - self.try_mine_pending(l1).await?; - Ok(()) + /// Panics where [`encode_only`](Self::encode_only) and + /// [`mine_pending`](Self::mine_pending) do. + pub async fn advance(&self, l1: &mut L1Miner) { + self.encode_only().await; + self.mine_pending(l1).await; } } diff --git a/actions/harness/src/batcher/l2_chain.rs b/actions/harness/src/batcher/l2_chain.rs new file mode 100644 index 0000000000..bedb7099d9 --- /dev/null +++ b/actions/harness/src/batcher/l2_chain.rs @@ -0,0 +1,204 @@ +//! The L2 chain a test builds, and the block source that polls it for the driver the way the +//! production source polls its L2 node. +//! +//! Hand-rolled rather than built on [`PollingBlockSource`], because that one waits its poll +//! interval when a block is not there yet. A push would not wake it, so the driver could +//! answer an idle marker before taking the block. This source wakes on every push, which +//! keeps "idle" meaning "every pushed block was taken". The test `ChannelBlockSource` would +//! not do either, because a delivered block is gone from it, so it cannot catch up from the +//! safe head after a reset. +//! +//! [`PollingBlockSource`]: base_batcher_source::PollingBlockSource + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use base_batcher_source::{L2BlockEvent, UnsafeBlockSource}; +use base_common_consensus::BaseBlock; +use base_protocol::BlockInfo; +use tokio::sync::watch; + +/// The L2 chain a test builds for its batcher, shared between the test and the driver's +/// block source. +#[derive(Debug, Default)] +pub struct SharedL2Chain { + /// The chain, one block per number. + blocks: Arc>>, + /// Counts the pushes, so a waiting source polls again. + pushes: watch::Sender, +} + +impl SharedL2Chain { + /// Create an empty chain. + pub fn new() -> Self { + Self::default() + } + + /// Make `block` the head of the chain, as a node would. The blocks at or above its number + /// are dropped, so a block on another parent is a fork replacing them. + pub fn push(&self, block: BaseBlock) { + { + let mut blocks = self.blocks.lock().unwrap(); + blocks.retain(|kept| kept.header.number < block.header.number); + blocks.push(block); + } + // Wake the source after the guard drops, so it does not block on the lock. + self.pushes.send_modify(|pushes| *pushes += 1); + } +} + +/// [`UnsafeBlockSource`] over a [`SharedL2Chain`]. +/// +/// Like [`PollingBlockSource`](base_batcher_source::PollingBlockSource), it delivers the +/// block after its tip once the test has pushed it, reports a reorg when that block does not +/// build on the tip, and starts again above the safe head on `reset_catchup`. A reorg is +/// reported once per state of the chain. A reset that finds the same block again waits for +/// the next push, as production waits its poll interval, instead of reporting it forever. +#[derive(Debug)] +pub struct HarnessBlockSource { + /// The chain, shared with the test. + blocks: Arc>>, + /// Wakes the source on every push, and parks it once the chain is dropped. + pushes: watch::Receiver, + /// The last delivered block, or the safe head the source last started from. + tip: BlockInfo, + /// The push count a reorg was last reported at. + reorg_reported_at: Option, +} + +impl HarnessBlockSource { + /// Create a source over `chain`, starting above `safe_head`. + pub fn new(chain: &SharedL2Chain, safe_head: BlockInfo) -> Self { + Self { + blocks: Arc::clone(&chain.blocks), + pushes: chain.pushes.subscribe(), + tip: safe_head, + reorg_reported_at: None, + } + } +} + +#[async_trait] +impl UnsafeBlockSource for HarnessBlockSource { + async fn next(&mut self) -> L2BlockEvent { + loop { + let pushes = *self.pushes.borrow_and_update(); + let next = self + .blocks + .lock() + .unwrap() + .iter() + .find(|block| block.header.number == self.tip.number + 1) + .cloned(); + if let Some(block) = next { + if block.header.parent_hash == self.tip.hash { + self.tip = BlockInfo::from(&block); + return L2BlockEvent::Block(Box::new(block)); + } + if self.reorg_reported_at != Some(pushes) { + self.reorg_reported_at = Some(pushes); + return L2BlockEvent::Reorg; + } + } + if self.pushes.changed().await.is_err() { + std::future::pending().await + } + } + } + + fn reset_catchup(&mut self, safe_head: BlockInfo) { + self.tip = safe_head; + } +} + +#[cfg(test)] +mod tests { + use std::{pin::pin, time::Duration}; + + use alloy_consensus::Header; + use alloy_primitives::B256; + use tokio::time::timeout; + + use super::*; + + /// A wait long enough for a source that had something to deliver to have done so. + const SETTLE: Duration = Duration::from_millis(10); + + fn block(number: u64, parent_hash: B256) -> BaseBlock { + BaseBlock { + header: Header { number, parent_hash, ..Default::default() }, + ..Default::default() + } + } + + /// A source waiting on an empty chain wakes on the next push, then delivers the pushed blocks + /// in order. + #[tokio::test] + async fn a_push_wakes_the_waiting_source_and_blocks_come_in_order() { + let chain = SharedL2Chain::new(); + let genesis = BlockInfo::default(); + let mut source = HarnessBlockSource::new(&chain, genesis); + let first = block(1, genesis.hash); + let second = block(2, first.header.hash_slow()); + + let woken = { + let mut next = pin!(source.next()); + assert!(timeout(SETTLE, &mut next).await.is_err(), "nothing pushed yet"); + chain.push(first.clone()); + chain.push(second.clone()); + timeout(SETTLE, next).await.expect("the push wakes the waiting source") + }; + assert!(matches!(woken, L2BlockEvent::Block(block) if *block == first)); + assert!(matches!(source.next().await, L2BlockEvent::Block(block) if *block == second)); + } + + /// A block pushed at a height the chain already has replaces it and drops every block above, + /// so after a reset the source delivers the new chain and nothing of the old one. + #[tokio::test] + async fn a_fork_replaces_the_blocks_above_it_and_is_delivered_after_a_reset() { + let chain = SharedL2Chain::new(); + let genesis = BlockInfo::default(); + let mut source = HarnessBlockSource::new(&chain, genesis); + let first = block(1, genesis.hash); + let second = block(2, first.header.hash_slow()); + chain.push(first.clone()); + chain.push(second.clone()); + chain.push(block(3, second.header.hash_slow())); + for _ in 0..3 { + assert!(matches!(source.next().await, L2BlockEvent::Block(_))); + } + + let mut fork = block(2, first.header.hash_slow()); + fork.header.timestamp = 1; + chain.push(fork.clone()); + + source.reset_catchup(genesis); + assert!(matches!(source.next().await, L2BlockEvent::Block(block) if *block == first)); + assert!(matches!(source.next().await, L2BlockEvent::Block(block) if *block == fork)); + let next = timeout(SETTLE, source.next()).await; + assert!(next.is_err(), "the old block 3 must be gone"); + } + + /// A chain that does not build on the source's tip is reported as a reorg once. After the + /// reset the source waits for the next push instead of reporting the same reorg again, which + /// would reset the driver in a loop. + #[tokio::test] + async fn a_reorg_is_reported_once_per_chain_state() { + let chain = SharedL2Chain::new(); + let genesis = BlockInfo::default(); + let mut source = HarnessBlockSource::new(&chain, genesis); + chain.push(block(1, B256::repeat_byte(0xab))); + + assert!(matches!(source.next().await, L2BlockEvent::Reorg)); + source.reset_catchup(genesis); + let again = timeout(SETTLE, source.next()).await; + assert!(again.is_err(), "the same reorg is not reported again"); + + chain.push(block(1, B256::repeat_byte(0xcd))); + assert!(matches!(source.next().await, L2BlockEvent::Reorg), "a new chain state"); + source.reset_catchup(genesis); + let first = block(1, genesis.hash); + chain.push(first.clone()); + assert!(matches!(source.next().await, L2BlockEvent::Block(block) if *block == first)); + } +} diff --git a/actions/harness/src/batcher/mod.rs b/actions/harness/src/batcher/mod.rs index 2b56048254..d61b6538ce 100644 --- a/actions/harness/src/batcher/mod.rs +++ b/actions/harness/src/batcher/mod.rs @@ -1,10 +1,13 @@ //! Batcher actor and supporting types for action tests. mod actor; -pub use actor::{Batcher, BatcherConfig, BatcherError}; +pub use actor::{Batcher, BatcherConfig}; mod source; pub use source::{HarnessL1HeadSource, L1HeadItem}; +mod l2_chain; +pub use l2_chain::{HarnessBlockSource, SharedL2Chain}; + mod tx_manager; pub use tx_manager::{Inner, L1MinerTxManager, L1SignedSubmission, Pending}; diff --git a/actions/harness/src/common/l2_source.rs b/actions/harness/src/common/l2_source.rs index 7453445e6e..e9baee34d6 100644 --- a/actions/harness/src/common/l2_source.rs +++ b/actions/harness/src/common/l2_source.rs @@ -1,59 +1,36 @@ -use std::collections::VecDeque; - use base_common_consensus::BaseBlock; -/// A pre-built queue of [`BaseBlock`]s for the batcher to drain. +/// The [`BaseBlock`]s a batcher is created with, in order, which form its L2 chain so far. /// -/// Tests push fully-formed blocks into the source, which the batcher consumes one at a -/// time via [`next_block`](Self::next_block). Each block must start with its L1-info -/// deposit: the batcher reads the block's L1 epoch from it. +/// Each block must start with its L1-info deposit, because the batcher reads the block's L1 +/// epoch from it. #[derive(Debug, Default)] pub struct ActionL2Source { - blocks: VecDeque, + blocks: Vec, } impl ActionL2Source { /// Create an empty source. pub const fn new() -> Self { - Self { blocks: VecDeque::new() } + Self { blocks: Vec::new() } } /// Create a source containing the supplied blocks in iteration order. pub fn from_blocks(blocks: impl IntoIterator) -> Self { - let mut source = Self::new(); - source.extend(blocks); - source + Self { blocks: blocks.into_iter().collect() } } - /// Push a block to the back of the queue. + /// Append a block. pub fn push(&mut self, block: BaseBlock) { - self.blocks.push_back(block); - } - - /// Return the next L2 block, or `None` if the source is exhausted. - pub fn next_block(&mut self) -> Option { - self.blocks.pop_front() - } - - /// Return the number of blocks remaining. - pub fn remaining(&self) -> usize { - self.blocks.len() - } - - /// Return `true` if the source has been fully drained. - pub fn is_empty(&self) -> bool { - self.blocks.is_empty() + self.blocks.push(block); } } -impl Extend for ActionL2Source { - fn extend>(&mut self, iter: T) { - self.blocks.extend(iter); - } -} +impl IntoIterator for ActionL2Source { + type Item = BaseBlock; + type IntoIter = std::vec::IntoIter; -impl FromIterator for ActionL2Source { - fn from_iter>(iter: T) -> Self { - Self::from_blocks(iter) + fn into_iter(self) -> Self::IntoIter { + self.blocks.into_iter() } } diff --git a/actions/harness/src/lib.rs b/actions/harness/src/lib.rs index 3e2714da95..eec532ec08 100644 --- a/actions/harness/src/lib.rs +++ b/actions/harness/src/lib.rs @@ -36,8 +36,8 @@ mod derivation_fixture; mod batcher; pub use batcher::{ - Batcher, BatcherConfig, BatcherError, HarnessL1HeadSource, Inner, L1HeadItem, L1MinerTxManager, - L1SignedSubmission, Pending, + Batcher, BatcherConfig, HarnessBlockSource, HarnessL1HeadSource, Inner, L1HeadItem, + L1MinerTxManager, L1SignedSubmission, Pending, SharedL2Chain, }; mod matrix; diff --git a/actions/harness/tests/azul/derivation.rs b/actions/harness/tests/azul/derivation.rs index 7f0782d0ff..83c5d80e0c 100644 --- a/actions/harness/tests/azul/derivation.rs +++ b/actions/harness/tests/azul/derivation.rs @@ -36,7 +36,7 @@ async fn azul_derivation_crosses_activation_boundary() { &mut builder, SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); node.initialize().await; for i in 1..=4u64 { diff --git a/actions/harness/tests/azul/env.rs b/actions/harness/tests/azul/env.rs index 66339aa148..1198500f48 100644 --- a/actions/harness/tests/azul/env.rs +++ b/actions/harness/tests/azul/env.rs @@ -72,7 +72,7 @@ impl AzulTestEnv { expected_safe_head: u64, boundary: &str, ) { - let mut batcher = Batcher::new( + let batcher = Batcher::new( ActionL2Source::new(), &self.harness.rollup_config, self.batcher_cfg.clone(), diff --git a/actions/harness/tests/batcher/blobs.rs b/actions/harness/tests/batcher/blobs.rs index 072eda47ea..112eeca406 100644 --- a/actions/harness/tests/batcher/blobs.rs +++ b/actions/harness/tests/batcher/blobs.rs @@ -65,7 +65,7 @@ async fn batcher_blob_da_end_to_end() { let mut sequencer = h.create_l2_sequencer(l1_chain); // One block per L1 inclusion block. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for _ in 1..=3u64 { batcher.push_block(sequencer.build_next_block_with_single_transaction().await); batcher.advance(&mut h.l1).await; @@ -104,7 +104,7 @@ async fn batcher_multi_frame_blob_packing() { let mut source = ActionL2Source::new(); source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.advance(&mut h.l1).await; let sidecar_count = h.l1.tip().blob_sidecars.len(); @@ -141,7 +141,7 @@ async fn batcher_calldata_da() { let mut sequencer = h.create_l2_sequencer(l1_chain); // One block per L1 inclusion block. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for _ in 1..=3u64 { batcher.push_block(sequencer.build_next_block_with_single_transaction().await); batcher.advance(&mut h.l1).await; @@ -176,18 +176,21 @@ async fn batcher_da_switching() { encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, ..BatcherConfig::default() }; - let blob_cfg = BatcherConfig::default(); // DaType::Blob by default // Blocks 1-3: submit as calldata. - let mut calldata_batcher = + let calldata_batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, calldata_cfg.clone()); for _ in 1..=3u64 { calldata_batcher.push_block(sequencer.build_next_block_with_single_transaction().await); calldata_batcher.advance(&mut h.l1).await; } - // Blocks 4-6: submit as blobs. - let mut blob_batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, blob_cfg.clone()); + // Blocks 4-6: submit as blobs, from a second batcher that starts after block 3. + let blob_cfg = BatcherConfig { + initial_safe_head: Some(sequencer.head().block_info), + ..BatcherConfig::default() // DaType::Blob by default + }; + let blob_batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, blob_cfg); for _ in 4..=6u64 { blob_batcher.push_block(sequencer.build_next_block_with_single_transaction().await); blob_batcher.advance(&mut h.l1).await; diff --git a/actions/harness/tests/batcher/channels.rs b/actions/harness/tests/batcher/channels.rs index 5a188b3d03..731fc89873 100644 --- a/actions/harness/tests/batcher/channels.rs +++ b/actions/harness/tests/batcher/channels.rs @@ -41,7 +41,7 @@ async fn channel_timeout_triggers_channel_invalidation() { // Encode block via Batcher — produces multiple frames with max_frame_size=80. let mut source = ActionL2Source::new(); source.push(block.clone()); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; let frame_count = batcher.pending_count(); @@ -82,7 +82,7 @@ async fn channel_timeout_triggers_channel_invalidation() { // Recovery: new Batcher (new BatchEncoder = new channel ID) with all frames in one L1 block. let mut source2 = ActionL2Source::new(); source2.push(block); - let mut batcher2 = Batcher::new(source2, &h.rollup_config, batcher_cfg.clone()); + let batcher2 = Batcher::new(source2, &h.rollup_config, batcher_cfg.clone()); batcher2.advance(&mut h.l1).await; chain.push(h.l1.tip().clone()); @@ -125,7 +125,7 @@ async fn channel_timeout_recovery_resubmits_successfully() { // Encode the block — will produce multiple frames with max_frame_size=80. let mut source = ActionL2Source::new(); source.push(block.clone()); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; let frame_count = batcher.pending_count(); @@ -160,7 +160,7 @@ async fn channel_timeout_recovery_resubmits_successfully() { // Recovery: new Batcher (new channel ID) submits all frames in one L1 block. let mut source2 = ActionL2Source::new(); source2.push(block); - let mut batcher2 = Batcher::new(source2, &h.rollup_config, batcher_cfg.clone()); + let batcher2 = Batcher::new(source2, &h.rollup_config, batcher_cfg.clone()); batcher2.advance(&mut h.l1).await; chain.push(h.l1.tip().clone()); @@ -199,13 +199,13 @@ async fn interleaved_channels_correctly_reassembled() { // Batcher A: block 1 in its own channel (distinct random channel ID). let mut source_a = ActionL2Source::new(); source_a.push(block_a); - let mut batcher_a = Batcher::new(source_a, &h.rollup_config, batcher_cfg.clone()); + let batcher_a = Batcher::new(source_a, &h.rollup_config, batcher_cfg.clone()); batcher_a.encode_only().await; // Batcher B: block 2 in its own channel (distinct random channel ID). let mut source_b = ActionL2Source::new(); source_b.push(block_b); - let mut batcher_b = Batcher::new(source_b, &h.rollup_config, batcher_cfg.clone()); + let batcher_b = Batcher::new(source_b, &h.rollup_config, batcher_cfg.clone()); batcher_b.encode_only().await; let n_a = batcher_a.pending_count(); @@ -272,7 +272,7 @@ async fn multi_block_channel_assembles_across_l1_blocks() { // Encode into multiple frames. let mut source = ActionL2Source::new(); source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; let frame_count = batcher.pending_count(); @@ -349,7 +349,7 @@ async fn multi_frame_channel_with_empty_l1_gap_derives_correctly() { // The admin flush at the end of encode_only() closes the channel; frames become pending. let mut source = ActionL2Source::new(); source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; let frame_count = batcher.pending_count(); diff --git a/actions/harness/tests/batcher/gap_filling.rs b/actions/harness/tests/batcher/gap_filling.rs index f519ff25ac..c430d16bbe 100644 --- a/actions/harness/tests/batcher/gap_filling.rs +++ b/actions/harness/tests/batcher/gap_filling.rs @@ -10,122 +10,11 @@ use base_action_harness::{ }; use base_batcher_encoder::{DaType, EncoderConfig}; -// --------------------------------------------------------------------------- -// A. Gap-filling with a single persistent batcher (reorg signal path) -// --------------------------------------------------------------------------- - -/// Verifies gap filling with a persistent [`Batcher`] whose encoder is reset -/// between block sequences via [`signal_reorg`]. -/// -/// Scenario (maps to the batcher's source-divergence handling): -/// -/// 1. **Phase 1** — Posts blocks 1-5. The verifier derives them and advances -/// its safe head to 5. -/// -/// 2. **Phase 2** — [`signal_reorg`] clears the encoder, then the batcher posts -/// blocks 8-10. -/// These land on L1 but the verifier **cannot** derive them because -/// blocks 6-7 are missing. Block 8's timestamp is ahead of the next expected -/// slot (block 6), so it is classified as future. -/// -/// 3. **Phase 3** — [`signal_reorg`] clears the encoder again, then the batcher -/// posts blocks 6-10. The verifier derives all remaining blocks and reaches -/// safe head 10. -/// -/// This tests: -/// - Encoder reset on reorg signal (no stale state leaks between repoints) -/// - Out-of-order batches on L1 do not advance the safe head -/// - Gap-filling batches allow the verifier to derive past the gap -/// - Duplicate batches (8-10 posted in both Phase 2 and Phase 3) are harmless -/// -/// [`signal_reorg`]: Batcher::signal_reorg -#[tokio::test] -async fn batcher_gap_fill_single_instance_reorg_signal() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - - // Build 10 L2 blocks up front so they share a consistent chain. - let mut blocks = Vec::with_capacity(10); - for _ in 0..10 { - blocks.push(sequencer.build_next_block_with_single_transaction().await); - } - - // Create the verifier node before any mining so `chain.push` makes - // subsequent L1 blocks visible to the derivation pipeline. - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // Create a single batcher that persists across all phases. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); - - // ----- Phase 1: post blocks 1-5, derive them ----- - for block in &blocks[..5] { - batcher.push_block(block.clone()); - } - batcher.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - node.initialize().await; - let derived = node.run_until_idle().await; - assert_eq!(derived, 5, "Phase 1: expected 5 L2 blocks derived"); - assert_eq!(node.l2_safe_number(), 5, "Phase 1: safe head must be 5"); - - // ----- Phase 2: reset the encoder, then post blocks 8-10 ----- - batcher.signal_reorg().await; - - for block in &blocks[7..10] { - batcher.push_block(block.clone()); - } - batcher.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - // The verifier should NOT advance past 5: block 8's timestamp is ahead of the next expected - // slot (block 6), so it is classified as future. - let derived = node.run_until_idle().await; - assert_eq!( - node.l2_safe_number(), - 5, - "Phase 2: safe head must remain at 5 — gap blocks 6-7 are missing" - ); - assert_eq!(derived, 0, "Phase 2: no new blocks should be derived"); - - // ----- Phase 3: reset the encoder, then fill the gap with blocks 6-10 ----- - batcher.signal_reorg().await; - - // Post blocks 6-10: fills the gap (6-7) and re-posts 8-10. - for block in &blocks[5..10] { - batcher.push_block(block.clone()); - } - batcher.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - let derived = node.run_until_idle().await; - assert_eq!(derived, 5, "Phase 3: expected 5 L2 blocks derived (6-10)"); - assert_eq!(node.l2_safe_number(), 10, "Phase 3: safe head must reach 10 after gap is filled"); -} - -// --------------------------------------------------------------------------- -// B. Gap-filling with separate batcher instances (restart model) -// --------------------------------------------------------------------------- - -/// Verifies the same derivation behavior using separate [`Batcher`] instances, -/// modelling a fresh encoder for each submitted block sequence. -/// -/// Each `Batcher` instance starts with a clean [`BatchEncoder`], which -/// is the state that results from the batcher's fresh-start path. -/// -/// [`BatchEncoder`]: base_batcher_encoder::BatchEncoder +/// Each phase posts its blocks from a fresh batcher, which starts from the parent of the +/// first block it is given. In phase 2 that is block 7, as if its node were ahead of the +/// verifier. #[tokio::test] -async fn batcher_gap_fill_separate_instances() { +async fn batcher_gap_fill() { let batcher_cfg = BatcherConfig { encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, ..BatcherConfig::default() @@ -161,7 +50,7 @@ async fn batcher_gap_fill_separate_instances() { assert_eq!(derived, 5, "Phase 1: expected 5 L2 blocks derived"); assert_eq!(node.l2_safe_number(), 5, "Phase 1: safe head must be 5"); - // ----- Phase 2: post blocks 8-10 with a fresh batcher (gap) ----- + // ----- Phase 2: a fresh batcher posts blocks 8-10 (gap) ----- { let mut source = ActionL2Source::new(); for block in &blocks[7..10] { @@ -179,7 +68,7 @@ async fn batcher_gap_fill_separate_instances() { ); assert_eq!(derived, 0, "Phase 2: no blocks derived (gap)"); - // ----- Phase 3: post blocks 6-10 with a fresh batcher ----- + // ----- Phase 3: a fresh batcher posts blocks 6-10 ----- { let mut source = ActionL2Source::new(); for block in &blocks[5..10] { diff --git a/actions/harness/tests/batcher/sequencer_drift.rs b/actions/harness/tests/batcher/sequencer_drift.rs index 920af1c680..6901072aaf 100644 --- a/actions/harness/tests/batcher/sequencer_drift.rs +++ b/actions/harness/tests/batcher/sequencer_drift.rs @@ -91,7 +91,7 @@ async fn sequencer_drift_produces_deposit_only_blocks() { &mut node_sequencer, SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.advance(&mut h.l1).await; chain.push(h.l1.tip().clone()); @@ -183,7 +183,7 @@ async fn sequencer_drift_forced_empty_blocks_accepted() { source.push(sequencer.build_empty_block().await); } - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.advance(&mut h.l1).await; chain.push(h.l1.tip().clone()); diff --git a/actions/harness/tests/batcher/submission.rs b/actions/harness/tests/batcher/submission.rs index a84aa02d63..e56929a036 100644 --- a/actions/harness/tests/batcher/submission.rs +++ b/actions/harness/tests/batcher/submission.rs @@ -1,8 +1,8 @@ //! Action tests for L2 batch submission via the Batcher actor. use base_action_harness::{ - ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, BatcherError, L1MinerConfig, - NodeStepResult, SharedL1Chain, TestRollupConfigBuilder, + ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, NodeStepResult, + SharedL1Chain, TestRollupConfigBuilder, }; use base_batcher_encoder::{DaType, EncoderConfig}; @@ -17,7 +17,7 @@ async fn batcher_mines_block_with_submissions() { let cfg = BatcherConfig::default(); let source = h.create_l2_source(3).await; - let mut batcher = Batcher::new(source, &h.rollup_config, cfg); + let batcher = Batcher::new(source, &h.rollup_config, cfg); batcher.advance(&mut h.l1).await; assert!(h.l1.latest_number() >= 1, "at least one L1 block should be mined"); @@ -28,20 +28,8 @@ async fn batcher_mines_block_with_submissions() { ); } -/// `try_advance` fails with `NoBlocks` when the source has no L2 block to batch. -#[tokio::test] -async fn batcher_errors_when_no_l2_blocks_async() { - let mut h = ActionTestHarness::default(); - let cfg = BatcherConfig::default(); - - let source = ActionL2Source::new(); // empty - let mut batcher = Batcher::new(source, &h.rollup_config, cfg); - let err = batcher.try_advance(&mut h.l1).await.expect_err("should fail with no blocks"); - assert!(matches!(err, BatcherError::NoBlocks)); -} - -/// A one-byte compressed size target makes every block close its own calldata channel, and -/// derivation still makes the four blocks safe one by one, in order. +/// With a one-byte compressed size target, derivation still makes the four blocks safe one by +/// one, in order. #[tokio::test] async fn batcher_soft_channel_target_derives_exact_blocks() { const BLOCK_COUNT: u64 = 4; @@ -132,7 +120,7 @@ async fn batcher_reorg_during_submission() { // below fires failure receipts for them. let mut source = ActionL2Source::new(); source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); batcher.encode_only().await; batcher.stage_n_frames(&mut h.l1, usize::MAX); h.l1.mine_block(); // L1 block 1 (original, about to be reorged) diff --git a/actions/harness/tests/batcher/submission_failure.rs b/actions/harness/tests/batcher/submission_failure.rs index 6969018f2a..cf5e2d716e 100644 --- a/actions/harness/tests/batcher/submission_failure.rs +++ b/actions/harness/tests/batcher/submission_failure.rs @@ -41,7 +41,7 @@ async fn submission_failure_requeues_and_derivation_recovers() { let mut source = ActionL2Source::new(); source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); // Fail the first submission so the driver must requeue and retry. batcher.fail_next_n_submissions(1); @@ -83,7 +83,7 @@ async fn consecutive_failures_then_success_derives_correctly() { let mut source = ActionL2Source::new(); source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); // Fail the next three attempts. The driver retries on each failure until // the fourth send_async call succeeds and the frame lands in pending. diff --git a/actions/harness/tests/batcher/upgrade_transitions.rs b/actions/harness/tests/batcher/upgrade_transitions.rs index 6b84ab89ef..60ff2fc70b 100644 --- a/actions/harness/tests/batcher/upgrade_transitions.rs +++ b/actions/harness/tests/batcher/upgrade_transitions.rs @@ -348,7 +348,7 @@ async fn granite_channel_timeout_enforced() { // Encode block into multiple frames (max_frame_size=80 forces multi-frame). let mut source = ActionL2Source::new(); source.push(block.clone()); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; let frame_count = batcher.pending_count(); @@ -504,7 +504,7 @@ async fn jovian_single_batch_transition_block_deposit_only() { // Submit each block as a separate SingleBatch channel, one L1 block each. // L1 blocks 1–4 each contain one singular batch. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in [block1, block2, block3_invalid, block4] { batcher.push_block(block); batcher.advance(&mut h.l1).await; diff --git a/actions/harness/tests/beryl/env.rs b/actions/harness/tests/beryl/env.rs index 1010b93ee3..8eecf7d35b 100644 --- a/actions/harness/tests/beryl/env.rs +++ b/actions/harness/tests/beryl/env.rs @@ -542,7 +542,7 @@ impl BerylTestEnv { blocks: impl IntoIterator, expected_safe_head: u64, ) { - let mut batcher = Batcher::new( + let batcher = Batcher::new( ActionL2Source::new(), &self.harness.rollup_config, self.batcher_cfg.clone(), diff --git a/actions/harness/tests/derivation/main.rs b/actions/harness/tests/derivation/main.rs index e0ba0ea6f1..1a92982f26 100644 --- a/actions/harness/tests/derivation/main.rs +++ b/actions/harness/tests/derivation/main.rs @@ -85,7 +85,7 @@ async fn multiple_l1_blocks_each_derive_one_l2_block() { let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); let mut builder = h.create_l2_sequencer(l1_chain); - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for _ in 1..=L2_BLOCK_COUNT { batcher.push_block(builder.build_next_block_with_single_transaction().await); batcher.advance(&mut h.l1).await; @@ -375,7 +375,7 @@ async fn orphaned_partial_channel_does_not_combine_with_canonical_remainder() { let mut source = ActionL2Source::new(); source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; assert!( batcher.pending_count() >= 2, @@ -521,7 +521,7 @@ async fn reorg_flip_flop_empty_middle_fork() { let l2_genesis = h.l2_genesis(); // --- Fork A: mine A1 (batch for L2 block 1) and A2 (batch for L2 block 2). --- - let mut batcher_a = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher_a = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in [block1.clone(), block2.clone()] { batcher_a.push_block(block); batcher_a.advance(&mut h.l1).await; @@ -564,7 +564,7 @@ async fn reorg_flip_flop_empty_middle_fork() { // --- Fork C: reorg to genesis; resubmit both batches; re-derive both blocks. --- h.l1.reorg_to(0).expect("reorg to fork C"); chain.truncate_to(0); - let mut batcher_c = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher_c = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in [block1, block2] { batcher_c.push_block(block); batcher_c.advance(&mut h.l1).await; @@ -863,7 +863,7 @@ async fn batcher_key_rotation_accepts_new_batcher() { let block3 = builder.build_next_block_with_single_transaction().await; // --- L1 blocks 1-2: batcher A submits → L2 blocks 1-2 derived. --- - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_a.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_a.clone()); for block in [block1, block2] { batcher.push_block(block); batcher.advance(&mut h.l1).await; @@ -980,7 +980,7 @@ async fn multi_l2_per_l1_epoch() { SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for _ in 1..=L2_COUNT { batcher.push_block(builder.build_next_block_with_single_transaction().await); batcher.advance(&mut h.l1).await; @@ -1112,7 +1112,7 @@ async fn multi_epoch_sequence() { ); // Batch each L2 block into a separate L1 inclusion block. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in &blocks { batcher.push_block(block.clone()); batcher.advance(&mut h.l1).await; @@ -1199,7 +1199,7 @@ async fn deep_reorg_multi_block() { } // Submit each block's batch individually and mine an L1 block for each. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in &blocks { batcher.push_block(block.clone()); batcher.advance(&mut h.l1).await; @@ -1228,7 +1228,7 @@ async fn deep_reorg_multi_block() { assert_eq!(node.l2_safe_number(), 0, "safe head reverted to genesis"); // Re-submit all 5 batches on the new fork. - let mut resubmit_batcher = + let resubmit_batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in &blocks { resubmit_batcher.push_block(block.clone()); @@ -1340,7 +1340,7 @@ async fn multi_frame_channel_reassembled() { // should spill across multiple frames — verified via pending_count before mining. let mut source = ActionL2Source::new(); source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; assert!( batcher.pending_count() >= 2, @@ -1720,7 +1720,7 @@ async fn l2_finalized_advances_via_l1_finalized_signal() { let block1 = sequencer.build_next_block_with_single_transaction().await; let block2 = sequencer.build_next_block_with_single_transaction().await; - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in [block1, block2] { batcher.push_block(block); batcher.advance(&mut h.l1).await; @@ -1863,7 +1863,7 @@ async fn derive_chain_from_near_l1_genesis() { let block_hashes = sequencer.block_hash_registry(); // Build 2 L2 blocks and batch them into L1 blocks #6 and #7. - let mut batcher = Batcher::new(ActionL2Source::new(), &rollup_cfg, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &rollup_cfg, batcher_cfg.clone()); for _ in 1..=2u64 { let block = sequencer.build_next_block_with_single_transaction().await; // With block_time=2 and L1 block 6 at ts=72, L2 block ts < 72 @@ -2028,7 +2028,7 @@ async fn batcher_config_update_rolled_back_on_reorg() { let block2_clone = block2.clone(); // --- Phase 2: Derive blocks 1-2 with batcher A (L1 blocks 1-2). --- - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_a.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_a.clone()); for block in [block1, block2] { batcher.push_block(block); batcher.advance(&mut h.l1).await; @@ -2084,8 +2084,7 @@ async fn batcher_config_update_rolled_back_on_reorg() { // also with batcher A (no config update log). --- // Re-submit the same L2 blocks that were derived pre-reorg, plus block 3. let resubmit_blocks = [block1_clone, block2_clone, block3]; - let mut resubmit_batcher = - Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_a.clone()); + let resubmit_batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_a.clone()); for block in resubmit_blocks { resubmit_batcher.push_block(block); resubmit_batcher.advance(&mut h.l1).await; diff --git a/actions/harness/tests/everest/derivation.rs b/actions/harness/tests/everest/derivation.rs index d3ea63aa3e..cec6af6934 100644 --- a/actions/harness/tests/everest/derivation.rs +++ b/actions/harness/tests/everest/derivation.rs @@ -49,7 +49,7 @@ async fn eip8130_batch_is_dropped_before_everest() { // 3 (ts = 6) carries an EIP-8130 transaction whose batch the verifier must // drop because Everest is not active until ts = 8. let mut eip8130_block_hash = B256::ZERO; - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg); for i in 1u64..=3 { if i == 3 { let tx = EverestTestEnv::eip8130_user_tx(chain_id, 0); diff --git a/actions/harness/tests/everest/env.rs b/actions/harness/tests/everest/env.rs index 3586fad766..8f30c49fb7 100644 --- a/actions/harness/tests/everest/env.rs +++ b/actions/harness/tests/everest/env.rs @@ -69,7 +69,7 @@ impl EverestTestEnv { blocks: [(BaseBlock, u64); N], expected_safe_head: u64, ) { - let mut batcher = Batcher::new( + let batcher = Batcher::new( ActionL2Source::new(), &self.harness.rollup_config, self.batcher_cfg.clone(), diff --git a/actions/harness/tests/finalization.rs b/actions/harness/tests/finalization.rs index 66171003b7..a5435a7eec 100644 --- a/actions/harness/tests/finalization.rs +++ b/actions/harness/tests/finalization.rs @@ -32,7 +32,7 @@ async fn finalization_advances_with_multiple_l2_blocks_per_epoch() { assert_eq!(sequencer.head().l1_origin.number, 0, "all blocks should be in epoch 0"); // Submit each block in a separate L1 inclusion block. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in blocks { batcher.push_block(block); batcher.advance(&mut h.l1).await; @@ -119,7 +119,7 @@ async fn finalization_advances_incrementally_with_l1_epochs() { SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in blocks { batcher.push_block(block); batcher.advance(&mut h.l1).await; @@ -176,7 +176,7 @@ async fn finalization_does_not_exceed_safe_head() { let block2 = sequencer.build_next_block_with_single_transaction().await; // Submit each block via the batcher. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in [block1, block2] { batcher.push_block(block); batcher.advance(&mut h.l1).await; @@ -234,7 +234,7 @@ async fn finalization_reorg_clears_state() { let block2 = sequencer.build_next_block_with_single_transaction().await; // Submit and mine. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in [block1, block2] { batcher.push_block(block); batcher.advance(&mut h.l1).await; @@ -288,7 +288,7 @@ async fn finalization_reorg_clears_state() { let mut source = ActionL2Source::new(); source.push(block1_fresh); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.advance(&mut h.l1).await; // Push the new block to the shared chain. @@ -350,7 +350,7 @@ async fn finalization_resumes_after_reset_without_new_l1_signal() { &mut sequencer, SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg); for block in [block1, block2] { batcher.push_block(block); batcher.advance(&mut h.l1).await; @@ -409,7 +409,7 @@ async fn finalization_does_not_regress() { SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for block in blocks { batcher.push_block(block); batcher.advance(&mut h.l1).await; diff --git a/actions/harness/tests/unsafe_gossip.rs b/actions/harness/tests/unsafe_gossip.rs index 35647b7163..34b81f4e22 100644 --- a/actions/harness/tests/unsafe_gossip.rs +++ b/actions/harness/tests/unsafe_gossip.rs @@ -117,7 +117,7 @@ async fn test_unsafe_chain_advances_safe_catches_up() { for block in &blocks { source.push(block.clone()); } - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.advance(&mut h.l1).await; chain.push(h.l1.tip().clone()); node.initialize().await; diff --git a/actions/harness/tests/upgrade/activation.rs b/actions/harness/tests/upgrade/activation.rs index 92c2b30b53..53cfac440d 100644 --- a/actions/harness/tests/upgrade/activation.rs +++ b/actions/harness/tests/upgrade/activation.rs @@ -348,7 +348,7 @@ async fn single_batch_derives_with_fjord() { let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); let mut builder = h.create_l2_sequencer(l1_chain); - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for _ in 0..2 { batcher.push_block(builder.build_next_block_with_single_transaction().await); batcher.advance(&mut h.l1).await; @@ -403,7 +403,7 @@ async fn jovian_derivation_crosses_activation_boundary() { let mut builder = h.create_l2_sequencer(l1_chain); let mut block_hashes = [Default::default(); 5]; - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for i in 1..=4u64 { let block = if i == 3 { // First Jovian block: must contain no user transactions. diff --git a/actions/harness/tests/upgrade/holocene.rs b/actions/harness/tests/upgrade/holocene.rs index f7bc5dc98f..acf0d2ecbe 100644 --- a/actions/harness/tests/upgrade/holocene.rs +++ b/actions/harness/tests/upgrade/holocene.rs @@ -62,7 +62,7 @@ async fn holocene_derivation_crosses_activation_boundary() { // Build and submit 4 L2 blocks; no upgrade-tx constraint at Holocene, // so user txs are valid in all blocks including block 3. - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for _ in 1..=4u64 { batcher.push_block(builder.build_next_block_with_single_transaction().await); batcher.advance(&mut h.l1).await; @@ -113,7 +113,7 @@ async fn holocene_activation_flushes_buffered_pre_holocene_channel_data() { let mut source = ActionL2Source::new(); source.push(block.clone()); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; assert!( batcher.pending_count() >= 2, @@ -284,7 +284,7 @@ async fn holocene_non_sequential_frame_pruned_channel_never_completes() { // Encode the block into frames without mining. let mut source = ActionL2Source::new(); source.push(block); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; let frame_count = batcher.pending_count(); assert!( @@ -391,12 +391,12 @@ async fn holocene_new_channel_abandons_incomplete_old_channel() { // Each Batcher instance generates a distinct random channel ID. let mut source_a = ActionL2Source::new(); source_a.push(block_a); - let mut batcher_a = Batcher::new(source_a, &h.rollup_config, batcher_cfg.clone()); + let batcher_a = Batcher::new(source_a, &h.rollup_config, batcher_cfg.clone()); batcher_a.encode_only().await; let mut source_b = ActionL2Source::new(); source_b.push(block_b); - let mut batcher_b = Batcher::new(source_b, &h.rollup_config, batcher_cfg.clone()); + let batcher_b = Batcher::new(source_b, &h.rollup_config, batcher_cfg.clone()); batcher_b.encode_only().await; let n_a = batcher_a.pending_count(); @@ -499,7 +499,7 @@ async fn holocene_non_sequential_frame_pruned_then_recovery_succeeds() { // Encode the block into frames without mining. let mut source = ActionL2Source::new(); source.push(block.clone()); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.encode_only().await; let frame_count = batcher.pending_count(); assert!(frame_count >= 3, "need ≥3 frames to skip frame 1; got {frame_count}"); @@ -552,7 +552,7 @@ async fn holocene_non_sequential_frame_pruned_then_recovery_succeeds() { // timestamp. The sequencer has not advanced since build_next_block_with_single_transaction(). let mut recovery_source = ActionL2Source::new(); recovery_source.push(block); - let mut batcher2 = Batcher::new(recovery_source, &h.rollup_config, batcher_cfg.clone()); + let batcher2 = Batcher::new(recovery_source, &h.rollup_config, batcher_cfg.clone()); batcher2.advance(&mut h.l1).await; chain.push(h.l1.tip().clone()); diff --git a/actions/harness/tests/upgrade/operator_fees.rs b/actions/harness/tests/upgrade/operator_fees.rs index 862660b629..b1a880a4c2 100644 --- a/actions/harness/tests/upgrade/operator_fees.rs +++ b/actions/harness/tests/upgrade/operator_fees.rs @@ -374,7 +374,7 @@ async fn isthmus_derivation_crosses_operator_fee_boundary() { let mut isthmus_block_hash = Default::default(); let mut block4_hash = Default::default(); - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for i in 1..=4u64 { // All blocks carry user transactions — Isthmus allows user txs at transition. let block = builder.build_next_block_with_single_transaction().await; @@ -465,7 +465,7 @@ async fn jovian_non_empty_transition_batch_generates_deposit_only_block() { // Block 3 (ts=6) is the first Jovian block. The batch validator will drop the // non-empty batch for that slot with NonEmptyTransitionBlock. let mut jovian_block_hash = Default::default(); - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); for i in 1u64..=3 { let block = builder.build_next_block_with_single_transaction().await; if i == 3 { @@ -627,7 +627,7 @@ async fn operator_fee_config_update_propagates_to_l1_info() { for block in epoch0_blocks { source.push(block); } - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.advance(&mut h.l1).await; // L1 block 2, ts=24 } @@ -635,7 +635,7 @@ async fn operator_fee_config_update_propagates_to_l1_info() { { let mut source = ActionL2Source::new(); source.push(block6); - let mut batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); batcher.advance(&mut h.l1).await; // L1 block 3, ts=36 } From 6dd14a3aed0c15139681c0a59e735a58184226b4 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 19:01:56 +0000 Subject: [PATCH 181/299] test(harness): let the batcher observe derivation progress (#5348) * test(harness): let the batcher observe derivation progress * docs(harness): tighten the derivation observation docs * refactor(harness): read the pipeline origin in derivation_status * test(harness): name the send error when the driver does not take a derivation status --- actions/README.md | 3 ++- actions/harness/README.md | 2 +- actions/harness/src/batcher/actor.rs | 38 ++++++++++++++++++---------- actions/harness/src/node.rs | 13 +++++++--- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/actions/README.md b/actions/README.md index bb1af86939..1bf677cf0f 100644 --- a/actions/README.md +++ b/actions/README.md @@ -177,7 +177,8 @@ For the common happy path, call `batcher.advance(&mut h.l1).await`. It waits for the driver to encode every block pushed so far, flushes the encoder, mines one L1 block, and shows it to the driver. For more exact scenarios, use `encode_only`, `stage_n_frames`, `observe_l1_block`, `mine_pending`, -`fail_next_n_submissions` and `reorg`. +`fail_next_n_submissions` and `reorg`. Report derivation progress with +`batcher.observe_derivation(node.derivation_status())`. Every `async` method of `Batcher` returns once the driver is idle again, so the test can read the tx manager's queues right after. diff --git a/actions/harness/README.md b/actions/harness/README.md index 63014a123e..2a8e3803d6 100644 --- a/actions/harness/README.md +++ b/actions/harness/README.md @@ -41,7 +41,7 @@ store, P2P transport, conductor behavior, and finality/reset orchestration. | L1 chain and miner | Alloy `Header`, block hash chaining, signed `TxEnvelope` bodies, consensus receipts consumed by derivation, RPC-shaped transaction receipts and log metadata for batcher confirmations and L1 events | `L1Miner`, `L1Block`, manual reorg/safe/finalized heads | No tx pool, contract execution, full gas accounting, or beacon sidecar service | | L1 calldata DA | Verifier nodes use `EthereumDataSource` and production `CalldataSource` over signed tx bodies | In-memory `ActionL1ChainProvider` backed by `SharedL1Chain` | RPC paging/provider edge cases are not covered by the default action path | | L1 blob DA | Verifier nodes use `EthereumDataSource`, production `BlobSource`, versioned hashes from signed EIP-4844 txs, and `ActionBlobProvider` sidecar lookup | Blob sidecars are stored in `L1Block::blob_sidecars` rather than fetched from a beacon API | Beacon API behavior, blob retention windows, and sidecar transport are not modeled | -| Batcher | `BatchDriver`, `BatchEncoder`, Single-batch encoding, signed calldata/blob tx construction | `L1MinerTxManager`, protocol-level Span fixtures for derivation coverage, a `SharedL2Chain` polled by `HarnessBlockSource`, an in-memory L1 head channel, synthetic inclusion receipts | Submission does not use a real RPC tx manager, replacement, fee bumping, or production receipt polling against an RPC provider | +| Batcher | `BatchDriver`, `BatchEncoder`, Single-batch encoding, signed calldata/blob tx construction | `L1MinerTxManager`, protocol-level Span fixtures for derivation coverage, a `SharedL2Chain` polled by `HarnessBlockSource`, an in-memory L1 head channel, derivation statuses the test reports, synthetic inclusion receipts | Submission does not use a real RPC tx manager, replacement, fee bumping, or production receipt polling against an RPC provider, and no `DerivationStatusPoller` polls a rollup node | | Sequencer | L1 origin selection, attributes building, payload construction, real signed L2 user txs | Test actor lifecycle and manual stepping | No real node service loop, txpool/RPC ingress, engine transport, or production unsafe block scheduling | | Engine (default) | `BasePayloadBuilder`, Base EVM config, temporary Reth database, state-root comparison | `ActionEngineClient` implements only the Engine API behavior tests need | Simplified payload statuses, forkchoice handling, transaction pool, networking, persistence lifecycle, and Engine API edge cases | | Builder (opt-in) | Production `FlashblocksServiceBuilder` + real transaction pool driving a real in-process Reth node over auth IPC (`BuilderBackedEngineClient`); real pool selection, DA/gas limits, metering | `L2Sequencer` via `create_l2_sequencer_with_builder`; the production `SequencerActor` drives it, but transactions still arrive via harness injection rather than RPC ingress | Flashblock scheduling is wall-clock based, so pool-dependent tests use the wall-clock timestamp mode (anchor L1+L2 genesis near `now`, within `max_sequencer_drift`) and must land within the inserted-block timeout | diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 40f0559f0d..321db554fb 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -6,7 +6,7 @@ use alloy_primitives::B256; use alloy_signer_local::PrivateKeySigner; use base_batcher_core::{ AdminHandle, BatchDriver, BatchDriverConfig, BatchDriverError, BatchDriverInputs, DaThrottle, - NoopThrottleClient, ThrottleController, + DerivationStatus, NoopThrottleClient, ThrottleController, }; use base_batcher_encoder::{BatchEncoder, EncoderConfig}; use base_common_consensus::BaseBlock; @@ -75,11 +75,11 @@ impl BatcherConfig { /// /// On construction, `Batcher` spawns a [`BatchDriver`] as a background tokio task backed by /// a [`HarnessBlockSource`] polling the L2 chain the test builds, a [`HarnessL1HeadSource`] -/// for L1 heads, and an admin channel. As in production, the driver owns its encoding -/// pipeline and transaction manager, runs its own async loop and catches up from the safe -/// head again after a reset. The test plays the world around it. It pushes L2 blocks with -/// [`push_block`], and mines L1 blocks and shows them to the driver with -/// [`observe_l1_block`]. +/// for L1 heads, a derivation-status channel and an admin channel. As in production, the +/// driver owns its encoding pipeline and transaction manager, runs its own async loop and +/// catches up from the safe head again after a reset. The test plays the world around it. +/// It pushes L2 blocks with [`push_block`], mines L1 blocks and shows them to the driver +/// with [`observe_l1_block`], and reports derivation progress with [`observe_derivation`]. /// /// Every `async` method returns once the driver is idle again, that is once it has taken /// what it was given, encoded it, handed the resulting submissions to the tx manager and @@ -101,6 +101,7 @@ impl BatcherConfig { /// [`advance`]: Batcher::advance /// [`push_block`]: Batcher::push_block /// [`observe_l1_block`]: Batcher::observe_l1_block +/// [`observe_derivation`]: Batcher::observe_derivation /// [`BatchDriver`]: base_batcher_core::BatchDriver #[derive(Debug)] pub struct Batcher { @@ -108,6 +109,8 @@ pub struct Batcher { chain: SharedL2Chain, /// Feeds the driver's L1 head source with mined heads, and with markers. l1_head_tx: mpsc::UnboundedSender, + /// Feeds the driver with derivation progress. + derivation_status_tx: mpsc::Sender, /// Admin channel to the driver, used to flush at the end of a cycle. admin: AdminHandle, /// Shared tx manager — used to stage submissions and fire their receipts. @@ -196,14 +199,9 @@ impl Batcher { }, ); - // No action test exercises derivation status: the driver task keeps the sender, so - // the channel stays open, and silent, for as long as the driver runs. - let driver_task = tokio::spawn(async move { - let _derivation_status_tx = derivation_status_tx; - driver.run().await - }); + let driver_task = tokio::spawn(driver.run()); - Self { chain, l1_head_tx, admin, tx_manager, driver_task } + Self { chain, l1_head_tx, derivation_status_tx, admin, tx_manager, driver_task } } /// Make `block` the head of the L2 chain, dropping the blocks at or above its number, as @@ -320,6 +318,20 @@ impl Batcher { self.deliver_l1_head(block.number()).await; } + /// Report derivation progress to the driver, as the production `DerivationStatusPoller` + /// does on each change, and wait until the driver is idle again. By then it has reconciled + /// with `status` and, after a reset, caught up from the new safe head. See + /// [`TestRollupNode::derivation_status`](crate::TestRollupNode::derivation_status). + pub async fn observe_derivation(&self, status: DerivationStatus) { + // The channel holds one status and is empty here, because every async method returns once + // the driver answered a marker and the driver takes a waiting status before it answers + // one. So the send fails only once the driver has exited. + self.derivation_status_tx + .try_send(status) + .unwrap_or_else(|error| panic!("the batch driver did not take the status: {error}")); + self.wait_until_idle().await; + } + /// Simulate an L1 reorg back to `block_number`. /// /// Truncates the L1 chain via [`L1Miner::reorg_to`], fires failure receipts for every diff --git a/actions/harness/src/node.rs b/actions/harness/src/node.rs index 0723258682..4fe1656287 100644 --- a/actions/harness/src/node.rs +++ b/actions/harness/src/node.rs @@ -4,6 +4,7 @@ use alloy_eips::BlockNumHash; use alloy_primitives::{B256, Bytes}; use alloy_rlp::Decodable; use alloy_rpc_types_engine::ForkchoiceState; +use base_batcher_core::DerivationStatus; use base_common_consensus::{BaseBlock, BaseTxEnvelope, TxDeposit}; use base_common_genesis::RollupConfig; use base_common_network::BaseEngineApi; @@ -270,9 +271,15 @@ impl TestRollupNode

{ self.safe_l1_number } - /// Return the current L1 origin the pipeline is positioned at. - pub fn l1_origin(&self) -> Option { - self.pipeline.origin() + /// Returns the derivation progress this node reports to the batcher, which is its safe L2 + /// head and the L1 block its derivation pipeline is processing. Before the pipeline has an + /// origin, that block is the default one, as `optimism_syncStatus` reports it. See + /// [`Batcher::observe_derivation`](crate::Batcher::observe_derivation). + pub fn derivation_status(&self) -> DerivationStatus { + DerivationStatus { + safe_l2: self.safe_head.block_info, + current_l1: self.pipeline.origin().unwrap_or_default(), + } } /// Query the safe head recorded for a given L1 block number from the persistent `SafeDB`. From ef4559ceaf48b5b565b596d2fa20fef5820e2235 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 19:31:40 +0000 Subject: [PATCH 182/299] test(harness): prove the batcher recovers on its own from a timed-out channel, a stalled channel, a safe head regress and an unsafe reorg (#5349) * test(harness): prove one batcher recovers from a channel timeout, a stalled channel and a safe head regress * test(harness): tighten the recovery test comments * docs(harness): reword the recovery test module doc * test(harness): cover a batcher following a replaced unsafe chain end to end * test(harness): pin the frame count of the timed-out channel test * test(harness): check that the timed-out channel spans several frames, not how many * docs(harness): say which forks the recovery tests run on * docs(harness): say the recovery tests prove the batcher recovers on its own --- actions/harness/tests/batcher/main.rs | 1 + actions/harness/tests/batcher/recovery.rs | 230 ++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 actions/harness/tests/batcher/recovery.rs diff --git a/actions/harness/tests/batcher/main.rs b/actions/harness/tests/batcher/main.rs index b834d0f6d8..2ebac63508 100644 --- a/actions/harness/tests/batcher/main.rs +++ b/actions/harness/tests/batcher/main.rs @@ -4,6 +4,7 @@ mod blobs; mod channels; mod gap_filling; mod production_da; +mod recovery; mod sequencer_drift; mod submission; mod submission_failure; diff --git a/actions/harness/tests/batcher/recovery.rs b/actions/harness/tests/batcher/recovery.rs new file mode 100644 index 0000000000..26b80d0054 --- /dev/null +++ b/actions/harness/tests/batcher/recovery.rs @@ -0,0 +1,230 @@ +//! How the [`Batcher`] recovers on its own, with every fork through Cobalt active, 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. + +use base_action_harness::{ + ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, + TestRollupConfigBuilder, +}; +use base_batcher_encoder::{DaType, EncoderConfig}; +use base_common_genesis::RollupConfig; + +/// The derivation channel timeout, in L1 blocks. +const CHANNEL_TIMEOUT: u64 = 2; + +/// Calldata frames of 80 bytes, so one block spans several transactions. Channels close one +/// L1 block after they open, below [`CHANNEL_TIMEOUT`] as +/// `EncoderConfig::validate_for_rollup_config` requires. +fn batcher_config() -> BatcherConfig { + BatcherConfig { + encoder: EncoderConfig { + da_type: DaType::Calldata, + max_frame_size: 80, + max_channel_duration: 1, + ..EncoderConfig::default() + }, + ..BatcherConfig::default() + } +} + +/// Every fork through Cobalt active from genesis, with [`CHANNEL_TIMEOUT`]. +fn rollup_config(batcher: &BatcherConfig) -> RollupConfig { + TestRollupConfigBuilder::base_mainnet(batcher) + .all_forks_active() + .with_cobalt_at(0) + .with_channel_timeout(CHANNEL_TIMEOUT) + .build() +} + +/// A channel whose first frame landed but whose window closed before the rest is dropped by +/// derivation. The batcher, seeing the same L1 blocks, replays the block in a fresh channel +/// without being told, and derivation reads it from that channel. +#[tokio::test] +async fn batcher_replays_a_channel_that_timed_out_before_its_last_frame_landed() { + 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 block = sequencer.build_next_block_with_single_transaction().await; + let (mut node, chain) = h.create_test_rollup_node_from_sequencer( + &mut sequencer, + SharedL1Chain::from_blocks(h.l1.chain().to_vec()), + ); + node.initialize().await; + + let batcher = + Batcher::new(ActionL2Source::from_blocks([block.clone()]), &h.rollup_config, batcher_cfg); + batcher.encode_only().await; + let frames = batcher.pending_count(); + assert!(frames >= 2, "the block must span several frames"); + + // Frame 0 lands in L1 block 1, the rest stays pending in the tx manager. + batcher.stage_n_frames(&mut h.l1, 1); + h.mine_and_push(&chain); + batcher.observe_l1_block(h.l1.tip()).await; + + // The next CHANNEL_TIMEOUT + 1 L1 blocks carry nothing. Once L1 passes the block frame 0 + // landed in plus CHANNEL_TIMEOUT, derivation times the channel out and the batcher + // re-encodes the block in a fresh channel. That channel is still open, so the stale + // frames are the only pending ones. + for _ in 0..=CHANNEL_TIMEOUT { + h.mine_and_push(&chain); + batcher.observe_l1_block(h.l1.tip()).await; + } + assert_eq!(batcher.pending_count(), frames - 1, "the fresh channel is still open"); + + // The next L1 block closes the fresh channel, and the one after carries it, behind the + // stale frames. + h.mine_and_push(&chain); + batcher.observe_l1_block(h.l1.tip()).await; + batcher.mine_pending(&mut h.l1).await; + chain.push(h.l1.tip().clone()); + + assert_eq!(node.run_until_idle().await, 1, "the replayed channel derives the block"); + assert_eq!( + node.l2_safe().block_info.hash, + block.header.hash_slow(), + "derivation read the block from the fresh channel" + ); +} + +/// A batch confirmed on L1 then dropped by an L1 reorg is never derived, so derivation passes +/// its inclusion height with the safe head unchanged. The batcher, told so, resends the block +/// and derivation reads it on the new fork. +#[tokio::test] +async fn batcher_resends_a_confirmed_batch_that_derivation_passed_over() { + 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 block = sequencer.build_next_block_with_single_transaction().await; + let (mut node, chain) = h.create_test_rollup_node_from_sequencer( + &mut sequencer, + SharedL1Chain::from_blocks(h.l1.chain().to_vec()), + ); + + // The batch lands, then an L1 reorg replaces its block with empty ones. The batcher only + // learns of it from derivation, which passes the inclusion height on the new fork. + let batcher = + Batcher::new(ActionL2Source::from_blocks([block.clone()]), &h.rollup_config, batcher_cfg); + batcher.encode_only().await; + let inclusion = batcher.mine_pending(&mut h.l1).await; + h.l1.reorg_to(inclusion - 1).expect("reorg below the inclusion block"); + while h.l1.latest_number() <= inclusion { + h.mine_and_push(&chain); + batcher.observe_l1_block(h.l1.tip()).await; + } + node.initialize().await; + assert_eq!(node.run_until_idle().await, 0, "the batch is gone from L1"); + let status = node.derivation_status(); + assert!(status.current_l1.number > inclusion, "derivation passed the inclusion height"); + + // Derivation reports it passed the inclusion height, so the batcher re-encodes the block. + batcher.observe_derivation(status).await; + + // The next L1 block closes the fresh channel, the one after carries it. + h.mine_and_push(&chain); + batcher.observe_l1_block(h.l1.tip()).await; + batcher.mine_pending(&mut h.l1).await; + chain.push(h.l1.tip().clone()); + + assert_eq!(node.run_until_idle().await, 1, "the resent batch derives the block"); + assert_eq!( + node.l2_safe().block_info.hash, + block.header.hash_slow(), + "derivation read the block from the resent batch" + ); +} + +/// 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. +#[tokio::test] +async fn batcher_resends_the_blocks_above_a_safe_head_that_went_back() { + 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. + let batcher = + Batcher::new(ActionL2Source::from_blocks(blocks.clone()), &h.rollup_config, batcher_cfg); + batcher.advance(&mut h.l1).await; + 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; + + // 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. + 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; + + // The next L1 block closes the fresh channel, the one after carries it. + h.mine_and_push(&chain); + batcher.observe_l1_block(h.l1.tip()).await; + batcher.mine_pending(&mut h.l1).await; + chain.push(h.l1.tip().clone()); + + assert_eq!(node.run_until_idle().await, 3, "the resent blocks derive again"); + assert_eq!( + node.l2_safe().block_info.hash, + blocks[2].header.hash_slow(), + "derivation read the three blocks again" + ); +} + +/// 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. +#[tokio::test] +async fn batcher_sends_the_unsafe_chain_that_replaced_the_blocks_it_took() { + let batcher_cfg = BatcherConfig { + encoder: EncoderConfig { max_channel_duration: 1, ..EncoderConfig::default() }, + ..BatcherConfig::default() + }; + let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_config(&batcher_cfg)); + let mut old_leader = h.create_l2_sequencer(SharedL1Chain::from_blocks(h.l1.chain().to_vec())); + let mut new_leader = h.create_l2_sequencer(SharedL1Chain::from_blocks(h.l1.chain().to_vec())); + let common = old_leader.build_next_blocks_with_single_transactions(2).await; + assert_eq!(new_leader.build_next_blocks_with_single_transactions(2).await, common); + let replaced = old_leader.build_next_block_with_single_transaction().await; + let fork = [ + new_leader.build_empty_block().await, + new_leader.build_next_block_with_single_transaction().await, + ]; + let (mut node, chain) = h.create_test_rollup_node_from_sequencer( + &mut new_leader, + SharedL1Chain::from_blocks(h.l1.chain().to_vec()), + ); + + // The batcher takes blocks 1 to 3 of the old leader into a channel that stays open. + let batcher = Batcher::new( + ActionL2Source::from_blocks(common.iter().cloned().chain([replaced])), + &h.rollup_config, + batcher_cfg, + ); + batcher.observe_l1_block(h.l1.tip()).await; + assert_eq!(batcher.pending_count(), 0, "nothing left the open channel"); + + // The new leader's blocks replace block 3 on the chain the batcher polls. + for block in &fork { + batcher.push_block(block.clone()); + } + batcher.advance(&mut h.l1).await; + chain.push(h.l1.tip().clone()); + + node.initialize().await; + assert_eq!(node.run_until_idle().await, 4, "the new chain derives"); + assert_eq!( + node.l2_safe().block_info.hash, + fork[1].header.hash_slow(), + "derivation read the new leader's blocks" + ); +} From d26da1c56d1774cae19b19290e1c67b46ceaa6ed Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 19:55:15 +0000 Subject: [PATCH 183/299] test(harness): keep the batcher action tests that pin a contract, and validate configs as production does (#5350) * test(harness): keep the batcher action tests that pin a contract, and validate configs as production does * test(harness): tighten the batcher action test docs and label the Holocene cases * docs(harness): drop the production-mode wording of the tx manager * test(harness): keep the batcher action tests that pin a batcher contract, on current forks * test(harness): move the derivation and Denim tests out of the batcher tests and drop dead helpers * test(harness): reword three comments of the moved Jovian span test * test(harness): keep the source park tests, pin frame counts and name the channel tests file * test(harness): check that the blocks span several frames, not how many * test(harness): drop the helper tests nothing relies on and document the rest * test(harness): compare the whole genesis activations and drop the reorg guard test * test(harness): drop the blob packing test that relied on frames the CLI never sets --- Cargo.lock | 2 - actions/README.md | 66 +- actions/harness/Cargo.toml | 4 - actions/harness/README.md | 7 +- actions/harness/src/batcher/actor.rs | 61 +- actions/harness/src/batcher/source.rs | 24 +- actions/harness/src/batcher/tx_manager.rs | 98 +-- actions/harness/src/engine.rs | 36 +- actions/harness/src/harness.rs | 21 - actions/harness/src/l1/miner.rs | 304 +--------- actions/harness/src/node.rs | 27 - actions/harness/tests/batcher/blobs.rs | 321 ---------- actions/harness/tests/batcher/channels.rs | 393 +----------- actions/harness/tests/batcher/gap_filling.rs | 84 --- actions/harness/tests/batcher/main.rs | 6 - .../harness/tests/batcher/production_da.rs | 95 --- actions/harness/tests/batcher/submission.rs | 157 ----- .../tests/batcher/submission_failure.rs | 95 +-- .../tests/batcher/upgrade_transitions.rs | 562 ------------------ actions/harness/tests/derivation/channels.rs | 58 ++ .../harness/tests/derivation/da_switching.rs | 53 ++ .../tests/derivation/holocene_span_batches.rs | 153 ++++- actions/harness/tests/derivation/main.rs | 61 +- .../sequencer_drift.rs | 99 +-- actions/harness/tests/upgrade/denim.rs | 78 +++ actions/harness/tests/upgrade/main.rs | 1 + 26 files changed, 470 insertions(+), 2396 deletions(-) delete mode 100644 actions/harness/tests/batcher/blobs.rs delete mode 100644 actions/harness/tests/batcher/gap_filling.rs delete mode 100644 actions/harness/tests/batcher/production_da.rs delete mode 100644 actions/harness/tests/batcher/submission.rs delete mode 100644 actions/harness/tests/batcher/upgrade_transitions.rs create mode 100644 actions/harness/tests/derivation/channels.rs create mode 100644 actions/harness/tests/derivation/da_switching.rs rename actions/harness/tests/{batcher => derivation}/sequencer_drift.rs (58%) create mode 100644 actions/harness/tests/upgrade/denim.rs diff --git a/Cargo.lock b/Cargo.lock index e2c930ff15..28bd59ab51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2951,7 +2951,6 @@ dependencies = [ "base-batcher-core", "base-batcher-encoder", "base-batcher-source", - "base-blobs", "base-builder-core", "base-common-chains", "base-common-consensus", @@ -2995,7 +2994,6 @@ dependencies = [ "tokio", "tokio-util", "tracing", - "tracing-subscriber", ] [[package]] diff --git a/actions/README.md b/actions/README.md index 1bf677cf0f..9e95beb111 100644 --- a/actions/README.md +++ b/actions/README.md @@ -95,7 +95,7 @@ public methods directly. Simple actors mutate each other through explicit references. Production-shaped actors use channels or background tasks when that is part of the behavior under test; for example, `Batcher` owns a background `BatchDriver` task and exposes methods that let tests stage, -mine, confirm, fail, or reorg L1 submissions at precise points. +mine, confirm, or fail L1 submissions at precise points. ## L1Miner @@ -148,17 +148,12 @@ in-memory. ## ActionL2Source and BaseBlock -`ActionL2Source` holds the `BaseBlock`s a batcher is created with, its L2 chain -so far. Tests usually fill it with blocks -produced by `L2Sequencer`, which uses the production L1 origin selector, -attributes builder, and in-process engine client. Each block therefore -contains a real L1-info deposit transaction and signed user transactions, -rather than a batcher-only mock shape. - -`ActionTestHarness::create_l2_source(n)` is the shortcut for building a source -with `n` sequenced blocks. Tests that need precise block contents can create -an empty `ActionL2Source`, build blocks through `L2Sequencer`, and push them -manually. +`ActionL2Source` holds the blocks a batcher is created with, in order. Tests +fill it with blocks produced by `L2Sequencer`, which uses the production L1 +origin selector, attributes builder, and in-process engine client. Each block +therefore contains a real L1-info deposit transaction and signed user +transactions, rather than a batcher-only mock shape. Blocks built later go to +the running batcher with `push_block`. ## Batcher actor @@ -171,14 +166,14 @@ again from the safe head. The driver owns a `BatchEncoder`, which builds the channels and their calldata or blob frames, and the submission flow. The harness-owned boundary is `L1MinerTxManager`, which turns the driver's transaction candidates into signed L1 transactions and lets tests control when -those transactions are staged, mined, confirmed, failed, or reorged. +those transactions are staged, mined, confirmed, or failed. For the common happy path, call `batcher.advance(&mut h.l1).await`. It waits for the driver to encode every block pushed so far, flushes the encoder, mines one L1 block, and shows it to the driver. For more exact scenarios, use -`encode_only`, `stage_n_frames`, `observe_l1_block`, `mine_pending`, -`fail_next_n_submissions` and `reorg`. Report derivation progress with -`batcher.observe_derivation(node.derivation_status())`. +`encode_only`, `stage_n_frames`, `observe_l1_block`, `mine_pending` and +`fail_next_n_submissions`. Report derivation progress with +`observe_derivation`. Every `async` method of `Batcher` returns once the driver is idle again, so the test can read the tx manager's queues right after. @@ -186,28 +181,35 @@ test can read the tx manager's queues right after. ## Writing a test ```rust -use base_action_harness::{ActionTestHarness, Batcher, BatcherConfig}; +use base_action_harness::{ + ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, + TestRollupConfigBuilder, +}; #[tokio::test] async fn example_action_test() { - let mut h = ActionTestHarness::default(); let batcher_cfg = BatcherConfig::default(); - - // Step 1: mine some L1 context. - h.mine_l1_blocks(3); - assert_eq!(h.l1.latest_number(), 3); - - // Step 2: build real L2 blocks and batch them into one L1 block. - let source = h.create_l2_source(5).await; - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); + let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) + .all_forks_active() + .with_cobalt_at(0) + .build(); + let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); + + // Build real L2 blocks and batch them into one L1 block. + 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()), + ); + 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()); - // Step 3: inspect the signed L1 submissions. - assert!(h.l1.latest_number() >= 4); - assert!( - !h.l1.tip().transactions.is_empty() || !h.l1.tip().blob_sidecars.is_empty(), - "mined block should contain signed batcher submissions" - ); + // Derivation reads them back. + node.initialize().await; + assert_eq!(node.run_until_idle().await, 3); + assert_eq!(node.l2_safe_number(), 3); } ``` diff --git a/actions/harness/Cargo.toml b/actions/harness/Cargo.toml index 1449ec079b..dc0e034a20 100644 --- a/actions/harness/Cargo.toml +++ b/actions/harness/Cargo.toml @@ -90,12 +90,8 @@ libp2p = { workspace = true, features = ["gossipsub"] } alloy-sol-types = { workspace = true, features = ["std"] } # base -base-blobs.workspace = true base-common-precompiles.workspace = true base-precompile-storage.workspace = true # tokio tokio = { workspace = true, features = ["rt", "macros"] } - -# tracing -tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/actions/harness/README.md b/actions/harness/README.md index 2a8e3803d6..62ff9045cb 100644 --- a/actions/harness/README.md +++ b/actions/harness/README.md @@ -54,8 +54,7 @@ store, P2P transport, conductor behavior, and finality/reset orchestration. Action tests are currently strongest for deterministic protocol-level scenarios where the important behavior lives inside the Rust components: -- Batcher channel construction, frame ordering, gap filling, requeueing, and - upgrade behavior. +- Batcher channel construction, requeueing, and recovery after a reset. - Sequencer/verifier agreement on derived payloads and state roots. - Derivation behavior across upgrade transitions, origin changes, drift, deposits, system-config updates, and L1 reorgs. @@ -156,8 +155,8 @@ Current behavior: submission remains staged so tests can model delayed inclusion. - Blob submissions link the signed EIP-4844 transaction, versioned hashes, sidecars, and mined receipt observed by derivation. -- Explicit reorg and submission-failure helpers still fire failed receipts so - the production `BatchDriver` requeues frames. +- The submission-failure helper fires failed receipts so the production + `BatchDriver` requeues frames. Remaining gaps: diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 321db554fb..7cb96a6b85 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -31,7 +31,7 @@ pub struct BatcherConfig { pub inbox_address: alloy_primitives::Address, /// Encoder configuration forwarded to [`BatchEncoder`]. pub encoder: EncoderConfig, - /// L1 signer used to produce signed `TxEnvelope`s for production-mode DA tests. + /// L1 signer of the batcher's submissions. /// /// When changed via [`with_l1_signer`](BatcherConfig::with_l1_signer), the /// signer address becomes [`batcher_address`](BatcherConfig::batcher_address) @@ -63,7 +63,7 @@ impl BatcherConfig { PrivateKeySigner::from_bytes(&B256::repeat_byte(0xBA)).expect("valid default L1 signer") } - /// Configure a signer for production-mode L1 transaction construction. + /// Sign the batcher's submissions with `signer`, and make its address the batcher address. pub fn with_l1_signer(mut self, signer: PrivateKeySigner) -> Self { self.batcher_address = signer.address(); self.l1_signer = signer; @@ -113,7 +113,7 @@ pub struct Batcher { derivation_status_tx: mpsc::Sender, /// Admin channel to the driver, used to flush at the end of a cycle. admin: AdminHandle, - /// Shared tx manager — used to stage submissions and fire their receipts. + /// Shared tx manager, used to stage submissions and fire their receipts. tx_manager: L1MinerTxManager, /// Background driver task, aborted on drop. driver_task: tokio::task::JoinHandle>, @@ -131,30 +131,36 @@ impl Batcher { /// /// # Panics /// - /// Panics if `config.encoder` is invalid, if `config.batcher_address` is not the address - /// of `config.l1_signer`, or if `config.initial_safe_head` is `None` and the first block is - /// the genesis block, which no batcher posts. + /// Panics if `config.encoder` is one production would refuse for `rollup_config` at the + /// timestamp of the block after the safe head, if `config.batcher_address` is not the + /// address of `config.l1_signer`, or if `config.initial_safe_head` is `None` and the first + /// block is the genesis block, which no batcher posts. pub fn new( l2_source: ActionL2Source, rollup_config: &RollupConfig, config: BatcherConfig, ) -> Self { let l1_chain_id = rollup_config.l1_chain_id; - let pipeline = BatchEncoder::new(Arc::new(rollup_config.clone()), config.encoder.clone()) - .expect("valid encoder config"); - let blocks: Vec = l2_source.into_iter().collect(); - // Only the number and hash of the safe head are ever read. let initial_safe_head = config.initial_safe_head.unwrap_or_else(|| { blocks.first().map_or_else( || BlockInfo::from_l2_genesis(&rollup_config.genesis), |block| BlockInfo { hash: block.header.parent_hash, number: block.header.number.checked_sub(1).expect("a block above genesis"), + timestamp: block.header.timestamp - rollup_config.block_time, ..Default::default() }, ) }); + let next_l2_timestamp = initial_safe_head.timestamp + rollup_config.block_time; + config + .encoder + .validate_for_rollup_config(rollup_config, next_l2_timestamp) + .expect("an encoder config production accepts"); + let pipeline = BatchEncoder::new(Arc::new(rollup_config.clone()), config.encoder.clone()) + .expect("the config was validated"); + let chain = SharedL2Chain::new(); for block in blocks { chain.push(block); @@ -163,8 +169,7 @@ impl Batcher { let (l1_head_source, l1_head_tx) = HarnessL1HeadSource::new(); let (admin, admin_rx) = AdminHandle::channel(); - let tx_manager = - L1MinerTxManager::new(config.l1_signer.clone(), config.inbox_address, l1_chain_id); + let tx_manager = L1MinerTxManager::new(config.l1_signer.clone(), l1_chain_id); assert_eq!( config.batcher_address, tx_manager.sender_address(), @@ -247,12 +252,6 @@ impl Batcher { assert!(matches!(reached, Ok(Ok(()))), "the batch driver exited or stalled"); } - /// Deliver `head` to the driver as the new L1 head and wait until it is applied. - async fn deliver_l1_head(&self, head: u64) { - self.l1_head_tx.send(L1HeadItem::Head(head)).expect("the batch driver has exited"); - self.wait_until_idle().await; - } - /// Returns the number of encoded-but-not-yet-staged pending frame submissions. pub fn pending_count(&self) -> usize { self.tx_manager.pending_count() @@ -290,7 +289,7 @@ impl Batcher { /// Stages every pending frame, mines one L1 block, fires all receipts and waits until /// the driver has confirmed them. Returns the mined block number. /// - /// Use this to confirm a requeued batch without encoding new L2 blocks. + /// Use this to land what the driver submitted without encoding new L2 blocks. pub async fn mine_pending(&self, l1: &mut L1Miner) -> u64 { self.tx_manager.stage_n_to_l1(l1, usize::MAX); let block = l1.mine_block().clone(); @@ -315,7 +314,10 @@ impl Batcher { // Fire the receipts first because the driver serves them before L1 heads, so a failed // submission is requeued before the head advances. self.tx_manager.confirm_block(block); - self.deliver_l1_head(block.number()).await; + self.l1_head_tx + .send(L1HeadItem::Head(block.number())) + .expect("the batch driver has exited"); + self.wait_until_idle().await; } /// Report derivation progress to the driver, as the production `DerivationStatusPoller` @@ -332,25 +334,6 @@ impl Batcher { self.wait_until_idle().await; } - /// Simulate an L1 reorg back to `block_number`. - /// - /// Truncates the L1 chain via [`L1Miner::reorg_to`], fires failure receipts for every - /// item in `pending` and `staged`, delivers the new L1 head to the driver, and waits - /// until the driver has requeued and resubmitted the failed frames. - /// - /// Submissions already confirmed through [`observe_l1_block`] are not revisited. - /// - /// # Panics - /// - /// Panics if `block_number` exceeds the current L1 chain tip (`ReorgError::BeyondTip`). - /// - /// [`observe_l1_block`]: Batcher::observe_l1_block - pub async fn reorg(&self, block_number: u64, l1: &mut L1Miner) { - // Failure receipts first, for the same reason as in `observe_l1_block`. - self.tx_manager.reorg_to(block_number, l1); - self.deliver_l1_head(block_number).await; - } - /// Run one full batch cycle through the production [`BatchDriver`] path. /// /// # Panics diff --git a/actions/harness/src/batcher/source.rs b/actions/harness/src/batcher/source.rs index 565a8897af..f2f5a810a2 100644 --- a/actions/harness/src/batcher/source.rs +++ b/actions/harness/src/batcher/source.rs @@ -72,11 +72,12 @@ mod tests { use std::{ pin::pin, task::{Context, Waker}, - time::Duration, }; use super::*; + /// A marker is answered by the poll after the one that takes it, so `wait_until_idle` returns + /// only once the driver has handled everything sent before the marker. #[tokio::test] async fn marker_is_answered_by_the_poll_after_the_one_that_takes_it() { let (mut source, tx) = HarnessL1HeadSource::new(); @@ -97,25 +98,4 @@ mod tests { assert!(next.as_mut().poll(&mut Context::from_waker(Waker::noop())).is_pending()); assert!(reached_rx.try_recv().is_ok(), "the poll after must answer the marker"); } - - #[tokio::test] - async fn marker_is_answered_even_when_nothing_follows() { - let (mut source, tx) = HarnessL1HeadSource::new(); - let (reached_tx, reached_rx) = oneshot::channel(); - tx.send(L1HeadItem::Marker(reached_tx)).unwrap(); - - // The source keeps waiting for a head, but the marker is already answered. - let next = tokio::time::timeout(Duration::from_millis(10), source.next()); - assert!(next.await.is_err(), "no head must be returned"); - assert!(reached_rx.await.is_ok()); - } - - #[tokio::test] - async fn parks_once_the_harness_is_gone() { - let (mut source, tx) = HarnessL1HeadSource::new(); - drop(tx); - - let next = tokio::time::timeout(Duration::from_millis(10), source.next()); - assert!(next.await.is_err(), "a closed source must park"); - } } diff --git a/actions/harness/src/batcher/tx_manager.rs b/actions/harness/src/batcher/tx_manager.rs index 81a29cd885..1968093f63 100644 --- a/actions/harness/src/batcher/tx_manager.rs +++ b/actions/harness/src/batcher/tx_manager.rs @@ -13,7 +13,6 @@ use base_tx_manager::{ BlobTxBuilder, SendHandle, SendResponse, TxCandidate, TxManager, TxManagerError, }; use tokio::sync::oneshot; -use tracing::info; use crate::{L1Block, L1Miner}; @@ -53,7 +52,7 @@ pub struct Inner { pending: Vec, /// Submissions in the miner's queue, waiting for a receipt. staged: Vec, - /// Next nonce to use for signed production-mode transactions. + /// Nonce of the next signed transaction. next_nonce: u64, /// Number of upcoming `send_async` calls to immediately fail with /// [`TxManagerError::Rpc`] before falling through to normal queuing. @@ -78,8 +77,6 @@ pub struct Inner { pub struct L1MinerTxManager { /// Pending and staged submissions, shared with the driver's clone. inner: Arc>, - /// Default `to` for candidates that do not name one. - inbox_address: Address, /// Signs every submission. Its address is the batcher's sender. signer: PrivateKeySigner, /// L1 chain id stamped on every signed transaction. @@ -88,8 +85,8 @@ pub struct L1MinerTxManager { impl L1MinerTxManager { /// Create a new manager. - pub fn new(signer: PrivateKeySigner, inbox_address: Address, chain_id: u64) -> Self { - Self { inner: Arc::new(Mutex::new(Inner::default())), inbox_address, signer, chain_id } + pub fn new(signer: PrivateKeySigner, chain_id: u64) -> Self { + Self { inner: Arc::new(Mutex::new(Inner::default())), signer, chain_id } } /// Returns the number of pending (not yet staged) submissions. @@ -177,44 +174,15 @@ impl L1MinerTxManager { } } - /// Simulate an L1 reorg back to `block_number`. - /// - /// Calls [`L1Miner::reorg_to`] to truncate the canonical chain and fires a - /// failure receipt for every pending and staged submission, since their - /// inclusion block has been discarded or they are no longer valid. - /// - /// Both `pending` (not yet staged) and `staged` (submitted to L1 but not - /// yet confirmed) items are drained, so every [`SendHandle`] resolves and no - /// submission holds an in-flight slot forever. - /// - /// Submissions already confirmed through [`confirm_block`] are not revisited. - /// - /// [`SendHandle`]: base_tx_manager::SendHandle - /// [`confirm_block`]: L1MinerTxManager::confirm_block - pub fn reorg_to(&self, block_number: u64, l1: &mut L1Miner) { - l1.reorg_to(block_number).expect("reorg_to should not fail"); - let (pending, staged) = { - let mut inner = self.inner.lock().unwrap(); - let pending: Vec = inner.pending.drain(..).collect(); - let staged: Vec = inner.staged.drain(..).collect(); - (pending, staged) - }; - let drained = pending.len() + staged.len(); - for p in pending.into_iter().chain(staged) { - let _ = p.responder.send(Err(TxManagerError::Rpc("reorg".to_string()))); - } - info!(block_number = %block_number, drained = %drained, "simulated L1 reorg"); - } - - /// Build a signed transaction envelope and matching blob sidecar index for - /// production-mode DA. + /// Build the signed transaction envelope of `candidate` and the blob sidecars it + /// references. pub fn sign_candidate( &self, candidate: &TxCandidate, nonce: u64, ) -> Result { let gas_limit = candidate.gas_limit.max(21_000); - let to = candidate.to.unwrap_or(self.inbox_address); + let to = candidate.to.expect("the driver addresses every candidate to the inbox"); if candidate.blobs.is_empty() { let tx = TxEip1559 { @@ -312,57 +280,3 @@ impl TxManager for L1MinerTxManager { self.signer.address() } } - -#[cfg(test)] -mod tests { - use alloy_primitives::{Address, B256, Bytes, U256}; - use alloy_signer_local::PrivateKeySigner; - use base_tx_manager::{TxCandidate, TxManager}; - - use super::L1MinerTxManager; - use crate::L1Miner; - - struct TxManagerFixture; - - impl TxManagerFixture { - fn signer() -> PrivateKeySigner { - PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).expect("valid test signer") - } - - fn candidate(to: Address) -> TxCandidate { - TxCandidate { - tx_data: Bytes::from_static(b"\x00frame"), - to: Some(to), - gas_limit: 21_000, - value: U256::ZERO, - ..Default::default() - } - } - } - - #[tokio::test] - async fn confirm_block_keeps_unincluded_staged_submission_polling() { - let inbox = Address::repeat_byte(0x42); - let manager = L1MinerTxManager::new(TxManagerFixture::signer(), inbox, 1); - let mut l1 = L1Miner::default(); - - let handle = manager.send_async(TxManagerFixture::candidate(inbox)).await; - assert_eq!(manager.pending_count(), 1); - assert_eq!(manager.stage_n_to_l1(&mut l1, 1), 1); - assert_eq!(manager.pending_count(), 0); - assert_eq!(manager.staged_count(), 1); - - let genesis = l1.tip().clone(); - manager.confirm_block(&genesis); - assert_eq!(manager.staged_count(), 1); - - let block = l1.mine_block().clone(); - manager.confirm_block(&block); - assert_eq!(manager.staged_count(), 0); - - let receipt = handle.await.expect("staged transaction should confirm"); - assert_eq!(receipt.block_number, Some(block.number())); - assert_eq!(receipt.transaction_index, Some(0)); - assert_eq!(receipt.to, Some(inbox)); - } -} diff --git a/actions/harness/src/engine.rs b/actions/harness/src/engine.rs index 7872360c70..aaa747cc35 100644 --- a/actions/harness/src/engine.rs +++ b/actions/harness/src/engine.rs @@ -350,12 +350,6 @@ impl ActionEngineClient { inner.executed_receipts.get(&block_number).cloned() } - /// Return the executed hash for an L2 block number. - pub fn block_hash_at(&self, block_number: u64) -> Option { - let inner = self.inner.lock().expect("engine client lock"); - inner.executed_infos.get(&block_number).map(|info| info.block_info.hash) - } - /// Check whether an account has non-empty code deployed. /// /// Returns `true` if the account exists and has code, `false` otherwise. @@ -1086,6 +1080,8 @@ mod tests { use super::*; + /// The Base fork activations of the rollup config reach the execution genesis, so the harness + /// EL runs the same forks as derivation. #[test] fn build_genesis_propagates_base_activations() { let config = RollupConfig { @@ -1105,22 +1101,16 @@ mod tests { let genesis = ActionEngineClient::build_genesis_for_rollup(&config); - assert_eq!(genesis.config.extra_fields["base"]["denim"], serde_json::json!(42)); - assert_eq!(genesis.config.extra_fields["base"]["everest"], serde_json::json!(42)); - assert_eq!(genesis.config.extra_fields["base"]["zenith"], serde_json::json!(42)); - } - - #[test] - #[should_panic(expected = "denim requires cobalt to be configured")] - fn build_genesis_requires_cobalt_before_denim() { - let config = RollupConfig { - upgrades: UpgradeConfig { - base: BaseUpgradeConfig { denim: Some(42), ..Default::default() }, - ..Default::default() - }, - ..Default::default() - }; - - ActionEngineClient::build_genesis_for_rollup(&config); + assert_eq!( + genesis.config.extra_fields["base"], + serde_json::json!({ + "azul": 42, + "beryl": 42, + "cobalt": 42, + "denim": 42, + "everest": 42, + "zenith": 42, + }) + ); } } diff --git a/actions/harness/src/harness.rs b/actions/harness/src/harness.rs index d06f2aea80..4d57ab564d 100644 --- a/actions/harness/src/harness.rs +++ b/actions/harness/src/harness.rs @@ -337,27 +337,6 @@ impl ActionTestHarness { L1BlockInfoTx::decode_calldata(sealed.inner().input.as_ref()) .expect("L1 info calldata must decode") } - - /// Build an [`ActionL2Source`] pre-populated with `n` real [`BaseBlock`]s - /// starting from L2 genesis. - /// - /// Use this when a test needs a ready-made block source and does not - /// require direct access to the underlying [`L2Sequencer`]. - /// - /// Note: this is an async operation because the sequencer now uses the - /// production engine. If you need a sync source builder, construct the - /// sequencer manually and drive it with an async runtime. - /// - /// [`BaseBlock`]: base_common_consensus::BaseBlock - pub async fn create_l2_source(&self, n: u64) -> ActionL2Source { - let chain = SharedL1Chain::from_blocks(self.l1.chain().to_vec()); - let mut sequencer = self.create_l2_sequencer(chain); - let mut source = ActionL2Source::new(); - for _ in 0..n { - source.push(sequencer.build_next_block_with_single_transaction().await); - } - source - } } impl Default for ActionTestHarness { diff --git a/actions/harness/src/l1/miner.rs b/actions/harness/src/l1/miner.rs index c42e563a20..0aacc97686 100644 --- a/actions/harness/src/l1/miner.rs +++ b/actions/harness/src/l1/miner.rs @@ -601,7 +601,7 @@ impl L1Miner { /// Enqueue a signed L1 transaction for inclusion in the next mined block. /// - /// Production-mode DA tests use this path so derivation receives the same + /// The batcher's signed submissions take this path, so derivation receives the same /// `TxEnvelope` shape it reads from an RPC-backed provider. pub fn submit_transaction(&mut self, tx: TxEnvelope) { self.submit_transaction_with_logs(tx, Vec::new()); @@ -819,13 +819,9 @@ impl Action for L1Miner { #[cfg(test)] mod tests { - use alloy_consensus::{Transaction, transaction::SignerRecoverable}; - use alloy_eips::eip4844::Blob; - use alloy_primitives::{Address, B256, Bloom, Bytes, Log, LogData}; - use alloy_signer_local::PrivateKeySigner; + use alloy_primitives::{Address, B256, Bytes, Log, LogData}; - use super::{L1Miner, L1TxBuilder, ReorgError}; - use crate::Action; + use super::L1Miner; struct MinerFixture; @@ -834,159 +830,13 @@ mod tests { L1Miner::default() } - fn signer() -> PrivateKeySigner { - PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).expect("valid test signer") - } - - fn signed_tx(input: Bytes, nonce: u64, to: Address) -> alloy_consensus::TxEnvelope { - L1TxBuilder::signed_calldata(&Self::signer(), 1, nonce, to, input) - .expect("test transaction signs") - } - fn log(address: Address, topic: B256) -> Log { Log { address, data: LogData::new_unchecked(vec![topic], Bytes::from_static(b"event")) } } } - #[test] - fn genesis_block_at_number_zero() { - let m = MinerFixture::miner(); - assert_eq!(m.latest_number(), 0); - assert_eq!(m.latest().timestamp(), 0); - } - - #[test] - fn mine_increments_number_and_timestamp() { - let mut m = MinerFixture::miner(); - m.mine_block(); - assert_eq!(m.latest_number(), 1); - assert_eq!(m.latest().timestamp(), 12); - m.mine_block(); - assert_eq!(m.latest_number(), 2); - assert_eq!(m.latest().timestamp(), 24); - } - - #[test] - fn blocks_form_valid_parent_hash_chain() { - let mut m = MinerFixture::miner(); - m.mine_block(); - m.mine_block(); - m.mine_block(); - for i in 1..=3u64 { - let block = m.block_by_number(i).unwrap(); - let parent = m.block_by_number(i - 1).unwrap(); - assert_eq!(block.header.parent_hash, parent.hash()); - } - } - - #[test] - fn safe_and_finalized_head_start_at_genesis() { - let m = MinerFixture::miner(); - assert_eq!(m.safe_head().number(), 0); - assert_eq!(m.finalized_head().number(), 0); - assert_eq!(m.safe_number(), 0); - assert_eq!(m.finalized_number(), 0); - } - - #[test] - fn explicit_safe_next_advances_pointer() { - let mut m = MinerFixture::miner(); - m.mine_block(); // 1 - m.mine_block(); // 2 - m.act_l1_safe_next(); - assert_eq!(m.safe_number(), 1); - assert_eq!(m.safe_head().number(), 1); - } - - #[test] - fn explicit_finalize_next_advances_pointer() { - let mut m = MinerFixture::miner(); - m.mine_block(); - m.act_l1_safe_next(); - m.act_l1_finalize_next(); - assert_eq!(m.finalized_number(), 1); - assert_eq!(m.finalized_head().number(), 1); - } - - #[test] - fn act_l1_safe_sets_exactly() { - let mut m = MinerFixture::miner(); - for _ in 0..5 { - m.mine_block(); - } - m.act_l1_safe(3); - assert_eq!(m.safe_number(), 3); - } - - #[test] - fn act_l1_finalize_sets_exactly() { - let mut m = MinerFixture::miner(); - for _ in 0..5 { - m.mine_block(); - } - m.act_l1_safe(4); - m.act_l1_finalize(2); - assert_eq!(m.finalized_number(), 2); - } - - #[test] - fn safe_clamped_to_latest_on_act_safe_next() { - let mut m = MinerFixture::miner(); - m.mine_block(); // 1 - m.act_l1_safe_next(); // 1 - m.act_l1_safe_next(); // would be 2, but latest=1 - assert_eq!(m.safe_number(), 1); - } - - #[test] - fn finalized_clamped_to_safe_on_act_finalize_next() { - let mut m = MinerFixture::miner(); - m.mine_block(); - m.act_l1_safe_next(); // safe=1 - m.act_l1_finalize_next(); // finalized=1 - m.act_l1_finalize_next(); // clamped to safe=1 - assert_eq!(m.finalized_number(), 1); - } - - #[test] - fn reorg_clamps_safe_and_finalized() { - let mut m = MinerFixture::miner(); - for _ in 0..5 { - m.mine_block(); - } - m.act_l1_safe(4); - m.act_l1_finalize(3); - - m.reorg_to(2).unwrap(); - assert_eq!(m.safe_number(), 2); - assert_eq!(m.finalized_number(), 2); - } - - #[test] - fn pending_transactions_included_in_next_block() { - let mut m = MinerFixture::miner(); - let to = Address::repeat_byte(0x22); - let input = Bytes::from_static(b"\x00hello"); - let expected_sender = MinerFixture::signer().address(); - m.submit_transaction(MinerFixture::signed_tx(input.clone(), 0, to)); - m.mine_block(); - assert_eq!(m.latest().transactions.len(), 1); - assert_eq!(m.latest().transactions[0].input(), &input); - assert_eq!(m.latest().transactions[0].to(), Some(to)); - assert_eq!(m.latest().transactions[0].recover_signer().unwrap(), expected_sender); - assert_eq!(m.latest().transaction_receipts[0].from, expected_sender); - assert_eq!(m.latest().transaction_receipts[0].to, Some(to)); - } - - #[test] - fn pending_transactions_cleared_after_mining() { - let mut m = MinerFixture::miner(); - m.submit_transaction(MinerFixture::signed_tx(Bytes::new(), 0, Address::ZERO)); - m.mine_block(); - m.mine_block(); - assert!(m.latest().transactions.is_empty()); - } - + /// Enqueued logs are mined as one event transaction each, whose receipt carries exactly that + /// log, so derivation reads deposits and system config updates from the right receipt. #[test] fn enqueued_logs_are_mined_as_signed_event_transaction_receipts() { let mut m = MinerFixture::miner(); @@ -1000,147 +850,7 @@ mod tests { m.mine_block(); let block = m.latest(); assert_eq!(block.transactions.len(), 2); - assert_eq!(block.transactions[0].to(), Some(first.address)); - assert_eq!(block.transactions[1].to(), Some(second.address)); - assert_eq!(block.receipts[0].logs, vec![first.clone()]); - assert_eq!(block.receipts[1].logs, vec![second.clone()]); - assert_ne!(block.header.logs_bloom, Bloom::ZERO); - - let first_receipt = &block.transaction_receipts[0]; - let second_receipt = &block.transaction_receipts[1]; - assert_ne!(*first_receipt.inner.logs_bloom(), Bloom::ZERO); - assert_ne!(*second_receipt.inner.logs_bloom(), Bloom::ZERO); - assert_eq!(first_receipt.logs()[0].inner, first); - assert_eq!(first_receipt.logs()[0].block_hash, Some(block.hash())); - assert_eq!(first_receipt.logs()[0].block_number, Some(block.number())); - assert_eq!(first_receipt.logs()[0].block_timestamp, Some(block.timestamp())); - assert_eq!(first_receipt.logs()[0].transaction_hash, Some(*block.transactions[0].hash())); - assert_eq!(first_receipt.logs()[0].transaction_index, Some(0)); - assert_eq!(first_receipt.logs()[0].log_index, Some(0)); - - assert_eq!(second_receipt.logs()[0].inner, second); - assert_eq!(second_receipt.logs()[0].transaction_hash, Some(*block.transactions[1].hash())); - assert_eq!(second_receipt.logs()[0].transaction_index, Some(1)); - assert_eq!(second_receipt.logs()[0].log_index, Some(1)); - } - - #[test] - fn act_returns_block_number() { - let mut m = MinerFixture::miner(); - assert_eq!(m.act().unwrap(), 1); - assert_eq!(m.act().unwrap(), 2); - } - - #[test] - fn blob_sidecars_drained_into_block() { - let mut m = MinerFixture::miner(); - let hash = B256::repeat_byte(0xAA); - let blob = Box::new(Blob::default()); - m.enqueue_blob(hash, blob); - m.mine_block(); - assert_eq!(m.latest().blob_sidecars.len(), 1); - assert_eq!(m.latest().blob_sidecars[0].0, hash); - // Next block has no blobs. - m.mine_block(); - assert!(m.latest().blob_sidecars.is_empty()); - } - - // ── reorg tests ────────────────────────────────────────────────────────── - - #[test] - fn reorg_truncates_chain() { - let mut m = MinerFixture::miner(); - m.mine_block(); // 1 - m.mine_block(); // 2 - m.mine_block(); // 3 - - let discarded = m.reorg_to(1).unwrap(); - assert_eq!(discarded.len(), 2); - assert_eq!(discarded[0].number(), 2); - assert_eq!(discarded[1].number(), 3); - assert_eq!(m.latest_number(), 1); - } - - #[test] - fn reorg_returns_transactions_from_discarded_blocks() { - let mut m = MinerFixture::miner(); - m.mine_block(); // 1 — empty - - let input = Bytes::from_static(b"\x00batch"); - m.submit_transaction(MinerFixture::signed_tx(input.clone(), 0, Address::ZERO)); - m.mine_block(); // 2 — contains the batch tx - - m.mine_block(); // 3 — empty - - let discarded = m.reorg_to(1).unwrap(); - // Block 2 contained the batch, block 3 was empty. - assert_eq!(discarded[0].number(), 2); - assert_eq!(discarded[0].transactions.len(), 1); - assert_eq!(discarded[0].transactions[0].input(), &input); - assert_eq!(discarded[1].number(), 3); - assert!(discarded[1].transactions.is_empty()); - } - - #[test] - fn reorg_to_tip_is_no_op() { - let mut m = MinerFixture::miner(); - m.mine_block(); - m.mine_block(); - - let discarded = m.reorg_to(2).unwrap(); - assert!(discarded.is_empty()); - assert_eq!(m.latest_number(), 2); - } - - #[test] - fn post_reorg_blocks_have_distinct_hashes() { - let mut m = MinerFixture::miner(); - m.mine_block(); // block 1 on fork 0 - let original_hash_1 = m.block_by_number(1).unwrap().hash(); - - // Reorg all the way back to genesis (now allowed); mine a new block 1 on fork 1. - m.reorg_to(0).unwrap(); // fork_id → 1; chain back to [genesis] - m.mine_block(); // new block 1 on fork 1 - - let new_hash_1 = m.block_by_number(1).unwrap().hash(); - assert_ne!( - new_hash_1, original_hash_1, - "block 1 on fork 1 must have a different hash than block 1 on fork 0", - ); - } - - #[test] - fn mine_after_reorg_builds_valid_parent_chain() { - let mut m = MinerFixture::miner(); - m.mine_block(); - m.mine_block(); - m.mine_block(); - - m.reorg_to(1).unwrap(); - m.mine_block(); // new 2 - m.mine_block(); // new 3 - - for i in 1..=3u64 { - let block = m.block_by_number(i).unwrap(); - let parent = m.block_by_number(i - 1).unwrap(); - assert_eq!(block.header.parent_hash, parent.hash(), "parent chain broken at {i}"); - } - } - - #[test] - fn reorg_beyond_tip_returns_error() { - let mut m = MinerFixture::miner(); - m.mine_block(); - assert!(matches!(m.reorg_to(5), Err(ReorgError::BeyondTip { requested: 5, tip: 1 }))); - } - - #[test] - fn reorg_to_genesis_discards_all_non_genesis_blocks() { - let mut m = MinerFixture::miner(); - m.mine_block(); // block 1 - m.mine_block(); // block 2 - let discarded = m.reorg_to(0).expect("reorg to genesis should succeed"); - assert_eq!(discarded.len(), 2, "blocks 1 and 2 should be discarded"); - assert_eq!(m.latest_number(), 0, "only genesis remains"); + assert_eq!(block.receipts[0].logs, vec![first]); + assert_eq!(block.receipts[1].logs, vec![second]); } } diff --git a/actions/harness/src/node.rs b/actions/harness/src/node.rs index 4fe1656287..510f0f9875 100644 --- a/actions/harness/src/node.rs +++ b/actions/harness/src/node.rs @@ -146,8 +146,6 @@ pub struct TestRollupNode, - /// Most recently signalled L1 safe block number. - safe_l1_number: u64, /// History of safe head updates paired with their L1 origin number. /// /// Each entry is `(l2_block_info, l1_origin_number)`. Used by @@ -204,7 +202,6 @@ impl TestRollupNode

{ unsafe_head: safe_head, finalized_head: safe_head, finalized_l1_number: None, - safe_l1_number: 0, safe_head_history: Vec::new(), derived_tx_counts: Vec::new(), derived_user_tx_counts: Vec::new(), @@ -238,11 +235,6 @@ impl TestRollupNode

{ self.safe_head.block_info.number } - /// Return the executed hash for a derived L2 block. - pub fn derived_block_hash(&self, block_number: u64) -> Option { - self.engine.block_hash_at(block_number) - } - /// Return the current L2 unsafe head. /// /// Advances ahead of the safe head as P2P gossip blocks are received. @@ -266,11 +258,6 @@ impl TestRollupNode

{ self.finalized_head.block_info.number } - /// Return the most recently signalled L1 safe block number. - pub const fn safe_l1_number(&self) -> u64 { - self.safe_l1_number - } - /// Returns the derivation progress this node reports to the batcher, which is its safe L2 /// head and the L1 block its derivation pipeline is processing. Before the pipeline has an /// origin, that block is the default one, as `optimism_syncStatus` reports it. See @@ -295,13 +282,6 @@ impl TestRollupNode

{ self.safe_db.safe_head_at_l1(l1_block_num).await } - /// Return the total transaction counts for each derived L2 block. - /// - /// Each entry is `(l2_block_number, tx_count)`. - pub fn derived_tx_counts(&self) -> &[(u64, usize)] { - &self.derived_tx_counts - } - /// Return the user transaction counts for each derived L2 block. /// /// Each entry is `(l2_block_number, user_tx_count)`. A count of `0` means @@ -346,12 +326,6 @@ impl TestRollupNode

{ self.engine.block_hash_registry().insert(number, hash, None); } - /// Record the L1 safe head number. Does not drive additional pipeline steps. - pub async fn act_l1_safe_signal(&mut self, head: BlockInfo) -> Result<(), VerifierError> { - self.safe_l1_number = head.number; - Ok(()) - } - /// Update the retained finalized L1 signal and retry finalization. pub async fn act_l1_finalized_signal(&mut self, head: BlockInfo) { if self.finalized_l1_number.is_some_and(|previous| head.number < previous) { @@ -393,7 +367,6 @@ impl TestRollupNode

{ self.safe_head = l2_safe_head; self.unsafe_head = l2_safe_head; self.finalized_head = l2_safe_head; - self.safe_l1_number = 0; self.safe_head_history.clear(); self.derived_tx_counts.clear(); self.derived_user_tx_counts.clear(); diff --git a/actions/harness/tests/batcher/blobs.rs b/actions/harness/tests/batcher/blobs.rs deleted file mode 100644 index 112eeca406..0000000000 --- a/actions/harness/tests/batcher/blobs.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Action tests for blob DA submission and mixed calldata/blob derivation. - -use std::sync::Arc; - -use alloy_primitives::{Bytes, U256}; -use base_action_harness::{ - ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, L1MinerTxManager, - SharedL1Chain, TestRollupConfigBuilder, -}; -use base_batcher_encoder::{ - BatchEncoder, BatchPipeline, BatchSubmission, DaType, EncoderConfig, SubmissionPayload, -}; -use base_blobs::BlobEncoder; -use base_protocol::Frame; -use base_tx_manager::TxCandidate; - -fn submission_frames(submission: &BatchSubmission) -> Vec> { - match submission.payload() { - SubmissionPayload::Blobs(blobs) => { - blobs.iter().flat_map(|blob| blob.frames().iter().cloned()).collect() - } - SubmissionPayload::Calldata(frame) => vec![Arc::clone(frame)], - } -} - -fn submit_signed_blob_frames( - h: &mut ActionTestHarness, - batcher_cfg: &BatcherConfig, - frames: &[Arc], - nonce: u64, -) { - let blob = BlobEncoder::encode_packed(frames).expect("frames encode into a blob"); - let candidate = TxCandidate { - to: Some(batcher_cfg.inbox_address), - tx_data: Bytes::new(), - value: U256::ZERO, - gas_limit: 0, - blobs: Arc::from(vec![blob]), - }; - let tx_manager = L1MinerTxManager::new( - batcher_cfg.l1_signer.clone(), - batcher_cfg.inbox_address, - h.rollup_config.l1_chain_id, - ); - let signed = tx_manager.sign_candidate(&candidate, nonce).expect("blob tx signs"); - h.l1.submit_transaction(signed.envelope); - for (hash, blob) in signed.blobs { - h.l1.enqueue_blob(hash, blob); - } -} - -// --------------------------------------------------------------------------- -// Blob DA end-to-end -// --------------------------------------------------------------------------- - -/// Encode 3 L2 blocks with EIP-4844 DA and verify that the blob verifier -/// pipeline derives all three. -#[tokio::test] -async fn batcher_blob_da_end_to_end() { - let batcher_cfg = BatcherConfig::default(); // DaType::Blob by default - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - - // One block per L1 inclusion block. - let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); - for _ in 1..=3u64 { - batcher.push_block(sequencer.build_next_block_with_single_transaction().await); - batcher.advance(&mut h.l1).await; - } - - let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - node.initialize().await; - - let total_derived = node.run_until_idle().await; - assert_eq!(total_derived, 3, "blob DA should derive 3 L2 blocks"); - assert_eq!(node.l2_safe_number(), 3, "safe head should reach L2 block 3"); -} - -// --------------------------------------------------------------------------- -// Multi-frame packing (many frames, one blob sidecar) -// --------------------------------------------------------------------------- - -/// Force channel fragmentation via a tiny `max_frame_size`, then verify that -/// the resulting frames are packed into one blob sidecar -/// and that the derivation pipeline can reconstruct the L2 block from them. -#[tokio::test] -async fn batcher_multi_frame_blob_packing() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { max_frame_size: 80, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - let mut source = ActionL2Source::new(); - source.push(block); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); - batcher.advance(&mut h.l1).await; - - let sidecar_count = h.l1.tip().blob_sidecars.len(); - assert_eq!(sidecar_count, 1, "fragmented frames should share one blob sidecar"); - - let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - node.initialize().await; - - let derived = node.run_until_idle().await; - - assert_eq!(derived, 1, "expected 1 L2 block derived from packed multi-frame blob"); - assert_eq!(node.l2_safe_number(), 1, "safe head should reach L2 block 1"); -} - -// --------------------------------------------------------------------------- -// Calldata DA (explicit) -// --------------------------------------------------------------------------- - -/// Encode 3 L2 blocks as calldata frames and verify the calldata verifier -/// pipeline derives all three. -#[tokio::test] -async fn batcher_calldata_da() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - - // One block per L1 inclusion block. - let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); - for _ in 1..=3u64 { - batcher.push_block(sequencer.build_next_block_with_single_transaction().await); - batcher.advance(&mut h.l1).await; - } - - let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - node.initialize().await; - - let total_derived = node.run_until_idle().await; - assert_eq!(total_derived, 3, "calldata DA should derive 3 L2 blocks"); - assert_eq!(node.l2_safe_number(), 3, "safe head should reach L2 block 3"); -} - -// --------------------------------------------------------------------------- -// Mixed calldata + blob derivation -// --------------------------------------------------------------------------- - -/// Submit 3 L2 blocks as calldata and 3 more as blobs, each in separate L1 -/// blocks, then derive all 6 using the blob verifier pipeline. -#[tokio::test] -async fn batcher_da_switching() { - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&BatcherConfig::default()).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - - let calldata_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - - // Blocks 1-3: submit as calldata. - let calldata_batcher = - Batcher::new(ActionL2Source::new(), &h.rollup_config, calldata_cfg.clone()); - for _ in 1..=3u64 { - calldata_batcher.push_block(sequencer.build_next_block_with_single_transaction().await); - calldata_batcher.advance(&mut h.l1).await; - } - - // Blocks 4-6: submit as blobs, from a second batcher that starts after block 3. - let blob_cfg = BatcherConfig { - initial_safe_head: Some(sequencer.head().block_info), - ..BatcherConfig::default() // DaType::Blob by default - }; - let blob_batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, blob_cfg); - for _ in 4..=6u64 { - blob_batcher.push_block(sequencer.build_next_block_with_single_transaction().await); - blob_batcher.advance(&mut h.l1).await; - } - - let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - node.initialize().await; - - let mut total_derived = 0; - for _ in 1..=6u64 { - total_derived += node.run_until_idle().await; - } - - assert_eq!(total_derived, 6, "expected 6 L2 blocks derived (3 calldata + 3 blob)"); - assert_eq!(node.l2_safe_number(), 6, "safe head should reach L2 block 6"); -} - -// --------------------------------------------------------------------------- -// Blob DA channel timeout -// --------------------------------------------------------------------------- - -/// A blob DA channel that is not completed within `channel_timeout` L1 blocks -/// is discarded by the pipeline. Late blob frames for the timed-out channel -/// are silently ignored; a fresh channel submitted inside the window recovers. -/// -/// This is the blob-DA variant of -/// `channel_timeout_triggers_channel_invalidation` in `batcher_channels.rs`. -/// It submits signed EIP-4844 transactions throughout. -/// -/// Setup: -/// - `max_frame_size = 80` to force a multi-frame channel. -/// - `channel_timeout = 2` (very tight: expires after 2 L1 blocks). -/// - Frame 0 submitted as a blob sidecar in L1 block 1. -/// - L1 blocks 2–4 are empty (`channel_timeout` + 1 = 3 blocks). -/// - Remaining frames arrive as blobs in L1 block 5 — channel already timed out. -/// - Recovery: all frames resubmitted in a fresh channel (L1 block 6). -/// -/// The safe head must remain at 0 through L1 block 5, then advance to 1 after -/// the fresh blob channel is processed. -#[tokio::test] -async fn blob_da_channel_timeout() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { max_frame_size: 80, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - let rollup_cfg = - TestRollupConfigBuilder::base_mainnet(&batcher_cfg).with_channel_timeout(2).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - // Encode the L2 block into multiple frames (tiny max_frame_size). - // Use BatchEncoder directly so we can control exactly which frames go into - // which L1 block — blob packing in the Batcher actor packs all frames into - // one blob, which would make partial submission impossible. - let mut encoder = - BatchEncoder::new(Arc::new(h.rollup_config.clone()), batcher_cfg.encoder.clone()) - .expect("valid encoder config"); - encoder.add_block(block.clone()).expect("add_block should succeed"); - let frames: Vec<_> = encoder - .encode_and_drain() - .expect("encode_and_drain should succeed") - .into_iter() - .flat_map(|submission| submission_frames(&submission)) - .collect(); - let frame_count = frames.len(); - assert!( - frame_count >= 2, - "expected multi-frame channel with max_frame_size=80, got {frame_count} frames" - ); - - // Submit ONLY frame 0 as a signed blob transaction in L1 block 1. - submit_signed_blob_frames(&mut h, &batcher_cfg, &frames[..1], 0); - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); // L1 block 1: blob with frame 0 only - - node.initialize().await; - node.run_until_idle().await; - - assert_eq!(node.l2_safe_number(), 0, "incomplete blob channel should not advance safe head"); - - // Mine channel_timeout + 1 = 3 empty L1 blocks to expire the channel. - for _ in 0..3 { - h.mine_and_push(&chain); - } - for _ in 2..=4 { - node.run_until_idle().await; - } - - // Submit remaining frames as blobs — channel already timed out; silently dropped. - submit_signed_blob_frames(&mut h, &batcher_cfg, &frames[1..], 1); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); // L1 block 5: late blob frames - - let derived = node.run_until_idle().await; - assert_eq!(derived, 0, "late blob frames after channel timeout must be silently dropped"); - - // Recovery: resubmit all frames as blobs in a fresh channel (new channel ID). - let mut encoder2 = - BatchEncoder::new(Arc::new(h.rollup_config.clone()), batcher_cfg.encoder.clone()) - .expect("valid encoder config"); - encoder2.add_block(block).expect("add_block should succeed"); - let fresh_frames: Vec<_> = encoder2 - .encode_and_drain() - .expect("encode_and_drain should succeed") - .into_iter() - .flat_map(|submission| submission_frames(&submission)) - .collect(); - submit_signed_blob_frames(&mut h, &batcher_cfg, &fresh_frames, 2); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); // L1 block 6: fresh blob channel with all frames - - let recovered = node.run_until_idle().await; - - assert_eq!(recovered, 1, "resubmitted blob channel should derive L2 block 1"); - assert_eq!(node.l2_safe_number(), 1, "safe head should recover to 1"); -} diff --git a/actions/harness/tests/batcher/channels.rs b/actions/harness/tests/batcher/channels.rs index 731fc89873..ca2938622d 100644 --- a/actions/harness/tests/batcher/channels.rs +++ b/actions/harness/tests/batcher/channels.rs @@ -1,4 +1,4 @@ -//! Action tests for channel timeout and interleaving scenarios. +//! Action tests for how the batcher closes channels. use base_action_harness::{ ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, @@ -6,397 +6,38 @@ use base_action_harness::{ }; use base_batcher_encoder::{DaType, EncoderConfig}; -// --------------------------------------------------------------------------- -// A. Channel timeout — first frame's inclusion span exceeds channel_timeout -// --------------------------------------------------------------------------- - -/// When a channel's frames are spread across L1 blocks separated by more than -/// `channel_timeout` blocks, the derivation pipeline discards the entire -/// channel. The batcher must detect this and resubmit the affected L2 blocks -/// in a new channel. -#[tokio::test] -async fn channel_timeout_triggers_channel_invalidation() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { - da_type: DaType::Calldata, - max_frame_size: 80, - ..EncoderConfig::default() - }, - ..BatcherConfig::default() - }; - let rollup_cfg = - TestRollupConfigBuilder::base_mainnet(&batcher_cfg).with_channel_timeout(2).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - // Create node before any mining so all future blocks are pushed to chain. - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // Encode block via Batcher — produces multiple frames with max_frame_size=80. - let mut source = ActionL2Source::new(); - source.push(block.clone()); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); - batcher.encode_only().await; - - let frame_count = batcher.pending_count(); - assert!( - frame_count >= 2, - "expected multi-frame channel with max_frame_size=80, got {frame_count} frames", - ); - - // L1 block 1: submit only frame 0. - batcher.stage_n_frames(&mut h.l1, 1); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - node.initialize().await; - node.run_until_idle().await; - - assert_eq!(node.l2_safe_number(), 0, "incomplete channel should not advance safe head"); - - // Mine `channel_timeout + 1 = 3` empty L1 blocks to expire the channel. - for _ in 0..3 { - h.mine_and_push(&chain); - } - - for _ in 2..=4 { - node.run_until_idle().await; - } - - // Submit the remaining frames — they should be silently ignored (channel timed out). - batcher.stage_n_frames(&mut h.l1, frame_count - 1); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - let derived = node.run_until_idle().await; - assert_eq!(derived, 0, "late frames after channel timeout must be ignored"); - - // Recovery: new Batcher (new BatchEncoder = new channel ID) with all frames in one L1 block. - let mut source2 = ActionL2Source::new(); - source2.push(block); - let batcher2 = Batcher::new(source2, &h.rollup_config, batcher_cfg.clone()); - batcher2.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - let recovered = node.run_until_idle().await; - - assert_eq!(recovered, 1, "resubmitted channel should derive L2 block 1"); - assert_eq!(node.l2_safe_number(), 1, "safe head should recover to 1"); -} - -// --------------------------------------------------------------------------- -// B. Channel timeout with recovery -// --------------------------------------------------------------------------- - -/// After a channel times out, the batcher creates a fresh channel containing -/// the same L2 blocks and submits it within the timeout window. +/// A compressed size target of one byte closes a channel after every block, so each block +/// goes out in its own calldata transaction, and derivation reads them in order. #[tokio::test] -async fn channel_timeout_recovery_resubmits_successfully() { +async fn soft_channel_target_closes_a_channel_per_block() { + const BLOCK_COUNT: usize = 4; let batcher_cfg = BatcherConfig { encoder: EncoderConfig { da_type: DaType::Calldata, - max_frame_size: 80, + compressed_size_target: Some(1), ..EncoderConfig::default() }, ..BatcherConfig::default() }; - let rollup_cfg = - TestRollupConfigBuilder::base_mainnet(&batcher_cfg).with_channel_timeout(2).build(); + let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) + .all_forks_active() + .with_cobalt_at(0) + .build(); let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - // Create node before any mining so all future blocks are pushed to chain. - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // Encode the block — will produce multiple frames with max_frame_size=80. - let mut source = ActionL2Source::new(); - source.push(block.clone()); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); - batcher.encode_only().await; - - let frame_count = batcher.pending_count(); - assert!( - frame_count >= 2, - "expected multi-frame channel with max_frame_size=80, got {frame_count} frames", - ); - - // L1 block 1: submit only frame 0 — channel stays incomplete. - batcher.stage_n_frames(&mut h.l1, 1); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - node.initialize().await; - - // Mine channel_timeout + 1 = 3 empty blocks to expire the channel. - for _ in 0..3 { - h.mine_and_push(&chain); - } - - for _ in 1..=h.l1.latest_number() { - node.run_until_idle().await; - } - - assert_eq!( - node.l2_safe_number(), - 0, - "channel should have timed out; safe head must remain at genesis" - ); - - // Recovery: new Batcher (new channel ID) submits all frames in one L1 block. - let mut source2 = ActionL2Source::new(); - source2.push(block); - let batcher2 = Batcher::new(source2, &h.rollup_config, batcher_cfg.clone()); - batcher2.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - let recovered = node.run_until_idle().await; - - assert_eq!(recovered, 1, "recovery channel should derive L2 block 1"); - assert_eq!(node.l2_safe_number(), 1, "safe head should recover to 1"); -} - -// --------------------------------------------------------------------------- -// C. Channel interleaving — frames from two channels interleaved in L1 -// --------------------------------------------------------------------------- - -/// Frames from two different channels are submitted to L1 in interleaved -/// order (A0, B0, A1, B1). The derivation pipeline's channel bank must -/// correctly track both channels simultaneously and reassemble them -/// independently. -#[tokio::test] -async fn interleaved_channels_correctly_reassembled() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { - da_type: DaType::Calldata, - max_frame_size: 80, - ..EncoderConfig::default() - }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); let mut sequencer = h.create_l2_sequencer(l1_chain); - let block_a = sequencer.build_next_block_with_single_transaction().await; - let block_b = sequencer.build_next_block_with_single_transaction().await; - - // Batcher A: block 1 in its own channel (distinct random channel ID). - let mut source_a = ActionL2Source::new(); - source_a.push(block_a); - let batcher_a = Batcher::new(source_a, &h.rollup_config, batcher_cfg.clone()); - batcher_a.encode_only().await; + let blocks = sequencer.build_next_blocks_with_single_transactions(BLOCK_COUNT as u64).await; - // Batcher B: block 2 in its own channel (distinct random channel ID). - let mut source_b = ActionL2Source::new(); - source_b.push(block_b); - let batcher_b = Batcher::new(source_b, &h.rollup_config, batcher_cfg.clone()); - batcher_b.encode_only().await; - - let n_a = batcher_a.pending_count(); - let n_b = batcher_b.pending_count(); - assert!(n_a >= 2, "channel A must produce 2+ frames with max_frame_size=80, got {n_a}"); - assert!(n_b >= 2, "channel B must produce 2+ frames with max_frame_size=80, got {n_b}"); - - // Interleave frames: A0, B0, A1, B1, ... - for i in 0..n_a.max(n_b) { - if i < n_a { - batcher_a.stage_n_frames(&mut h.l1, 1); - } - if i < n_b { - batcher_b.stage_n_frames(&mut h.l1, 1); - } - } - - // Mine one L1 block containing all interleaved frames. - h.l1.mine_block(); - batcher_a.observe_l1_block(h.l1.tip()).await; - batcher_b.observe_l1_block(h.l1.tip()).await; + Batcher::new(ActionL2Source::from_blocks(blocks.clone()), &h.rollup_config, batcher_cfg) + .advance(&mut h.l1) + .await; + assert_eq!(h.l1.tip().transactions.len(), BLOCK_COUNT, "one channel per block"); let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( &mut sequencer, SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); node.initialize().await; - - let derived = node.run_until_idle().await; - - assert_eq!(derived, 2, "expected 2 L2 blocks derived from interleaved channels"); - assert_eq!(node.l2_safe_number(), 2); -} - -// --------------------------------------------------------------------------- -// D. Multi-block channel — frames split across consecutive L1 blocks -// --------------------------------------------------------------------------- - -/// A single channel whose frames are spread across two consecutive L1 blocks -/// is correctly reassembled by the derivation pipeline. -#[tokio::test] -async fn multi_block_channel_assembles_across_l1_blocks() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { - da_type: DaType::Calldata, - max_frame_size: 80, - ..EncoderConfig::default() - }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - // Create node before any mining so all future blocks are pushed to chain. - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // Encode into multiple frames. - let mut source = ActionL2Source::new(); - source.push(block); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); - batcher.encode_only().await; - - let frame_count = batcher.pending_count(); - assert!( - frame_count >= 2, - "need at least 2 frames for this test; got {frame_count} (increase payload or decrease max_frame_size)", - ); - - // L1 block 1: frame 0 only. - batcher.stage_n_frames(&mut h.l1, 1); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - node.initialize().await; - node.run_until_idle().await; - - assert_eq!( - node.l2_safe_number(), - 0, - "channel incomplete after block 1; safe head must stay at genesis" - ); - - // L1 block 2: remaining frames (well within channel_timeout). - batcher.stage_n_frames(&mut h.l1, frame_count - 1); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - let derived = node.run_until_idle().await; - - assert_eq!(derived, 1, "multi-block channel must yield 1 L2 block"); - assert_eq!(node.l2_safe_number(), 1, "safe head must advance to 1"); -} - -// --------------------------------------------------------------------------- -// E. Multi-frame channel with an empty L1 gap between submissions -// --------------------------------------------------------------------------- - -/// Frames from a single channel are submitted to L1 in two separate L1 blocks -/// with an **empty L1 block** between them. The derivation pipeline must -/// correctly reassemble the channel across the gap. -/// -/// Note: `encode_only()` ends with an admin flush that closes the channel -/// immediately, so all frames are in the pending queue before any L1 head -/// events arrive. This means this test exercises the multi-frame split -/// submission scenario (frame 0 in block 1, empty block 2, rest in block 3), -/// not duration-based channel closure — that would require the channel to -/// remain open while L1 blocks are mined. -#[tokio::test] -async fn multi_frame_channel_with_empty_l1_gap_derives_correctly() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { - da_type: DaType::Calldata, - max_frame_size: 80, - ..EncoderConfig::default() - }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - // Create node before any mining so all future blocks are pushed to chain. - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // Encode block — produces multiple frames with max_frame_size=80. - // The admin flush at the end of encode_only() closes the channel; frames become pending. - let mut source = ActionL2Source::new(); - source.push(block); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); - batcher.encode_only().await; - - let frame_count = batcher.pending_count(); - assert!( - frame_count >= 2, - "expected multi-frame channel with max_frame_size=80, got {frame_count} frames", - ); - - // L1 block 1: submit only frame 0. - batcher.stage_n_frames(&mut h.l1, 1); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - node.initialize().await; - node.run_until_idle().await; - - assert_eq!( - node.l2_safe_number(), - 0, - "incomplete channel after block 1; safe head must stay at genesis" - ); - - // Mine an empty L1 block 2 and show it to the batcher. It holds no batcher transaction, so - // it only moves the driver's L1 head to 2. The remaining frames wait in `pending`. - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - // L1 block 3: submit the remaining frames. - let remaining = batcher.pending_count(); - batcher.stage_n_frames(&mut h.l1, remaining); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - // Signal node for all L1 blocks. Track the total L2 blocks derived - // to confirm exactly one block was produced across the 3-block span. - let mut total_derived = 0usize; - for _ in 2..=h.l1.latest_number() { - total_derived += node.run_until_idle().await; - } - - assert_eq!(total_derived, 1, "exactly one L2 block must be derived across the 3-block span"); - assert_eq!( - node.l2_safe_number(), - 1, - "frames split across 3 L1 blocks (with an empty intermediate block) must derive L2 block 1" - ); + assert_eq!(node.run_until_idle().await, BLOCK_COUNT); + assert_eq!(node.l2_safe().block_info.hash, blocks[BLOCK_COUNT - 1].header.hash_slow()); } diff --git a/actions/harness/tests/batcher/gap_filling.rs b/actions/harness/tests/batcher/gap_filling.rs deleted file mode 100644 index c430d16bbe..0000000000 --- a/actions/harness/tests/batcher/gap_filling.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Action tests for derivation across gaps in submitted L2 blocks. -//! -//! Batches submitted after a gap cannot advance the verifier's safe head. -//! Submitting the missing sequence fills the gap, and duplicate later blocks -//! remain harmless. - -use base_action_harness::{ - ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, - TestRollupConfigBuilder, -}; -use base_batcher_encoder::{DaType, EncoderConfig}; - -/// Each phase posts its blocks from a fresh batcher, which starts from the parent of the -/// first block it is given. In phase 2 that is block 7, as if its node were ahead of the -/// verifier. -#[tokio::test] -async fn batcher_gap_fill() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - - let mut blocks = Vec::with_capacity(10); - for _ in 0..10 { - blocks.push(sequencer.build_next_block_with_single_transaction().await); - } - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // ----- Phase 1: post blocks 1-5 ----- - { - let mut source = ActionL2Source::new(); - for block in &blocks[..5] { - source.push(block.clone()); - } - Batcher::new(source, &h.rollup_config, batcher_cfg.clone()).advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - } - - node.initialize().await; - let derived = node.run_until_idle().await; - assert_eq!(derived, 5, "Phase 1: expected 5 L2 blocks derived"); - assert_eq!(node.l2_safe_number(), 5, "Phase 1: safe head must be 5"); - - // ----- Phase 2: a fresh batcher posts blocks 8-10 (gap) ----- - { - let mut source = ActionL2Source::new(); - for block in &blocks[7..10] { - source.push(block.clone()); - } - Batcher::new(source, &h.rollup_config, batcher_cfg.clone()).advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - } - - let derived = node.run_until_idle().await; - assert_eq!( - node.l2_safe_number(), - 5, - "Phase 2: safe head must remain at 5 — gap blocks 6-7 are missing" - ); - assert_eq!(derived, 0, "Phase 2: no blocks derived (gap)"); - - // ----- Phase 3: a fresh batcher posts blocks 6-10 ----- - { - let mut source = ActionL2Source::new(); - for block in &blocks[5..10] { - source.push(block.clone()); - } - Batcher::new(source, &h.rollup_config, batcher_cfg.clone()).advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - } - - let derived = node.run_until_idle().await; - assert_eq!(derived, 5, "Phase 3: expected 5 L2 blocks derived (6-10)"); - assert_eq!(node.l2_safe_number(), 10, "Phase 3: safe head must reach 10 after gap is filled"); -} diff --git a/actions/harness/tests/batcher/main.rs b/actions/harness/tests/batcher/main.rs index 2ebac63508..7714ed00ad 100644 --- a/actions/harness/tests/batcher/main.rs +++ b/actions/harness/tests/batcher/main.rs @@ -1,11 +1,5 @@ //! Action tests for the batcher and L1 data-availability submission flows. -mod blobs; mod channels; -mod gap_filling; -mod production_da; mod recovery; -mod sequencer_drift; -mod submission; mod submission_failure; -mod upgrade_transitions; diff --git a/actions/harness/tests/batcher/production_da.rs b/actions/harness/tests/batcher/production_da.rs deleted file mode 100644 index 08ad1a79a2..0000000000 --- a/actions/harness/tests/batcher/production_da.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Production-mode L1 data-availability action tests. - -use alloy_consensus::{Transaction, transaction::SignerRecoverable}; -use alloy_primitives::B256; -use alloy_signer_local::PrivateKeySigner; -use base_action_harness::{ - ActionBlobProvider, ActionL1ChainProvider, ActionL2Source, ActionTestHarness, Batcher, - BatcherConfig, L1MinerConfig, SharedL1Chain, TestRollupConfigBuilder, block_info_from, -}; -use base_batcher_encoder::{DaType, EncoderConfig}; -use base_consensus_derive::{DataAvailabilityProvider, EthereumDataSource}; - -fn test_l1_signer() -> PrivateKeySigner { - PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).expect("valid test signer") -} - -#[tokio::test] -async fn production_da_calldata_uses_signed_l1_transactions() { - let signer = test_l1_signer(); - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - } - .with_l1_signer(signer.clone()); - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let mut source = ActionL2Source::new(); - source.push(sequencer.build_next_block_with_single_transaction().await); - - Batcher::new(source, &h.rollup_config, batcher_cfg.clone()).advance(&mut h.l1).await; - - let block = h.l1.tip(); - assert_eq!(block.transactions.len(), 1); - let tx = &block.transactions[0]; - assert!(tx.is_eip1559()); - assert_eq!(tx.recover_signer().expect("signed tx recovers"), signer.address()); - assert_eq!(tx.to(), Some(batcher_cfg.inbox_address)); - - let mut pre_ecotone_cfg = h.rollup_config.clone(); - pre_ecotone_cfg.upgrades.ecotone_time = None; - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut dap = EthereumDataSource::new_from_parts( - ActionL1ChainProvider::new(l1_chain.clone()), - ActionBlobProvider::new(l1_chain), - &pre_ecotone_cfg, - ); - let calldata = dap - .next(&block_info_from(block), signer.address()) - .await - .expect("production calldata source reads signed tx input"); - assert_eq!(calldata.as_ref(), tx.input().as_ref()); - - let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - node.initialize().await; - assert_eq!(node.run_until_idle().await, 1); - assert_eq!(node.l2_safe_number(), 1); -} - -#[tokio::test] -async fn production_da_blobs_link_versioned_hashes_to_sidecars() { - let signer = test_l1_signer(); - let batcher_cfg = BatcherConfig::default().with_l1_signer(signer.clone()); - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let mut source = ActionL2Source::new(); - source.push(sequencer.build_next_block_with_single_transaction().await); - - Batcher::new(source, &h.rollup_config, batcher_cfg).advance(&mut h.l1).await; - - let block = h.l1.tip(); - assert_eq!(block.transactions.len(), 1); - assert_eq!(block.blob_sidecars.len(), 1); - let tx = &block.transactions[0]; - assert!(tx.is_eip4844()); - assert_eq!(tx.recover_signer().expect("signed tx recovers"), signer.address()); - let hashes = tx.blob_versioned_hashes().expect("blob tx has versioned hashes"); - assert_eq!(hashes, &[block.blob_sidecars[0].0]); - - let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - node.initialize().await; - assert_eq!(node.run_until_idle().await, 1); - assert_eq!(node.l2_safe_number(), 1); -} diff --git a/actions/harness/tests/batcher/submission.rs b/actions/harness/tests/batcher/submission.rs deleted file mode 100644 index e56929a036..0000000000 --- a/actions/harness/tests/batcher/submission.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Action tests for L2 batch submission via the Batcher actor. - -use base_action_harness::{ - ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, NodeStepResult, - SharedL1Chain, TestRollupConfigBuilder, -}; -use base_batcher_encoder::{DaType, EncoderConfig}; - -// --------------------------------------------------------------------------- -// Batcher: persistent pipeline end-to-end path -// --------------------------------------------------------------------------- - -/// `advance` on three L2 blocks mines an L1 block that holds the batcher's submissions. -#[tokio::test] -async fn batcher_mines_block_with_submissions() { - let mut h = ActionTestHarness::default(); - let cfg = BatcherConfig::default(); - - let source = h.create_l2_source(3).await; - let batcher = Batcher::new(source, &h.rollup_config, cfg); - batcher.advance(&mut h.l1).await; - - assert!(h.l1.latest_number() >= 1, "at least one L1 block should be mined"); - // Default EncoderConfig uses DaType::Blob, so submissions appear as blob sidecars. - assert!( - !h.l1.tip().transactions.is_empty() || !h.l1.tip().blob_sidecars.is_empty(), - "mined block should contain signed batcher submissions" - ); -} - -/// With a one-byte compressed size target, derivation still makes the four blocks safe one by -/// one, in order. -#[tokio::test] -async fn batcher_soft_channel_target_derives_exact_blocks() { - const BLOCK_COUNT: u64 = 4; - - let mut batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let blocks = sequencer.build_next_blocks_with_single_transactions(BLOCK_COUNT).await; - - // Force each accepted batch to cross the soft target and close its channel. - batcher_cfg.encoder.compressed_size_target = Some(1); - - let mut source = ActionL2Source::new(); - for block in &blocks { - source.push(block.clone()); - } - Batcher::new(source, &h.rollup_config, batcher_cfg).advance(&mut h.l1).await; - - let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - node.initialize().await; - - let mut derived = 0; - loop { - match node.step().await { - NodeStepResult::DerivationProgress => { - let expected = blocks[derived].header.hash_slow(); - assert_eq!(node.l2_safe().block_info.hash, expected); - derived += 1; - } - NodeStepResult::AdvancedOrigin => {} - NodeStepResult::Idle => break, - } - } - assert_eq!(derived, BLOCK_COUNT as usize); -} - -// --------------------------------------------------------------------------- -// Batcher: L1 reorg during submission -// --------------------------------------------------------------------------- - -/// An L1 reorg fires failure receipts for frames that were staged but not yet -/// confirmed, causing the [`BatchDriver`] to requeue them in the encoder -/// pipeline and resubmit on the new fork — **without creating a new -/// [`Batcher`]**. -/// -/// Sequence: -/// 1. Encode and stage all frames; mine L1 block 1 (original). -/// 2. Reorg to genesis **before** calling `observe_l1_block`. The frames are still -/// in `staged`, so `reorg_to` fires `Err(TxManagerError::Rpc("reorg"))` on -/// each oneshot responder. -/// 3. The driver handles each failed receipt: `pipeline.requeue(id)` rewinds the -/// encoder channel cursor. On the next loop iteration, the driver -/// calls `submit_pending()` → `send_async()` and the frames are back in the -/// `L1MinerTxManager` pending queue. -/// 4. The same batcher stages the requeued frames and mines a new L1 block on -/// the new fork. The verifier re-derives L2 block 1 from this block. -/// -/// [`BatchDriver`]: base_batcher_core::BatchDriver -#[tokio::test] -async fn batcher_reorg_during_submission() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - // Build L2 block 1. - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // Encode and stage all frames; mine L1 block 1 (original). - // Do NOT call observe_l1_block, so the frames remain in `staged` and the reorg - // below fires failure receipts for them. - let mut source = ActionL2Source::new(); - source.push(block); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); - batcher.encode_only().await; - batcher.stage_n_frames(&mut h.l1, usize::MAX); - h.l1.mine_block(); // L1 block 1 (original, about to be reorged) - chain.push(h.l1.tip().clone()); - - // --- L1 reorg back to genesis (frames still in staged) --- - // Every staged item gets a failure receipt and the driver learns of L1 head 0. It - // handles each failed receipt with pipeline.requeue(id), rewinding the channel cursor - // without re-encoding, then resubmits the frame. - batcher.reorg(0, &mut h.l1).await; - assert_eq!(batcher.pending_count(), 1, "the reorged frame must be resubmitted"); - - // Mine an empty replacement block on the new fork, then resubmit the - // requeued frames using the same Batcher (no drop/recreate required). - h.l1.mine_block(); // block 1' (empty, on new fork) - chain.truncate_to(0); - chain.push(h.l1.tip().clone()); - - batcher.stage_n_frames(&mut h.l1, usize::MAX); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - // Verify the node re-derives L2 block 1 from the new-fork submission. - node.initialize().await; - - let derived = node.run_until_idle().await; - assert_eq!(derived, 1, "same-batcher resubmission must derive L2 block 1"); - assert_eq!( - node.l2_safe_number(), - 1, - "safe head must recover to 1 after same-batcher resubmission on new fork" - ); -} diff --git a/actions/harness/tests/batcher/submission_failure.rs b/actions/harness/tests/batcher/submission_failure.rs index cf5e2d716e..9f908c05e5 100644 --- a/actions/harness/tests/batcher/submission_failure.rs +++ b/actions/harness/tests/batcher/submission_failure.rs @@ -1,13 +1,4 @@ -//! Action tests for batcher recovery when L1 frame submission fails. -//! -//! These tests verify the end-to-end path: -//! sequencer → batcher (submission fails) → batcher requeues → derivation -//! -//! The [`BatchDriver`] reacts to a failed [`SendHandle`] by calling -//! `pipeline.requeue(id)` and retrying on the next loop iteration. -//! The [`L1MinerTxManager`]'s `fail_next_n` mechanism fires an immediate -//! [`TxManagerError::Rpc`] receipt so this path is exercised without any -//! real L1 interaction. +//! Action tests for a batcher whose L1 submission fails. use base_action_harness::{ ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, @@ -15,19 +6,18 @@ use base_action_harness::{ }; use base_batcher_encoder::{DaType, EncoderConfig}; -fn calldata_batcher_config() -> BatcherConfig { - BatcherConfig { +/// Failed submissions are retried until one goes through, and derivation reads the block +/// from the one that did. +#[tokio::test] +async fn failed_submissions_are_retried_until_one_lands() { + let batcher_cfg = BatcherConfig { encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, ..BatcherConfig::default() - } -} - -/// When the first frame submission fails, the [`BatchDriver`] requeues the -/// frame, retries, and the derivation node successfully derives the L2 block. -#[tokio::test] -async fn submission_failure_requeues_and_derivation_recovers() { - let batcher_cfg = calldata_batcher_config(); - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); + }; + let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) + .all_forks_active() + .with_cobalt_at(0) + .build(); let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); @@ -39,68 +29,15 @@ async fn submission_failure_requeues_and_derivation_recovers() { SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - let mut source = ActionL2Source::new(); - source.push(block); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); - - // Fail the first submission so the driver must requeue and retry. - batcher.fail_next_n_submissions(1); - batcher.encode_only().await; - - // The driver has processed the failure receipt and returned the frame to the - // pending queue via requeue + re-submit. - assert_eq!(batcher.pending_count(), 1, "the failed frame must be resubmitted"); - - // Mine the successfully requeued frame into an L1 block. - let block_num = batcher.mine_pending(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - node.initialize().await; - let derived = node.run_until_idle().await; - - assert_eq!(derived, 1, "requeued frame must produce one derived L2 block"); - assert_eq!(node.l2_safe_number(), 1, "safe head must reach 1 after recovery"); - assert!(block_num >= 1, "requeued frame was mined into an L1 block"); -} - -/// With three consecutive submission failures the [`BatchDriver`] requeues and -/// retries three times before the fourth attempt succeeds. No data is lost: the -/// derivation node sees the correct L2 block. -#[tokio::test] -async fn consecutive_failures_then_success_derives_correctly() { - let batcher_cfg = calldata_batcher_config(); - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - let mut source = ActionL2Source::new(); - source.push(block); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); - - // Fail the next three attempts. The driver retries on each failure until - // the fourth send_async call succeeds and the frame lands in pending. + let batcher = Batcher::new(ActionL2Source::from_blocks([block]), &h.rollup_config, batcher_cfg); batcher.fail_next_n_submissions(3); batcher.encode_only().await; + assert_eq!(batcher.pending_count(), 1, "the frame is resubmitted after every failure"); - // The frame has made it through all three failures and back to pending via the - // successful fourth submission. - assert_eq!(batcher.pending_count(), 1, "the failed frame must be resubmitted"); - - let block_num = batcher.mine_pending(&mut h.l1).await; + batcher.mine_pending(&mut h.l1).await; chain.push(h.l1.tip().clone()); node.initialize().await; - let derived = node.run_until_idle().await; - - assert_eq!(derived, 1, "frame must survive three consecutive failures and derive one L2 block"); - assert_eq!(node.l2_safe_number(), 1, "safe head must reach 1 after three retries"); - assert!(block_num >= 1, "frame was eventually mined into an L1 block"); + assert_eq!(node.run_until_idle().await, 1, "the retry derives the block"); + assert_eq!(node.l2_safe_number(), 1); } diff --git a/actions/harness/tests/batcher/upgrade_transitions.rs b/actions/harness/tests/batcher/upgrade_transitions.rs deleted file mode 100644 index 60ff2fc70b..0000000000 --- a/actions/harness/tests/batcher/upgrade_transitions.rs +++ /dev/null @@ -1,562 +0,0 @@ -//! Action tests for batch format transitions across upgrade boundaries. - -use alloy_primitives::B256; -use base_action_harness::{ - ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, DerivedBlock, L1MinerConfig, - SharedL1Chain, TestRollupConfigBuilder, -}; -use base_batcher_encoder::{DaType, EncoderConfig}; -use base_common_genesis::{BaseUpgradeConfig, RollupConfig, UpgradeConfig}; -use tracing_subscriber::EnvFilter; - -// --------------------------------------------------------------------------- -// A. Span batch with non-empty upgrade transition block is rejected -// --------------------------------------------------------------------------- - -/// A span batch covering blocks 1–4 where block 3 is the first Jovian block -/// but **contains user transactions** (which is illegal for the upgrade block) -/// is partially rejected. The pipeline derives blocks 1–2 from the span batch, -/// then fails on block 3 (`NonEmptyTransitionBlock` → `FlushChannel` under Holocene), -/// dropping the span batch's channel. Blocks 3–4 are never derived from the -/// span batch. -/// -/// This demonstrates the **all-or-nothing** failure mode for span batches: a -/// single bad block mid-span loses the remaining blocks in the channel, forcing -/// a re-submission of blocks 3–4. This is the key difference from singular -/// batches where only the offending block is dropped and all others derive fine -/// (tested in `jovian_non_empty_transition_batch_generates_deposit_only_block`). -/// -/// Recovery: blocks 3 (empty) and 4 are resubmitted as a corrected span batch -/// in a new channel; safe head advances to 4. -/// -/// Note: `NonEmptyTransitionBlock` only fires for the first Jovian block, not -/// for earlier upgrades like Ecotone or Isthmus. -#[tokio::test] -async fn span_batch_with_non_empty_transition_block_rejected() { - let _ = tracing_subscriber::fmt() - .with_env_filter(EnvFilter::new("error")) - .with_test_writer() - .try_init(); - // All forks through Isthmus active at genesis. Jovian activates at ts=6 - // (L2 block 3 with block_time=2). Because only Jovian is "new" at ts=6, - // `is_first_jovian_block(6)` returns true and the NonEmptyTransitionBlock - // check fires for block 3 alone. - let jovian_time = 6u64; - let upgrades = UpgradeConfig { - canyon_time: Some(0), - delta_time: Some(0), - ecotone_time: Some(0), - fjord_time: Some(0), - granite_time: Some(0), - holocene_time: Some(0), - isthmus_time: Some(0), - jovian_time: Some(jovian_time), - ..Default::default() - }; - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - let rollup_cfg = - TestRollupConfigBuilder::base_mainnet(&batcher_cfg).with_upgrades(upgrades).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder = h.create_l2_sequencer(l1_chain); - - // Build 4 L2 blocks. build_next_block_with_single_transaction() includes a user transaction in - // every block. Block 3 (ts=6) is the first Jovian block, which must be - // deposit-only — including a user tx here is the deliberate error. - let block1 = builder.build_next_block_with_single_transaction().await; // ts=2 - let block2 = builder.build_next_block_with_single_transaction().await; // ts=4 - let block3_invalid = builder.build_next_block_with_single_transaction().await; // ts=6 - let block4 = builder.build_next_block_with_single_transaction().await; // ts=8 - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut builder, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // --- Phase 1: submit all 4 blocks as one span fixture (block 3 has user txs) --- - h.submit_span_batch_brotli_calldata( - &batcher_cfg, - &[block1.clone(), block2.clone(), block3_invalid, block4.clone()], - 0, - ) - .expect("span fixture submission"); - chain.push(h.l1.tip().clone()); // L1 block 1: span batch with invalid block 3 - - // The sequencer registered state roots for blocks 3 and 4 that will not match - // what derivation produces (block 3 becomes deposit-only; block 4 has a different - // parent). Clear the state root entries so the engine skips validation for these. - node.register_block_hash(3, B256::ZERO); - node.register_block_hash(4, B256::ZERO); - - node.initialize().await; - node.run_until_idle().await; - - // Under Holocene, when the pipeline reaches block 3 in the span batch and - // detects a user tx in the upgrade block, it sends FlushChannel (via - // BatchStream::flush), discarding the channel entirely. Blocks 1 and 2 were - // already emitted as individual batches before the failure, so safe head is 2. - assert_eq!( - node.l2_safe_number(), - 2, - "blocks 1 and 2 should derive before span batch fails on block 3" - ); - - // --- Phase 2: resubmit blocks 3–4 with block 3 correctly empty --- - // - // The primary builder is now at block 4; build_empty_block() on it would - // produce block 5 (wrong timestamp). Instead, create a fresh sequencer - // starting from genesis, advance it to block 2's state, then build the - // correct recovery blocks 3 (empty, ts=6) and 4 (user tx, ts=8). - // - // Rebuilding blocks 1–2 deterministically makes the recovery span's parent check match - // canonical block 2. `BatchStream` then assigns each span-derived singular the current - // safe-head hash when emitting it. - { - let l1_chain2 = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder2 = h.create_l2_sequencer(l1_chain2); - let _rb1 = builder2.build_next_block_with_single_transaction().await; - let _rb2 = builder2.build_next_block_with_single_transaction().await; - let block3_empty = builder2.build_empty_block().await; - let block4_recovery = builder2.build_next_block_with_single_transaction().await; - - h.submit_span_batch_brotli_calldata(&batcher_cfg, &[block3_empty, block4_recovery], 100) - .expect("recovery span fixture submission"); - } - chain.push(h.l1.tip().clone()); // L1 block 2: recovery span batch (blocks 3–4) - - node.run_until_idle().await; - - assert_eq!(node.l2_safe_number(), 4, "after recovery submission, safe head must reach block 4"); -} - -// --------------------------------------------------------------------------- -// B. Mixed singular and span batches in the same derivation run -// --------------------------------------------------------------------------- - -/// After Fjord (which cascades to activate Delta), the pipeline must accept -/// **both** singular and span batches in the same derivation run. This test -/// submits block 1 as a singular batch in L1 block 1 and block 2 as a span -/// batch in L1 block 2. -/// -/// Prior to Delta, span batches are rejected outright (`SpanBatchPreDelta`). -/// After Delta, both formats are valid. The derivation pipeline must derive -/// all 2 L2 blocks regardless of which format each batch uses. -#[tokio::test] -async fn mixed_singular_and_span_batches_after_delta() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - // Fjord cascades: Canyon, Delta, Ecotone, Fjord all active at genesis. - let upgrades = UpgradeConfig { fjord_time: Some(0), ..Default::default() }; - let rollup_cfg = - TestRollupConfigBuilder::base_mainnet(&batcher_cfg).with_upgrades(upgrades).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder = h.create_l2_sequencer(l1_chain); - - let block1 = builder.build_next_block_with_single_transaction().await; - let block2 = builder.build_next_block_with_single_transaction().await; - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut builder, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // L1 block 1: block 1 as a SINGULAR batch. - { - let mut source = ActionL2Source::new(); - source.push(block1); - Batcher::new(source, &h.rollup_config, batcher_cfg.clone()).advance(&mut h.l1).await; - } - chain.push(h.l1.tip().clone()); // L1 block 1: singular batch for L2 block 1 - - // L1 block 2: block 2 as a SPAN batch. - { - h.submit_span_batch_brotli_calldata(&batcher_cfg, &[block2], 100) - .expect("span fixture submission"); - } - chain.push(h.l1.tip().clone()); // L1 block 2: span batch for L2 block 2 - - node.initialize().await; - - let total_derived = node.run_until_idle().await; - assert_eq!( - total_derived, 2, - "mixed singular + span batches must both derive; safe head should reach 2" - ); - assert_eq!( - node.l2_safe_number(), - 2, - "mixed singular + span batches must both derive; safe head should reach 2" - ); -} - -// --------------------------------------------------------------------------- -// C. Span batches stop at Denim and recover through production SingleBatch -// --------------------------------------------------------------------------- - -/// A historical Span fixture may derive its pre-Denim prefix, but the cached -/// tail must be discarded before the first Denim block. Resubmitting that tail -/// through the production `SingleBatch` batcher must derive across Denim's 200ms -/// block cadence. -#[tokio::test] -async fn span_batch_stops_at_denim_and_recovers_with_single_batches() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - let upgrades = UpgradeConfig { - regolith_time: Some(0), - canyon_time: Some(0), - delta_time: Some(0), - ecotone_time: Some(0), - fjord_time: Some(0), - granite_time: Some(0), - holocene_time: Some(0), - isthmus_time: Some(0), - jovian_time: Some(0), - base: BaseUpgradeConfig { - azul: Some(0), - beryl: Some(0), - cobalt: Some(0), - denim: Some(6), - ..Default::default() - }, - ..Default::default() - }; - let rollup_cfg = - TestRollupConfigBuilder::base_mainnet(&batcher_cfg).with_upgrades(upgrades).build(); - assert_eq!( - (3..=8).map(|number| rollup_cfg.l2_block_timestamp_parts(number)).collect::>(), - vec![(6, 0), (6, 200), (6, 400), (6, 600), (6, 800), (7, 0)] - ); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder = h.create_l2_sequencer(l1_chain); - let mut blocks = Vec::new(); - for _ in 0..8 { - blocks.push(builder.build_next_block_with_single_transaction().await); - } - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut builder, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - h.submit_span_batch_brotli_calldata(&batcher_cfg, &blocks, 100) - .expect("span fixture submission"); - chain.push(h.l1.tip().clone()); - - node.initialize().await; - node.run_until_idle().await; - assert_eq!( - node.l2_safe_number(), - 2, - "only blocks before Denim activation at block 3 may derive from the span" - ); - - let mut source = ActionL2Source::new(); - for block in blocks.into_iter().skip(2) { - source.push(block); - } - Batcher::new(source, &h.rollup_config, batcher_cfg).advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - let recovered = node.run_until_idle().await; - assert_eq!(recovered, 6, "the SingleBatch path must recover all post-Denim blocks"); - assert_eq!(node.l2_safe_number(), 8, "safe head must advance across Denim"); -} - -// --------------------------------------------------------------------------- -// D. Granite channel timeout enforcement -// -// Verifies that the post-Granite 50-block channel timeout is enforced. -// --------------------------------------------------------------------------- - -/// After Granite activates, the channel timeout drops from 300 to 50 blocks. -/// A channel whose first frame is included in L1 block 1 must time out when -/// 51 or more additional L1 blocks pass without the channel being completed -/// (i.e., origin.number > `open_block` + 50). -/// -/// Setup: All forks through Fjord active at genesis; Granite activates at -/// timestamp 6 (L2 block 3 with `block_time=2`). Because the default L1 -/// `block_time` is 12 seconds, L1 block 1's timestamp is 12 — well past -/// Granite activation — so the 50-block timeout applies from the first L1 -/// block that contains batch data. -/// -/// Phase 1: encode one L2 block into a multi-frame channel (`max_frame_size=80`), -/// submit only frame 0 in L1 block 1, then mine 51 more empty L1 blocks. -/// The channel's `open_block_number` is 1 and `1 + 50 = 51 < 52`, so the -/// channel is timed out by the time the pipeline reaches L1 block 52. -/// -/// Phase 2 (recovery): a new batcher submits all frames in a single L1 block -/// and derivation advances the safe head to 1. -#[tokio::test] -async fn granite_channel_timeout_enforced() { - // All forks through Fjord at genesis, Granite at timestamp 6. - // The pre-Granite channel_timeout (300) is never exercised because every - // L1 origin processed by the pipeline has timestamp >= 12 > 6. - let upgrades = UpgradeConfig { - canyon_time: Some(0), - delta_time: Some(0), - ecotone_time: Some(0), - fjord_time: Some(0), - granite_time: Some(6), - ..Default::default() - }; - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { - da_type: DaType::Calldata, - max_frame_size: 80, - ..EncoderConfig::default() - }, - ..BatcherConfig::default() - }; - let rollup_cfg = - TestRollupConfigBuilder::base_mainnet(&batcher_cfg).with_upgrades(upgrades).build(); - - // Verify the config has the expected timeout values. - assert_eq!( - rollup_cfg.granite_channel_timeout, - RollupConfig::GRANITE_CHANNEL_TIMEOUT, - "granite_channel_timeout must be {}", - RollupConfig::GRANITE_CHANNEL_TIMEOUT - ); - assert_eq!( - rollup_cfg.channel_timeout, 300, - "pre-Granite channel_timeout must be 300 (Base mainnet default)" - ); - - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut sequencer = h.create_l2_sequencer(l1_chain); - let block = sequencer.build_next_block_with_single_transaction().await; - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // Encode block into multiple frames (max_frame_size=80 forces multi-frame). - let mut source = ActionL2Source::new(); - source.push(block.clone()); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); - batcher.encode_only().await; - - let frame_count = batcher.pending_count(); - assert!( - frame_count >= 2, - "expected multi-frame channel with max_frame_size=80, got {frame_count} frames", - ); - - // L1 block 1: submit only frame 0. Channel opens at block 1. - batcher.stage_n_frames(&mut h.l1, 1); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - node.initialize().await; - node.run_until_idle().await; - - assert_eq!(node.l2_safe_number(), 0, "incomplete channel should not advance safe head"); - - // Mine 51 empty L1 blocks (blocks 2..=52). After block 52, the channel - // opened at block 1 has been open for 51 blocks: 1 + 50 = 51 < 52, - // triggering timeout under Granite's 50-block limit. - for _ in 0..51 { - h.mine_and_push(&chain); - } - - // Signal all empty blocks to the verifier. - for _ in 2..=52u64 { - node.run_until_idle().await; - } - - assert_eq!( - node.l2_safe_number(), - 0, - "channel must have timed out under Granite's 50-block limit; safe head stays at 0" - ); - - // Submit remaining frames — they arrive after the channel timed out. - // ChannelBank silently drops frames for timed-out channels (lazy eviction: - // the timeout check fires in read() before any frame is delivered). If the - // timed-out entry was already flushed, the late frames would create a new - // incomplete channel starting from a non-zero frame number, which can also - // never become ready. Either way, no L2 block is derived. - batcher.stage_n_frames(&mut h.l1, frame_count - 1); - h.l1.mine_block(); - chain.push(h.l1.tip().clone()); - batcher.observe_l1_block(h.l1.tip()).await; - - let derived = node.run_until_idle().await; - assert_eq!( - derived, 0, - "late non-zero frames after timeout create an incomplete channel; no L2 block derived" - ); - assert_eq!( - node.l2_safe_number(), - 0, - "safe head must still be at genesis before recovery; channel timed out" - ); - - // --- Recovery: new batcher, all frames in one L1 block --- - let mut source2 = ActionL2Source::new(); - source2.push(block); - Batcher::new(source2, &h.rollup_config, batcher_cfg).advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - - let recovered = node.run_until_idle().await; - - assert_eq!(recovered, 1, "recovery channel should derive L2 block 1"); - assert_eq!(node.l2_safe_number(), 1, "safe head should recover to 1"); -} - -// --------------------------------------------------------------------------- -// E. Jovian SingleBatch transition block is deposit-only -// --------------------------------------------------------------------------- - -/// When a `SingleBatch` is submitted for the first Jovian upgrade block (block 3 -/// at ts=6) containing user transactions, derivation drops the batch -/// (`NonEmptyTransitionBlock`) and generates a deposit-only block in its place -/// once the sequencer window expires. Unlike span batches (test A above), only -/// the offending block is dropped — the remaining singular batches for blocks -/// 1, 2, and 4 derive successfully. -/// -/// Setup: all forks through Isthmus active at genesis, Jovian at timestamp 6 -/// (L2 block 3 with `block_time=2`). Each L2 block is submitted as a separate -/// `SingleBatch` channel. A small `seq_window_size` (4) ensures the sequencer -/// window expires quickly so the pipeline can force-generate the deposit-only -/// block for block 3 without mining thousands of L1 blocks. -/// -/// After the pipeline drops block 3's batch and the sequencer window expires, -/// it auto-generates a deposit-only block 3 and then derives block 4 from its -/// submitted batch. Safe head reaches 4. -#[tokio::test] -async fn jovian_single_batch_transition_block_deposit_only() { - let jovian_time = 6u64; - let upgrades = UpgradeConfig { - canyon_time: Some(0), - delta_time: Some(0), - ecotone_time: Some(0), - fjord_time: Some(0), - granite_time: Some(0), - holocene_time: Some(0), - isthmus_time: Some(0), - jovian_time: Some(jovian_time), - ..Default::default() - }; - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..BatcherConfig::default() - }; - // Use a small seq_window_size so the pipeline can force-generate deposit-only - // blocks without needing to mine thousands of empty L1 blocks. - // - // L1 block_time=2 ensures L1 timestamps closely track L2 timestamps and - // don't create a wide gap that would generate many spurious empty L2 blocks - // when the sequencer window expires. - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) - .with_upgrades(upgrades) - .with_seq_window_size(4) - .build(); - let l1_config = L1MinerConfig { block_time: 2, ..Default::default() }; - let mut h = ActionTestHarness::new(l1_config, rollup_cfg); - - // The sequencer is initialized with the L1 chain at genesis (before any - // L1 blocks are mined). As a result every L2 block built by `builder` will - // have L1 origin = genesis (block 0). This means the sequencer window - // [0, 0 + seq_window_size) governs *all* four L2 blocks, which is the - // behaviour the deposit-only assertion relies on. - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder = h.create_l2_sequencer(l1_chain); - - // Build 4 L2 blocks. build_next_block_with_single_transaction() includes a user transaction in - // every block. Block 3 (ts=6) is the first Jovian block — including a - // user tx is the deliberate error that derivation must handle. - let block1 = builder.build_next_block_with_single_transaction().await; // ts=2 - let block2 = builder.build_next_block_with_single_transaction().await; // ts=4 - let block3_invalid = builder.build_next_block_with_single_transaction().await; // ts=6 - let block4 = builder.build_next_block_with_single_transaction().await; // ts=8 - - // Precondition: block 3 must contain at least one user transaction (deposits - // don't count). If build_next_block_with_single_transaction() ever started returning empty blocks, - // this test would silently stop exercising the NonEmptyTransitionBlock path. - assert!( - block3_invalid.body.transactions.len() > 1, - "block 3 must have deposit tx + at least one user tx to trigger NonEmptyTransitionBlock; \ - got {} txs", - block3_invalid.body.transactions.len(), - ); - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut builder, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // Submit each block as a separate SingleBatch channel, one L1 block each. - // L1 blocks 1–4 each contain one singular batch. - let batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); - for block in [block1, block2, block3_invalid, block4] { - batcher.push_block(block); - batcher.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - } - - // Mine additional empty L1 blocks so the sequencer window (size=4) expires - // for block 3's epoch. The sequencer is initialized with only L1 genesis in - // its view, so all L2 blocks have L1 origin = genesis (epoch 0). The window - // expires at L1 block 0 + 4 = 4; the pipeline generates the deposit-only block - // when processing L1 block 5 (the first block past the window). We already - // have L1 blocks 1–4 from batch submission; mine 2 more (blocks 5-6) to - // ensure the window expires before the pipeline tip. - for _ in 0..2 { - h.mine_and_push(&chain); - } - - // The sequencer registered state roots for blocks 3 and 4 that will not match - // what derivation produces (block 3 becomes deposit-only; block 4 has a different - // parent). Clear the state root entries so the engine skips validation for these. - node.register_block_hash(3, B256::ZERO); - node.register_block_hash(4, B256::ZERO); - - node.initialize().await; - - // Signal all L1 blocks to the node and drive derivation. - let tip = h.l1.latest_number(); - for _ in 1..=tip { - node.run_until_idle().await; - } - - // With singular batches and the Holocene batch validator: the pipeline - // derives blocks 1 and 2 from their submitted batches; drops block 3's - // batch (NonEmptyTransitionBlock for the first Jovian block containing - // user txs); force-generates a deposit-only block 3 once the sequencer - // window expires; then derives block 4 from its submitted batch. - assert_eq!( - node.l2_safe_number(), - 4, - "singular batches: safe head must reach 4 (block 3 replaced with deposit-only)" - ); - - // Verify that block 3 is genuinely deposit-only — not that the pipeline - // accepted the invalid batch and derived a normal block. Without this - // assertion, removing the NonEmptyTransitionBlock validation would still - // produce safe_head == 4. - let block3: DerivedBlock = - node.derived_block(3).expect("block 3 must have been derived before safe head reached 4"); - assert!( - block3.is_deposit_only(), - "block 3 must be deposit-only (NonEmptyTransitionBlock batch dropped); \ - got {} user txs", - block3.user_tx_count, - ); -} diff --git a/actions/harness/tests/derivation/channels.rs b/actions/harness/tests/derivation/channels.rs new file mode 100644 index 0000000000..c827e473bd --- /dev/null +++ b/actions/harness/tests/derivation/channels.rs @@ -0,0 +1,58 @@ +//! Action tests for a channel whose frames land in several L1 blocks. + +use base_action_harness::{ + ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, + TestRollupConfigBuilder, +}; +use base_batcher_encoder::{DaType, EncoderConfig}; + +/// A channel whose frames are spread across two consecutive L1 blocks derives once its last +/// frame lands. +#[tokio::test] +async fn multi_block_channel_assembles_across_l1_blocks() { + let batcher_cfg = BatcherConfig { + encoder: EncoderConfig { + da_type: DaType::Calldata, + max_frame_size: 80, + ..EncoderConfig::default() + }, + ..BatcherConfig::default() + }; + let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) + .all_forks_active() + .with_cobalt_at(0) + .build(); + let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); + + let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); + let mut sequencer = h.create_l2_sequencer(l1_chain); + let block = sequencer.build_next_block_with_single_transaction().await; + let (mut node, chain) = h.create_test_rollup_node_from_sequencer( + &mut sequencer, + SharedL1Chain::from_blocks(h.l1.chain().to_vec()), + ); + + let batcher = Batcher::new(ActionL2Source::from_blocks([block]), &h.rollup_config, batcher_cfg); + batcher.encode_only().await; + let frame_count = batcher.pending_count(); + assert!(frame_count >= 2, "the block must span several frames"); + + // L1 block 1 carries frame 0 only. + batcher.stage_n_frames(&mut h.l1, 1); + h.l1.mine_block(); + chain.push(h.l1.tip().clone()); + batcher.observe_l1_block(h.l1.tip()).await; + + node.initialize().await; + node.run_until_idle().await; + assert_eq!(node.l2_safe_number(), 0, "the channel is incomplete"); + + // L1 block 2 carries the rest, well within the channel timeout. + batcher.stage_n_frames(&mut h.l1, frame_count - 1); + h.l1.mine_block(); + chain.push(h.l1.tip().clone()); + batcher.observe_l1_block(h.l1.tip()).await; + + assert_eq!(node.run_until_idle().await, 1, "the complete channel derives the block"); + assert_eq!(node.l2_safe_number(), 1); +} diff --git a/actions/harness/tests/derivation/da_switching.rs b/actions/harness/tests/derivation/da_switching.rs new file mode 100644 index 0000000000..8e470cf728 --- /dev/null +++ b/actions/harness/tests/derivation/da_switching.rs @@ -0,0 +1,53 @@ +//! Action tests for batches of one sender that switches from calldata to blobs. + +use base_action_harness::{ + ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, + TestRollupConfigBuilder, +}; +use base_batcher_encoder::{DaType, EncoderConfig}; + +/// Batches one sender posts as calldata, then as blobs, derive in order. +#[tokio::test] +async fn calldata_then_blob_batches_derive_in_order() { + let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&BatcherConfig::default()) + .all_forks_active() + .with_cobalt_at(0) + .build(); + let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); + + let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); + let mut sequencer = h.create_l2_sequencer(l1_chain); + + let calldata_cfg = BatcherConfig { + encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, + ..BatcherConfig::default() + }; + + // Blocks 1-3: submit as calldata. + let calldata_batcher = + Batcher::new(ActionL2Source::new(), &h.rollup_config, calldata_cfg.clone()); + for _ in 1..=3u64 { + calldata_batcher.push_block(sequencer.build_next_block_with_single_transaction().await); + calldata_batcher.advance(&mut h.l1).await; + } + + // Blocks 4-6: submit as blobs, from a second batcher that starts after block 3. + let blob_cfg = BatcherConfig { + initial_safe_head: Some(sequencer.head().block_info), + ..BatcherConfig::default() // DaType::Blob by default + }; + let blob_batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, blob_cfg); + for _ in 4..=6u64 { + blob_batcher.push_block(sequencer.build_next_block_with_single_transaction().await); + blob_batcher.advance(&mut h.l1).await; + } + + let (mut node, _chain) = h.create_test_rollup_node_from_sequencer( + &mut sequencer, + SharedL1Chain::from_blocks(h.l1.chain().to_vec()), + ); + node.initialize().await; + + assert_eq!(node.run_until_idle().await, 6, "the calldata and blob batches derive"); + assert_eq!(node.l2_safe_number(), 6, "safe head should reach L2 block 6"); +} diff --git a/actions/harness/tests/derivation/holocene_span_batches.rs b/actions/harness/tests/derivation/holocene_span_batches.rs index 1e0e62adb5..f69ac8f294 100644 --- a/actions/harness/tests/derivation/holocene_span_batches.rs +++ b/actions/harness/tests/derivation/holocene_span_batches.rs @@ -1,10 +1,12 @@ //! Action tests for span-batch derivation before and after Holocene. +use alloy_primitives::B256; use base_action_harness::{ ActionTestHarness, BatcherConfig, L1MinerConfig, SharedL1Chain, TestRollupConfigBuilder, }; use base_batcher_encoder::{DaType, EncoderConfig}; use base_common_consensus::BaseBlock; +use base_common_genesis::UpgradeConfig; /// Shared setup helpers for Holocene span-batch action tests. #[derive(Debug)] @@ -288,28 +290,145 @@ async fn pre_holocene_past_singular_does_not_poison_channel() { assert_eq!(node.l2_safe_number(), 2); } -/// Post-Holocene derivation accepts singular and span batches in the same L1 stream. +/// Derivation accepts singular and span batches in the same L1 stream, before and after +/// Holocene. #[tokio::test] -async fn post_holocene_mixed_singular_and_span_batches_derive() { +async fn mixed_singular_and_span_batches_derive_before_and_after_holocene() { let batcher_cfg = HoloceneSpanFixture::batcher_config(); - let mut harness = HoloceneSpanFixture::post_holocene_harness(&batcher_cfg); - - let l1_chain = SharedL1Chain::from_blocks(harness.l1.chain().to_vec()); - let mut sequencer = harness.create_l2_sequencer(l1_chain); - let mut blocks = sequencer.build_next_blocks_with_single_transactions(2).await; - let block_1 = blocks.remove(0); - let block_2 = blocks.remove(0); + for (fork, mut harness) in [ + ("pre-Holocene", HoloceneSpanFixture::pre_holocene_harness(&batcher_cfg)), + ("post-Holocene", HoloceneSpanFixture::post_holocene_harness(&batcher_cfg)), + ] { + let l1_chain = SharedL1Chain::from_blocks(harness.l1.chain().to_vec()); + let mut sequencer = harness.create_l2_sequencer(l1_chain); + let mut blocks = sequencer.build_next_blocks_with_single_transactions(2).await; + let block_1 = blocks.remove(0); + let block_2 = blocks.remove(0); + + let (mut node, chain) = harness.create_test_rollup_node_from_sequencer( + &mut sequencer, + SharedL1Chain::from_blocks(harness.l1.chain().to_vec()), + ); + harness.submit_l2_blocks(&chain, batcher_cfg.clone(), vec![block_1]).await; + submit_span_fixture(&mut harness, &chain, &batcher_cfg, &[block_2], 100); + + node.initialize().await; + let derived = node.run_until_idle().await; + + assert_eq!(derived, 2, "{fork}: singular and span batches should both derive"); + assert_eq!(node.l2_safe_number(), 2, "{fork}: safe head should include both batch formats"); + } +} - let (mut node, chain) = harness.create_test_rollup_node_from_sequencer( - &mut sequencer, - SharedL1Chain::from_blocks(harness.l1.chain().to_vec()), +/// A span batch covering blocks 1–4 where block 3 is the first Jovian block +/// but contains user transactions, which the upgrade block may not, is +/// partially rejected. The pipeline derives blocks 1–2 from the span batch, +/// then fails on block 3 (`NonEmptyTransitionBlock` → `FlushChannel` under Holocene), +/// dropping the span batch's channel. Blocks 3–4 are never derived from the +/// span batch. +/// +/// A single bad block mid-span loses the remaining blocks of the channel, so +/// blocks 3–4 must be submitted again. This is the key difference from singular +/// batches where only the offending block is dropped and all others derive fine +/// (tested in `jovian_non_empty_transition_batch_generates_deposit_only_block`). +/// +/// Blocks 3, now empty, and 4 then derive from a corrected span batch in a new +/// channel. `NonEmptyTransitionBlock` only fires for the first Jovian block, not +/// for earlier upgrades like Ecotone or Isthmus. +#[tokio::test] +async fn span_batch_with_non_empty_transition_block_rejected() { + // All forks through Isthmus active at genesis. Jovian activates at ts=6 + // (L2 block 3 with block_time=2). Because only Jovian is "new" at ts=6, + // `is_first_jovian_block(6)` returns true and the NonEmptyTransitionBlock + // check fires for block 3 alone. + let jovian_time = 6u64; + let upgrades = UpgradeConfig { + canyon_time: Some(0), + delta_time: Some(0), + ecotone_time: Some(0), + fjord_time: Some(0), + granite_time: Some(0), + holocene_time: Some(0), + isthmus_time: Some(0), + jovian_time: Some(jovian_time), + ..Default::default() + }; + let batcher_cfg = BatcherConfig { + encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, + ..BatcherConfig::default() + }; + let rollup_cfg = + TestRollupConfigBuilder::base_mainnet(&batcher_cfg).with_upgrades(upgrades).build(); + let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); + + let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); + let mut builder = h.create_l2_sequencer(l1_chain); + + // Build 4 L2 blocks. build_next_block_with_single_transaction() includes a user transaction in + // every block. Block 3 (ts=6) is the first Jovian block, which must be + // deposit-only. Including a user tx here is the deliberate error. + let block1 = builder.build_next_block_with_single_transaction().await; // ts=2 + let block2 = builder.build_next_block_with_single_transaction().await; // ts=4 + let block3_invalid = builder.build_next_block_with_single_transaction().await; // ts=6 + let block4 = builder.build_next_block_with_single_transaction().await; // ts=8 + + let (mut node, chain) = h.create_test_rollup_node_from_sequencer( + &mut builder, + SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - harness.submit_l2_blocks(&chain, batcher_cfg.clone(), vec![block_1]).await; - submit_span_fixture(&mut harness, &chain, &batcher_cfg, &[block_2], 100); + + // --- Phase 1: submit all 4 blocks as one span fixture (block 3 has user txs) --- + h.submit_span_batch_brotli_calldata( + &batcher_cfg, + &[block1.clone(), block2.clone(), block3_invalid, block4.clone()], + 0, + ) + .expect("span fixture submission"); + chain.push(h.l1.tip().clone()); // L1 block 1: span batch with invalid block 3 + + // The sequencer registered state roots for blocks 3 and 4 that will not match + // what derivation produces (block 3 becomes deposit-only and block 4 has a different + // parent). Clear the state root entries so the engine skips validation for these. + node.register_block_hash(3, B256::ZERO); + node.register_block_hash(4, B256::ZERO); node.initialize().await; - let derived = node.run_until_idle().await; + node.run_until_idle().await; + + // Under Holocene, when the pipeline reaches block 3 in the span batch and + // detects a user tx in the upgrade block, it sends FlushChannel (via + // BatchStream::flush), discarding the channel entirely. Blocks 1 and 2 were + // already emitted as individual batches before the failure, so safe head is 2. + assert_eq!( + node.l2_safe_number(), + 2, + "blocks 1 and 2 should derive before span batch fails on block 3" + ); + + // --- Phase 2: resubmit blocks 3–4 with block 3 correctly empty --- + // + // The primary builder is now at block 4, so build_empty_block() on it would + // produce block 5 (wrong timestamp). Instead, create a fresh sequencer + // starting from genesis, advance it to block 2's state, then build the + // correct recovery blocks 3 (empty, ts=6) and 4 (user tx, ts=8). + // + // Rebuilding blocks 1–2 deterministically makes the recovery span's parent check match + // canonical block 2. `BatchStream` then assigns each span-derived singular the current + // safe-head hash when emitting it. + { + let l1_chain2 = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); + let mut builder2 = h.create_l2_sequencer(l1_chain2); + let _rb1 = builder2.build_next_block_with_single_transaction().await; + let _rb2 = builder2.build_next_block_with_single_transaction().await; + let block3_empty = builder2.build_empty_block().await; + let block4_recovery = builder2.build_next_block_with_single_transaction().await; + + h.submit_span_batch_brotli_calldata(&batcher_cfg, &[block3_empty, block4_recovery], 100) + .expect("recovery span fixture submission"); + } + chain.push(h.l1.tip().clone()); // L1 block 2: recovery span batch (blocks 3–4) + + node.run_until_idle().await; - assert_eq!(derived, 2, "singular and span batches should both derive"); - assert_eq!(node.l2_safe_number(), 2, "safe head should include both batch formats"); + assert_eq!(node.l2_safe_number(), 4, "after recovery submission, safe head must reach block 4"); } diff --git a/actions/harness/tests/derivation/main.rs b/actions/harness/tests/derivation/main.rs index 1a92982f26..01659ae8b3 100644 --- a/actions/harness/tests/derivation/main.rs +++ b/actions/harness/tests/derivation/main.rs @@ -20,8 +20,11 @@ use base_consensus_derive::{ }; use base_protocol::{DERIVATION_VERSION_0, DepositDecodeError, Deposits, L2BlockInfo}; +mod channels; +mod da_switching; mod holocene_span_batches; mod node; +mod sequencer_drift; /// The derivation pipeline reads a single batcher frame from L1 and derives /// the corresponding L2 block, advancing the safe head from genesis (0) to 1. @@ -1303,64 +1306,6 @@ async fn garbage_frame_data_ignored() { assert_eq!(node.l2_safe_number(), 1); } -/// A channel whose compressed data exceeds `max_frame_size` is split across -/// multiple frames. All frames are submitted in the same L1 block (as separate -/// transactions) and the `ChannelBank` reassembles them into the original -/// channel data, deriving the L2 block. -/// -/// This exercises the `ChannelDriver` multi-frame output path and verifies -/// that a small `max_frame_size` causes the encoder to produce multiple frame -/// transactions that the derivation pipeline reassembles correctly. -/// -/// NOTE: All frames must land in the same L1 block. -#[tokio::test] -async fn multi_frame_channel_reassembled() { - let batcher_cfg = BatcherConfig { - // Small max_frame_size forces the channel to spill across multiple frames. - encoder: EncoderConfig { - max_frame_size: 80, - da_type: DaType::Calldata, - ..EncoderConfig::default() - }, - ..BatcherConfig::default() - }; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg).build(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder = h.create_l2_sequencer(l1_chain); - let block = builder.build_next_block_with_single_transaction().await; - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut builder, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - // Encode the L2 block. With max_frame_size=80, the compressed channel data - // should spill across multiple frames — verified via pending_count before mining. - let mut source = ActionL2Source::new(); - source.push(block); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); - batcher.encode_only().await; - assert!( - batcher.pending_count() >= 2, - "expected at least 2 frame submissions with max_frame_size=80, got {}", - batcher.pending_count() - ); - - // Stage all frames, mine one L1 block and show it to the batcher. - let n = batcher.pending_count(); - batcher.stage_n_frames(&mut h.l1, n); - h.l1.mine_block(); - batcher.observe_l1_block(h.l1.tip()).await; - chain.push(h.l1.tip().clone()); - - node.initialize().await; - let derived = node.run_until_idle().await; - assert_eq!(derived, 1, "multi-frame channel should be reassembled and derived"); - assert_eq!(node.l2_safe_number(), 1); -} - // ── Span-batch derivation variants ──────────────────────────────────────────── /// Derive a single L2 block encoded as a [`SpanBatch`] from one L1 inclusion diff --git a/actions/harness/tests/batcher/sequencer_drift.rs b/actions/harness/tests/derivation/sequencer_drift.rs similarity index 58% rename from actions/harness/tests/batcher/sequencer_drift.rs rename to actions/harness/tests/derivation/sequencer_drift.rs index 6901072aaf..5f68f8bbec 100644 --- a/actions/harness/tests/batcher/sequencer_drift.rs +++ b/actions/harness/tests/derivation/sequencer_drift.rs @@ -1,4 +1,4 @@ -//! TDD action test skeletons for sequencer drift scenarios. +//! Action tests for batches past `max_sequencer_drift`. use base_action_harness::{ ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, @@ -6,35 +6,10 @@ use base_action_harness::{ }; use base_batcher_encoder::{DaType, EncoderConfig}; -// --------------------------------------------------------------------------- -// A. Sequencer drift — L2 timestamp exceeds L1 origin time + max_sequencer_drift -// --------------------------------------------------------------------------- - -/// When the L2 sequencer is pinned to a stale L1 origin and builds enough -/// blocks that `L2_timestamp > L1_origin_time + max_sequencer_drift`, the -/// derivation pipeline should still derive those blocks — but any non-empty -/// batch (one containing user transactions) whose timestamp is past the drift -/// boundary is dropped. Only deposit-only (default) blocks are produced for -/// the over-drift slots. -/// -/// ## Setup -/// -/// - Fjord active → `max_sequencer_drift = 1800 s`, `block_time = 300 s`, L1 -/// `block_time = 4 s` -/// - L1 genesis at ts=0 → L1 block 1 at ts=4 -/// - Pin the sequencer to L1 genesis (epoch 0, ts=0) -/// - Build L2 blocks: ts=300, 600, …, 1800, 2100, 2400 -/// - After L2 block 6 (ts=1800), `1800 ≤ 0 + 1800 = 1800` → still within -/// - L2 block 7 (ts=2100): `2100 > 1800` → drift exceeded -/// -/// ## Expected behaviour -/// -/// The derivation pipeline: -/// 1. Accepts L2 blocks 1-6 (timestamps 300-1800, within drift) as submitted -/// 2. For L2 blocks 7-8 (timestamps 2100-2400, over drift), drops the -/// batcher's non-empty batch and generates deposit-only default blocks +/// Past `max_sequencer_drift`, derivation drops the batches with user transactions and, +/// once epoch 0's sequencing window closes, fills their slots with deposit-only blocks. #[tokio::test] -async fn sequencer_drift_produces_deposit_only_blocks() { +async fn over_drift_batches_with_transactions_become_deposit_only_once_the_window_closes() { let l1_cfg = L1MinerConfig { block_time: 4, ..Default::default() }; let batcher_cfg = BatcherConfig { encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, @@ -50,8 +25,8 @@ async fn sequencer_drift_produces_deposit_only_blocks() { .build(); let mut h = ActionTestHarness::new(l1_cfg, rollup_cfg.clone()); - // Mine L1 block 1 (ts=4) so the sequencer has an epoch to reference, - // but we will PIN the sequencer to epoch 0 (ts=0) to force drift. + // Mine L1 block 1 (ts=4) so the sequencer has a later epoch to reference, then pin it to + // epoch 0 (ts=0) to force drift. h.mine_l1_blocks(1); let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); @@ -91,7 +66,7 @@ async fn sequencer_drift_produces_deposit_only_blocks() { &mut node_sequencer, SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); batcher.advance(&mut h.l1).await; chain.push(h.l1.tip().clone()); @@ -103,12 +78,7 @@ async fn sequencer_drift_produces_deposit_only_blocks() { h.mine_and_push(&chain); node.initialize().await; - - // Drive derivation through all L1 blocks. - let mut total_derived = 0; - for _ in 1..=h.l1.latest_number() { - total_derived += node.run_until_idle().await; - } + let total_derived = node.run_until_idle().await; // The pipeline should derive blocks for all L2 slots. Blocks 1-6 use the // batcher's submitted batches. Blocks 7-8 are generated as deposit-only @@ -121,33 +91,18 @@ async fn sequencer_drift_produces_deposit_only_blocks() { ); assert_eq!(total_derived, 8, "all 8 blocks derived"); - // Verify deposit-only behaviour: blocks 1-6 carry 2 txs each (deposit + - // user tx), blocks 7-8 must carry exactly 1 tx (L1 info deposit only). - let tx_counts = node.derived_tx_counts(); - for &(number, count) in tx_counts { - if number <= 6 { - assert_eq!(count, 2, "block {number} should have deposit + user tx"); - } else { - assert_eq!(count, 1, "block {number} past drift boundary should be deposit-only"); - } + // Blocks 1-6 keep their user transaction, blocks 7-8 are deposit-only. + for number in 1..=8 { + let block = node.derived_block(number).expect("derived block"); + assert_eq!(block.is_deposit_only(), number > 6, "block {number}"); } } -// --------------------------------------------------------------------------- -// B. Sequencer drift with forced-empty blocks -// --------------------------------------------------------------------------- - -/// When `max_sequencer_drift` is exceeded, the sequencer should produce -/// deposit-only (empty) blocks. This test verifies that the pipeline correctly -/// handles the over-drift region by deriving blocks for all L2 slots, even -/// when the submitted batches are dropped. -/// -/// This test uses `L2Sequencer::build_empty_block()` for the over-drift -/// blocks (7-8). The pipeline drops those batches (they still reference the -/// stale epoch 0, triggering `SequencerDriftNotAdoptedNextOrigin`), and then -/// generates deposit-only default blocks for those slots. +/// Past `max_sequencer_drift`, an empty batch that keeps the stale L1 origin is dropped too +/// once the next origin is due. Derivation stops at the last block within drift, and fills no +/// deposit-only block while epoch 0's sequencing window is open. #[tokio::test] -async fn sequencer_drift_forced_empty_blocks_accepted() { +async fn over_drift_empty_batches_are_dropped_when_a_next_origin_exists() { let l1_cfg = L1MinerConfig { block_time: 4, ..Default::default() }; let batcher_cfg = BatcherConfig { encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, @@ -176,30 +131,18 @@ async fn sequencer_drift_forced_empty_blocks_accepted() { for _ in 1u64..=6 { source.push(sequencer.build_next_block_with_single_transaction().await); } - // Build empty blocks past the drift boundary. The empty block has only - // the deposit tx — the batcher encodes it but the pipeline drops it - // (stale epoch) and produces a default block. + // Empty blocks past the drift boundary, still on epoch 0 although L1 block 1 exists. for _ in 7u64..=8 { source.push(sequencer.build_empty_block().await); } - let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg.clone()); + let batcher = Batcher::new(source, &h.rollup_config, batcher_cfg); batcher.advance(&mut h.l1).await; chain.push(h.l1.tip().clone()); node.initialize().await; + let total_derived = node.run_until_idle().await; - let mut total_derived = 0; - for _ in 1..=h.l1.latest_number() { - total_derived += node.run_until_idle().await; - } - - // All 8 blocks should be derived: 6 normal + 2 empty (pipeline-generated - // deposit-only blocks for the over-drift slots). - assert!(total_derived >= 6, "at least the 6 within-drift blocks should be derived"); - assert_eq!( - node.l2_safe_number(), - total_derived as u64, - "safe head should match number of derived blocks" - ); + assert_eq!(total_derived, 6, "the over-drift batches are dropped"); + assert_eq!(node.l2_safe_number(), 6); } diff --git a/actions/harness/tests/upgrade/denim.rs b/actions/harness/tests/upgrade/denim.rs new file mode 100644 index 0000000000..1c58349964 --- /dev/null +++ b/actions/harness/tests/upgrade/denim.rs @@ -0,0 +1,78 @@ +//! Action tests for derivation across the Denim block cadence. + +use base_action_harness::{ + ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, + TestRollupConfigBuilder, +}; +use base_batcher_encoder::{DaType, EncoderConfig}; +use base_common_genesis::{BaseUpgradeConfig, UpgradeConfig}; + +/// A historical Span fixture may derive its pre-Denim prefix, but the cached +/// tail must be discarded before the first Denim block. Resubmitting that tail +/// through the production `SingleBatch` batcher must derive across Denim's 200ms +/// block cadence. +#[tokio::test] +async fn span_batch_stops_at_denim_and_recovers_with_single_batches() { + let batcher_cfg = BatcherConfig { + encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, + ..BatcherConfig::default() + }; + let upgrades = UpgradeConfig { + regolith_time: Some(0), + canyon_time: Some(0), + delta_time: Some(0), + ecotone_time: Some(0), + fjord_time: Some(0), + granite_time: Some(0), + holocene_time: Some(0), + isthmus_time: Some(0), + jovian_time: Some(0), + base: BaseUpgradeConfig { + azul: Some(0), + beryl: Some(0), + cobalt: Some(0), + denim: Some(6), + ..Default::default() + }, + ..Default::default() + }; + let rollup_cfg = + TestRollupConfigBuilder::base_mainnet(&batcher_cfg).with_upgrades(upgrades).build(); + assert_eq!( + (3..=8).map(|number| rollup_cfg.l2_block_timestamp_parts(number)).collect::>(), + vec![(6, 0), (6, 200), (6, 400), (6, 600), (6, 800), (7, 0)] + ); + let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); + + let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); + let mut builder = h.create_l2_sequencer(l1_chain); + let mut blocks = Vec::new(); + for _ in 0..8 { + blocks.push(builder.build_next_block_with_single_transaction().await); + } + + let (mut node, chain) = h.create_test_rollup_node_from_sequencer( + &mut builder, + SharedL1Chain::from_blocks(h.l1.chain().to_vec()), + ); + + h.submit_span_batch_brotli_calldata(&batcher_cfg, &blocks, 100) + .expect("span fixture submission"); + chain.push(h.l1.tip().clone()); + + node.initialize().await; + node.run_until_idle().await; + assert_eq!( + node.l2_safe_number(), + 2, + "only blocks before Denim activation at block 3 may derive from the span" + ); + + let source = ActionL2Source::from_blocks(blocks.into_iter().skip(2)); + Batcher::new(source, &h.rollup_config, batcher_cfg).advance(&mut h.l1).await; + chain.push(h.l1.tip().clone()); + + let recovered = node.run_until_idle().await; + assert_eq!(recovered, 6, "the SingleBatch path must recover all post-Denim blocks"); + assert_eq!(node.l2_safe_number(), 8, "safe head must advance across Denim"); +} diff --git a/actions/harness/tests/upgrade/main.rs b/actions/harness/tests/upgrade/main.rs index 8117105e7c..aebf43943a 100644 --- a/actions/harness/tests/upgrade/main.rs +++ b/actions/harness/tests/upgrade/main.rs @@ -1,6 +1,7 @@ //! Action tests for upgrade activation and fork-specific protocol behavior. mod activation; +mod denim; mod ecotone; mod holocene; mod isthmus; From 090b7763692e96e0df30fa868c11a677603b061b Mon Sep 17 00:00:00 2001 From: Joe Parks Date: Wed, 30 Sep 2026 20:19:29 +0000 Subject: [PATCH 184/299] feat(consensus): catch isolated sequencers up to canonical tip on startup (#5407) Isolated sequencers now run the network and derivation actors and follow canonical catch-up like a conductor follower. Once caught up they enter IsolatedActive, which drops canonical gossip, safe/finalized signals, and derivation resets so the private chain is never reorged. Payloads are still never published. --- crates/consensus/cli/src/sequencer.rs | 3 +- .../service/src/actors/sequencer/config.rs | 26 +-- .../sequencer/engine_request_coordinator.rs | 148 +++++++++++++++++- .../actors/sequencer/shadow_reconciliation.rs | 3 + crates/consensus/service/src/service/node.rs | 107 +++++-------- 5 files changed, 192 insertions(+), 95 deletions(-) diff --git a/crates/consensus/cli/src/sequencer.rs b/crates/consensus/cli/src/sequencer.rs index 8554a622df..8d52e89d3f 100644 --- a/crates/consensus/cli/src/sequencer.rs +++ b/crates/consensus/cli/src/sequencer.rs @@ -51,7 +51,8 @@ pub struct SequencerArgs { )] pub recover: bool, - /// Run the sequencer without canonical-chain ingress or payload publication. + /// Catch up to the canonical chain on startup, then sequence privately without following or + /// publishing to it. #[arg( long = "sequencer.isolated", default_value = "false", diff --git a/crates/consensus/service/src/actors/sequencer/config.rs b/crates/consensus/service/src/actors/sequencer/config.rs index 42adf7eec3..c148504052 100644 --- a/crates/consensus/service/src/actors/sequencer/config.rs +++ b/crates/consensus/service/src/actors/sequencer/config.rs @@ -20,7 +20,8 @@ pub struct SequencerConfig { pub sequencer_stopped: bool, /// Whether or not the sequencer is in recovery mode. pub sequencer_recovery_mode: bool, - /// Whether the sequencer runs without canonical-chain ingress or payload publication. + /// Whether the sequencer stops following the canonical chain after startup catch-up and never + /// publishes its payloads. pub isolated: bool, /// Number of private blocks to build per cycle when running as a shadow sequencer. /// @@ -62,16 +63,6 @@ impl SequencerConfig { pub const fn is_shadow_sequencer(&self) -> bool { self.shadow_blocks_per_cycle.is_some() } - - /// Returns whether the consensus network actor should be constructed. - pub const fn network_enabled(&self) -> bool { - !self.isolated - } - - /// Returns whether a derivation actor should be constructed. - pub const fn derivation_enabled(&self) -> bool { - !self.isolated - } } impl Default for SequencerConfig { @@ -91,16 +82,3 @@ impl Default for SequencerConfig { } } } - -#[cfg(test)] -mod tests { - use super::SequencerConfig; - - #[test] - fn isolated_disables_network_and_derivation_actor_construction() { - let config = SequencerConfig { isolated: true, ..Default::default() }; - - assert!(!config.network_enabled()); - assert!(!config.derivation_enabled()); - } -} diff --git a/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs b/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs index 961b8366de..9d630b0a60 100644 --- a/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs +++ b/crates/consensus/service/src/actors/sequencer/engine_request_coordinator.rs @@ -48,6 +48,8 @@ where sequencer_state: SequencerEngineState, conductor: Option>, sequencer_stopped: bool, + /// Whether the sequencer stops following the canonical chain once catch-up completes. + isolated: bool, unsafe_head_tx: watch::Sender, } @@ -73,7 +75,33 @@ where } else { SequencerEngineState::Regular }; - Self { processor, sequencer_state, conductor, sequencer_stopped, unsafe_head_tx } + Self { + processor, + sequencer_state, + conductor, + sequencer_stopped, + isolated: false, + unsafe_head_tx, + } + } + + /// Configures the coordinator as an isolated sequencer. + pub const fn with_isolated(mut self, isolated: bool) -> Self { + self.isolated = isolated; + self + } + + /// Returns the state entered once a non-shadow sequencer has caught up to the canonical tip. + const fn caught_up_state(&self) -> SequencerEngineState { + if self.isolated { + SequencerEngineState::IsolatedActive + } else { + SequencerEngineState::Regular + } + } + + const fn is_isolated_active(&self) -> bool { + matches!(self.sequencer_state, SequencerEngineState::IsolatedActive) } /// Returns the coordinator's sequencer routing state. @@ -129,7 +157,12 @@ where } } - self.sequencer_state = SequencerEngineState::Regular; + if matches!( + self.sequencer_state, + SequencerEngineState::Regular | SequencerEngineState::CatchingUp { .. } + ) { + self.sequencer_state = self.caught_up_state(); + } Ok(()) } @@ -154,7 +187,7 @@ where } async fn resolve_bootstrap_role(&self) -> BootstrapRole { - if self.sequencer_stopped || self.is_shadow_sequencer() { + if self.sequencer_stopped || self.isolated || self.is_shadow_sequencer() { return BootstrapRole::ConductorFollower; } match &self.conductor { @@ -269,6 +302,10 @@ where catchup: CanonicalUnsafeCatchup::default(), }; } + // At genesis there is no canonical chain to catch up to. + if self.isolated && matches!(self.sequencer_state, SequencerEngineState::Regular) { + self.sequencer_state = SequencerEngineState::IsolatedActive; + } match bootstrap_role { BootstrapRole::ConductorFollower => { self.processor.bootstrap_conductor_follower(opt_head).await @@ -416,6 +453,12 @@ where } } } + EngineActorRequest::ProcessSafeL2SignalRequest(_) + | EngineActorRequest::ProcessFinalizedL2BlockNumberRequest(_) + if self.is_isolated_active() => + { + debug!(target: "engine", "Isolated sequencer dropping canonical safety signal"); + } EngineActorRequest::ProcessSafeL2SignalRequest(safe_signal) => { // Canonical ancestors of the cycle anchor cannot reorg the private unsafe // branch, so confirm them immediately and let derivation keep advancing. @@ -470,6 +513,9 @@ where SequencerEngineState::ShadowActive(gate) => { gate.buffer_payload(*envelope); } + SequencerEngineState::IsolatedActive => { + debug!(target: "engine", "Isolated sequencer dropping canonical unsafe payload"); + } SequencerEngineState::Regular => { let block_number = envelope.execution_payload.block_number(); let unsafe_head = @@ -514,6 +560,9 @@ where SequencerEngineState::ShadowActive(_) => { warn!(target: "engine", "Ignoring admin unsafe payload on shadow sequencer"); } + SequencerEngineState::IsolatedActive => { + warn!(target: "engine", "Ignoring admin unsafe payload on isolated sequencer"); + } SequencerEngineState::Regular | SequencerEngineState::CatchingUp { shadow: false, .. } => { self.processor.handle_admin_unsafe_l2_block(*envelope); @@ -542,7 +591,8 @@ where } } SequencerEngineState::Regular - | SequencerEngineState::CatchingUp { .. } => { + | SequencerEngineState::CatchingUp { .. } + | SequencerEngineState::IsolatedActive => { Err(EngineClientError::ShadowReconciliationDisabled) } }; @@ -563,6 +613,15 @@ where let sync_state = self.processor.engine_state().sync_state; let head = sync_state.unsafe_head(); let unsafe_before = head; + // Derivation follows the canonical chain, so its resets would rewind the + // private chain. Acknowledge them without touching forkchoice. + if origin == ResetOrigin::Derivation && self.is_isolated_active() { + debug!(target: "engine", "Isolated sequencer ignoring derivation reset"); + if result_tx.send(Ok(())).await.is_err() { + warn!(target: "engine", "Sending reset response failed"); + } + continue; + } if origin != ResetOrigin::Derivation && let SequencerEngineState::CatchingUp { shadow, catchup } = &self.sequencer_state @@ -654,7 +713,7 @@ where canonical_hash = %head.block_info.hash, "Sequencer canonical catch-up completed" ); - self.sequencer_state = SequencerEngineState::Regular; + self.sequencer_state = self.caught_up_state(); } // Do not reset the engine while the EL is still syncing. A Reset sends a // forkchoice_updated to reth pointing at the sync-start block, which will @@ -686,7 +745,8 @@ where SequencerEngineState::CatchingUp { catchup, .. } => { *catchup = CanonicalUnsafeCatchup::default(); } - SequencerEngineState::Regular => {} + SequencerEngineState::Regular + | SequencerEngineState::IsolatedActive => {} } self.unsafe_head_tx.send_replace(anchor); if let Err(error) = @@ -954,6 +1014,82 @@ mod tests { assert!(matches!(coordinator.sequencer_state(), SequencerEngineState::ShadowActive(_))); } + #[tokio::test] + async fn isolated_sequencer_bootstraps_as_follower() { + let coordinator = coordinator(false, false, None).with_isolated(true); + assert_eq!(coordinator.resolve_bootstrap_role().await, BootstrapRole::ConductorFollower); + } + + #[rstest] + #[case::caught_up(&[(100, 100)], true)] + #[case::gap(&[(102, 102)], false)] + fn isolated_start_stops_following_canonical_chain( + #[case] observations: &[(u64, u8)], + #[case] accepted: bool, + ) { + let head = L2BlockInfo { + block_info: BlockInfo { + number: 100, + hash: B256::with_last_byte(100), + ..Default::default() + }, + ..Default::default() + }; + let state = TestEngineStateBuilder::new() + .with_unsafe_head(head) + .with_safe_head(L2BlockInfo::default()) + .with_el_sync_finished(true) + .build(); + let (state_tx, _) = watch::channel(state); + let (queue_tx, _) = watch::channel(0usize); + let processor = EngineProcessor::new( + Arc::new(test_engine_client_builder().build()), + Arc::new(RollupConfig::default()), + MockEngineDerivationClient::new(), + Engine::new(state, state_tx, queue_tx), + ); + let (head_tx, _) = watch::channel(head); + let mut coordinator = + SequencerEngineRequestCoordinator::new(processor, false, None, true, head_tx) + .with_isolated(true); + let mut catchup = CanonicalUnsafeCatchup::default(); + for &(number, hash) in observations { + catchup.buffer_payload(BaseExecutionPayloadEnvelope { + execution_payload: BaseExecutionPayload::V1(ExecutionPayloadV1 { + block_number: number, + block_hash: B256::with_last_byte(hash), + parent_hash: B256::ZERO, + fee_recipient: Default::default(), + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Default::default(), + prev_randao: B256::ZERO, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: number, + extra_data: Default::default(), + base_fee_per_gas: Default::default(), + transactions: vec![], + }), + parent_beacon_block_root: None, + }); + } + catchup.commit(head); + *coordinator.sequencer_state_mut() = + SequencerEngineState::CatchingUp { shadow: false, catchup }; + + assert_eq!(coordinator.prepare_sequencer_start(head.block_info.hash).is_ok(), accepted); + assert_eq!( + matches!(coordinator.sequencer_state(), SequencerEngineState::IsolatedActive), + accepted + ); + // Once active, an isolated sequencer never resumes following the canonical chain. + if accepted { + assert!(coordinator.prepare_sequencer_start(head.block_info.hash).is_ok()); + assert!(matches!(coordinator.sequencer_state(), SequencerEngineState::IsolatedActive)); + } + } + #[rstest] #[case(B256::ZERO)] #[case(B256::with_last_byte(100))] diff --git a/crates/consensus/service/src/actors/sequencer/shadow_reconciliation.rs b/crates/consensus/service/src/actors/sequencer/shadow_reconciliation.rs index 068cd51d04..2a1d85da16 100644 --- a/crates/consensus/service/src/actors/sequencer/shadow_reconciliation.rs +++ b/crates/consensus/service/src/actors/sequencer/shadow_reconciliation.rs @@ -24,6 +24,9 @@ pub enum SequencerEngineState { }, /// Private block production is active and canonical inputs are buffered for reconciliation. ShadowActive(Box), + /// An isolated sequencer finished startup catch-up and now drops all canonical inputs so + /// they cannot reorg its private chain. + IsolatedActive, } /// Rolling canonical unsafe payloads retained while safe derivation catches up. diff --git a/crates/consensus/service/src/service/node.rs b/crates/consensus/service/src/service/node.rs index cd496036ca..b900e6454f 100644 --- a/crates/consensus/service/src/service/node.rs +++ b/crates/consensus/service/src/service/node.rs @@ -308,13 +308,16 @@ impl RollupNode { engine_processor, )) } else { - ConfiguredEngineReceiver::Sequencer(SequencerEngineRequestCoordinator::new( - engine_processor, - shadow_sequencer, - conductor, - self.sequencer_config.sequencer_stopped, - unsafe_head_tx, - )) + ConfiguredEngineReceiver::Sequencer( + SequencerEngineRequestCoordinator::new( + engine_processor, + shadow_sequencer, + conductor, + self.sequencer_config.sequencer_stopped, + unsafe_head_tx, + ) + .with_isolated(self.sequencer_config.isolated), + ) }; let engine_actor = EngineActor::new(cancellation_token, engine_request_rx, engine_handler); @@ -474,11 +477,8 @@ impl RollupNode { let engine_conductor: Option> = conductor.clone().map(|c| Arc::new(c) as Arc); - let engine_derivation_client = if self.sequencer_config.derivation_enabled() { - QueuedEngineDerivationClient::new(derivation_actor_request_tx.clone()) - } else { - QueuedEngineDerivationClient::disabled() - }; + let engine_derivation_client = + QueuedEngineDerivationClient::new(derivation_actor_request_tx.clone()); let (engine_actor, engine_rpc_processor, sequencer_engine_state_rx) = self .create_engine_actor( engine_client, @@ -492,11 +492,8 @@ impl RollupNode { // Select the concrete derivation actor implementation based on // RollupNode configuration. - let derivation: Option> = if self - .sequencer_config - .derivation_enabled() - { - Some(if let Some(provider) = self.derivation_delegate_provider.clone() { + let derivation: ConfiguredDerivationActor

= + if let Some(provider) = self.derivation_delegate_provider.clone() { // L1 Provider for sanity checking Derivation Delegation let l1_provider = AlloyChainProvider::new( self.l1_config.engine_provider.clone(), @@ -523,41 +520,24 @@ impl RollupNode { safe_head_listener, derivation_origin_tx, ))) - }) - } else { - None - }; + }; // Create the p2p actor. - let (signer, network_rpc, gossip_payload_tx, net_admin_rpc, network) = - if self.sequencer_config.network_enabled() { - let ( - NetworkInboundData { - signer, - p2p_rpc: network_rpc, - gossip_payload_tx, - admin_rpc: net_admin_rpc, - }, - network, - ) = NetworkActor::new( - QueuedNetworkEngineClient { - engine_actor_request_tx: engine_actor_request_tx.clone(), - }, - cancellation.clone(), - self.network_builder(), - ) - .await - .map_err(|e| format!("Failed to start network actor: {e}"))?; - ( - Some(signer), - Some(network_rpc), - Some(gossip_payload_tx), - Some(net_admin_rpc), - Some(network), - ) - } else { - (None, None, None, None, None) - }; + let ( + NetworkInboundData { + signer, + p2p_rpc: network_rpc, + gossip_payload_tx, + admin_rpc: net_admin_rpc, + }, + network, + ) = NetworkActor::new( + QueuedNetworkEngineClient { engine_actor_request_tx: engine_actor_request_tx.clone() }, + cancellation.clone(), + self.network_builder(), + ) + .await + .map_err(|e| format!("Failed to start network actor: {e}"))?; let (l1_head_updates_tx, l1_head_updates_rx) = watch::channel(None); @@ -582,12 +562,8 @@ impl RollupNode { Arc::clone(&self.config), AlloyL1BlockFetcher(self.l1_config.engine_provider.clone()), l1_head_updates_tx.clone(), - if self.sequencer_config.derivation_enabled() { - QueuedL1WatcherDerivationClient::new(derivation_actor_request_tx) - } else { - QueuedL1WatcherDerivationClient::disabled() - }, - signer, + QueuedL1WatcherDerivationClient::new(derivation_actor_request_tx), + Some(signer), cancellation.clone(), head_stream, finalized_stream, @@ -629,10 +605,13 @@ impl RollupNode { // Create the admin API channel let (sequencer_admin_api_tx, sequencer_admin_api_rx) = mpsc::channel(1024); - let queued_gossip_client = gossip_payload_tx.map_or_else( - QueuedUnsafePayloadGossipClient::private, - QueuedUnsafePayloadGossipClient::new, - ); + // Isolated sequencers keep the network actor for startup catch-up but never + // publish their private fork. + let queued_gossip_client = if self.sequencer_config.isolated { + QueuedUnsafePayloadGossipClient::private() + } else { + QueuedUnsafePayloadGossipClient::new(gossip_payload_tx) + }; let recovery_mode = RecoveryModeGuard::new(self.sequencer_config.sequencer_recovery_mode); @@ -697,18 +676,18 @@ impl RollupNode { r, RpcContext { cancellation: cancellation.clone(), - p2p_network: network_rpc, - network_admin: net_admin_rpc, + p2p_network: Some(network_rpc), + network_admin: Some(net_admin_rpc), isolated_sequencer: self.sequencer_config.isolated, l1_watcher_queries: l1_query_tx, } )), sequencer_actor.map(|s| (s, ())), - network.map(|network| (network, ())), + Some((network, ())), Some((l1_watcher, ())), Some((l1_query_processor, ())), upgrade_signal_metrics_actor.map(|actor| (actor, ())), - derivation.map(|derivation| (derivation, ())), + Some((derivation, ())), Some((checkpoint_actor, cancellation.clone())), Some((engine_actor, ())), engine_rpc_actor, From fade4e5ed4592894e05b323d95e650c2d8755f0a Mon Sep 17 00:00:00 2001 From: xenoliss Date: Wed, 30 Sep 2026 21:33:43 +0000 Subject: [PATCH 185/299] test(batcher): drop duplicate and golden-byte tests across comp, source, service, cli and basectl (#5351) * test(batcher): drop duplicate and golden-byte tests across comp, source, service, cli and basectl * test(batcher): name the Brotli round trip after its three qualities * test(batcher): drop the CLI test of clap enum parsing * test(batcher): keep the periphery tests that pin a contract and cover the startup refusals * test(batcher): cover the setup refusals left untested and drop two copied defaults * test(batcher): check the Brotli level reaches the compressor and merge the blob forcing flag tests * test(batcher): cover the operator flags and the throttle refusal, document the periphery tests * test(batcher): check every operator flag and every setup refusal * test(batcher): round trip Brotli channels on 4 KB instead of 400 KB --- Cargo.lock | 2 +- crates/batcher/cli/src/cli.rs | 259 ++++++++---------- crates/batcher/cli/src/metrics.rs | 14 +- crates/batcher/comp/Cargo.toml | 1 - crates/batcher/comp/src/brotli.rs | 47 ---- crates/batcher/comp/src/types.rs | 37 +-- crates/batcher/service/Cargo.toml | 1 + crates/batcher/service/src/l2_block_parity.rs | 152 ++++------ crates/batcher/service/src/service.rs | 143 ++++++---- crates/batcher/service/src/throttle.rs | 57 ++-- crates/batcher/source/src/l1_hybrid.rs | 65 ++--- crates/batcher/source/src/l1_polling.rs | 1 + .../batcher/source/src/test_utils/channel.rs | 87 ------ .../source/src/test_utils/l1_channel.rs | 48 ---- crates/infra/basectl/src/rpc/batcher.rs | 21 +- 15 files changed, 350 insertions(+), 585 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 28bd59ab51..829bdd989a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3122,6 +3122,7 @@ dependencies = [ "httpmock", "jsonrpsee", "metrics", + "mockall", "rstest", "serde_json", "tokio", @@ -3810,7 +3811,6 @@ dependencies = [ name = "base-comp" version = "0.0.0" dependencies = [ - "alloy-primitives", "base-common-genesis", "base-protocol", "brotli", diff --git a/crates/batcher/cli/src/cli.rs b/crates/batcher/cli/src/cli.rs index aaef2928ac..593f06dcc5 100644 --- a/crates/batcher/cli/src/cli.rs +++ b/crates/batcher/cli/src/cli.rs @@ -364,6 +364,8 @@ mod tests { use super::*; + const PRIVATE_KEY: &str = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + fn base_args_without_signer() -> Vec<&'static str> { vec![ "batcher", @@ -378,10 +380,7 @@ mod tests { fn base_args() -> Vec<&'static str> { let mut args = base_args_without_signer(); - args.extend_from_slice(&[ - "--private-key", - "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - ]); + args.extend_from_slice(&["--private-key", PRIVATE_KEY]); args } @@ -391,6 +390,7 @@ mod tests { BatcherArgs::try_parse_from(args).expect("CLI should parse") } + /// A remote signer endpoint and address configure the signer in place of a private key. #[test] fn into_config_accepts_remote_signer() { let mut args = base_args_without_signer(); @@ -407,90 +407,64 @@ mod tests { assert_eq!(signer.address(), Address::repeat_byte(0x42)); } + /// `--shadow-mode` and the inbox override only go together. #[test] - fn into_config_sets_metrics_enabled() { - let cli = parse_cli(&[]); - let config = cli.into_config(true).expect("config should build"); - - assert!(config.metrics_enabled); - } - - #[test] - fn into_config_rejects_shadow_mode_without_batch_inbox_override() { - let cli = parse_cli(&["--shadow-mode"]); - let err = cli.into_config(false).expect_err("shadow mode alone should fail"); - - assert!( - err.to_string() - .contains("--shadow-mode and --dangerously-override-batch-inbox-address") - ); - } - - #[test] - fn into_config_rejects_batch_inbox_override_without_shadow_mode() { - let cli = parse_cli(&[ + fn into_config_requires_shadow_mode_and_the_inbox_override_together() { + let shadow_mode_alone: &[&'static str] = &["--shadow-mode"]; + let override_alone: &[&'static str] = &[ "--dangerously-override-batch-inbox-address", "0x1111111111111111111111111111111111111111", - ]); - let err = cli.into_config(false).expect_err("override without shadow mode should fail"); - - assert!( - err.to_string() - .contains("--shadow-mode and --dangerously-override-batch-inbox-address") - ); + ]; + for flags in [shadow_mode_alone, override_alone] { + let error = parse_cli(flags).into_config(false).unwrap_err(); + + assert_eq!( + error.to_string(), + "--shadow-mode and --dangerously-override-batch-inbox-address must be set together", + "{flags:?}" + ); + } } + /// `--shadow-mode` with the inbox override sends batches to that inbox and takes the parity + /// validator's L2 RPC. #[test] fn into_config_accepts_shadow_batch_inbox_override() { let cli = parse_cli(&[ "--shadow-mode", "--dangerously-override-batch-inbox-address", "0x1111111111111111111111111111111111111111", + "--parity-validator-l2-rpc-url", + "http://validator:9545", ]); let config = cli.into_config(false).expect("config should build"); assert_eq!(config.batch_inbox_override, Some(Address::repeat_byte(0x11))); + assert_eq!( + config.parity_validator_l2_rpc_url.expect("a parity validator").as_str(), + "http://validator:9545/" + ); } + /// Without flags the batcher runs blobs at full blob frames and Brotli quality 9, starts + /// running and does not wait for the node to sync. #[test] - fn into_config_defaults_to_blob_da() { + fn into_config_applies_the_defaults() { let cli = parse_cli(&[]); let config = cli.into_config(false).expect("config should build"); - assert_eq!(config.encoder_config.da_type, base_batcher_encoder::DaType::Blob); - } - - #[test] - fn into_config_uses_full_blob_frame_capacity() { - let cli = parse_cli(&[]); - let config = cli.into_config(false).expect("config should build"); + assert!(!config.stopped); + assert!(!config.wait_node_sync); + assert_eq!(config.encoder_config.da_type, base_batcher_encoder::DaType::Blob); assert_eq!( config.encoder_config.max_frame_size, base_batcher_encoder::EncoderConfig::MAX_BLOB_FRAME_SIZE ); - assert_eq!(config.encoder_config.compressed_size_target, None); - assert_eq!(config.encoder_config.max_blobs_per_tx, 6); assert_eq!(config.encoder_config.brotli_level, base_batcher_encoder::BrotliLevel::Brotli9); } - #[test] - fn into_config_accepts_compressed_target_and_blob_limit() { - let cli = parse_cli(&["--compressed-size-target", "700000", "--max-blobs-per-tx", "4"]); - let config = cli.into_config(false).expect("config should build"); - - assert_eq!(config.encoder_config.compressed_size_target, Some(700_000)); - assert_eq!(config.encoder_config.max_blobs_per_tx, 4); - } - - #[test] - fn into_config_accepts_brotli_quality() { - let cli = parse_cli(&["--brotli-quality", "11"]); - let config = cli.into_config(false).expect("config should build"); - - assert_eq!(config.encoder_config.brotli_level, base_batcher_encoder::BrotliLevel::Brotli11); - } - + /// A Brotli quality above 11, the encoder's highest level, is refused at parse time. #[test] fn cli_rejects_brotli_quality_out_of_range() { let mut args = base_args(); @@ -499,14 +473,7 @@ mod tests { assert!(BatcherArgs::try_parse_from(args).is_err()); } - #[test] - fn into_config_accepts_calldata_da_mode() { - let cli = parse_cli(&["--data-availability-type", "calldata"]); - let config = cli.into_config(false).expect("config should build"); - - assert_eq!(config.encoder_config.da_type, base_batcher_encoder::DaType::Calldata); - } - + /// A calldata batcher's frame size is its calldata cap minus the derivation version byte. #[test] fn into_config_reserves_derivation_prefix_from_calldata_size_cap() { let cli = parse_cli(&[ @@ -520,95 +487,105 @@ mod tests { assert_eq!(config.encoder_config.max_frame_size, 129_999); } + /// Every encoder, submission, throttle, startup and admin flag reaches the config, so no + /// operator flag is silently ignored. #[test] - fn cli_rejects_auto_da_mode_for_now() { - let mut args = base_args(); - args.extend_from_slice(["--data-availability-type", "auto"].as_slice()); - - assert!(BatcherArgs::try_parse_from(args).is_err()); - } - - #[test] - fn stopped_defaults_to_false() { - let cli = parse_cli(&[]); - let config = cli.into_config(false).expect("config should build"); - - assert!(!config.stopped); - } - - #[test] - fn stopped_flag_sets_stopped_in_config() { - let cli = parse_cli(&["--stopped"]); + fn into_config_applies_the_operator_flags() { + let cli = parse_cli(&[ + "--data-availability-type", + "calldata", + "--compressed-size-target", + "1000", + "--max-blobs-per-tx", + "3", + "--brotli-quality", + "5", + "--publish-max-retries", + "7", + "--publish-retry-delay", + "3s", + "--max-channel-duration", + "10", + "--sub-safety-margin", + "4", + "--max-pending-transactions", + "4", + "--num-confirmations", + "3", + "--resubmission-timeout", + "30", + "--poll-interval", + "2", + "--throttle-threshold", + "500000", + "--check-recent-txs-depth", + "16", + "--wait-node-sync-timeout", + "60", + "--admin-addr", + "0.0.0.0", + "--admin-port", + "7000", + "--l1-ws-url", + "ws://localhost:8546", + "--stopped", + "--wait-node-sync", + ]); let config = cli.into_config(false).expect("config should build"); + assert_eq!(config.encoder_config.da_type, base_batcher_encoder::DaType::Calldata); + 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.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.poll_interval, Duration::from_secs(2)); + assert_eq!(config.throttle.expect("the throttle is on").threshold_bytes, 500_000); + assert_eq!(config.check_recent_txs_depth, 16); + assert_eq!(config.wait_node_sync_timeout, Duration::from_secs(60)); + assert_eq!(config.admin_addr, Some(SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 7000))); + assert_eq!(config.l1_ws_url.expect("a WebSocket URL").as_str(), "ws://localhost:8546/"); assert!(config.stopped); + assert!(config.wait_node_sync); } - #[test] - fn into_config_sets_publish_retry_policy() { - let cli = parse_cli(&["--publish-max-retries", "12", "--publish-retry-delay", "250ms"]); - let config = cli.into_config(false).expect("config should build"); - - assert_eq!(config.tx_manager.publish_max_retries, 12); - assert_eq!(config.tx_manager.publish_retry_delay, Duration::from_millis(250)); - } - - #[test] - fn rpc_urls_default_to_single_endpoint() { - let cli = parse_cli(&[]); - let config = cli.into_config(false).expect("config should build"); - assert_eq!(config.l1_rpc_url.len(), 1); - assert_eq!(config.l2_rpc_url.len(), 1); - assert_eq!(config.rollup_rpc_url.len(), 1); - } - - #[test] - fn into_config_accepts_parity_validator_l2_rpc_url() { - let cli = parse_cli(&["--parity-validator-l2-rpc-url", "http://127.0.0.1:9545"]); - let config = cli.into_config(false).expect("config should build"); - - assert_eq!(config.parity_validator_l2_rpc_url.unwrap().as_str(), "http://127.0.0.1:9545/"); - } - + /// An RPC flag takes a comma-separated list of endpoints. #[test] fn rpc_urls_accept_comma_separated_list() { - // base_args() already sets `--l1-rpc-url http://localhost:8545`, so - // appending a second `--l1-rpc-url` with three comma-separated values - // accumulates: clap appends rather than overrides for `Vec` args. - let cli = - parse_cli(&["--l1-rpc-url", "http://l1-a:8545,http://l1-b:8545,http://l1-c:8545"]); - let config = cli.into_config(false).expect("config should build"); - assert_eq!(config.l1_rpc_url.len(), 4); - assert_eq!(config.l1_rpc_url[0].as_str(), "http://localhost:8545/"); - assert_eq!(config.l1_rpc_url[1].as_str(), "http://l1-a:8545/"); - assert_eq!(config.l1_rpc_url[3].as_str(), "http://l1-c:8545/"); - } - - #[test] - fn wait_node_sync_defaults_to_false() { - let cli = parse_cli(&[]); - let config = cli.into_config(false).expect("config should build"); - assert!(!config.wait_node_sync); - } + let args = [ + "batcher", + "--l1-rpc-url", + "http://l1-a:8545,http://l1-b:8545", + "--l2-rpc-url", + "http://localhost:9545", + "--rollup-rpc-url", + "http://localhost:7545", + "--private-key", + PRIVATE_KEY, + ]; + let config = BatcherArgs::try_parse_from(args).unwrap().into_config(false).unwrap(); - #[test] - fn wait_node_sync_flag_sets_config() { - let cli = parse_cli(&["--wait-node-sync"]); - let config = cli.into_config(false).expect("config should build"); - assert!(config.wait_node_sync); + let l1_rpc_urls: Vec<_> = config.l1_rpc_url.iter().map(Url::as_str).collect(); + assert_eq!(l1_rpc_urls, ["http://l1-a:8545/", "http://l1-b:8545/"]); } + /// The DA throttle is on unless `--no-throttle` is set. #[test] - fn force_blobs_when_throttling_defaults_to_true() { - let cli = parse_cli(&[]); - let config = cli.into_config(false).expect("config should build"); - assert!(config.force_blobs_when_throttling); + fn no_throttle_turns_the_da_throttle_off() { + assert!(parse_cli(&[]).into_config(false).unwrap().throttle.is_some()); + assert!(parse_cli(&["--no-throttle"]).into_config(false).unwrap().throttle.is_none()); } + /// Throttling forces blobs unless `--no-force-blobs-when-throttling` is set. #[test] - fn no_force_blobs_when_throttling_flag_inverts_default() { + fn no_force_blobs_when_throttling_turns_blob_forcing_off() { + assert!(parse_cli(&[]).into_config(false).unwrap().force_blobs_when_throttling); let cli = parse_cli(&["--no-force-blobs-when-throttling"]); - let config = cli.into_config(false).expect("config should build"); - assert!(!config.force_blobs_when_throttling); + assert!(!cli.into_config(false).unwrap().force_blobs_when_throttling); } } diff --git a/crates/batcher/cli/src/metrics.rs b/crates/batcher/cli/src/metrics.rs index ee5be86d02..1fc6d1228a 100644 --- a/crates/batcher/cli/src/metrics.rs +++ b/crates/batcher/cli/src/metrics.rs @@ -1,3 +1,5 @@ +//! Prometheus histogram buckets for the batcher metrics. + use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder}; const COMPRESSION_RATIO_BUCKETS: &[f64] = @@ -25,19 +27,13 @@ pub fn configure_prometheus(builder: PrometheusBuilder) -> Result = (0..1_000u32).flat_map(|i| (i % 251).to_le_bytes()).collect(); + let sizes = + [BrotliLevel::Brotli0, BrotliLevel::DEFAULT, BrotliLevel::Brotli11].map(|level| { + let channel = level.compress_channel(&input).unwrap(); + assert_eq!(channel[0], BrotliLevel::CHANNEL_VERSION); + let decompressed = Brotli + .decompress( + &channel[1..], + RollupConfig::MAX_RLP_BYTES_PER_CHANNEL_FJORD as usize, + ) + .unwrap(); + assert_eq!(decompressed, input); + channel.len() + }); + assert!(sizes[2] < sizes[0], "quality 11 must compress smaller than quality 0: {sizes:?}"); } + /// Qualities 0 to 11 each map to their Brotli level and back, and 12 is refused. #[test] fn brotli_levels_cover_the_full_encoder_range() { let levels = [ diff --git a/crates/batcher/service/Cargo.toml b/crates/batcher/service/Cargo.toml index d3c709baa4..6cdc8d91c9 100644 --- a/crates/batcher/service/Cargo.toml +++ b/crates/batcher/service/Cargo.toml @@ -43,6 +43,7 @@ jsonrpsee = { workspace = true, features = ["http-client", "macros"] } [dev-dependencies] rstest.workspace = true +mockall.workspace = true httpmock.workspace = true serde_json.workspace = true alloy-node-bindings.workspace = true diff --git a/crates/batcher/service/src/l2_block_parity.rs b/crates/batcher/service/src/l2_block_parity.rs index b71e6b682a..6139d81630 100644 --- a/crates/batcher/service/src/l2_block_parity.rs +++ b/crates/batcher/service/src/l2_block_parity.rs @@ -19,6 +19,7 @@ type BaseRpcBlock = ::BlockResponse; pub const DEFAULT_MAX_BLOCKS_PER_TICK: u64 = 25; /// Provider abstraction for derived L2 block parity checks. +#[cfg_attr(test, mockall::automock)] #[async_trait] pub trait L2BlockProvider: Send + Sync { /// Fetch the unsafe L2 head number from this provider. @@ -433,56 +434,22 @@ where #[cfg(test)] mod tests { - use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, - }; - - use tokio::sync::Mutex; + use std::collections::BTreeMap; use super::*; - #[derive(Debug, Default)] - struct MockL2BlockProvider { - unsafe_head: u64, - safe_head: u64, - blocks: BTreeMap, - fail_blocks: BTreeSet, - } - - impl MockL2BlockProvider { - fn new(unsafe_head: u64, blocks: impl IntoIterator) -> Self { - Self { - unsafe_head, - safe_head: unsafe_head, - blocks: blocks.into_iter().map(|block| (block.number, block)).collect(), - fail_blocks: BTreeSet::new(), - } - } - - fn with_fail_blocks(mut self, fail_blocks: impl IntoIterator) -> Self { - self.fail_blocks = fail_blocks.into_iter().collect(); - self - } - } - - #[async_trait] - impl L2BlockProvider for Arc> { - async fn unsafe_block_number(&self) -> eyre::Result { - Ok(self.lock().await.unsafe_head) - } - - async fn safe_block_number(&self) -> eyre::Result { - Ok(self.lock().await.safe_head) - } - - async fn block_by_number(&self, number: u64) -> eyre::Result> { - let provider = self.lock().await; - if provider.fail_blocks.contains(&number) { - eyre::bail!("mock fetch failed for block {number}"); - } - Ok(provider.blocks.get(&number).cloned()) - } + /// A provider whose unsafe and safe heads are `head`, serving `blocks`. + fn provider( + head: u64, + blocks: impl IntoIterator, + ) -> MockL2BlockProvider { + let blocks: BTreeMap<_, _> = + blocks.into_iter().map(|block| (block.number, block)).collect(); + let mut provider = MockL2BlockProvider::new(); + provider.expect_unsafe_block_number().returning(move || Ok(head)); + provider.expect_safe_block_number().returning(move || Ok(head)); + provider.expect_block_by_number().returning(move |number| Ok(blocks.get(&number).cloned())); + provider } fn snapshot(number: u64, hash_byte: u8, tx_hashes: &[u8]) -> L2BlockSnapshot { @@ -495,79 +462,68 @@ mod tests { } } + /// Blocks both nodes agree on count as matches, and the monitor moves past them. #[tokio::test] async fn process_once_records_matching_blocks() { - let sequencer = Arc::new(Mutex::new(MockL2BlockProvider::new( - 2, - [snapshot(1, 1, &[10]), snapshot(2, 2, &[20])], - ))); - let validator = Arc::new(Mutex::new(MockL2BlockProvider::new( - 2, - [snapshot(1, 1, &[10]), snapshot(2, 2, &[20])], - ))); - let config = L2BlockParityMonitorConfig { - start_block: 1, - max_blocks_per_tick: 10, - ..L2BlockParityMonitorConfig::new(1, Duration::from_secs(1)) - }; - let mut monitor = L2BlockParityMonitor::new(sequencer, validator, config); + let blocks = [snapshot(1, 1, &[10]), snapshot(2, 2, &[20])]; + let config = L2BlockParityMonitorConfig::new(1, Duration::from_secs(1)); + let mut monitor = + L2BlockParityMonitor::new(provider(2, blocks.clone()), provider(2, blocks), config); let stats = monitor.process_once().await.unwrap(); - assert_eq!(stats.checked, 2); - assert_eq!(stats.matches, 2); - assert_eq!(stats.mismatches, 0); + assert_eq!(stats, L2BlockParityStats { checked: 2, matches: 2, ..Default::default() }); assert_eq!(monitor.next_block, 3); } + /// A fetch error ends the pass but keeps the progress made before it, so the next pass resumes + /// at the block that failed. #[tokio::test] async fn process_once_keeps_progress_before_fetch_error() { - let sequencer = Arc::new(Mutex::new(MockL2BlockProvider::new( - 3, - [snapshot(1, 1, &[10]), snapshot(2, 2, &[20]), snapshot(3, 3, &[30])], - ))); - let validator = Arc::new(Mutex::new( - MockL2BlockProvider::new( - 3, - [snapshot(1, 1, &[10]), snapshot(2, 2, &[20]), snapshot(3, 3, &[30])], - ) - .with_fail_blocks([3]), - )); - let config = L2BlockParityMonitorConfig { - start_block: 1, - max_blocks_per_tick: 10, - ..L2BlockParityMonitorConfig::new(1, Duration::from_secs(1)) - }; - let mut monitor = L2BlockParityMonitor::new(sequencer, validator, config); + 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(3)); + let served = blocks.clone(); + validator.expect_block_by_number().returning(move |number| match number { + 3 => Err(eyre::eyre!("block 3 unavailable")), + _ => 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.checked, 2); - assert_eq!(stats.matches, 2); + assert_eq!(stats, L2BlockParityStats { checked: 2, matches: 2, ..Default::default() }); assert_eq!(monitor.next_block, 3); } + /// A block the two nodes disagree on counts as a mismatch, and the monitor moves past it. #[tokio::test] async fn process_once_records_mismatching_blocks() { - let sequencer = Arc::new(Mutex::new(MockL2BlockProvider::new(1, [snapshot(1, 1, &[10])]))); - let validator = Arc::new(Mutex::new(MockL2BlockProvider::new(1, [snapshot(1, 2, &[11])]))); let config = L2BlockParityMonitorConfig::new(1, Duration::from_secs(1)); - let mut monitor = L2BlockParityMonitor::new(sequencer, validator, config); + let mut monitor = L2BlockParityMonitor::new( + provider(1, [snapshot(1, 1, &[10])]), + provider(1, [snapshot(1, 2, &[11])]), + config, + ); let stats = monitor.process_once().await.unwrap(); - assert_eq!(stats.checked, 1); - assert_eq!(stats.matches, 0); - assert_eq!(stats.mismatches, 1); + assert_eq!(stats, L2BlockParityStats { checked: 1, mismatches: 1, ..Default::default() }); assert_eq!(monitor.next_block, 2); } + /// A block above the validator's unsafe 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() { - let sequencer = Arc::new(Mutex::new(MockL2BlockProvider::new(5, [snapshot(5, 5, &[50])]))); - let validator = Arc::new(Mutex::new(MockL2BlockProvider::new(3, []))); let config = L2BlockParityMonitorConfig::new(4, Duration::from_secs(1)); - let mut monitor = L2BlockParityMonitor::new(sequencer, validator, config); + let mut monitor = L2BlockParityMonitor::new( + provider(5, [snapshot(5, 5, &[50])]), + provider(3, []), + config, + ); let stats = monitor.process_once().await.unwrap(); @@ -575,16 +531,16 @@ mod tests { assert_eq!(monitor.next_block, 4); } - #[tokio::test] - async fn verification_backlog_counts_blocks_left_below_the_common_unsafe_head() { - let sequencer = Arc::new(Mutex::new(MockL2BlockProvider::new(9, []))); - let validator = Arc::new(Mutex::new(MockL2BlockProvider::new(9, []))); + /// The verification backlog counts the blocks from the cursor up to the common unsafe head, and + /// is zero while the validator is behind the cursor. + #[test] + fn verification_backlog_counts_blocks_left_below_the_common_unsafe_head() { let config = L2BlockParityMonitorConfig::new(7, Duration::from_secs(1)); - let monitor = L2BlockParityMonitor::new(sequencer, validator, config); + 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); - // Caught up: the cursor sits one past the common unsafe head. + // Once caught up, the cursor sits one past the common unsafe head. assert_eq!(monitor.verification_backlog(6), 0); // A common unsafe head below the cursor is a validator that has not caught up, // not backlog. diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index 59cea6f876..597562c7de 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -744,6 +744,7 @@ mod tests { use alloy_primitives::Address; use base_batcher_core::ThrottleConfig; use base_common_genesis::RollupConfig; + use base_protocol::SyncStatus; use base_tx_manager::SignerConfig; use httpmock::{Mock, prelude::*}; use rstest::rstest; @@ -805,6 +806,7 @@ mod tests { .await } + /// A startup RPC read is retried until it succeeds. #[tokio::test] async fn rpc_retry_succeeds_after_transient_failure() { let attempts = AtomicU8::new(0); @@ -818,6 +820,7 @@ mod tests { assert_eq!(attempts.load(Ordering::SeqCst), 3); } + /// A startup RPC read that keeps failing gives up at its timeout, naming the operation. #[tokio::test] async fn rpc_retry_times_out_while_failing() { let error = BatcherService::rpc_retry( @@ -828,73 +831,110 @@ mod tests { ) .await .expect_err("retry should time out while the RPC keeps failing"); - assert!( - error.to_string().contains("test-op"), - "timeout error should name the operation, got {error}" - ); - } - - #[tokio::test] - async fn setup_rejects_zero_max_pending_transactions() { - let config = BatcherConfig { max_pending_transactions: 0, ..BatcherConfig::default() }; - - let error = BatcherService::new(config) - .setup(TokioRuntime::new()) - .await - .expect_err("a batcher that can never submit must not start"); - - assert!( - error.to_string().contains("max_pending_transactions"), - "error should name the setting, got {error}" - ); + assert_eq!(error.to_string(), "test-op timed out after 20ms"); } - /// Shadow mode requires the parity validator L2 RPC URL and canonical mode rejects it. + /// Setup refuses a config the batcher cannot run before connecting to anything. #[rstest] - #[case::shadow_without_url( - Some(Address::ZERO), - None, + #[case::zero_poll_interval( + BatcherConfig { poll_interval: Duration::ZERO, ..BatcherConfig::default() }, + "poll_interval must be greater than zero" + )] + #[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 \ + transaction" + )] + #[case::stopped_without_admin_server( + BatcherConfig { stopped: true, ..BatcherConfig::default() }, + "--stopped requires --admin-port: the batcher would start stopped with no way to start \ + it because the admin JSON-RPC server is not enabled" + )] + #[case::no_l1_rpc_endpoint( + BatcherConfig { l1_rpc_url: Vec::new(), ..BatcherConfig::default() }, + "at least one L1 RPC endpoint is required" + )] + #[case::no_l2_rpc_endpoint( + BatcherConfig { l2_rpc_url: Vec::new(), ..BatcherConfig::default() }, + "at least one L2 RPC endpoint is required" + )] + #[case::no_rollup_rpc_endpoint( + BatcherConfig { rollup_rpc_url: Vec::new(), ..BatcherConfig::default() }, + "at least one rollup RPC endpoint is required" + )] + #[case::invalid_throttle( + BatcherConfig { + throttle: Some(ThrottleConfig { block_size_lower_limit: 0, ..ThrottleConfig::default() }), + ..BatcherConfig::default() + }, + "block_size_lower_limit must be greater than zero" + )] + #[case::recent_txs_depth_above_the_cap( + BatcherConfig { check_recent_txs_depth: 129, wait_node_sync: true, ..BatcherConfig::default() }, + "check_recent_txs_depth 129 exceeds maximum of 128" + )] + #[case::no_signer(BatcherConfig::default(), "signer must be set before starting")] + #[case::recent_txs_without_node_sync( + BatcherConfig { check_recent_txs_depth: 1, ..BatcherConfig::default() }, + "check_recent_txs_depth requires wait_node_sync" + )] + #[case::shadow_without_parity_validator( + BatcherConfig { batch_inbox_override: Some(Address::ZERO), ..BatcherConfig::default() }, "shadow mode requires a parity validator L2 RPC URL" )] - #[case::canonical_with_url( - None, - Some("http://127.0.0.1:1"), + #[case::parity_validator_without_shadow( + BatcherConfig { + parity_validator_l2_rpc_url: Some("http://127.0.0.1:1".parse().unwrap()), + ..BatcherConfig::default() + }, "parity validator L2 RPC URL requires shadow mode" )] #[tokio::test] - async fn setup_rejects_a_parity_validator_url_that_does_not_match_shadow_mode( - #[case] batch_inbox_override: Option

, - #[case] parity_validator_l2_rpc_url: Option<&str>, + async fn setup_refuses_a_config_it_cannot_run( + #[case] config: BatcherConfig, #[case] expected: &str, ) { - let config = BatcherConfig { - batch_inbox_override, - parity_validator_l2_rpc_url: parity_validator_l2_rpc_url - .map(|url| url.parse().unwrap()), - ..BatcherConfig::default() - }; - - let error = BatcherService::new(config) - .setup(TokioRuntime::new()) - .await - .expect_err("a batcher whose endpoints do not match its mode must not start"); + let error = BatcherService::new(config).setup(TokioRuntime::new()).await.unwrap_err(); assert_eq!(error.to_string(), expected); } + /// Startup goes on once the rollup node has processed the L1 target, and gives up at the + /// timeout while the node is behind it. + #[rstest] + #[case::reached(5, Ok(()))] + #[case::behind(6, Err("wait_for_node_sync timed out"))] #[tokio::test] - async fn setup_rejects_invalid_throttle_config() { - let throttle = ThrottleConfig { block_size_lower_limit: 0, ..ThrottleConfig::default() }; - let config = BatcherConfig { throttle: Some(throttle), ..BatcherConfig::default() }; + async fn wait_for_node_sync_waits_for_the_l1_target( + #[case] target_l1: u64, + #[case] expected: Result<(), &str>, + ) { + let server = MockServer::start_async().await; + let status = SyncStatus { + current_l1: BlockInfo { number: 5, ..Default::default() }, + ..Default::default() + }; + mock_rpc( + &server, + r#"{"method":"optimism_syncStatus"}"#, + serde_json::to_string(&status).unwrap(), + ) + .await; + let client = HttpClientBuilder::default().build(server.url("/")).unwrap(); - let error = BatcherService::new(config) - .setup(TokioRuntime::new()) - .await - .expect_err("a throttle that can send a zero limit must not start"); + let result = BatcherService::wait_for_node_sync( + &client, + target_l1, + Duration::from_millis(10), + Duration::from_millis(100), + ) + .await; - assert_eq!(error.to_string(), "block_size_lower_limit must be greater than zero"); + assert_eq!(result.map_err(|error| error.to_string()), expected.map_err(String::from)); } + /// Setup refuses a signer the L1 `SystemConfig` does not authorize, since derivation would + /// ignore its batches. #[tokio::test] async fn setup_rejects_a_signer_the_system_config_does_not_authorize() { let server = MockServer::start_async().await; @@ -915,6 +955,7 @@ mod tests { ); } + /// 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() { let server = MockServer::start_async().await; @@ -930,6 +971,8 @@ mod tests { assert!(l1_head.calls_async().await > 0, "setup must go past the check"); } + /// A shadow batcher posts to its own inbox, so setup does not check its signer against the + /// `SystemConfig`. #[tokio::test] async fn setup_skips_the_batcher_check_in_shadow_mode() { let server = MockServer::start_async().await; @@ -949,12 +992,14 @@ mod tests { batcher_hash.assert_calls_async(0).await; } + /// The L1 head stream keeps its WebSocket provider alive after the builder returns, so new L1 + /// heads keep arriving. #[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; - // The builder has returned: the stream alone must keep the WS provider alive. + // The builder has returned, so the stream alone must keep the WS provider alive. let miner = RootProvider::::new_http(anvil.endpoint_url()); for expected in 1..=2 { miner.raw_request::<(), String>("evm_mine".into(), ()).await.unwrap(); diff --git a/crates/batcher/service/src/throttle.rs b/crates/batcher/service/src/throttle.rs index a98b7d43c5..74f1b6781a 100644 --- a/crates/batcher/service/src/throttle.rs +++ b/crates/batcher/service/src/throttle.rs @@ -120,14 +120,15 @@ mod tests { format!(r#"{{"jsonrpc":"2.0","id":0,"result":{result}}}"#) } + /// The limits go out as `miner_setMaxDASize` with the tx and block sizes as hex quantities. #[tokio::test] async fn set_max_da_size_sends_correct_request() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { - when.method(POST) - .path("/") - .json_body_includes(r#"{"method":"miner_setMaxDASize"}"#); + when.method(POST).path("/").json_body_includes( + r#"{"method":"miner_setMaxDASize","params":["0x96","0x4e20"]}"#, + ); then.status(200) .header("content-type", "application/json") .body(json_rpc_response("true")); @@ -139,10 +140,12 @@ mod tests { mock.assert_async().await; } + /// A node that answers `false` refused the limits, which is an error, and the next endpoint + /// is not asked. #[tokio::test] - async fn set_max_da_size_false_return_is_error() { - let server = MockServer::start_async().await; - server + async fn set_max_da_size_false_return_is_an_error_without_failover() { + let refusing = MockServer::start_async().await; + refusing .mock_async(|when, then| { when.method(POST).path("/"); then.status(200) @@ -150,24 +153,24 @@ mod tests { .body(json_rpc_response("false")); }) .await; + let fallback = MockServer::start_async().await; + let fallback_calls = fallback + .mock_async(|when, then| { + when.method(POST).path("/"); + then.status(200) + .header("content-type", "application/json") + .body(json_rpc_response("true")); + }) + .await; - let client = RpcThrottleClient::new(&[server.url("/")]).unwrap(); - assert!( - client.set_max_da_size(150, 20_000).await.is_err(), - "false response must be treated as an error" - ); - } + let client = RpcThrottleClient::new(&[refusing.url("/"), fallback.url("/")]).unwrap(); + let error = client.set_max_da_size(150, 20_000).await.unwrap_err(); - #[tokio::test] - async fn set_max_da_size_transport_error_propagates() { - // Port 1 has no listener — connection will fail. - let client = RpcThrottleClient::new(&["http://127.0.0.1:1"]).unwrap(); - assert!( - client.set_max_da_size(150, 20_000).await.is_err(), - "connection failure must propagate as error" - ); + assert_eq!(error.to_string(), "miner_setMaxDASize returned false"); + assert_eq!(fallback_calls.calls_async().await, 0); } + /// An endpoint that cannot be reached falls over to the next one. #[tokio::test] async fn set_max_da_size_falls_over_to_second_endpoint() { // First endpoint refuses connections; second endpoint accepts. @@ -188,20 +191,10 @@ mod tests { .expect("failover from a dead first endpoint must succeed via the second"); } + /// The push fails when no endpoint can be reached. #[tokio::test] async fn set_max_da_size_all_endpoints_fail() { let client = RpcThrottleClient::new(&["http://127.0.0.1:1", "http://127.0.0.1:2"]).unwrap(); - let err = client.set_max_da_size(150, 20_000).await.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("all 2 throttle endpoints failed"), - "error must list endpoint count, got: {msg}" - ); - } - - #[test] - fn new_rejects_empty_url_list() { - let urls: &[&str] = &[]; - assert!(RpcThrottleClient::new(urls).is_err(), "empty endpoint list must be rejected"); + assert!(client.set_max_da_size(150, 20_000).await.is_err()); } } diff --git a/crates/batcher/source/src/l1_hybrid.rs b/crates/batcher/source/src/l1_hybrid.rs index f8a8ab836f..13d766eba4 100644 --- a/crates/batcher/source/src/l1_hybrid.rs +++ b/crates/batcher/source/src/l1_hybrid.rs @@ -80,63 +80,46 @@ impl L1HeadSource for HybridL1HeadSource

{ #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicU64, Ordering}; - use base_runtime::{Config, Runner}; use super::*; - use crate::SourceError; - - struct IncrementingPoller(AtomicU64); - - #[async_trait] - impl L1HeadPolling for IncrementingPoller { - async fn latest_head(&self) -> Result { - Ok(self.0.fetch_add(1, Ordering::Relaxed)) - } - } - - struct ProviderErrorPoller; - - #[async_trait] - impl L1HeadPolling for ProviderErrorPoller { - async fn latest_head(&self) -> Result { - Err(SourceError::Provider("poll down".to_string())) - } - } + use crate::{SourceError, l1_polling::MockL1HeadPolling}; + /// Only heads above the last one reported come out, so a stale or repeated head is skipped. #[test] - fn test_hybrid_l1_stale_and_duplicate_heads_skipped() { + fn hybrid_l1_skips_stale_and_duplicate_heads() { Runner::start(Config::seeded(0), |ctx| async move { - // Only the stream can produce heads: the poller always fails. + // The poller always fails, so only the stream produces heads. + let mut poller = MockL1HeadPolling::new(); + poller + .expect_latest_head() + .returning(|| Err(SourceError::Provider("poll down".to_string()))); let stream = futures::stream::iter(vec![10u64, 9, 10, 11]); - let mut source = HybridL1HeadSource::new( - ctx, - stream.boxed(), - ProviderErrorPoller, - Duration::from_secs(100), - ); + let mut source = + HybridL1HeadSource::new(ctx, stream.boxed(), poller, Duration::from_secs(100)); assert_eq!(source.next().await, 10); - // 9 is stale and the second 10 a duplicate: the next head is 11. + // 9 is stale and the second 10 a duplicate, so the next head is 11. assert_eq!(source.next().await, 11); }); } + /// Once the subscription ends, the source keeps delivering heads from the poller. #[test] - fn test_hybrid_l1_polls_after_subscription_ends() { + fn hybrid_l1_polls_after_subscription_ends() { Runner::start(Config::seeded(0), |ctx| async move { - // The stream and the poller both start at head 5; the stream then ends and the - // poller keeps returning a new head on every call. Whichever arm `select!` polls - // first, the other one's 5 is a duplicate and the heads that follow come from the - // poller. + // The stream and the poller both start at head 5. The stream then ends and the + // poller returns a new head on every call. Whichever arm `select!` polls first, + // the other one's 5 is a duplicate and the heads that follow come from the poller. + let mut poller = MockL1HeadPolling::new(); + let mut head = 5; + poller.expect_latest_head().returning(move || { + head += 1; + Ok(head - 1) + }); let stream = futures::stream::once(async { 5u64 }); - let mut source = HybridL1HeadSource::new( - ctx, - stream.boxed(), - IncrementingPoller(AtomicU64::new(5)), - Duration::from_secs(100), - ); + let mut source = + HybridL1HeadSource::new(ctx, stream.boxed(), poller, Duration::from_secs(100)); let mut heads = Vec::new(); for _ in 0..3 { diff --git a/crates/batcher/source/src/l1_polling.rs b/crates/batcher/source/src/l1_polling.rs index 46f124b29c..63eebbfcd8 100644 --- a/crates/batcher/source/src/l1_polling.rs +++ b/crates/batcher/source/src/l1_polling.rs @@ -5,6 +5,7 @@ use async_trait::async_trait; use crate::SourceError; /// A provider that can return the current L1 head block number by polling. +#[cfg_attr(test, mockall::automock)] #[async_trait] pub trait L1HeadPolling: Send + Sync { /// Fetch the current L1 head block number. diff --git a/crates/batcher/source/src/test_utils/channel.rs b/crates/batcher/source/src/test_utils/channel.rs index adbdd406fc..411beb48c1 100644 --- a/crates/batcher/source/src/test_utils/channel.rs +++ b/crates/batcher/source/src/test_utils/channel.rs @@ -32,90 +32,3 @@ impl UnsafeBlockSource for ChannelBlockSource { } } } - -#[cfg(test)] -mod tests { - use alloy_primitives::B256; - use base_common_consensus::BaseBlock; - use futures::FutureExt; - - use super::*; - - /// Helper to build a minimal [`BaseBlock`] with a given number. - fn make_block(number: u64) -> BaseBlock { - BaseBlock { - header: alloy_consensus::Header { - number, - parent_hash: B256::ZERO, - ..Default::default() - }, - body: Default::default(), - } - } - - #[tokio::test] - async fn receive_block_event() { - let (mut source, tx) = ChannelBlockSource::new(); - tx.send(L2BlockEvent::Block(Box::new(make_block(1)))).unwrap(); - - let event = source.next().await; - match event { - L2BlockEvent::Block(b) => assert_eq!(b.header.number, 1), - _ => panic!("expected Block event"), - } - } - - #[tokio::test] - async fn receive_reorg_event() { - let (mut source, tx) = ChannelBlockSource::new(); - tx.send(L2BlockEvent::Reorg).unwrap(); - - assert!(matches!(source.next().await, L2BlockEvent::Reorg)); - } - - #[tokio::test] - async fn parks_when_empty_then_receives() { - let (mut source, tx) = ChannelBlockSource::new(); - - // Spawn a task that sends a block after yielding, so the receiver - // must park on `recv().await` (the channel is empty at call time). - let handle = tokio::spawn(async move { - tokio::task::yield_now().await; - tx.send(L2BlockEvent::Block(Box::new(make_block(42)))).unwrap(); - }); - - let event = source.next().await; - match event { - L2BlockEvent::Block(b) => assert_eq!(b.header.number, 42), - _ => panic!("expected Block event"), - } - - handle.await.unwrap(); - } - - #[tokio::test] - async fn fifo_ordering() { - let (mut source, tx) = ChannelBlockSource::new(); - tx.send(L2BlockEvent::Block(Box::new(make_block(1)))).unwrap(); - tx.send(L2BlockEvent::Block(Box::new(make_block(2)))).unwrap(); - tx.send(L2BlockEvent::Reorg).unwrap(); - - match source.next().await { - L2BlockEvent::Block(b) => assert_eq!(b.header.number, 1), - _ => panic!("expected Block(1)"), - } - match source.next().await { - L2BlockEvent::Block(b) => assert_eq!(b.header.number, 2), - _ => panic!("expected Block(2)"), - } - assert!(matches!(source.next().await, L2BlockEvent::Reorg)); - } - - #[tokio::test] - async fn parks_once_all_senders_are_dropped() { - let (mut source, tx) = ChannelBlockSource::new(); - drop(tx); - - assert!(source.next().now_or_never().is_none(), "a closed source must park"); - } -} diff --git a/crates/batcher/source/src/test_utils/l1_channel.rs b/crates/batcher/source/src/test_utils/l1_channel.rs index 50a441128e..a998884946 100644 --- a/crates/batcher/source/src/test_utils/l1_channel.rs +++ b/crates/batcher/source/src/test_utils/l1_channel.rs @@ -32,51 +32,3 @@ impl L1HeadSource for ChannelL1HeadSource { } } } - -#[cfg(test)] -mod tests { - use futures::FutureExt; - - use super::*; - - #[tokio::test] - async fn receive_l1_head() { - let (mut source, tx) = ChannelL1HeadSource::new(); - tx.send(42).unwrap(); - - assert_eq!(source.next().await, 42); - } - - #[tokio::test] - async fn parks_when_empty_then_receives() { - let (mut source, tx) = ChannelL1HeadSource::new(); - - let handle = tokio::spawn(async move { - tokio::task::yield_now().await; - tx.send(99).unwrap(); - }); - - assert_eq!(source.next().await, 99); - handle.await.unwrap(); - } - - #[tokio::test] - async fn fifo_ordering() { - let (mut source, tx) = ChannelL1HeadSource::new(); - tx.send(1).unwrap(); - tx.send(2).unwrap(); - tx.send(3).unwrap(); - - assert_eq!(source.next().await, 1); - assert_eq!(source.next().await, 2); - assert_eq!(source.next().await, 3); - } - - #[tokio::test] - async fn parks_once_all_senders_are_dropped() { - let (mut source, tx) = ChannelL1HeadSource::new(); - drop(tx); - - assert!(source.next().now_or_never().is_none(), "a closed source must park"); - } -} diff --git a/crates/infra/basectl/src/rpc/batcher.rs b/crates/infra/basectl/src/rpc/batcher.rs index 252f282ea8..7487f22ea1 100644 --- a/crates/infra/basectl/src/rpc/batcher.rs +++ b/crates/infra/basectl/src/rpc/batcher.rs @@ -88,15 +88,18 @@ mod tests { use super::*; + /// Each admin call decodes the batcher's answer, and a rejection reports the method and the + /// server's message against the URL without its credentials. #[tokio::test] async fn calls_decode_answers_and_report_rejections() { let mut module = RpcModule::new(()); module .register_method("admin_getBatcherStatus", |_, _, _| { - serde_json::json!({ "stopped": true, "in_flight": 0, "da_backlog_bytes": 7 }) + serde_json::json!({ "stopped": true, "in_flight": 2, "da_backlog_bytes": 7 }) }) .unwrap(); module.register_method("admin_stopBatcher", |_, _, _| ()).unwrap(); + module.register_method("admin_startBatcher", |_, _, _| ()).unwrap(); module .register_method("admin_flushBatcher", |_, _, _| { Err::<(), _>(ErrorObjectOwned::owned(-32002, "batcher is stopped", None::<()>)) @@ -108,9 +111,10 @@ mod tests { let rpc = Url::parse(&format!("http://operator:secret@{address}")).unwrap(); let status = BatcherClient::status(&rpc).await.unwrap(); - assert_eq!(status, BatcherStatus { stopped: true, in_flight: 0, da_backlog_bytes: 7 }); + assert_eq!(status, BatcherStatus { stopped: true, in_flight: 2, da_backlog_bytes: 7 }); BatcherClient::stop(&rpc).await.unwrap(); + BatcherClient::start(&rpc).await.unwrap(); let error = BatcherClient::flush(&rpc).await.unwrap_err().to_string(); assert_eq!( @@ -123,22 +127,11 @@ mod tests { handle.stop().unwrap(); } + /// The URL shown to the operator keeps only the scheme, host and port. #[test] fn display_url_drops_credentials_path_and_query() { let rpc = Url::parse("https://operator:secret@batcher.example:6545/admin?key=abc").unwrap(); assert_eq!(BatcherClient::display_url(&rpc), "https://batcher.example:6545"); } - - #[test] - fn status_deserializes_the_admin_response() { - let status: BatcherStatus = serde_json::from_value(serde_json::json!({ - "stopped": true, - "in_flight": 2, - "da_backlog_bytes": 1024, - })) - .unwrap(); - - assert_eq!(status, BatcherStatus { stopped: true, in_flight: 2, da_backlog_bytes: 1024 }); - } } From ab8a94eacdd824dec2256709bd0f7e237abe7f4a Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 1 Oct 2026 06:44:33 +0000 Subject: [PATCH 186/299] test(batcher): call the admin RPC end to end through a real server and driver (#5352) * test(batcher): call the admin RPC end to end through a real server and driver * test(batcher): tighten the admin RPC test comments * test(batcher): drop the admin handle tests the RPC tests cover * test(batcher): check that a throttle reset over RPC pushes the limits and the full stopped status * test(batcher): drop the handle throttle test the RPC test covers and document the admin tests * test(batcher): read the startup throttle push from the defaults --- Cargo.lock | 3 + crates/batcher/admin/Cargo.toml | 7 + crates/batcher/admin/src/api.rs | 31 ----- crates/batcher/admin/src/server.rs | 8 +- crates/batcher/admin/tests/rpc.rs | 204 +++++++++++++++++++++++++++++ crates/batcher/core/src/admin.rs | 46 ------- 6 files changed, 221 insertions(+), 78 deletions(-) create mode 100644 crates/batcher/admin/tests/rpc.rs diff --git a/Cargo.lock b/Cargo.lock index 829bdd989a..4a0252036c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3014,9 +3014,12 @@ name = "base-batcher-admin" version = "0.0.0" dependencies = [ "base-batcher-core", + "base-runtime", "derive_more 2.1.1", "eyre", "jsonrpsee", + "serde_json", + "tokio", "tracing", ] diff --git a/crates/batcher/admin/Cargo.toml b/crates/batcher/admin/Cargo.toml index 9de701f432..1980ec2f45 100644 --- a/crates/batcher/admin/Cargo.toml +++ b/crates/batcher/admin/Cargo.toml @@ -17,3 +17,10 @@ base-batcher-core.workspace = true tracing = { workspace = true, features = ["std"] } derive_more = { workspace = true, features = ["debug"] } jsonrpsee = { workspace = true, features = ["server", "macros"] } + +[dev-dependencies] +serde_json.workspace = true +tokio = { workspace = true, features = ["macros", "rt"] } +base-runtime = { workspace = true, features = ["tokio"] } +jsonrpsee = { workspace = true, features = ["http-client"] } +base-batcher-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/batcher/admin/src/api.rs b/crates/batcher/admin/src/api.rs index 6a58f04eee..f376a45103 100644 --- a/crates/batcher/admin/src/api.rs +++ b/crates/batcher/admin/src/api.rs @@ -117,34 +117,3 @@ impl BatcherAdminApiServer for BatcherAdminApiServerImpl { self.handle.set_log_level(level).map_err(Self::admin_error) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn admin_error_not_supported_uses_method_not_found_code() { - let err = BatcherAdminApiServerImpl::admin_error(AdminError::NotSupported("test")); - assert_eq!(err.code(), -32601); - assert!(err.message().contains("not yet supported")); - } - - #[test] - fn admin_error_channel_closed_uses_server_error_code() { - let err = BatcherAdminApiServerImpl::admin_error(AdminError::ChannelClosed); - assert_eq!(err.code(), -32001); - } - - #[test] - fn admin_error_stopped_uses_invalid_state_code() { - let err = BatcherAdminApiServerImpl::admin_error(AdminError::Stopped); - assert_eq!(err.code(), -32002); - } - - #[test] - fn admin_error_invalid_throttle_config_uses_invalid_params_code() { - let config = ThrottleConfig { max_intensity: 2.0, ..ThrottleConfig::default() }; - let err = BatcherAdminApiServerImpl::admin_error(config.validate().unwrap_err().into()); - assert_eq!(err.code(), ErrorCode::InvalidParams.code()); - } -} diff --git a/crates/batcher/admin/src/server.rs b/crates/batcher/admin/src/server.rs index 72aca43ec5..c51317d5f8 100644 --- a/crates/batcher/admin/src/server.rs +++ b/crates/batcher/admin/src/server.rs @@ -17,6 +17,7 @@ use crate::{BatcherAdminApiServer, BatcherAdminApiServerImpl}; pub struct AdminServer { #[debug(skip)] handle: ServerHandle, + addr: SocketAddr, } impl AdminServer { @@ -28,7 +29,12 @@ impl AdminServer { let module = BatcherAdminApiServerImpl::new(admin_handle).into_rpc(); let handle = server.start(module); info!(addr = %addr, "admin RPC server listening"); - Ok(Self { handle }) + Ok(Self { handle, addr }) + } + + /// The bound address, with the port the OS picked when spawned on port 0. + pub const fn local_addr(&self) -> SocketAddr { + self.addr } /// Future that resolves when the server stops. diff --git a/crates/batcher/admin/tests/rpc.rs b/crates/batcher/admin/tests/rpc.rs new file mode 100644 index 0000000000..0858c1507d --- /dev/null +++ b/crates/batcher/admin/tests/rpc.rs @@ -0,0 +1,204 @@ +//! End-to-end tests of the admin JSON-RPC API. A real [`AdminServer`] over a running +//! `BatchDriver` is called over HTTP with the method names and payloads operators send. + +use std::{ + net::Ipv4Addr, + sync::{Arc, Mutex}, +}; + +use base_batcher_admin::AdminServer; +use base_batcher_core::{ + BatchDriverError, DaThrottle, ThrottleConfig, ThrottleController, + test_utils::{ + DriverFixture, DriverHandles, Recorded, ScriptedTxManager, ThrottleCallLog, + TrackingPipeline, TrackingThrottleClient, + }, +}; +use base_runtime::{Cancellation, TokioRuntime}; +use jsonrpsee::{ + core::{ClientError, client::ClientT, params::ArrayParams}, + http_client::{HttpClient, HttpClientBuilder}, + rpc_params, +}; +use serde_json::{Value, json}; +use tokio::task::JoinHandle; + +/// The DA backlog the driver's pipeline reports, above the threshold of [`throttle_config`]. +const DA_BACKLOG_BYTES: u64 = 1_500; + +/// A driver running in the background, its admin server, and an HTTP client on it. +struct AdminRpc { + client: HttpClient, + runtime: TokioRuntime, + driver: JoinHandle>, + recorded: Arc>, + /// The limits the driver pushed to the block builder, in order. + throttle_pushes: ThrottleCallLog, + _server: AdminServer, + /// Kept alive because the driver exits once the derivation status sender is dropped. + _handles: DriverHandles, +} + +impl AdminRpc { + async fn start() -> Self { + let runtime = TokioRuntime::new(); + let pipeline = TrackingPipeline::new().with_da_backlog(DA_BACKLOG_BYTES); + let recorded = pipeline.recorded(); + let (throttle_client, throttle_pushes) = TrackingThrottleClient::new(); + let (driver, handles) = + DriverFixture::new(runtime.clone(), pipeline, ScriptedTxManager::new([])) + .throttle(DaThrottle::new( + ThrottleController::disabled(), + Arc::new(throttle_client), + )) + .build(); + let driver = tokio::spawn(driver.run()); + let server = AdminServer::spawn((Ipv4Addr::LOCALHOST, 0).into(), handles.admin.clone()) + .await + .expect("the admin server binds"); + let client = HttpClientBuilder::default() + .build(format!("http://{}", server.local_addr())) + .expect("the client builds"); + Self { + client, + runtime, + driver, + recorded, + throttle_pushes, + _server: server, + _handles: handles, + } + } + + /// The JSON-RPC error code and message the call fails with. + async fn error(&self, method: &str, params: ArrayParams) -> (i32, String) { + match self.client.request::(method, params).await { + Err(ClientError::Call(error)) => (error.code(), error.message().to_string()), + other => panic!("expected a JSON-RPC error, got {other:?}"), + } + } +} + +/// A throttle config as an operator sends it, with no value at its default. +fn throttle_config(max_intensity: f64) -> Value { + json!({ + "threshold_bytes": 1_000, + "max_intensity": max_intensity, + "block_size_lower_limit": 3_000, + "block_size_upper_limit": 100_000, + "tx_size_lower_limit": 200, + "tx_size_upper_limit": 10_000, + }) +} + +/// `admin_getBatcherStatus` answers with exactly the fields the admin README documents. +#[tokio::test] +async fn status_reports_the_driver_state_with_the_documented_fields() { + let rpc = AdminRpc::start().await; + + let status: Value = rpc.client.request("admin_getBatcherStatus", rpc_params![]).await.unwrap(); + + assert_eq!( + status, + json!({ "stopped": false, "in_flight": 0, "da_backlog_bytes": DA_BACKLOG_BYTES }) + ); +} + +/// A stop is reported by the status and refuses a flush. A start lets the flush through to +/// the pipeline. +#[tokio::test] +async fn stop_and_start_gate_the_flush() { + let rpc = AdminRpc::start().await; + + let () = rpc.client.request("admin_stopBatcher", rpc_params![]).await.unwrap(); + let status: Value = rpc.client.request("admin_getBatcherStatus", rpc_params![]).await.unwrap(); + assert_eq!( + status, + json!({ "stopped": true, "in_flight": 0, "da_backlog_bytes": DA_BACKLOG_BYTES }) + ); + assert_eq!( + rpc.error("admin_flushBatcher", rpc_params![]).await, + (-32002, "batcher is stopped".into()) + ); + + let () = rpc.client.request("admin_startBatcher", rpc_params![]).await.unwrap(); + let () = rpc.client.request("admin_flushBatcher", rpc_params![]).await.unwrap(); + assert_eq!(rpc.recorded.lock().unwrap().flushes(), 1); +} + +/// The throttle controller is read back as set and applied to the backlog, an invalid config +/// is refused as invalid params, and a set or a reset pushes the limits to the block builder. +#[tokio::test] +async fn throttle_controller_is_set_read_and_reset() { + let rpc = AdminRpc::start().await; + + let params = rpc_params!["step", throttle_config(0.5)]; + let () = rpc.client.request("admin_setThrottleController", params).await.unwrap(); + + // The backlog is above the threshold, so the step strategy throttles at half intensity and + // the limits sit halfway between their lower and upper bounds. + let info: Value = + rpc.client.request("admin_getThrottleController", rpc_params![]).await.unwrap(); + assert_eq!( + info, + json!({ + "strategy": "step", + "threshold_bytes": 1_000, + "max_intensity": 0.5, + "current_intensity": 0.5, + "max_block_size": 51_500, + "max_tx_size": 5_100, + }) + ); + + let params = rpc_params!["step", throttle_config(2.0)]; + assert_eq!( + rpc.error("admin_setThrottleController", params).await, + (-32602, "invalid throttle config: max_intensity (2) must be within [0, 1]".into()) + ); + + let () = rpc.client.request("admin_resetThrottleController", rpc_params![]).await.unwrap(); + + // The driver serves the next command after the pass that pushes the reset limits. + let _: Value = rpc.client.request("admin_getBatcherStatus", rpc_params![]).await.unwrap(); + // The upper limits of the disabled controller at startup, then the step limits twice. + let defaults = ThrottleConfig::default(); + let upper = (defaults.tx_size_upper_limit, defaults.block_size_upper_limit); + assert_eq!(*rpc.throttle_pushes.lock().unwrap(), [upper, (5_100, 51_500), (5_100, 51_500)]); +} + +/// `admin_setLogLevel` answers method-not-found until log levels can be changed at runtime. +#[tokio::test] +async fn set_log_level_is_not_supported() { + let rpc = AdminRpc::start().await; + + assert_eq!( + rpc.error("admin_setLogLevel", rpc_params!["debug"]).await, + (-32601, "not yet supported: set_log_level".into()) + ); +} + +/// Once the driver has exited, every command that reaches it fails with the shut-down code. +#[tokio::test] +async fn driver_commands_fail_once_the_driver_has_exited() { + let mut rpc = AdminRpc::start().await; + rpc.runtime.cancel(); + (&mut rpc.driver).await.unwrap().unwrap(); + + let commands = [ + ("admin_startBatcher", rpc_params![]), + ("admin_stopBatcher", rpc_params![]), + ("admin_flushBatcher", rpc_params![]), + ("admin_getBatcherStatus", rpc_params![]), + ("admin_getThrottleController", rpc_params![]), + ("admin_setThrottleController", rpc_params!["step", throttle_config(1.0)]), + ("admin_resetThrottleController", rpc_params![]), + ]; + for (method, params) in commands { + assert_eq!( + rpc.error(method, params).await, + (-32001, "admin channel closed: driver has shut down".into()), + "{method} after the driver exited" + ); + } +} diff --git a/crates/batcher/core/src/admin.rs b/crates/batcher/core/src/admin.rs index e5556e5534..475e77142a 100644 --- a/crates/batcher/core/src/admin.rs +++ b/crates/batcher/core/src/admin.rs @@ -186,49 +186,3 @@ impl AdminHandle { rx.await.map_err(|_| AdminError::ChannelClosed) } } - -#[cfg(test)] -mod tests { - use futures::FutureExt; - - use super::*; - - #[tokio::test] - async fn start_returns_channel_closed_when_rx_dropped() { - let (handle, rx) = AdminHandle::channel(); - drop(rx); - let err = handle.start().await.unwrap_err(); - assert!(matches!(err, AdminError::ChannelClosed)); - } - - #[tokio::test] - async fn get_status_returns_channel_closed_when_rx_dropped() { - let (handle, rx) = AdminHandle::channel(); - drop(rx); - let err = handle.get_status().await.unwrap_err(); - assert!(matches!(err, AdminError::ChannelClosed)); - } - - #[tokio::test] - async fn set_throttle_rejects_an_invalid_config_before_the_driver() { - let (handle, mut rx) = AdminHandle::channel(); - let config = ThrottleConfig { max_intensity: 2.0, ..ThrottleConfig::default() }; - - // Rejected before any wait on the driver, which never answers here. - let err = handle - .set_throttle(ThrottleStrategy::Linear, config) - .now_or_never() - .expect("an invalid config must be rejected without waiting on the driver") - .unwrap_err(); - - assert!(matches!(err, AdminError::InvalidThrottleConfig(_))); - assert!(rx.try_recv().is_err(), "the driver must not receive the command"); - } - - #[test] - fn set_log_level_returns_not_supported() { - let (handle, _rx) = AdminHandle::channel(); - let err = handle.set_log_level("debug".to_string()).unwrap_err(); - assert!(matches!(err, AdminError::NotSupported(_))); - } -} From ed948146e3e96d736aecebf065517f9948e99d47 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Thu, 1 Oct 2026 13:57:51 +0000 Subject: [PATCH 187/299] refactor(shadow-metrics): inline postgres setup match and drop HealthState wrapper (#5445) --- bin/shadow-metrics/src/main.rs | 43 +++++++++++++--------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/bin/shadow-metrics/src/main.rs b/bin/shadow-metrics/src/main.rs index d7237b994e..7816e6d3dc 100644 --- a/bin/shadow-metrics/src/main.rs +++ b/bin/shadow-metrics/src/main.rs @@ -22,11 +22,6 @@ use tracing::{error, info}; base_cli_utils::define_log_args!("SHADOW_METRICS"); base_cli_utils::define_metrics_args!("SHADOW_METRICS", 9003); -#[derive(Debug, Clone)] -struct HealthState { - store: Option, -} - #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { @@ -85,14 +80,17 @@ async fn main() -> Result<()> { async fn run_server(args: Args) -> Result<()> { let http_addr = SocketAddr::from(([0, 0, 0, 0], args.http_port)); - let connection = match (&args.postgres_host, &args.postgres_password) { - (Some(host), Some(password)) => Some(PgConnectionParams { - host: host.clone(), - port: args.postgres_port, - database: args.postgres_database.clone(), - username: args.postgres_user.clone(), - password: password.clone(), - }), + let store = match (&args.postgres_host, &args.postgres_password) { + (Some(host), Some(password)) => { + let connection = PgConnectionParams { + host: host.clone(), + port: args.postgres_port, + database: args.postgres_database.clone(), + username: args.postgres_user.clone(), + password: password.clone(), + }; + Some(ShadowMetricsStore::connect(&connection, args.postgres_max_connections).await?) + } // Connecting with an empty password would surface as an opaque Postgres auth // failure rather than a configuration error. (Some(_), None) => anyhow::bail!( @@ -102,18 +100,11 @@ async fn run_server(args: Args) -> Result<()> { (None, _) => None, }; - let store = match &connection { - Some(connection) => { - Some(ShadowMetricsStore::connect(connection, args.postgres_max_connections).await?) - } - None => None, - }; - info!( http_addr = %http_addr, metrics_addr = %args.metrics.addr, metrics_port = args.metrics.port, - postgres_enabled = connection.is_some(), + postgres_enabled = store.is_some(), "Starting shadow-metrics service" ); @@ -160,15 +151,15 @@ fn health_router(store: Option) -> Router { Router::new() .route("/healthz", get(healthz_handler)) .route("/readyz", get(readyz_handler)) - .with_state(HealthState { store }) + .with_state(store) } async fn healthz_handler() -> &'static str { "ok\n" } -async fn readyz_handler(State(state): State) -> Response { - let readiness = match &state.store { +async fn readyz_handler(State(store): State>) -> Response { + let readiness = match &store { Some(store) => store.check_schema_ready().await, None => Ok(()), }; @@ -187,9 +178,7 @@ mod tests { #[tokio::test] async fn readyz_is_ready_without_postgres() { - let state = HealthState { store: None }; - - let response = readyz_handler(State(state)).await; + let response = readyz_handler(State(None)).await; assert_eq!(response.status(), StatusCode::OK); } From 8a2a5724835b2d527ef9cd09edfa9cc02f721ec7 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Thu, 1 Oct 2026 14:01:54 +0000 Subject: [PATCH 188/299] refactor(load-tester): drop redundant locals in rescue batch futures (#5435) * refactor(load-tester): drop redundant locals in rescue batch futures * fix(rpc-types): gate thiserror behind the eip8130 feature --- bin/load-tester/src/cli.rs | 33 +++++++++++------------------- crates/common/rpc-types/Cargo.toml | 12 ++++++++--- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/bin/load-tester/src/cli.rs b/bin/load-tester/src/cli.rs index 03715158a0..00068c7934 100644 --- a/bin/load-tester/src/cli.rs +++ b/bin/load-tester/src/cli.rs @@ -9,7 +9,7 @@ use alloy_rpc_types::{BlockNumberOrTag, TransactionRequest}; use alloy_signer_local::PrivateKeySigner; use base_cli_utils::RuntimeManager; use base_load_tests::{ - AccountPool, BaselineError, DEFAULT_MAX_GAS_PRICE, FundedAccount, LoadRunner, LoadTestDisplay, + AccountPool, DEFAULT_MAX_GAS_PRICE, FundedAccount, LoadRunner, LoadTestDisplay, LoadTestDisplayConfig, LoadTestRunHooks, LoadTestRunOptions, MetricsSummary, QueryProvider, ReceiptCoverage, Result as LoadResult, RpcProviders, RpcResultExt, TestConfig, create_wallet_provider, @@ -511,14 +511,12 @@ async fn rescue_batch( .iter() .map(|a| { let client = client.clone(); - let address = a.address; async move { - let balance = client - .get_balance(address) + client + .get_balance(a.address) .block_id(BlockNumberOrTag::Pending.into()) .await - .rpc("get pending balance")?; - Ok::<_, BaselineError>((address, balance)) + .rpc("get pending balance") } }) .collect(); @@ -529,7 +527,7 @@ async fn rescue_batch( let mut to_drain: Vec<(&FundedAccount, U256)> = Vec::new(); for (result, account) in balance_results.into_iter().zip(accounts.accounts().iter()) { pb.inc(1); - let (_, balance) = result?; + let balance = result?; if balance > params.drain_gas_cost { to_drain.push((account, balance)); } @@ -552,19 +550,12 @@ async fn rescue_batch( let drain_futs: Vec<_> = to_drain .iter() .map(|&(account, balance)| { - let rpc_url = params.rpc_url.clone(); - let funder_address = params.funder_address; - let chain_id = params.chain_id; - let max_fee = params.max_fee; - let max_priority_fee = params.max_priority_fee; - let drain_gas_cost = params.drain_gas_cost; - let drain_gas_limit = params.drain_gas_limit; let signer = account.signer.clone(); let address = account.address; async move { - let send_amount = balance.saturating_sub(drain_gas_cost); + let send_amount = balance.saturating_sub(params.drain_gas_cost); let wallet = EthereumWallet::from(signer); - let provider = create_wallet_provider(rpc_url, wallet); + let provider = create_wallet_provider(params.rpc_url.clone(), wallet); let nonce = provider .get_transaction_count(address) .pending() @@ -572,13 +563,13 @@ async fn rescue_batch( .rpc("get pending transaction count")?; let tx = TransactionRequest::default() - .with_to(funder_address) + .with_to(params.funder_address) .with_value(send_amount) .with_nonce(nonce) - .with_chain_id(chain_id) - .with_gas_limit(drain_gas_limit as u64) - .with_max_fee_per_gas(max_fee) - .with_max_priority_fee_per_gas(max_priority_fee); + .with_chain_id(params.chain_id) + .with_gas_limit(params.drain_gas_limit as u64) + .with_max_fee_per_gas(params.max_fee) + .with_max_priority_fee_per_gas(params.max_priority_fee); match provider.send_transaction(tx).await { Ok(pending) => { diff --git a/crates/common/rpc-types/Cargo.toml b/crates/common/rpc-types/Cargo.toml index 4cd3605112..8e4b4bfbbe 100644 --- a/crates/common/rpc-types/Cargo.toml +++ b/crates/common/rpc-types/Cargo.toml @@ -47,7 +47,7 @@ alloy-signer = { workspace = true, optional = true } arbitrary = { workspace = true, features = ["derive"], optional = true } # misc -thiserror.workspace = true +thiserror = { workspace = true, optional = true } derive_more = { workspace = true, features = ["as_ref", "deref_mut", "try_from"] } [dev-dependencies] @@ -75,7 +75,7 @@ std = [ "revm?/std", "serde/std", "serde_json/std", - "thiserror/std", + "thiserror?/std", ] arbitrary = [ "alloy-consensus/arbitrary", @@ -102,7 +102,13 @@ serde = [ "revm?/serde", ] network = [ "dep:alloy-network" ] -eip8130 = [ "dep:alloy-evm", "dep:base-common-evm", "dep:revm", "std" ] +eip8130 = [ + "dep:alloy-evm", + "dep:base-common-evm", + "dep:revm", + "dep:thiserror", + "std", +] reth = [ "dep:alloy-signer", "dep:reth-primitives-traits", From ca11298828449b3f30a0d1fe4fd06028960b07d2 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Thu, 1 Oct 2026 14:02:07 +0000 Subject: [PATCH 189/299] refactor(snapshotter): drop UploadProgressLogger::stop in favor of Drop (#5446) --- crates/infra/snapshotter/src/progress.rs | 13 +------------ crates/infra/snapshotter/src/upload.rs | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/crates/infra/snapshotter/src/progress.rs b/crates/infra/snapshotter/src/progress.rs index 921bce9dd9..bd2a0cb043 100644 --- a/crates/infra/snapshotter/src/progress.rs +++ b/crates/infra/snapshotter/src/progress.rs @@ -222,19 +222,8 @@ pub(crate) struct UploadProgressLogger { join_handle: Option>, } -impl UploadProgressLogger { - /// Stops the background upload logger. - pub(crate) fn stop(mut self) { - if let Some(stop_tx) = self.stop_tx.take() { - let _ = stop_tx.send(()); - } - if let Some(join_handle) = self.join_handle.take() { - let _ = join_handle.join(); - } - } -} - impl Drop for UploadProgressLogger { + /// Stops the background logger: sends the stop signal and joins its thread. fn drop(&mut self) { if let Some(stop_tx) = self.stop_tx.take() { let _ = stop_tx.send(()); diff --git a/crates/infra/snapshotter/src/upload.rs b/crates/infra/snapshotter/src/upload.rs index a841702d17..d5b6c66984 100644 --- a/crates/infra/snapshotter/src/upload.rs +++ b/crates/infra/snapshotter/src/upload.rs @@ -1056,7 +1056,7 @@ impl SnapshotUploader { } .await; - progress_logger.stop(); + drop(progress_logger); if let Err(error) = upload_result { error!( error = %error, From 331dd37c5c8a12e873a31d255e8fc90082e2604e Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Thu, 1 Oct 2026 14:02:18 +0000 Subject: [PATCH 190/299] refactor(websocket-proxy): share the API key check between authenticated handlers (#5447) --- crates/infra/websocket-proxy/src/server.rs | 60 +++++++++---------- .../websocket-proxy/tests/integration.rs | 26 ++++++++ 2 files changed, 55 insertions(+), 31 deletions(-) diff --git a/crates/infra/websocket-proxy/src/server.rs b/crates/infra/websocket-proxy/src/server.rs index 4869b228b6..dabcdc28ee 100644 --- a/crates/infra/websocket-proxy/src/server.rs +++ b/crates/infra/websocket-proxy/src/server.rs @@ -32,6 +32,26 @@ struct ServerState { trusted_proxy_config: TrustedProxyConfig, } +impl ServerState { + /// Validates the API key, counting either an unauthorized request or a successful + /// connection for the key's application, and returns the 401 response for invalid keys. + fn authenticate(&self, api_key: &str) -> Result<(), Box> { + let Some(app) = self.auth.get_application_for_key(api_key).cloned() else { + Metrics::unauthorized_requests().increment(1); + + return Err(Box::new( + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from(json!({"message": "Invalid API key"}).to_string())) + .unwrap(), + )); + }; + + Metrics::connections_by_app(app).increment(1); + Ok(()) + } +} + /// WebSocket proxy server that accepts client connections and forwards messages /// from a shared registry of upstream sources. #[derive(Clone)] @@ -166,22 +186,11 @@ async fn authenticated_websocket_handler( headers: HeaderMap, Path(api_key): Path, ) -> impl IntoResponse { - let application = state.auth.get_application_for_key(&api_key).cloned(); - - application.map_or_else( - || { - Metrics::unauthorized_requests().increment(1); + if let Err(response) = state.authenticate(&api_key) { + return *response; + } - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body(Body::from(json!({"message": "Invalid API key"}).to_string())) - .unwrap() - }, - |app| { - Metrics::connections_by_app(app).increment(1); - websocket_handler(state, ws, addr, headers, FilterType::None) - }, - ) + websocket_handler(state, ws, addr, headers, FilterType::None) } async fn authenticated_filter_websocket_handler( @@ -192,23 +201,12 @@ async fn authenticated_filter_websocket_handler( Path(api_key): Path, query: Query, ) -> impl IntoResponse { - let application = state.auth.get_application_for_key(&api_key).cloned(); - - application.map_or_else( - || { - Metrics::unauthorized_requests().increment(1); + if let Err(response) = state.authenticate(&api_key) { + return *response; + } - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body(Body::from(json!({"message": "Invalid API key"}).to_string())) - .unwrap() - }, - |app| { - Metrics::connections_by_app(app).increment(1); - let filter = create_filter_from_query(query.0); - websocket_handler(state, ws, addr, headers, filter) - }, - ) + let filter = create_filter_from_query(query.0); + websocket_handler(state, ws, addr, headers, filter) } async fn unauthenticated_websocket_handler( diff --git a/crates/infra/websocket-proxy/tests/integration.rs b/crates/infra/websocket-proxy/tests/integration.rs index 4ee6151835..d4b898e6b3 100644 --- a/crates/infra/websocket-proxy/tests/integration.rs +++ b/crates/infra/websocket-proxy/tests/integration.rs @@ -360,6 +360,32 @@ async fn test_authentication_allows_known_api_keys() { assert!(!(harness.can_connect("ws/key4").await)); } +#[tokio::test] +async fn test_authentication_rejects_unknown_api_keys_with_401() { + let addr = TestHarness::alloc_port().await; + let auth = Authentication::new(HashMap::from([("key1".to_string(), "app1".to_string())])); + + let harness = TestHarness::new_with_auth(addr, Some(auth)); + harness.start_server().await; + + // Send a well-formed WebSocket upgrade request so the request reaches the handler's + // authentication check instead of being rejected by the upgrade extractor. + for path in ["ws/invalid-key", "ws/invalid-key/filter"] { + let response = reqwest::Client::new() + .get(format!("http://{}/{path}", harness.server_addr)) + .header(reqwest::header::CONNECTION, "upgrade") + .header(reqwest::header::UPGRADE, "websocket") + .header(reqwest::header::SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") + .header(reqwest::header::SEC_WEBSOCKET_VERSION, "13") + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + assert_eq!(response.text().await.unwrap(), "{\"message\":\"Invalid API key\"}"); + } +} + #[tokio::test] async fn test_ping_timeout_disconnects_client() { let addr = TestHarness::alloc_port().await; From 9b3b592500613252e1ba386b3054598d607c497b Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 1 Oct 2026 15:36:14 +0000 Subject: [PATCH 191/299] refactor(harness): name the batcher staging helpers after the submissions they move (#5427) --- actions/README.md | 2 +- actions/harness/README.md | 2 +- actions/harness/src/batcher/actor.rs | 36 ++++++++++---------- actions/harness/src/batcher/tx_manager.rs | 3 +- actions/harness/src/l1/miner.rs | 4 +-- actions/harness/tests/batcher/recovery.rs | 2 +- actions/harness/tests/derivation/channels.rs | 4 +-- actions/harness/tests/derivation/main.rs | 4 +-- actions/harness/tests/upgrade/holocene.rs | 20 +++++------ 9 files changed, 38 insertions(+), 39 deletions(-) diff --git a/actions/README.md b/actions/README.md index 9e95beb111..75786d5973 100644 --- a/actions/README.md +++ b/actions/README.md @@ -171,7 +171,7 @@ those transactions are staged, mined, confirmed, or failed. For the common happy path, call `batcher.advance(&mut h.l1).await`. It waits for the driver to encode every block pushed so far, flushes the encoder, mines one L1 block, and shows it to the driver. For more exact scenarios, use -`encode_only`, `stage_n_frames`, `observe_l1_block`, `mine_pending` and +`encode_only`, `stage_n_submissions`, `observe_l1_block`, `mine_pending` and `fail_next_n_submissions`. Report derivation progress with `observe_derivation`. Every `async` method of `Batcher` returns once the driver is idle again, so the diff --git a/actions/harness/README.md b/actions/harness/README.md index 62ff9045cb..8b82bc304b 100644 --- a/actions/harness/README.md +++ b/actions/harness/README.md @@ -197,7 +197,7 @@ The production-shaped synthetic L1/DA implementation is now the default: signer recovery and inbox filtering. 4. Use the L1 event helpers for system-config, operator-fee, and deposit tests so derivation reads logs from signed transaction receipts. -5. Use `Batcher::stage_n_frames`, `Batcher::observe_l1_block`, and +5. Use `Batcher::stage_n_submissions`, `Batcher::observe_l1_block`, and `Batcher::staged_count` when a test needs to distinguish submission from L1 inclusion. diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index 7cb96a6b85..462a0df3f0 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -218,7 +218,7 @@ impl Batcher { /// Wait for the driver to encode every block pushed so far, then flush. /// - /// Performs steps 1–3 of [`advance`] without mining. Once it returns, every frame the + /// Performs steps 1–3 of [`advance`] without mining. Once it returns, every submission the /// flush released has been handed to the tx manager, so [`pending_count`] counts them all. /// /// # Panics @@ -230,7 +230,7 @@ impl Batcher { pub async fn encode_only(&self) { assert!( self.pending_count() == 0 && self.staged_count() == 0, - "cannot start a batch cycle with outstanding frame submissions" + "cannot start a batch cycle with outstanding submissions" ); // Admin commands outrank the block source in the driver's select, so wait until every @@ -239,7 +239,7 @@ impl Batcher { self.admin.flush().await.unwrap_or_else(|e| panic!("flush failed: {e}")); // The flush is answered before the driver's next encode-and-submit pass. Wait for that - // pass so every frame the flush released has been handed to the tx manager. + // pass so every submission the flush released has been handed to the tx manager. self.wait_until_idle().await; } @@ -252,30 +252,30 @@ impl Batcher { assert!(matches!(reached, Ok(Ok(()))), "the batch driver exited or stalled"); } - /// Returns the number of encoded-but-not-yet-staged pending frame submissions. + /// Returns the number of submissions the driver sent that are not staged to L1 yet. pub fn pending_count(&self) -> usize { self.tx_manager.pending_count() } - /// Returns the number of submitted frame transactions waiting for inclusion receipts. + /// Returns the number of staged submissions waiting for inclusion receipts. pub fn staged_count(&self) -> usize { self.tx_manager.staged_count() } - /// Submit the first `n` pending frame txs/blobs to the L1 miner's queue - /// without mining. Returns the actual count staged. - pub fn stage_n_frames(&self, l1: &mut L1Miner, n: usize) -> usize { + /// Stage the first `n` pending submissions to the L1 miner's queue without mining. + /// Returns the actual count staged. + pub fn stage_n_submissions(&self, l1: &mut L1Miner, n: usize) -> usize { self.tx_manager.stage_n_to_l1(l1, n) } - /// Schedule the next `n` frame submissions to fail immediately. + /// Schedule the next `n` submissions to fail immediately. /// /// Each of the next `n` calls the background [`BatchDriver`] makes to /// [`TxManager::send_async`] will resolve with /// [`TxManagerError::Rpc`] instead of queuing to the L1 miner. The driver - /// requeues the frame and retries, so calling this before [`encode_only`] + /// requeues the submission and retries, so calling this before [`encode_only`] /// simulates transient L1 submission failures without losing data. Once - /// [`encode_only`] returns, the retried frames are back in the pending queue. + /// [`encode_only`] returns, the retried submissions are back in the pending queue. /// /// [`TxManager::send_async`]: base_tx_manager::TxManager::send_async /// [`TxManagerError::Rpc`]: base_tx_manager::TxManagerError::Rpc @@ -284,9 +284,9 @@ impl Batcher { self.tx_manager.fail_next_n(n); } - /// Mine all pending frame submissions in one L1 block. + /// Mine all pending submissions in one L1 block. /// - /// Stages every pending frame, mines one L1 block, fires all receipts and waits until + /// Stages every pending submission, mines one L1 block, fires all receipts and waits until /// the driver has confirmed them. Returns the mined block number. /// /// Use this to land what the driver submitted without encoding new L2 blocks. @@ -297,12 +297,12 @@ impl Batcher { block.number() } - /// Drop the first `n` pending frame submissions without staging them to L1. + /// Drop the first `n` pending submissions without staging them to L1. /// - /// Returns the actual number dropped. Use this to skip specific frame - /// positions when testing non-sequential frame submission scenarios. The driver sees - /// each dropped submission fail and resubmits it on its next `async` call. - pub fn drop_n_frames(&self, n: usize) -> usize { + /// Returns the actual number dropped. Use this to skip specific submissions when testing + /// frames that land out of order. The driver sees each dropped submission fail and + /// resubmits it on its next `async` call. + pub fn drop_n_submissions(&self, n: usize) -> usize { self.tx_manager.drop_n(n) } diff --git a/actions/harness/src/batcher/tx_manager.rs b/actions/harness/src/batcher/tx_manager.rs index 1968093f63..5ec2aca3ea 100644 --- a/actions/harness/src/batcher/tx_manager.rs +++ b/actions/harness/src/batcher/tx_manager.rs @@ -115,8 +115,7 @@ impl L1MinerTxManager { /// Drop the first `n` pending submissions without staging them to L1. /// - /// Returns the actual number dropped (≤ `n`). Use this to skip specific - /// frame positions when testing non-sequential frame submission. + /// Returns the actual number dropped (≤ `n`). pub fn drop_n(&self, n: usize) -> usize { let mut inner = self.inner.lock().unwrap(); let count = n.min(inner.pending.len()); diff --git a/actions/harness/src/l1/miner.rs b/actions/harness/src/l1/miner.rs index 0aacc97686..8a200bdeec 100644 --- a/actions/harness/src/l1/miner.rs +++ b/actions/harness/src/l1/miner.rs @@ -534,8 +534,8 @@ impl L1Miner { /// /// Returns the discarded blocks in order (lowest number first) so tests /// can inspect which batcher transactions were reorged out. The pending - /// transaction queue is left untouched — the batcher can choose to - /// resubmit or discard pending frames as appropriate for the scenario. + /// transaction queue is left untouched, so the batcher can choose to + /// resubmit or discard pending submissions as appropriate for the scenario. /// /// After a reorg, `mine_block` builds on top of block `number`. Because /// the `fork_id` is incremented, new blocks will have different hashes diff --git a/actions/harness/tests/batcher/recovery.rs b/actions/harness/tests/batcher/recovery.rs index 26b80d0054..ae6f9aecc5 100644 --- a/actions/harness/tests/batcher/recovery.rs +++ b/actions/harness/tests/batcher/recovery.rs @@ -59,7 +59,7 @@ async fn batcher_replays_a_channel_that_timed_out_before_its_last_frame_landed() assert!(frames >= 2, "the block must span several frames"); // Frame 0 lands in L1 block 1, the rest stays pending in the tx manager. - batcher.stage_n_frames(&mut h.l1, 1); + batcher.stage_n_submissions(&mut h.l1, 1); h.mine_and_push(&chain); batcher.observe_l1_block(h.l1.tip()).await; diff --git a/actions/harness/tests/derivation/channels.rs b/actions/harness/tests/derivation/channels.rs index c827e473bd..944271394d 100644 --- a/actions/harness/tests/derivation/channels.rs +++ b/actions/harness/tests/derivation/channels.rs @@ -38,7 +38,7 @@ async fn multi_block_channel_assembles_across_l1_blocks() { assert!(frame_count >= 2, "the block must span several frames"); // L1 block 1 carries frame 0 only. - batcher.stage_n_frames(&mut h.l1, 1); + batcher.stage_n_submissions(&mut h.l1, 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); batcher.observe_l1_block(h.l1.tip()).await; @@ -48,7 +48,7 @@ async fn multi_block_channel_assembles_across_l1_blocks() { assert_eq!(node.l2_safe_number(), 0, "the channel is incomplete"); // L1 block 2 carries the rest, well within the channel timeout. - batcher.stage_n_frames(&mut h.l1, frame_count - 1); + batcher.stage_n_submissions(&mut h.l1, frame_count - 1); h.l1.mine_block(); chain.push(h.l1.tip().clone()); batcher.observe_l1_block(h.l1.tip()).await; diff --git a/actions/harness/tests/derivation/main.rs b/actions/harness/tests/derivation/main.rs index 01659ae8b3..3a1032253f 100644 --- a/actions/harness/tests/derivation/main.rs +++ b/actions/harness/tests/derivation/main.rs @@ -391,7 +391,7 @@ async fn orphaned_partial_channel_does_not_combine_with_canonical_remainder() { SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - batcher.stage_n_frames(&mut h.l1, 1); + batcher.stage_n_submissions(&mut h.l1, 1); h.l1.mine_block(); batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); @@ -406,7 +406,7 @@ async fn orphaned_partial_channel_does_not_combine_with_canonical_remainder() { let remaining = batcher.pending_count(); assert!(remaining > 0, "test requires canonical remainder frames"); - batcher.stage_n_frames(&mut h.l1, remaining); + batcher.stage_n_submissions(&mut h.l1, remaining); h.l1.mine_block(); batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); diff --git a/actions/harness/tests/upgrade/holocene.rs b/actions/harness/tests/upgrade/holocene.rs index acf0d2ecbe..9b0cd8b4bd 100644 --- a/actions/harness/tests/upgrade/holocene.rs +++ b/actions/harness/tests/upgrade/holocene.rs @@ -126,7 +126,7 @@ async fn holocene_activation_flushes_buffered_pre_holocene_channel_data() { SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - batcher.stage_n_frames(&mut h.l1, 1); + batcher.stage_n_submissions(&mut h.l1, 1); h.l1.mine_block(); batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); @@ -143,7 +143,7 @@ async fn holocene_activation_flushes_buffered_pre_holocene_channel_data() { let remaining = batcher.pending_count(); assert!(remaining > 0, "test requires post-Holocene remainder frames"); - batcher.stage_n_frames(&mut h.l1, remaining); + batcher.stage_n_submissions(&mut h.l1, remaining); h.l1.mine_block(); batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); @@ -300,9 +300,9 @@ async fn holocene_non_sequential_frame_pruned_channel_never_completes() { // Submit frame 0 and frame 2 in the same L1 block — skipping frame 1. // Under Holocene, FrameQueue::prune removes frame 2 because // frame 0.number + 1 != frame 2.number (0 + 1 = 1 ≠ 2). - batcher.stage_n_frames(&mut h.l1, 1); // frame 0 - batcher.drop_n_frames(1); // drop frame 1 - batcher.stage_n_frames(&mut h.l1, 1); // frame 2 (non-sequential) + batcher.stage_n_submissions(&mut h.l1, 1); // frame 0 + batcher.drop_n_submissions(1); // drop frame 1 + batcher.stage_n_submissions(&mut h.l1, 1); // frame 2 (non-sequential) h.l1.mine_block(); batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); // L1 block 1: frames 0 and 2 @@ -409,7 +409,7 @@ async fn holocene_new_channel_abandons_incomplete_old_channel() { ); // L1 block 1: only frame 0 of channel A (channel is incomplete). - batcher_a.stage_n_frames(&mut h.l1, 1); // frame 0 of channel A + batcher_a.stage_n_submissions(&mut h.l1, 1); // frame 0 of channel A h.l1.mine_block(); batcher_a.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); // L1 block 1: frame 0 of channel A @@ -429,7 +429,7 @@ async fn holocene_new_channel_abandons_incomplete_old_channel() { // channel B's frame 0 arrives (different ID, B is not last), the queue // drains all of channel A's frames. Channel B assembles and derives. for _ in 0..n_b { - batcher_b.stage_n_frames(&mut h.l1, 1); + batcher_b.stage_n_submissions(&mut h.l1, 1); } h.l1.mine_block(); batcher_b.observe_l1_block(h.l1.tip()).await; @@ -510,9 +510,9 @@ async fn holocene_non_sequential_frame_pruned_then_recovery_succeeds() { ); // Submit frame 0 and frame 2 in the same L1 block — skipping frame 1. - batcher.stage_n_frames(&mut h.l1, 1); // frame 0 - batcher.drop_n_frames(1); // drop frame 1 - batcher.stage_n_frames(&mut h.l1, 1); // frame 2 (non-sequential) + batcher.stage_n_submissions(&mut h.l1, 1); // frame 0 + batcher.drop_n_submissions(1); // drop frame 1 + batcher.stage_n_submissions(&mut h.l1, 1); // frame 2 (non-sequential) h.l1.mine_block(); batcher.observe_l1_block(h.l1.tip()).await; chain.push(h.l1.tip().clone()); From af0a933943d75f7fa01fbc8c8b8b5601b1743079 Mon Sep 17 00:00:00 2001 From: refcell Date: Thu, 1 Oct 2026 15:51:24 +0000 Subject: [PATCH 192/299] feat(acceptance): Add Configurable Devnet Acceptance Testing (#5179) * feat(acceptance): add configurable devnet acceptance testing Amp-Thread-ID: https://ampcode.com/threads/T-01a0b9eb-7672-730d-ab15-98453c4f374f Co-authored-by: Amp * fix(acceptance): fix CI checks and cover review invariants Amp-Thread-ID: https://ampcode.com/threads/T-01a0b9eb-7672-730d-ab15-98453c4f374f Co-authored-by: Amp * fix(acceptance): address publisher and readiness review feedback Amp-Thread-ID: https://ampcode.com/threads/T-01a0b9eb-7672-730d-ab15-98453c4f374f Co-authored-by: Amp * refactor(acceptance): align crate with repository standards Amp-Thread-ID: https://ampcode.com/threads/T-01a0b9eb-7672-730d-ab15-98453c4f374f Co-authored-by: Amp * refactor(acceptance): replace Python publisher with Rust Amp-Thread-ID: https://ampcode.com/threads/T-01a0b9eb-7672-730d-ab15-98453c4f374f Co-authored-by: Amp * fix(acceptance): address review feedback Amp-Thread-ID: https://ampcode.com/threads/T-01a0b9eb-7672-730d-ab15-98453c4f374f Co-authored-by: Amp * docs(acceptance): remove completed implementation plan Amp-Thread-ID: https://ampcode.com/threads/T-01a0b9eb-7672-730d-ab15-98453c4f374f Co-authored-by: Amp * fix(acceptance): distinguish terminal observation outages Amp-Thread-ID: https://ampcode.com/threads/T-01a0c490-b863-70b8-8840-755728aa8a51 Co-authored-by: Amp * fix(acceptance): normalize aggregated check ordering Amp-Thread-ID: https://ampcode.com/threads/T-01a0c490-b863-70b8-8840-755728aa8a51 Co-authored-by: Amp * fix(acceptance): render literal Markdown code safely Amp-Thread-ID: https://ampcode.com/threads/T-01a0c490-b863-70b8-8840-755728aa8a51 Co-authored-by: Amp * docs(acceptance): clarify advisory artifact trust Amp-Thread-ID: https://ampcode.com/threads/T-01a0c490-b863-70b8-8840-755728aa8a51 Co-authored-by: Amp * style(acceptance): align Rust layout with repository conventions Amp-Thread-ID: https://ampcode.com/threads/T-01a0c490-b863-70b8-8840-755728aa8a51 Co-authored-by: Amp * fix(acceptance): simplify acceptance test PR comments Amp-Thread-ID: https://ampcode.com/threads/T-01a0c490-b863-70b8-8840-755728aa8a51 Co-authored-by: Amp * fix(acceptance): include a collapsed inline PR report * fix(acceptance): address genesis fork, freshness, and mount path review feedback Genesis activations map to the rollup genesis timestamp, matching the devnet generator. Freshness checks require a duration shorter than their timeout, and output paths containing commas are rejected before building Docker mount options. Co-authored-by: Toshi * fix(acceptance): retry fork waits and reject unreachable check windows Builder RPC failures while waiting for a fork window are retried until the scenario deadline instead of aborting the run. A reachable builder that never reaches an after_fork activation fails only that check, and the pre-fork coverage recheck no longer overrides a genuine failure. heads_converge no longer accepts a common height of zero, which made safe and finalized comparisons pass trivially at genesis. Validation rejects before_fork windows that can never open and non-positive or fractional freshness ages. The report command reads results through the shared bounded reader, the CLI verdict and reproduction quoting gain tests, and the unused tokio test-util feature is removed. Co-authored-by: Toshi --------- Co-authored-by: Amp Co-authored-by: Toshi --- .depot/workflows/acceptance.yml | 238 +++++ Cargo.lock | 26 + Cargo.toml | 2 + Justfile | 4 + acceptance/Cargo.toml | 26 + acceptance/README.md | 237 +++++ .../reports/evidence/healthy-chain.json | 1 + .../fixtures/reports/evidence/lag-heads.json | 1 + .../fixtures/reports/synthetic-mixed.json | 80 ++ acceptance/report/style.css | 6 + acceptance/scenarios/denim-transition.toml | 35 + acceptance/scenarios/derivation.toml | 26 + acceptance/scenarios/smoke.toml | 25 + acceptance/src/aggregate.rs | 629 +++++++++++ acceptance/src/check.rs | 986 ++++++++++++++++++ acceptance/src/cli.rs | 513 +++++++++ acceptance/src/config.rs | 905 ++++++++++++++++ acceptance/src/lib.rs | 33 + acceptance/src/provision.rs | 561 ++++++++++ acceptance/src/publish.rs | 927 ++++++++++++++++ acceptance/src/report.rs | 967 +++++++++++++++++ acceptance/src/result.rs | 174 ++++ acceptance/src/runner.rs | 816 +++++++++++++++ bin/acceptance/Cargo.toml | 21 + bin/acceptance/README.md | 18 + bin/acceptance/src/main.rs | 18 + etc/docker/Dockerfile.rust-services | 1 + etc/docker/docker-compose.ha.yml | 28 +- etc/docker/docker-compose.yml | 60 +- 29 files changed, 7320 insertions(+), 44 deletions(-) create mode 100644 .depot/workflows/acceptance.yml create mode 100644 acceptance/Cargo.toml create mode 100644 acceptance/README.md create mode 100644 acceptance/fixtures/reports/evidence/healthy-chain.json create mode 100644 acceptance/fixtures/reports/evidence/lag-heads.json create mode 100644 acceptance/fixtures/reports/synthetic-mixed.json create mode 100644 acceptance/report/style.css create mode 100644 acceptance/scenarios/denim-transition.toml create mode 100644 acceptance/scenarios/derivation.toml create mode 100644 acceptance/scenarios/smoke.toml create mode 100644 acceptance/src/aggregate.rs create mode 100644 acceptance/src/check.rs create mode 100644 acceptance/src/cli.rs create mode 100644 acceptance/src/config.rs create mode 100644 acceptance/src/lib.rs create mode 100644 acceptance/src/provision.rs create mode 100644 acceptance/src/publish.rs create mode 100644 acceptance/src/report.rs create mode 100644 acceptance/src/result.rs create mode 100644 acceptance/src/runner.rs create mode 100644 bin/acceptance/Cargo.toml create mode 100644 bin/acceptance/README.md create mode 100644 bin/acceptance/src/main.rs diff --git a/.depot/workflows/acceptance.yml b/.depot/workflows/acceptance.yml new file mode 100644 index 0000000000..e1c55a47c3 --- /dev/null +++ b/.depot/workflows/acceptance.yml @@ -0,0 +1,238 @@ +name: Acceptance (advisory) + +on: + pull_request: + workflow_dispatch: + inputs: + scenario: + description: Acceptance scenario + type: choice + options: + - smoke + - denim-transition + - derivation + default: smoke + +concurrency: + group: acceptance-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + TESTED_SHA: ${{ github.sha }} + RUN_KEY: ${{ github.run_id }}-${{ github.run_attempt }} + +jobs: + select: + name: Select acceptance scenarios + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: depot-ubuntu-24.04-16 + timeout-minutes: 20 + outputs: + matrix: ${{ steps.select.outputs.matrix }} + started_at: ${{ steps.identity.outputs.started_at }} + steps: + - id: identity + name: Capture trusted workflow start time + shell: bash + run: echo "started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + - uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + - name: Test acceptance runner and publisher + run: | + cargo test --locked -p base-acceptance -p base-acceptance-cli + - id: select + shell: bash + env: + REQUESTED_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || 'smoke' }} + run: | + set -euo pipefail + case "$REQUESTED_SCENARIO" in + smoke|denim-transition|derivation) ;; + *) echo 'unsupported scenario' >&2; exit 2 ;; + esac + scenarios=("acceptance/scenarios/${REQUESTED_SCENARIO}.toml") + cargo run --locked -p base-acceptance-cli -- validate "${scenarios[@]}" + mkdir -p expected + cargo run --locked -p base-acceptance-cli -- manifest "${scenarios[@]}" \ + --run-id "$RUN_KEY" --tested-sha "$TESTED_SHA" --output expected/manifest.json + jq -e '.schema_version == 1 and .run_id == env.RUN_KEY and .tested_sha == env.TESTED_SHA and (.scenarios | length == 1)' expected/manifest.json + jq -cn --arg id "$REQUESTED_SCENARIO" --arg path "${scenarios[0]}" \ + '{include:[{id:$id,path:$path}]}' | sed 's/^/matrix=/' >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-expected-${{ github.run_attempt }} + path: expected/manifest.json + if-no-files-found: error + + scenario: + name: Acceptance / ${{ matrix.id }} + needs: select + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.select.outputs.matrix) }} + runs-on: depot-ubuntu-24.04-16 + timeout-minutes: 60 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + - uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + - uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1 + - name: Build and load tested images + env: + PROFILE: dev + BASE_SUCCINCT_ELF_REQUIRE: "0" + run: | + set -euo pipefail + depot bake --project "${{ vars.DEPOT_PROJECT_ID }}" -f etc/docker/docker-bake.hcl \ + base op-batcher --set '*.platform=linux/amd64' --load + depot build --project "${{ vars.DEPOT_PROJECT_ID }}" --load \ + --tag devnet-setup:local-v2 -f etc/docker/Dockerfile.devnet . + - name: Run scenario + shell: bash + env: + SCENARIO_ID: ${{ matrix.id }} + SCENARIO_PATH: ${{ matrix.path }} + run: | + set -o pipefail + cargo run --locked -p base-acceptance-cli -- run "$SCENARIO_PATH" \ + --output "target/acceptance/$RUN_KEY/$SCENARIO_ID" --no-build \ + --run-id "$RUN_KEY" --tested-sha "$TESTED_SHA" + - name: Preserve startup failure evidence + if: always() + env: + SCENARIO_ID: ${{ matrix.id }} + run: | + report="target/acceptance/$RUN_KEY/$SCENARIO_ID/report" + mkdir -p "$report" + if [ ! -f "$report/result.json" ]; then + echo 'No scenario result; inspect the job log for the infrastructure failure.' > "$report/runner-status.txt" + fi + - name: Upload sanitized scenario report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-result-${{ matrix.id }}-${{ github.run_attempt }} + path: target/acceptance/${{ env.RUN_KEY }}/${{ matrix.id }}/report + if-no-files-found: warn + + aggregate: + name: Acceptance / Aggregate (advisory) + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + needs: [select, scenario] + runs-on: depot-ubuntu-24.04-16 + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + - uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + continue-on-error: true + with: + pattern: acceptance-* + path: downloaded + - name: Aggregate (missing and invalid results remain failures) + run: | + set -o pipefail + mkdir -p downloaded + cargo run --locked -p base-acceptance-cli -- aggregate \ + --expected "downloaded/acceptance-expected-${GITHUB_RUN_ATTEMPT}/manifest.json" \ + --results downloaded --output aggregate-report + - if: always() + run: test ! -f aggregate-report/summary.md || cat aggregate-report/summary.md >> "$GITHUB_STEP_SUMMARY" + - name: Preserve aggregation failure evidence + if: always() + run: | + mkdir -p aggregate-report + if [ ! -f aggregate-report/result.json ]; then + echo 'No aggregate result; inspect the job log for the infrastructure failure.' > aggregate-report/runner-status.txt + fi + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-aggregate-${{ github.run_attempt }} + path: aggregate-report + if-no-files-found: warn + + publish: + name: Publish trusted acceptance summary + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + needs: [select, aggregate] + runs-on: depot-ubuntu-24.04 + timeout-minutes: 20 + concurrency: + group: acceptance-publisher-${{ github.event.pull_request.number }} + cancel-in-progress: false + permissions: + actions: read + contents: read + pull-requests: write + steps: + - name: Checkout trusted base revision + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 1 + persist-credentials: false + - name: Set up trusted Rust toolchain + if: hashFiles('acceptance/src/publish.rs') != '' + uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + - name: Build trusted publisher + if: hashFiles('acceptance/src/publish.rs') != '' + run: cargo build --locked -p base-acceptance-cli + - name: Download acceptance data + if: hashFiles('acceptance/src/publish.rs') != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + continue-on-error: true + with: + pattern: acceptance-* + path: downloaded + - name: Publish validated acceptance result + if: hashFiles('acceptance/src/publish.rs') != '' + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + TESTED_SHA: ${{ github.sha }} + BOT_LOGIN: ${{ vars.BOT_LOGIN }} + STARTED_AT: ${{ needs.select.outputs.started_at }} + run: | + target/debug/base-acceptance publish --event "$GITHUB_EVENT_PATH" \ + --result "downloaded/acceptance-aggregate-${GITHUB_RUN_ATTEMPT}/result.json" \ + --expected "downloaded/acceptance-expected-${GITHUB_RUN_ATTEMPT}/manifest.json" \ + --run-id "$RUN_KEY" --attempt "$GITHUB_RUN_ATTEMPT" --started-at "$STARTED_AT" + - name: First-introduction notice + if: hashFiles('acceptance/src/publish.rs') == '' + run: echo 'Trusted publisher is not on the base revision yet; reports are artifact-only.' >> "$GITHUB_STEP_SUMMARY" diff --git a/Cargo.lock b/Cargo.lock index 4a0252036c..d2b4ff9db5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2930,6 +2930,31 @@ dependencies = [ "url", ] +[[package]] +name = "base-acceptance" +version = "0.0.0" +dependencies = [ + "chrono", + "clap", + "eyre", + "humantime", + "reqwest 0.13.4", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "base-acceptance-cli" +version = "0.0.0" +dependencies = [ + "base-acceptance", + "clap", + "tokio", +] + [[package]] name = "base-action-harness" version = "0.0.0" @@ -22697,6 +22722,7 @@ dependencies = [ "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index dd8fbbe895..b8c854b8e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ exclude = [".github/"] resolver = "2" exclude = ["actions/fixtures", "crates/proof/zk/programs"] members = [ + "acceptance", "actions/*", "bin/[a-o]*", "bin/[q-z]*", @@ -223,6 +224,7 @@ base-execution-eip8130-rpc-node = { path = "crates/execution/eip8130-rpc-node" } base-execution-eip8130 = { path = "crates/execution/eip8130", default-features = false } # Infra +base-acceptance = { path = "acceptance" } basectl-cli = { path = "crates/infra/basectl" } base-roxy = { path = "crates/infra/roxy" } base-sidecrush = { path = "crates/infra/sidecrush" } diff --git a/Justfile b/Justfile index 5f3083288d..4bfdbc2392 100644 --- a/Justfile +++ b/Justfile @@ -103,6 +103,10 @@ test: install-nextest build::contracts build::elfs check-snapshot-manifests: cargo test --locked -p base --test snapshot_defaults -- --ignored --nocapture +# Runs one real-devnet acceptance scenario (requires Docker and no existing devnet) +acceptance scenario="smoke": + cargo run --locked -p base-acceptance-cli -- run acceptance/scenarios/{{ scenario }}.toml --output target/acceptance/{{ scenario }}-$(date +%s) + # Runs tests only for crates affected by changes vs main (excludes system tests) test-affected base="main": install-nextest build::contracts build::elfs #!/usr/bin/env bash diff --git a/acceptance/Cargo.toml b/acceptance/Cargo.toml new file mode 100644 index 0000000000..5ad7dba31b --- /dev/null +++ b/acceptance/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "base-acceptance" +description = "Configurable real-devnet acceptance scenarios and offline reports" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +eyre.workspace = true +chrono.workspace = true +humantime.workspace = true +clap = { workspace = true, features = ["derive"] } +reqwest = { workspace = true, features = ["json"] } +serde_json = { workspace = true, features = ["std"] } +serde = { workspace = true, features = ["derive", "std"] } +toml = { workspace = true, features = ["parse", "serde", "display"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time", "signal", "process", "io-util", "net", "sync"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/acceptance/README.md b/acceptance/README.md new file mode 100644 index 0000000000..122593a478 --- /dev/null +++ b/acceptance/README.md @@ -0,0 +1,237 @@ +# Acceptance testing + +The acceptance runner provisions the repository's real Docker Compose devnet and +checks its externally visible RPC behavior. Scenarios are strict, versioned TOML; +each run produces a portable JSON result and an offline visual report. + +The checked-in scenarios are: + +- `smoke`: chain identity, block production, and validator convergence. +- `derivation`: unsafe production, safe-head derivation, and head freshness. +- `denim-transition`: progress and convergence immediately before and after Denim. + +## Requirements + +- Linux, Git, Rust/Cargo, Docker with Compose 2.24.4+ and Buildx, and enough resources to + build and run the canonical devnet. +- Acceptance publishes only execution RPCs on dynamically allocated localhost ports. +- No canonical devnet containers may already exist. + +Run commands from the repository root. `cargo run` compiles the CLI as needed: + +```console +cargo run -p base-acceptance-cli -- validate acceptance/scenarios/smoke.toml +cargo run -p base-acceptance-cli -- plan acceptance/scenarios/smoke.toml +cargo run -p base-acceptance-cli -- run acceptance/scenarios/smoke.toml \ + --output target/acceptance/smoke +``` + +The output directory must be new or empty. A managed run builds images, generates +fresh chain state, verifies genesis and the rollup schedule, waits for the required +RPCs to advance, executes checks, collects bounded logs, and removes owned Docker +resources and private chain state. + +## Scenario format: provision, then check + +A scenario has two phases in one TOML document. Top-level, `devnet`, and +`readiness` fields describe what to provision and when it is usable; `[[checks]]` +tables describe read-only observations made after readiness. + +```toml +schema_version = 1 +id = "example" +description = "Production and agreement across Denim" +timeout = "10m" + +# Phase 1: provisioning and readiness. +[devnet] +topology = "single-sequencer" + +[devnet.l1] +chain_id = 1337 +slot_duration = "12s" + +[devnet.l2] +chain_id = 84538453 +verifier_l1_confirmations = 15 + +[devnet.l2.forks] +azul = { at_block = 20 } +beryl = { at_block = 21 } +cobalt = { at_block = 22 } +denim = { at_block = 150 } +zenith = { disabled = true } + +[readiness] +timeout = "4m" +request_timeout = "2s" +poll_interval = "1s" + +# Phase 2: ordered, bounded checks. +[[checks]] +id = "before-denim" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 3 +timeout = "30s" +start = { before_fork = "denim", chain = "l2" } + +[[checks]] +id = "after-denim-agreement" +kind = "heads_converge" +endpoints = ["builder", "validator"] +head = "latest" +max_lag_blocks = 5 +timeout = "90s" +start = { after_fork = "denim", chain = "l2" } +``` + +Unknown fields are rejected. Defaults are the single-sequencer topology, chain +IDs shown above, a 12-second L1 slot, 15 confirmations, and L2 forks Azul/Beryl/ +Cobalt/Denim at blocks 20/21/22/25 with Zenith disabled. Supported endpoint roles +are `l1`, `builder`, `validator`, `rpc`, and `shadow`. Supported check kinds are: + +- `chain_id`: `endpoint`, `expected`, and `timeout`. +- `head_progress` and `safe_head_progress`: `endpoint`, `minimum_blocks`, and `timeout`. +- `heads_converge`: at least two `endpoints`, optional `head` (`latest`, `safe`, or + `finalized`), `max_lag_blocks`, and `timeout`. +- `head_fresh`: `endpoint`, `maximum_age` (whole seconds), `duration`, and `timeout`; `duration` must be + shorter than `timeout`. + +Every check may have one L2 `start` condition with exactly one of `before_fork` or +`after_fork`. Checks run in file order. A `before_fork` check cannot target a fork active at genesis or +follow an `after_fork` check whose fork activates at or after its own. Builder RPC failures while +waiting for a fork are retried until the scenario deadline; a reachable builder that never reaches +an `after_fork` activation fails that check. `heads_converge` never accepts a common height of zero, +so `safe` and `finalized` comparisons wait for derivation to progress. See `scenarios/` for complete examples. + +## CLI workflows + +Validation and planning do not contact Docker or RPCs. `plan` prints the resolved +configuration, including defaults, as JSON: + +```console +cargo run -p base-acceptance-cli -- validate acceptance/scenarios/*.toml +cargo run -p base-acceptance-cli -- plan acceptance/scenarios/denim-transition.toml +``` + +Use `--no-build` only when the required images have already been built and loaded: + +```console +cargo run -p base-acceptance-cli -- run acceptance/scenarios/smoke.toml \ + --output target/acceptance/smoke-cached --no-build \ + --run-id local-smoke --tested-sha "$(git rev-parse HEAD)" +``` + +Attach mode performs no provisioning, log collection, or cleanup. The endpoint +file is a JSON object mapping logical roles to credential-free HTTP(S) URLs. Add +`--rollup` when a scenario has a fork-relative check (or when schedule reporting +is desired); its chain IDs and fork timestamps are verified before use. + +```json +{"l1":"http://127.0.0.1:4545","builder":"http://127.0.0.1:7545","validator":"http://127.0.0.1:8545"} +``` + +```console +cargo run -p base-acceptance-cli -- check acceptance/scenarios/smoke.toml \ + --endpoints endpoints.json --output target/acceptance/attached +``` + +Re-render a portable result and its referenced evidence into another directory: + +```console +cargo run -p base-acceptance-cli -- report \ + target/acceptance/smoke/report/result.json --output target/acceptance/rendered +``` + +Sharded CI first records exactly which scenarios and checks are expected, then +strictly aggregates one matching result for each scenario: + +```console +cargo run -p base-acceptance-cli -- manifest acceptance/scenarios/*.toml \ + --run-id run-1 --tested-sha "$GIT_SHA" --output expected.json +cargo run -p base-acceptance-cli -- aggregate --expected expected.json \ + --results downloaded-results --output target/acceptance/aggregate +``` + +The CI-only `publish` command validates aggregate artifacts and safely updates the +marker-owned PR comment. Its `GitHub` credentials and provenance inputs are supplied +by the trusted-base publication job; it is not part of the local runner workflow. +It accepts `--event`, `--result`, `--expected`, `--run-id`, `--attempt`, and +`--started-at`, with `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, `TESTED_SHA`, and optional +`BOT_LOGIN` in the environment. + +If a managed process is killed before cleanup, recover only the resources named +by its private ownership manifest: + +```console +cargo run -p base-acceptance-cli -- cleanup \ + --manifest target/acceptance/smoke/private/ownership.json +``` + +Cleanup requires the local `devnet-setup:local-v2` image to remove root-owned chain +state. If that image was removed after the run, rebuild it from the same checkout +before retrying cleanup: + +```console +docker build --tag devnet-setup:local-v2 -f etc/docker/Dockerfile.devnet . +``` + +Recovery must run on the original machine and Docker daemon with the recorded +repository and output paths intact; the manifest is not a portable backup. + +## Results and status + +A run writes these user-facing files below `/report/`: + +- `report.html`: self-contained offline visual report; open it in a browser. +- `result.json`: versioned portable source of truth. +- `summary.md`: concise CI/PR summary. +- `evidence/heads.json` and, for managed runs, `evidence/compose.log`. +- `environment.json` for managed runs, containing inspected image IDs and whether + this invocation built them. + +Check and lifecycle statuses are `passed`, `failed`, `error`, `blocked`, and +`cancelled`. Exit code 0 means all checks passed, 1 means an assertion failed, 2 +means invalid invocation/configuration, and 3 means infrastructure prevented a +complete evaluation. Infrastructure takes precedence when outcomes are mixed. + +The advisory Depot workflow validates scenarios, runs one selected shard, uploads +its report bundle, and strictly aggregates expected results. Reports are artifacts; +PR publication is available only when the trusted base revision contains the +publisher. Same-repository PRs run smoke automatically; manual dispatch selects +any checked-in scenario. The publisher updates only its marker-owned +`depot-code-access[bot]` comment (override with the `BOT_LOGIN` repository variable +for a different bot). Reports remain artifact-only on the first introduction PR. + +The publisher treats artifacts as data and validates their identity, schema, and +expected check sets. Both the results and expected manifest come from PR-controlled +code: a passing advisory comment is not an independent attestation that tests ran. +Trusted rendering protects the publisher's credentials, not the truth of those +observations; review the scenario and runner changes alongside the results. + +## Testing the runner and report + +```console +cargo test --locked -p base-acceptance -p base-acceptance-cli +cargo run -p base-acceptance-cli -- report \ + acceptance/fixtures/reports/synthetic-mixed.json --output target/acceptance/preview +``` + +The synthetic fixture deliberately contains a failure and a startup error, so +the final command returns exit code 3 while still writing the visual report. +It is a rendering example, not evidence of a real devnet run. + +## Limitations and safety + +- Only the fixed `single-sequencer` topology is supported. Fixed container names + and its subnet, plus a host lock, mean acceptance runs must be serial and cannot + share a machine with the canonical developer devnet. +- Configurable L1 forks, including Glamsterdam, are unsupported: the pinned + devnet generator does not expose a compatible Glamsterdam schedule. +- `--no-build` inspects and reports cached local image IDs. It does **not** prove + that those images correspond to the current source revision. +- Attach mode validates RPC identity and behavior but does not own the target or + apply the provisioning configuration. A supplied rollup verifies its schedule. +- Results are bounded observations, not a proof of protocol correctness. A fork + crossing records the first observed active block, not proof of fork behavior. diff --git a/acceptance/fixtures/reports/evidence/healthy-chain.json b/acceptance/fixtures/reports/evidence/healthy-chain.json new file mode 100644 index 0000000000..fd2b2359ec --- /dev/null +++ b/acceptance/fixtures/reports/evidence/healthy-chain.json @@ -0,0 +1 @@ +{"synthetic":true,"chain_id":84538453,"note":"Illustrative fixture, not a real RPC observation."} diff --git a/acceptance/fixtures/reports/evidence/lag-heads.json b/acceptance/fixtures/reports/evidence/lag-heads.json new file mode 100644 index 0000000000..417ac80d92 --- /dev/null +++ b/acceptance/fixtures/reports/evidence/lag-heads.json @@ -0,0 +1 @@ +{"synthetic":true,"at_elapsed_ms":50000,"builder":2025,"validator":2011,"lag_blocks":14,"maximum_lag_blocks":5} diff --git a/acceptance/fixtures/reports/synthetic-mixed.json b/acceptance/fixtures/reports/synthetic-mixed.json new file mode 100644 index 0000000000..5ade930648 --- /dev/null +++ b/acceptance/fixtures/reports/synthetic-mixed.json @@ -0,0 +1,80 @@ +{ + "schema_version": 1, + "run_id": "synthetic-report-preview-NOT-A-REAL-RUN", + "tested_sha": "synthetic-fixture", + "started_at_unix_ms": 1789785600000, + "scenarios": [ + { + "id": "healthy-synthetic", + "status": "passed", + "duration_ms": 42000, + "config": {"fixture": true, "topology": "single-sequencer", "l2_chain_id": 84538453, "generator_revision": "synthetic"}, + "stages": [ + {"id": "setup", "status": "passed", "duration_ms": 12000, "message": "Synthetic setup completed"}, + {"id": "readiness", "status": "passed", "duration_ms": 7000, "message": "Synthetic endpoints ready"}, + {"id": "checks", "status": "passed", "duration_ms": 21000, "message": "All synthetic checks passed"}, + {"id": "cleanup", "status": "passed", "duration_ms": 2000, "message": "Synthetic cleanup completed"} + ], + "checks": [{"id":"chain-identity","kind":"chain_id","status":"passed","duration_ms":900,"expected":{"chain_id":84538453},"observed":{"chain_id":84538453},"message":"Observed the configured synthetic chain identity.","next_step":"No action required.","samples":1,"rpc_errors":0,"evidence":["evidence/healthy-chain.json"]}], + "samples": [ + {"endpoint":"builder","elapsed_ms":0,"number":1000,"timestamp":1789785600,"hash":"0x1000"}, + {"endpoint":"builder","elapsed_ms":10000,"number":1005,"timestamp":1789785610,"hash":"0x1005"}, + {"endpoint":"validator","elapsed_ms":0,"number":999,"timestamp":1789785598,"hash":"0x0999"}, + {"endpoint":"validator","elapsed_ms":10000,"number":1005,"timestamp":1789785610,"hash":"0x1005"} + ], + "forks": [], + "diagnostics": ["Synthetic fixture data; not evidence of a real devnet run."], + "reproduction": "SYNTHETIC ONLY: base-acceptance run acceptance/scenarios/smoke.toml" + }, + { + "id": "lag-failure-synthetic", + "status": "passed", + "duration_ms": 76000, + "config": {"fixture": true, "max_lag_blocks": 5, "fork_schedule": {"denim": 1789785630}}, + "stages": [ + {"id":"setup","status":"passed","duration_ms":11000,"message":"Synthetic setup completed"}, + {"id":"readiness","status":"passed","duration_ms":5000,"message":"Synthetic endpoints ready"}, + {"id":"checks","status":"failed","duration_ms":58000,"message":"A synthetic assertion failed"}, + {"id":"cleanup","status":"passed","duration_ms":2000,"message":"Synthetic cleanup completed"} + ], + "checks": [{"id":"validator-catches-up","kind":"heads_converge","status":"failed","duration_ms":58000,"expected":{"max_lag_blocks":5,"common_height_hashes_equal":true},"observed":{"lag_blocks":14,"common_height_hashes_equal":true,"at_elapsed_ms":50000},"message":"Validator lag was 14 blocks at the deadline; the allowed maximum was 5.","next_step":"Inspect validator derivation and engine logs for the recorded observation window.","samples":9,"rpc_errors":1,"evidence":["evidence/lag-heads.json"]}], + "samples": [ + {"endpoint":"builder","elapsed_ms":0,"number":2000,"timestamp":1789785620,"hash":"0x2000"}, + {"endpoint":"builder","elapsed_ms":10000,"number":2005,"timestamp":1789785630,"hash":"0x2005"}, + {"endpoint":"builder","elapsed_ms":20000,"number":2010,"timestamp":1789785640,"hash":"0x2010"}, + {"endpoint":"builder","elapsed_ms":30000,"number":null,"timestamp":null,"hash":null}, + {"endpoint":"builder","elapsed_ms":40000,"number":2020,"timestamp":1789785660,"hash":"0x2020"}, + {"endpoint":"builder","elapsed_ms":50000,"number":2025,"timestamp":1789785670,"hash":"0x2025"}, + {"endpoint":"validator","elapsed_ms":0,"number":1998,"timestamp":1789785616,"hash":"0x1998"}, + {"endpoint":"validator","elapsed_ms":10000,"number":2001,"timestamp":1789785622,"hash":"0x2001"}, + {"endpoint":"validator","elapsed_ms":20000,"number":null,"timestamp":null,"hash":null}, + {"endpoint":"validator","elapsed_ms":30000,"number":2005,"timestamp":1789785630,"hash":"0x2005"}, + {"endpoint":"validator","elapsed_ms":40000,"number":2008,"timestamp":1789785636,"hash":"0x2008"}, + {"endpoint":"validator","elapsed_ms":50000,"number":2011,"timestamp":1789785642,"hash":"0x2011"} + ], + "forks": [{"name":"denim","chain":"l2","activation_timestamp":1789785630,"observed_block":2005,"observed_elapsed_ms":10000}], + "diagnostics": ["One explicit builder sampling gap and one validator sampling gap are retained.", "Observed boundary crossing is not proof of Denim behavior."], + "reproduction": "SYNTHETIC ONLY: base-acceptance run acceptance/scenarios/denim-transition.toml" + }, + { + "id": "startup-error-synthetic", + "status": "passed", + "duration_ms": 19000, + "config": {"fixture": true, "failure_injection": "synthetic startup timeout"}, + "stages": [ + {"id":"setup","status":"error","duration_ms":17000,"message":"Synthetic startup timed out before readiness"}, + {"id":"readiness","status":"blocked","duration_ms":0,"message":"Blocked by setup"}, + {"id":"checks","status":"blocked","duration_ms":0,"message":"Blocked by setup"}, + {"id":"cleanup","status":"passed","duration_ms":2000,"message":"Synthetic cleanup completed"} + ], + "checks": [ + {"id":"chain-identity","kind":"chain_id","status":"blocked","duration_ms":0,"expected":{"chain_id":84538453},"observed":{"reason":"setup prerequisite error"},"message":"Not evaluated because setup failed.","next_step":"Inspect the setup-stage diagnostic before retrying.","samples":0,"rpc_errors":0,"evidence":[]}, + {"id":"head-progress","kind":"head_progress","status":"blocked","duration_ms":0,"expected":{"minimum_blocks":3},"observed":{"reason":"setup prerequisite error"},"message":"Not evaluated because setup failed.","next_step":"Resolve startup, then rerun the complete scenario.","samples":0,"rpc_errors":0,"evidence":[]} + ], + "samples": [], + "forks": [], + "diagnostics": ["No chain observations were made; this is synthetic fixture data."], + "reproduction": "SYNTHETIC ONLY: base-acceptance run acceptance/scenarios/smoke.toml" + } + ] +} diff --git a/acceptance/report/style.css b/acceptance/report/style.css new file mode 100644 index 0000000000..ccd5689ab0 --- /dev/null +++ b/acceptance/report/style.css @@ -0,0 +1,6 @@ +:root{color-scheme:dark;--bg:#0b1020;--panel:#151c30;--line:#34415f;--text:#f3f6ff;--muted:#b8c1d9;--ok:#69d391;--bad:#ff8181;--err:#ffbd66;--block:#c4b5fd}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,sans-serif}main{max-width:1180px;margin:auto;padding:28px}.hero,.card,details{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;margin:0 0 16px}h1,h2,h3{line-height:1.2}.eyebrow,.muted{color:var(--muted)}.verdict{font-size:1.35rem;font-weight:800}.passed{color:var(--ok)}.failed{color:var(--bad)}.error{color:var(--err)}.blocked,.cancelled{color:var(--block)}.counters{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr));gap:10px}.counter{background:#0d1427;padding:12px;border-radius:8px}.counter strong{display:block;font-size:1.5rem}table{width:100%;border-collapse:collapse;margin:10px 0}th,td{text-align:left;border-bottom:1px solid var(--line);padding:9px;vertical-align:top}th{color:var(--muted)}summary{cursor:pointer;font-weight:700;padding:3px}summary:focus-visible,a:focus-visible{outline:3px solid #8ec5ff;outline-offset:3px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.kv{white-space:pre-wrap;overflow-wrap:anywhere;background:#0d1427;padding:10px;border-radius:6px}.chart{width:100%;height:auto;background:#0d1427;border-radius:8px}.chart text{fill:var(--muted);font:12px system-ui}.chart .line{fill:none;stroke-width:3}.chart .gap{stroke:#ffbd66;stroke-dasharray:4 4}.chart-table{font-size:.85rem}.pill{font-weight:700;text-transform:uppercase}.evidence{overflow-wrap:anywhere}.scenario{scroll-margin-top:1rem}@media(max-width:650px){main{padding:12px}.counters,.grid{grid-template-columns:1fr 1fr}.scroll{overflow-x:auto}th,td{padding:7px}}@media print{body{background:#fff;color:#111}.hero,.card,details{background:#fff;border-color:#777}details{display:block}details>*{display:block!important}.kv,.counter,.chart{background:#f3f3f3}.passed,.failed,.error,.blocked,.cancelled{color:#111}} +/* Reporter-specific warnings and lifecycle diagnostics. */ +.warning{color:var(--err)}.chart .gap-label{fill:var(--err)}.stage-message{color:var(--muted)} +a{color:#8ec5ff}a:visited{color:#c4b5fd}.hero p,summary{overflow-wrap:anywhere} +.chart text{paint-order:stroke;stroke:#0d1427;stroke-width:4px;stroke-linejoin:round}.card{overflow-x:auto} +@media(max-width:650px){.grid{grid-template-columns:1fr}.chart{min-width:600px}} diff --git a/acceptance/scenarios/denim-transition.toml b/acceptance/scenarios/denim-transition.toml new file mode 100644 index 0000000000..2bda92cbe8 --- /dev/null +++ b/acceptance/scenarios/denim-transition.toml @@ -0,0 +1,35 @@ +schema_version = 1 +id = "denim-transition" +description = "Production and validator agreement across Denim" +timeout = "10m" + +[devnet.l2.forks] +azul = { at_block = 20 } +beryl = { at_block = 21 } +cobalt = { at_block = 22 } +denim = { at_block = 150 } +zenith = { disabled = true } + +[[checks]] +id = "before-denim" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 3 +timeout = "30s" +start = { before_fork = "denim", chain = "l2" } + +[[checks]] +id = "after-denim" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 5 +timeout = "45s" +start = { after_fork = "denim", chain = "l2" } + +[[checks]] +id = "post-denim-convergence" +kind = "heads_converge" +endpoints = ["builder", "validator"] +max_lag_blocks = 5 +timeout = "90s" +start = { after_fork = "denim", chain = "l2" } diff --git a/acceptance/scenarios/derivation.toml b/acceptance/scenarios/derivation.toml new file mode 100644 index 0000000000..5ffb034c1c --- /dev/null +++ b/acceptance/scenarios/derivation.toml @@ -0,0 +1,26 @@ +schema_version = 1 +id = "derivation" +description = "Unsafe production is independently derived into safe heads" +timeout = "12m" + +[[checks]] +id = "unsafe-production" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 5 +timeout = "45s" + +[[checks]] +id = "safe-derivation" +kind = "safe_head_progress" +endpoint = "validator" +minimum_blocks = 2 +timeout = "5m" + +[[checks]] +id = "fresh-head" +kind = "head_fresh" +endpoint = "builder" +maximum_age = "15s" +duration = "20s" +timeout = "30s" diff --git a/acceptance/scenarios/smoke.toml b/acceptance/scenarios/smoke.toml new file mode 100644 index 0000000000..b82faafeda --- /dev/null +++ b/acceptance/scenarios/smoke.toml @@ -0,0 +1,25 @@ +schema_version = 1 +id = "smoke" +description = "Basic production and validator agreement" +timeout = "6m" + +[[checks]] +id = "chain-identity" +kind = "chain_id" +endpoint = "builder" +expected = 84538453 +timeout = "10s" + +[[checks]] +id = "production" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 3 +timeout = "30s" + +[[checks]] +id = "validator-converges" +kind = "heads_converge" +endpoints = ["builder", "validator"] +max_lag_blocks = 5 +timeout = "60s" diff --git a/acceptance/src/aggregate.rs b/acceptance/src/aggregate.rs new file mode 100644 index 0000000000..3dbdcd31c6 --- /dev/null +++ b/acceptance/src/aggregate.rs @@ -0,0 +1,629 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Component, Path, PathBuf}, +}; + +use eyre::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{CheckResult, Report, RunResult, ScenarioResult, StageResult, Status}; + +const MAX_BYTES: u64 = 20 * 1024 * 1024; +const MAX_RESULTS: usize = 200; +const MAX_DEPTH: usize = 5; + +/// Strict description of scenarios expected from a sharded run. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectedManifest { + /// Manifest schema version. + pub schema_version: u32, + /// Shared run identity, including the workflow attempt. + pub run_id: String, + /// Exact tested revision. + pub tested_sha: String, + /// Expected scenarios and checks. + pub scenarios: Vec, +} + +/// One expected scenario and its complete check set. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectedScenario { + /// Scenario identifier. + pub id: String, + /// Exact expected check identifiers. + pub checks: Vec, +} + +/// Strict result aggregation and bounded evidence relocation. +#[derive(Debug)] +pub struct Aggregate; + +impl Aggregate { + /// Reads one file only after enforcing the per-file 20 `MiB` limit. + pub fn bounded_read(path: &Path) -> Result> { + let metadata = + fs::symlink_metadata(path).wrap_err_with(|| format!("inspect {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("input is not a regular file: {}", path.display()); + } + if metadata.len() > MAX_BYTES { + bail!("input exceeds 20 MiB: {}", path.display()); + } + fs::read(path).wrap_err_with(|| format!("read {}", path.display())) + } + + /// Loads and strictly validates an expected-run manifest. + pub fn load_expected(path: &Path) -> Result { + let expected: ExpectedManifest = serde_json::from_slice(&Self::bounded_read(path)?) + .wrap_err("parse expected manifest")?; + if expected.schema_version != 1 + || expected.run_id.is_empty() + || expected.tested_sha.is_empty() + { + bail!("invalid expected manifest identity or schema"); + } + if expected.scenarios.is_empty() || expected.scenarios.len() > 100 { + bail!("expected manifest must contain 1..=100 scenarios"); + } + let mut scenario_ids = BTreeSet::new(); + for scenario in &expected.scenarios { + Self::validate_id("scenario", &scenario.id)?; + if !scenario_ids.insert(&scenario.id) { + bail!("duplicate expected scenario id {}", scenario.id); + } + if scenario.checks.is_empty() || scenario.checks.len() > 1_000 { + bail!("scenario {} has an invalid check count", scenario.id); + } + let mut checks = BTreeSet::new(); + for check in &scenario.checks { + Self::validate_id("check", check)?; + if !checks.insert(check) { + bail!("scenario {} has duplicate check {check}", scenario.id); + } + } + } + Ok(expected) + } + + /// Aggregates result bundles and writes result.json, report.html, summary.md and evidence directly. + /// Invalid or missing manifests still produce an aggregate-error report. + pub fn write(expected_path: &Path, results: &Path, output: &Path) -> Result { + fs::create_dir_all(output).wrap_err("create aggregate output")?; + let expected = match Self::load_expected(expected_path) { + Ok(expected) => expected, + Err(error) => { + let run = Self::fallback(format!("invalid or missing expected manifest: {error}")); + Report::write(&run, output)?; + return Ok(run); + } + }; + + let results_root = match results.canonicalize().wrap_err("resolve results directory") { + Ok(path) => path, + Err(error) => { + let run = Self::run_error(&expected, error.to_string()); + Report::write(&run, output)?; + return Ok(run); + } + }; + let output_absolute = if output.exists() { + output.canonicalize()? + } else { + std::env::current_dir()?.join(output) + }; + if output_absolute.starts_with(&results_root) { + let run = Self::run_error(&expected, "aggregate output is inside results input".into()); + Report::write(&run, output)?; + return Ok(run); + } + + let mut paths = Vec::new(); + let mut discovery_errors = Vec::new(); + if let Err(error) = Self::discover(&results_root, 0, &mut paths, &mut discovery_errors) { + let run = Self::run_error(&expected, format!("discover result artifacts: {error}")); + Report::write(&run, output)?; + return Ok(run); + } + let mut found: BTreeMap> = BTreeMap::new(); + let mut errors = discovery_errors; + for path in paths { + match Self::bounded_read(&path).and_then(|bytes| { + let run: RunResult = serde_json::from_slice(&bytes)?; + Report::validate(&run)?; + Ok(run) + }) { + Ok(run) + if run.run_id == expected.run_id && run.tested_sha == expected.tested_sha => + { + for scenario in run.scenarios { + found + .entry(scenario.id.clone()) + .or_default() + .push((path.clone(), scenario)); + } + } + Ok(_) => errors.push(format!("stale result identity: {}", path.display())), + Err(error) => errors.push(format!("invalid result {}: {error}", path.display())), + } + } + + let mut scenarios = Vec::new(); + let mut evidence_bytes = 0; + for wanted in &expected.scenarios { + let entries = found.remove(&wanted.id).unwrap_or_default(); + if entries.len() != 1 { + scenarios.push(Self::synthetic( + wanted, + format!("expected exactly one result, found {}", entries.len()), + )); + continue; + } + let (source, mut scenario) = entries.into_iter().next().expect("checked one entry"); + let actual: BTreeSet<_> = + scenario.checks.iter().map(|check| check.id.as_str()).collect(); + let required: BTreeSet<_> = wanted.checks.iter().map(String::as_str).collect(); + if actual != required || scenario.checks.len() != wanted.checks.len() { + scenarios.push(Self::synthetic( + wanted, + "result check set does not exactly match manifest".into(), + )); + continue; + } + scenario.checks.sort_by_key(|check| { + wanted.checks.iter().position(|id| id == &check.id).expect("validated check set") + }); + Self::copy_evidence(&source, output, &mut scenario, &mut evidence_bytes); + scenario.status = scenario.outcome(); + scenarios.push(scenario); + } + for id in found.keys() { + errors.push(format!("unexpected scenario artifact: {id}")); + } + if !errors.is_empty() { + let scenario = scenarios.first_mut().expect("validated manifest is nonempty"); + scenario.diagnostics.push(errors.join("; ")); + scenario.stages.retain(|stage| stage.id != "aggregate-integrity"); + scenario.stages.push(StageResult { + id: "aggregate-integrity".into(), + status: Status::Error, + duration_ms: 0, + message: "aggregate artifacts were invalid or unexpected; inspect diagnostics" + .into(), + }); + scenario.status = scenario.outcome(); + } + let run = RunResult { + schema_version: 1, + run_id: expected.run_id, + tested_sha: expected.tested_sha, + started_at_unix_ms: 0, + scenarios, + }; + Report::write(&run, output)?; + Ok(run) + } + + /// Discovers result files with hard depth and path-count limits, rejecting symlink artifacts. + pub fn discover( + directory: &Path, + depth: usize, + paths: &mut Vec, + errors: &mut Vec, + ) -> Result<()> { + if depth > MAX_DEPTH { + errors.push(format!("result traversal exceeded depth at {}", directory.display())); + return Ok(()); + } + for entry in fs::read_dir(directory).wrap_err("read results directory")? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_symlink() { + errors.push(format!("symlink artifact rejected: {}", entry.path().display())); + } else if ty.is_dir() { + Self::discover(&entry.path(), depth + 1, paths, errors)?; + } else if entry.file_name() == "result.json" { + if paths.len() == MAX_RESULTS { + bail!("too many result artifacts"); + } + paths.push(entry.path()); + } + } + Ok(()) + } + + /// Copies evidence only when its canonical source remains in the source bundle and total bytes are bounded. + /// Source and destination bundles must not be mutated concurrently; path checks do not isolate + /// aggregation from another process changing the filesystem during a copy. + pub fn copy_evidence( + source_result: &Path, + output: &Path, + scenario: &mut ScenarioResult, + copied_bytes: &mut u64, + ) { + let Some(bundle) = source_result.parent() else { return }; + let canonical_bundle = match bundle.canonicalize() { + Ok(path) => path, + Err(_) => return, + }; + for check in &mut scenario.checks { + for evidence in &mut check.evidence { + let original = evidence.clone(); + let source = bundle.join(&original); + let safe = Report::validate_evidence_path(&original) + .and_then(|_| source.canonicalize().map_err(Into::into)) + .and_then(|canonical| { + if canonical.starts_with(&canonical_bundle) { + Ok(canonical) + } else { + bail!("evidence escapes bundle") + } + }) + .and_then(|canonical| { + let metadata = fs::symlink_metadata(&source)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("evidence is not a regular file") + } + if *copied_bytes + metadata.len() > MAX_BYTES { + bail!("aggregate evidence exceeds 20 MiB") + } + Ok((canonical, metadata.len())) + }); + let relative = + PathBuf::from("evidence").join(&scenario.id).join(&check.id).join(&original); + let copied = safe.and_then(|(canonical, size)| { + let destination = output.join(&relative); + fs::create_dir_all(destination.parent().expect("evidence has parent"))?; + fs::copy(canonical, destination)?; + *copied_bytes += size; + Ok(()) + }); + if copied.is_ok() { + *evidence = relative.to_string_lossy().into_owned(); + } else { + scenario.diagnostics.push(format!("evidence unavailable: {original}")); + check.status = Status::Error; + evidence.clear(); + } + } + check.evidence.retain(|path| !path.is_empty()); + } + } + + /// Creates a complete nonpassing result when the manifest cannot be trusted. + pub fn fallback(message: String) -> RunResult { + RunResult { + schema_version: 1, + run_id: "aggregate-error".into(), + tested_sha: "unknown".into(), + started_at_unix_ms: 0, + scenarios: vec![Self::error_scenario("aggregate-error", message)], + } + } + + /// Creates a manifest-bound aggregate error result. + pub fn run_error(expected: &ExpectedManifest, message: String) -> RunResult { + RunResult { + schema_version: 1, + run_id: expected.run_id.clone(), + tested_sha: expected.tested_sha.clone(), + started_at_unix_ms: 0, + scenarios: expected + .scenarios + .iter() + .map(|scenario| Self::synthetic(scenario, message.clone())) + .collect(), + } + } + + /// Creates a blocked representation of one missing or invalid expected scenario. + pub fn synthetic(expected: &ExpectedScenario, message: String) -> ScenarioResult { + let checks = expected + .checks + .iter() + .map(|id| CheckResult { + id: id.clone(), + kind: "expected".into(), + status: Status::Blocked, + duration_ms: 0, + expected: json!({}), + observed: json!({}), + message: message.clone(), + next_step: "inspect shard result and rerun".into(), + samples: 0, + rpc_errors: 0, + evidence: Vec::new(), + }) + .collect(); + ScenarioResult { + id: expected.id.clone(), + status: Status::Error, + duration_ms: 0, + config: json!({}), + stages: vec![StageResult { + id: "aggregate".into(), + status: Status::Error, + duration_ms: 0, + message, + }], + checks, + samples: Vec::new(), + forks: Vec::new(), + diagnostics: Vec::new(), + reproduction: String::new(), + } + } + + /// Creates an infrastructure-error scenario describing aggregate integrity failures. + pub fn error_scenario(id: &str, message: String) -> ScenarioResult { + ScenarioResult { + id: id.into(), + status: Status::Error, + duration_ms: 0, + config: json!({}), + stages: vec![StageResult { + id: "aggregate".into(), + status: Status::Error, + duration_ms: 0, + message, + }], + checks: vec![CheckResult { + id: "artifact-integrity".into(), + kind: "aggregate".into(), + status: Status::Error, + duration_ms: 0, + expected: json!({}), + observed: json!({}), + message: "aggregate artifacts were invalid or unexpected".into(), + next_step: "inspect aggregate diagnostics".into(), + samples: 0, + rpc_errors: 0, + evidence: Vec::new(), + }], + samples: Vec::new(), + forks: Vec::new(), + diagnostics: Vec::new(), + reproduction: String::new(), + } + } + + /// Validates identifiers before they can become report paths or anchors. + pub fn validate_id(kind: &str, value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 100 + || value == "." + || value == ".." + || value.chars().any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + || Path::new(value).components().any(|c| !matches!(c, Component::Normal(_))) + { + bail!("unsafe {kind} id {value:?}"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::symlink; + + use serde_json::json; + use tempfile::TempDir; + + use super::*; + + fn expected() -> ExpectedManifest { + ExpectedManifest { + schema_version: 1, + run_id: "run-1".into(), + tested_sha: "abc123".into(), + scenarios: vec![ExpectedScenario { + id: "scenario-a".into(), + checks: vec!["check-a".into(), "check-b".into()], + }], + } + } + + fn scenario(checks: &[&str]) -> ScenarioResult { + ScenarioResult { + id: "scenario-a".into(), + status: Status::Passed, + duration_ms: 1, + config: json!({}), + stages: Vec::new(), + checks: checks + .iter() + .map(|id| CheckResult { + id: (*id).into(), + kind: "test".into(), + status: Status::Passed, + duration_ms: 1, + expected: json!(true), + observed: json!(true), + message: "passed".into(), + next_step: "none".into(), + samples: 1, + rpc_errors: 0, + evidence: Vec::new(), + }) + .collect(), + samples: Vec::new(), + forks: Vec::new(), + diagnostics: Vec::new(), + reproduction: String::new(), + } + } + + fn run(run_id: &str, checks: &[&str]) -> RunResult { + RunResult { + schema_version: 1, + run_id: run_id.into(), + tested_sha: "abc123".into(), + started_at_unix_ms: 1, + scenarios: vec![scenario(checks)], + } + } + + fn write_json(path: &Path, value: &impl Serialize) { + fs::create_dir_all(path.parent().expect("test path has parent")).unwrap(); + fs::write(path, serde_json::to_vec(value).unwrap()).unwrap(); + } + + fn aggregate( + temp: &TempDir, + expected: &ExpectedManifest, + results: &Path, + name: &str, + ) -> RunResult { + let manifest = temp.path().join(format!("{name}-manifest.json")); + let output = temp.path().join(format!("{name}-output")); + write_json(&manifest, expected); + let result = Aggregate::write(&manifest, results, &output).unwrap(); + assert!(output.join("result.json").is_file()); + assert!(output.join("report.html").is_file()); + assert!(output.join("summary.md").is_file()); + result + } + + #[test] + fn missing_stale_duplicate_malformed_and_wrong_checkset_never_pass() { + let temp = TempDir::new().unwrap(); + let manifest = expected(); + for case in ["missing", "stale", "duplicate", "malformed", "checkset"] { + let results = temp.path().join(case); + fs::create_dir_all(&results).unwrap(); + match case { + "stale" => write_json( + &results.join("one/result.json"), + &run("old-run", &["check-a", "check-b"]), + ), + "duplicate" => { + write_json( + &results.join("one/result.json"), + &run("run-1", &["check-a", "check-b"]), + ); + write_json( + &results.join("two/result.json"), + &run("run-1", &["check-a", "check-b"]), + ); + } + "malformed" => { + fs::create_dir_all(results.join("one")).unwrap(); + fs::write(results.join("one/result.json"), b"not json").unwrap(); + } + "checkset" => write_json( + &results.join("one/result.json"), + &run("run-1", &["check-a", "extra"]), + ), + _ => {} + } + let result = aggregate(&temp, &manifest, &results, case); + assert!(!result.passed(), "{case} artifacts must not pass"); + let expected_scenario = + result.scenarios.iter().find(|scenario| scenario.id == "scenario-a").unwrap(); + assert_eq!( + expected_scenario + .checks + .iter() + .map(|check| check.id.as_str()) + .collect::>(), + BTreeSet::from(["check-a", "check-b"]), + ); + } + } + + #[test] + fn invalid_manifest_and_result_discovery_failures_still_write_reports() { + let temp = TempDir::new().unwrap(); + let bad_manifest = temp.path().join("bad-manifest.json"); + let bad_output = temp.path().join("bad-output"); + fs::write(&bad_manifest, b"{}").unwrap(); + let bad = + Aggregate::write(&bad_manifest, &temp.path().join("absent"), &bad_output).unwrap(); + assert!(!bad.passed()); + assert!(bad_output.join("report.html").is_file()); + + let manifest = expected(); + let missing_results = temp.path().join("missing-results"); + let result = aggregate(&temp, &manifest, &missing_results, "discovery"); + assert!(!result.passed()); + let scenario = + result.scenarios.iter().find(|scenario| scenario.id == "scenario-a").unwrap(); + assert_eq!(scenario.checks.len(), 2, "infrastructure failures retain expected checks"); + } + + #[test] + fn evidence_is_relocated_without_collisions_and_missing_evidence_is_an_error() { + let temp = TempDir::new().unwrap(); + let results = temp.path().join("results"); + let bundle = results.join("one"); + fs::create_dir_all(bundle.join("evidence/logs/a")).unwrap(); + fs::create_dir_all(bundle.join("evidence/logs/b")).unwrap(); + fs::write(bundle.join("evidence/logs/a/out.txt"), "a").unwrap(); + fs::write(bundle.join("evidence/logs/b/out.txt"), "b").unwrap(); + let mut shard = run("run-1", &["check-a", "check-b"]); + shard.scenarios[0].checks[0].evidence = + vec!["evidence/logs/a/out.txt".into(), "evidence/logs/b/out.txt".into()]; + shard.scenarios[0].checks[1].evidence = vec!["evidence/logs/missing.txt".into()]; + write_json(&bundle.join("result.json"), &shard); + + let result = aggregate(&temp, &expected(), &results, "evidence"); + assert!(!result.passed()); + let checks = &result.scenarios[0].checks; + assert_eq!(checks[0].evidence.len(), 2); + assert_ne!(checks[0].evidence[0], checks[0].evidence[1]); + let output = temp.path().join("evidence-output"); + assert_eq!(fs::read_to_string(output.join(&checks[0].evidence[0])).unwrap(), "a"); + assert_eq!(fs::read_to_string(output.join(&checks[0].evidence[1])).unwrap(), "b"); + assert_eq!(checks[1].status, Status::Error); + assert!(checks[1].evidence.is_empty()); + } + + #[test] + fn bounded_read_accepts_the_limit_and_rejects_larger_files() { + let file = tempfile::NamedTempFile::new().unwrap(); + file.as_file().set_len(20 * 1024 * 1024).unwrap(); + assert_eq!(Aggregate::bounded_read(file.path()).unwrap().len(), 20 * 1024 * 1024); + file.as_file().set_len(20 * 1024 * 1024 + 1).unwrap(); + assert!(Aggregate::bounded_read(file.path()).unwrap_err().to_string().contains("20 MiB")); + } + + #[cfg(unix)] + #[test] + fn evidence_symlinks_and_escaping_parent_links_are_not_copied() { + let temp = TempDir::new().unwrap(); + let bundle = temp.path().join("bundle"); + let output = temp.path().join("output"); + let outside = temp.path().join("outside"); + fs::create_dir_all(bundle.join("evidence")).unwrap(); + fs::create_dir(&outside).unwrap(); + fs::write(bundle.join("evidence/regular.txt"), "allowed evidence").unwrap(); + fs::write(outside.join("secret.txt"), "must not be copied").unwrap(); + symlink("regular.txt", bundle.join("evidence/internal-link.txt")).unwrap(); + symlink(outside.join("secret.txt"), bundle.join("evidence/external-link.txt")).unwrap(); + symlink(&outside, bundle.join("evidence/parent-link")).unwrap(); + let mut scenario = scenario(&["check-a"]); + scenario.checks[0].evidence = vec![ + "evidence/regular.txt".into(), + "evidence/internal-link.txt".into(), + "evidence/external-link.txt".into(), + "evidence/parent-link/secret.txt".into(), + ]; + let mut copied = 0; + Aggregate::copy_evidence(&bundle.join("result.json"), &output, &mut scenario, &mut copied); + assert_eq!(scenario.checks[0].status, Status::Error); + assert_eq!(scenario.checks[0].evidence.len(), 1); + assert_eq!(scenario.diagnostics.len(), 3); + assert_eq!(copied, 16); + assert_eq!( + fs::read_to_string(output.join(&scenario.checks[0].evidence[0])).unwrap(), + "allowed evidence" + ); + let directory = output.join("evidence/scenario-a/check-a/evidence"); + assert_eq!(fs::read_dir(directory).unwrap().count(), 1); + } +} diff --git a/acceptance/src/check.rs b/acceptance/src/check.rs new file mode 100644 index 0000000000..a0ae8bb547 --- /dev/null +++ b/acceptance/src/check.rs @@ -0,0 +1,986 @@ +use std::{ + collections::BTreeMap, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use eyre::{Result, bail, eyre}; +use reqwest::{Client, redirect::Policy}; +use serde_json::{Value, json}; +use tokio::time::Instant; + +use crate::{AcceptanceCheck, CheckResult, HeadSample, Status}; + +const RESPONSE_LIMIT: usize = 1_048_576; +const SAMPLE_LIMIT: usize = 10_000; + +/// A validated block returned by an Ethereum JSON-RPC endpoint. +#[derive(Debug, Clone)] +pub struct ObservedBlock { + /// Block height reported by the endpoint. + pub number: u64, + /// Unix timestamp in the block header. + pub timestamp: u64, + /// Canonical 32-byte block hash. + pub hash: String, +} + +/// Mutable evidence and counters shared by one acceptance check. +/// +/// Samples use the scenario-wide `origin`; once the report limit is reached, +/// `samples_truncated` records that further observations were not retained. +#[derive(Debug)] +pub struct ObservationState<'a> { + /// Number of valid RPC observations. + pub count: u64, + /// Number of failed RPC observations, including recovered failures. + pub errors: u64, + /// Machine-readable expected value. + pub expected: Value, + /// Machine-readable latest observation and failure thresholds. + pub observed: Value, + /// Scenario-wide head sample collection. + pub samples: &'a mut Vec, + /// Scenario-wide monotonic sample origin. + pub origin: Instant, + /// Whether the sample collection reached its fixed report limit. + pub samples_truncated: bool, + /// Whether the terminal failure was caused by unavailable observations. + pub observation_unavailable: bool, +} + +impl<'a> ObservationState<'a> { + /// Creates state for a check and its scenario-wide sample stream. + pub fn new(expected: Value, samples: &'a mut Vec, origin: Instant) -> Self { + Self { + count: 0, + errors: 0, + expected, + observed: json!({}), + samples, + origin, + samples_truncated: false, + observation_unavailable: false, + } + } + + /// Records a valid block or an explicit sampling gap without storing URLs. + pub fn sample(&mut self, endpoint: &str, block: Option<&ObservedBlock>) { + if self.samples.len() >= SAMPLE_LIMIT { + self.samples_truncated = true; + return; + } + self.samples.push(HeadSample { + endpoint: endpoint.into(), + elapsed_ms: self.origin.elapsed().as_millis() as u64, + number: block.map(|value| value.number), + timestamp: block.map(|value| value.timestamp), + hash: block.map(|value| value.hash.clone()), + }); + } + + /// Records a successful block observation. + pub fn success(&mut self, endpoint: &str, block: &ObservedBlock) { + self.count += 1; + self.sample(endpoint, Some(block)); + } + + /// Records a failed observation and its sampling gap. + pub fn failure(&mut self, endpoint: &str) { + self.errors += 1; + self.sample(endpoint, None); + } +} + +/// Bounded JSON-RPC observation client used by the runner. +#[derive(Debug)] +pub struct RpcObserver { + client: Client, + poll: Duration, + request_timeout: Duration, +} + +impl RpcObserver { + /// Creates an observer with per-request and polling bounds. + pub fn new(request_timeout: Duration, poll: Duration) -> Result { + let client = + Client::builder().redirect(Policy::none()).pool_max_idle_per_host(2).build()?; + Ok(Self { client, poll, request_timeout }) + } + + /// Returns the endpoint chain identifier before `deadline`. + pub async fn chain_id(&self, url: &str, deadline: Instant) -> Result { + let value = self.rpc(url, "eth_chainId", json!([]), deadline).await?; + Self::hex(Some(&value)) + } + + /// Returns a validated block selected by tag or quantity before `deadline`. + pub async fn block(&self, url: &str, tag: &str, deadline: Instant) -> Result { + let value = self.rpc(url, "eth_getBlockByNumber", json!([tag, false]), deadline).await?; + if value.is_null() { + bail!("{tag} block is absent"); + } + let block = ObservedBlock { + number: Self::hex(value.get("number"))?, + timestamp: Self::hex(value.get("timestamp"))?, + hash: Self::hash(value.get("hash"))?, + }; + if tag.starts_with("0x") && block.number != Self::hex(Some(&Value::String(tag.into())))? { + bail!("RPC returned block {} for requested height {tag}", block.number); + } + Ok(block) + } + + /// Executes one check within both its own and the scenario deadline. + pub async fn run( + &self, + check: &AcceptanceCheck, + endpoints: &BTreeMap, + samples: &mut Vec, + origin: Instant, + scenario_deadline: Instant, + ) -> CheckResult { + let started = Instant::now(); + let deadline = (started + check.timeout()).min(scenario_deadline); + let mut state = ObservationState::new(json!({ "check": check.kind() }), samples, origin); + let evaluation = self.evaluate(check, endpoints, deadline, &mut state).await; + let (status, message) = match evaluation { + Ok(message) => (Status::Passed, message), + Err(error) + if state.observation_unavailable || (state.count == 0 && state.errors > 0) => + { + (Status::Error, error.to_string()) + } + Err(error) => (Status::Failed, error.to_string()), + }; + if state.samples_truncated { + state.observed["samples_truncated_at"] = json!(SAMPLE_LIMIT); + } + CheckResult { + id: check.id().into(), + kind: check.kind().into(), + status, + duration_ms: started.elapsed().as_millis() as u64, + expected: state.expected, + observed: state.observed, + message, + next_step: if status == Status::Passed { + "no action required" + } else { + "inspect bounded Compose logs and endpoint health" + } + .into(), + samples: state.count, + rpc_errors: state.errors, + evidence: Vec::new(), + } + } + + /// Dispatches a configured check to its observation algorithm. + pub async fn evaluate( + &self, + check: &AcceptanceCheck, + endpoints: &BTreeMap, + deadline: Instant, + state: &mut ObservationState<'_>, + ) -> Result { + match check { + AcceptanceCheck::ChainId { endpoint, expected, .. } => { + state.expected = json!(expected); + match self.chain_id(Self::endpoint(endpoints, endpoint)?, deadline).await { + Ok(actual) => { + state.count += 1; + state.observed = json!(actual); + if actual == *expected { + Ok("chain identity matched".into()) + } else { + bail!("chain id mismatch: expected {expected}, observed {actual}") + } + } + Err(error) => { + state.errors += 1; + Err(error) + } + } + } + AcceptanceCheck::HeadProgress { endpoint, minimum_blocks, .. } + | AcceptanceCheck::SafeHeadProgress { endpoint, minimum_blocks, .. } => { + state.expected = json!({ "minimum_blocks": minimum_blocks }); + let tag = if matches!(check, AcceptanceCheck::SafeHeadProgress { .. }) { + "safe" + } else { + "latest" + }; + self.progress( + Self::endpoint(endpoints, endpoint)?, + endpoint, + tag, + *minimum_blocks, + deadline, + state, + ) + .await + } + AcceptanceCheck::HeadsConverge { endpoints: roles, head, max_lag_blocks, .. } => { + state.expected = json!({ + "max_lag_blocks": max_lag_blocks, + "common_height_hash": true, + }); + self.converge(endpoints, roles, head, *max_lag_blocks, deadline, state).await + } + AcceptanceCheck::HeadFresh { endpoint, maximum_age, duration, .. } => { + state.expected = json!({ + "maximum_age_seconds": maximum_age.0.as_secs(), + "duration_ms": duration.0.as_millis(), + }); + self.fresh( + Self::endpoint(endpoints, endpoint)?, + endpoint, + maximum_age.0, + duration.0, + deadline, + state, + ) + .await + } + } + } + + /// Observes a head until it advances by the configured delta. + pub async fn progress( + &self, + url: &str, + role: &str, + tag: &str, + minimum: u64, + deadline: Instant, + state: &mut ObservationState<'_>, + ) -> Result { + let sample_role = if tag == "latest" { role.into() } else { format!("{role}:{tag}") }; + let first = loop { + match self.block(url, tag, deadline).await { + Ok(block) => { + state.success(&sample_role, &block); + break block; + } + Err(error) => { + state.failure(&sample_role); + if !self.wait(deadline).await { + state.observation_unavailable = true; + return Err(error); + } + } + } + }; + state.observed = json!({ + "from": first.number, + "last": first.number, + "required_delta": minimum, + }); + let mut observation_error = None; + loop { + if !self.wait(deadline).await { + if let Some(error) = observation_error { + state.observation_unavailable = true; + return Err(error); + } + let delta = state.observed["delta"].as_u64().unwrap_or_default(); + bail!("head advanced {delta} blocks; required {minimum}"); + } + match self.block(url, tag, deadline).await { + Ok(block) => { + observation_error = None; + state.success(&sample_role, &block); + let delta = block.number.saturating_sub(first.number); + state.observed = json!({ + "from": first.number, + "last": block.number, + "delta": delta, + "required_delta": minimum, + }); + if block.number < first.number { + bail!("head reorged below the initial observation"); + } + if delta >= minimum { + return Ok("head advanced".into()); + } + } + Err(error) => { + state.failure(&sample_role); + observation_error = Some(error); + } + } + } + } + + /// Observes endpoint heads and verifies lag and common-height canonicality. + /// + /// A common height of zero is not comparable: `safe` and `finalized` heads remain at genesis + /// until derivation progresses, so the check keeps polling until a non-genesis block matches. + pub async fn converge( + &self, + map: &BTreeMap, + roles: &[String], + tag: &str, + max_lag: u64, + deadline: Instant, + state: &mut ObservationState<'_>, + ) -> Result { + loop { + let mut heads = Vec::new(); + let mut observation_error = None; + for role in roles { + let sample_role = + if tag == "latest" { role.clone() } else { format!("{role}:{tag}") }; + match self.block(Self::endpoint(map, role)?, tag, deadline).await { + Ok(block) => { + state.success(&sample_role, &block); + heads.push((role, block)); + } + Err(error) => { + state.failure(&sample_role); + observation_error = Some(error); + } + } + } + if heads.len() == roles.len() { + let low = heads.iter().map(|(_, block)| block.number).min().unwrap_or_default(); + let high = heads.iter().map(|(_, block)| block.number).max().unwrap_or_default(); + state.observed = json!({ + "low": low, + "high": high, + "lag": high.saturating_sub(low), + "maximum_lag": max_lag, + }); + if low > 0 && high.saturating_sub(low) <= max_lag { + let mut common: Option = None; + let mut compared = Vec::new(); + for (role, sampled) in &heads { + match self + .block(Self::endpoint(map, role)?, &format!("0x{low:x}"), deadline) + .await + { + Ok(block) => { + // Historical hash verification is not a new head observation. + state.count += 1; + if sampled.number == low && sampled.hash != block.hash { + bail!( + "endpoint {role} reorganized sampled block {low} during comparison" + ); + } + compared.push(json!({ + "endpoint": role, + "hash": block.hash, + })); + if common.as_ref().is_some_and(|hash| hash != &block.hash) { + state.observed["common_height"] = json!(low); + state.observed["compared"] = json!(compared); + bail!("heads disagree at common height {low}"); + } + common = Some(block.hash); + } + Err(error) => { + state.errors += 1; + observation_error = Some(error); + } + } + } + if compared.len() == roles.len() { + state.observed["common_height"] = json!(low); + state.observed["hash"] = json!(common); + return Ok("heads converged on a common canonical block".into()); + } + } + } + if !self.wait(deadline).await { + if let Some(error) = observation_error { + state.observation_unavailable = true; + return Err(error); + } + bail!("heads did not converge before deadline"); + } + } + } + + /// Requires every sample to be available and fresh for the complete window. + /// Block age uses the host's Unix wall clock, while the observation window uses monotonic time. + /// Keep the host clock synchronized: clock skew or backward adjustments can cause a fresh + /// head to appear stale or in the future. + pub async fn fresh( + &self, + url: &str, + role: &str, + maximum_age: Duration, + duration: Duration, + deadline: Instant, + state: &mut ObservationState<'_>, + ) -> Result { + let start = Instant::now(); + let Some(until) = start.checked_add(duration) else { + bail!("freshness duration overflow"); + }; + if until > deadline { + bail!("freshness duration exceeds remaining check deadline"); + } + let mut oldest = 0; + loop { + match self.block(url, "latest", deadline).await { + Ok(block) => { + state.success(role, &block); + let age = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_secs() + .checked_sub(block.timestamp) + .ok_or_else(|| eyre!("head timestamp is in the future"))?; + oldest = oldest.max(age); + state.observed = json!({ + "oldest_age_seconds": oldest, + "last_block": block.number, + "maximum_age_seconds": maximum_age.as_secs(), + }); + if age > maximum_age.as_secs() { + bail!("head age {age}s exceeds freshness limit"); + } + } + Err(error) => { + state.failure(role); + state.observation_unavailable = true; + return Err(error.wrap_err("freshness sample unavailable")); + } + } + if Instant::now() >= until { + break; + } + tokio::time::sleep(self.poll.min(until.saturating_duration_since(Instant::now()))) + .await; + } + if state.count == 0 { + bail!("freshness window produced no samples"); + } + Ok("head remained fresh for the observation window".into()) + } + + /// Performs one strictly bounded and protocol-validated JSON-RPC request. + pub async fn rpc( + &self, + url: &str, + method: &str, + params: Value, + deadline: Instant, + ) -> Result { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| eyre!("observation deadline elapsed"))?; + let limit = self.request_timeout.min(remaining); + let operation = async { + let sent = self + .client + .post(url) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + })) + .send() + .await; + let mut response = sent.map_err(|error| Self::transport(&error))?; + if !response.status().is_success() { + bail!("RPC HTTP status {}", response.status()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = + response.chunk().await.map_err(|error| Self::transport(&error))? + { + if bytes.len().saturating_add(chunk.len()) > RESPONSE_LIMIT { + bail!("RPC response exceeds 1 MiB"); + } + bytes.extend_from_slice(&chunk); + } + let value: Value = serde_json::from_slice(&bytes) + .map_err(|_| eyre!("RPC response is not valid JSON"))?; + if value.get("jsonrpc") != Some(&json!("2.0")) { + bail!("RPC response has invalid protocol version"); + } + if value.get("id") != Some(&json!(1)) { + bail!("RPC response has mismatched id"); + } + let result = value.get("result"); + let error = value.get("error"); + match (result, error) { + (Some(result), None) => Ok(result.clone()), + (None, Some(error)) => bail!("RPC {method} returned an error: {error}"), + _ => bail!("RPC response must contain exactly one of result or error"), + } + }; + tokio::time::timeout(limit, operation) + .await + .map_err(|_| eyre!("RPC request deadline elapsed"))? + } + + /// Sleeps for one polling interval, returning false at the deadline. + pub async fn wait(&self, deadline: Instant) -> bool { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { return false }; + tokio::time::sleep(self.poll.min(remaining)).await; + Instant::now() < deadline + } + + /// Resolves a logical role without exposing its URL in errors. + pub fn endpoint<'a>(map: &'a BTreeMap, role: &str) -> Result<&'a str> { + map.get(role).map(String::as_str).ok_or_else(|| eyre!("endpoint {role} was not resolved")) + } + + /// Parses a canonical JSON-RPC hexadecimal quantity. + pub fn hex(value: Option<&Value>) -> Result { + let text = value.and_then(Value::as_str).ok_or_else(|| eyre!("hex quantity missing"))?; + let digits = + text.strip_prefix("0x").ok_or_else(|| eyre!("hex quantity lacks 0x prefix"))?; + if digits.is_empty() || (digits.len() > 1 && digits.starts_with('0')) { + bail!("non-canonical hex quantity"); + } + u64::from_str_radix(digits, 16).map_err(|_| eyre!("invalid hex quantity")) + } + + /// Validates and returns a lowercase-or-uppercase 32-byte hash. + pub fn hash(value: Option<&Value>) -> Result { + let hash = value.and_then(Value::as_str).ok_or_else(|| eyre!("block hash missing"))?; + if hash.len() != 66 + || !hash.starts_with("0x") + || !hash[2..].bytes().all(|byte| byte.is_ascii_hexdigit()) + { + bail!("block hash is not 0x-prefixed 32-byte hex"); + } + Ok(hash.into()) + } + + /// Converts a reqwest failure to a credential-free transport reason. + pub fn transport(error: &reqwest::Error) -> eyre::Report { + let reason = if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connection failure" + } else if error.is_body() { + "response body failure" + } else if error.is_request() { + "request failure" + } else { + "transport failure" + }; + eyre!("RPC {reason}") + } +} + +#[cfg(test)] +mod tests { + //! A hand-rolled HTTP fake is required because these tests need an ordered + //! cross-request response script, delayed bodies, and oversized wire data. + + use std::{ + collections::{BTreeMap, VecDeque}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + use serde_json::{Value, json}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Mutex, + time::Instant, + }; + + use super::{ObservationState, RpcObserver}; + use crate::{AcceptanceCheck, Span, Status}; + + // Socket scheduling is real time; ordinary assertions must not benchmark the CI host. + const RPC_BUDGET: Duration = Duration::from_secs(5); + + #[derive(Clone)] + struct Reply { + delay: Duration, + body: String, + } + + async fn server(replies: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let fallback = replies + .last() + .cloned() + .unwrap_or_else(|| Reply { delay: Duration::ZERO, body: rpc(Value::Null) }); + let replies = Arc::new(Mutex::new(VecDeque::from(replies))); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { break }; + let replies = Arc::clone(&replies); + let fallback = fallback.clone(); + tokio::spawn(async move { + let mut request = vec![0; 8192]; + let _ = socket.read(&mut request).await; + let mut reply = replies.lock().await.pop_front().unwrap_or(fallback); + tokio::time::sleep(reply.delay).await; + if let Ok(mut body) = serde_json::from_str::(&reply.body) + && let Some(offset) = body["result"]["timestamp"].as_i64() + { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + body["result"]["timestamp"] = + json!(format!("0x{:x}", now.saturating_add_signed(offset))); + reply.body = body.to_string(); + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + reply.body.len(), + reply.body + ); + let _ = socket.write_all(response.as_bytes()).await; + }); + } + }); + format!("http://{address}") + } + + fn rpc(result: Value) -> String { + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": result, + }) + .to_string() + } + + fn block(number: u64, hash: char) -> Value { + json!({ + "number": format!("0x{number:x}"), + "timestamp": 0, + "hash": format!("0x{}", hash.to_string().repeat(64)), + }) + } + + fn replies(values: &[Value]) -> Vec { + values + .iter() + .cloned() + .map(|value| Reply { delay: Duration::ZERO, body: rpc(value) }) + .collect() + } + + fn observer() -> RpcObserver { + RpcObserver::new(RPC_BUDGET, Duration::from_millis(2)).unwrap() + } + + fn progress(timeout: Duration, minimum: u64) -> AcceptanceCheck { + AcceptanceCheck::HeadProgress { + id: "p".into(), + endpoint: "rpc".into(), + minimum_blocks: minimum, + timeout: Span(timeout), + start: None, + } + } + + #[tokio::test] + async fn progress_uses_delta_and_stalled_large_head_fails() { + let url = server(replies(&[block(1_000_000, 'a'), block(1_000_001, 'b')])).await; + let map = BTreeMap::from([("rpc".into(), url)]); + let mut samples = Vec::new(); + let result = RpcObserver::new(RPC_BUDGET, Duration::from_millis(200)) + .unwrap() + .run( + &progress(Duration::from_secs(1), 2), + &map, + &mut samples, + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await; + assert_eq!(result.status, Status::Failed); + assert_eq!(result.observed["from"], 1_000_000); + assert!(result.message.contains("head advanced 1 blocks")); + + let url = server(replies(&[block(9, 'a'), block(10, 'b'), block(11, 'c')])).await; + let mut samples = Vec::new(); + let result = observer() + .run( + &progress(RPC_BUDGET, 2), + &BTreeMap::from([("rpc".into(), url)]), + &mut samples, + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await; + assert_eq!(result.status, Status::Passed); + assert_eq!(result.observed["delta"], 2); + } + + #[tokio::test] + async fn progress_distinguishes_outage_from_recovered_violation() { + let result = run_progress(replies(&[block(10, 'a'), Value::Null])).await; + assert_eq!(result.status, Status::Error); + assert_eq!(result.samples, 1); + + // A single request consuming the remaining deadline is also infrastructure failure. + let mut delayed = replies(&[block(10, 'a'), block(11, 'b')]); + delayed[1].delay = RPC_BUDGET; + let result = run_progress(delayed).await; + assert_eq!(result.status, Status::Error); + assert_eq!(result.samples, 1); + assert_eq!(result.rpc_errors, 1); + + let result = run_progress(replies(&[block(10, 'a'), Value::Null, block(9, 'b')])).await; + assert_eq!(result.status, Status::Failed); + assert!(result.message.contains("reorged")); + assert!(result.rpc_errors > 0); + + let result = run_progress(replies(&[block(10, 'a'), Value::Null, block(11, 'b')])).await; + assert_eq!(result.status, Status::Passed); + assert!(result.rpc_errors > 0); + } + + async fn run_progress(script: Vec) -> crate::CheckResult { + let url = server(script).await; + let mut samples = Vec::new(); + observer() + .run( + &progress(Duration::from_secs(1), 1), + &BTreeMap::from([("rpc".into(), url)]), + &mut samples, + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await + } + + #[tokio::test] + async fn convergence_compares_common_height_and_accepts_lag() { + let left = server(replies(&[block(10, 'a'), block(10, 'a')])).await; + let right = server(replies(&[block(10, 'b'), block(10, 'b')])).await; + let roles = vec!["a".into(), "b".into()]; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + let result = observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "latest", + 0, + Instant::now() + RPC_BUDGET, + &mut state, + ) + .await; + assert!(result.unwrap_err().to_string().contains("disagree")); + + let left = server(replies(&[block(10, 'a'), block(10, 'a')])).await; + let right = server(replies(&[block(12, 'c'), block(10, 'a')])).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + assert!( + observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "latest", + 2, + Instant::now() + RPC_BUDGET, + &mut state + ) + .await + .is_ok() + ); + assert_eq!( + samples.iter().filter_map(|sample| sample.number).collect::>(), + vec![10, 12] + ); + } + + #[tokio::test] + async fn convergence_waits_past_genesis_for_safe_heads() { + let roles = vec!["a".into(), "b".into()]; + let left = server(replies(&[block(0, 'e'), block(5, 'a'), block(5, 'a')])).await; + let right = server(replies(&[block(0, 'e'), block(5, 'a'), block(5, 'a')])).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "safe", + 0, + Instant::now() + RPC_BUDGET, + &mut state, + ) + .await + .unwrap(); + assert_eq!(state.observed["common_height"], json!(5)); + + let left = server(replies(&[block(0, 'e')])).await; + let right = server(replies(&[block(0, 'e')])).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + let result = observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "safe", + 0, + Instant::now() + Duration::from_millis(100), + &mut state, + ) + .await; + assert!(result.is_err()); + assert!(state.observed.get("common_height").is_none()); + } + + #[tokio::test] + async fn partial_convergence_and_freshness_observations_remain_infrastructure_errors() { + // Exercise both unavailable heads and unavailable historical comparison blocks. + for script in [vec![Value::Null], vec![block(10, 'a'), Value::Null]] { + let left = server(replies(&script)).await; + let right = server(replies(&[block(10, 'a')])).await; + let check = AcceptanceCheck::HeadsConverge { + id: "converge".into(), + endpoints: vec!["a".into(), "b".into()], + head: "latest".into(), + max_lag_blocks: 0, + timeout: Span(Duration::from_secs(1)), + start: None, + }; + let result = RpcObserver::new(RPC_BUDGET, Duration::from_secs(2)) + .unwrap() + .run( + &check, + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &mut Vec::new(), + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await; + assert_eq!(result.status, Status::Error); + assert!(result.samples > 0); + assert_eq!(result.rpc_errors, 1); + } + + let url = server(replies(&[block(10, 'a'), Value::Null])).await; + let check = AcceptanceCheck::HeadFresh { + id: "fresh".into(), + endpoint: "rpc".into(), + maximum_age: Span(RPC_BUDGET), + duration: Span(Duration::from_secs(1)), + timeout: Span(RPC_BUDGET), + start: None, + }; + let result = observer() + .run( + &check, + &BTreeMap::from([("rpc".into(), url)]), + &mut Vec::new(), + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await; + assert_eq!(result.status, Status::Error); + assert_eq!(result.samples, 1); + assert_eq!(result.rpc_errors, 1); + } + + #[tokio::test] + async fn block_rejects_wrong_height_and_response_cap() { + let url = server(replies(&[block(8, 'a')])).await; + assert!( + observer() + .block(&url, "0x7", Instant::now() + RPC_BUDGET) + .await + .unwrap_err() + .to_string() + .contains("requested") + ); + let huge = Reply { delay: Duration::ZERO, body: "x".repeat(1_048_577) }; + let url = server(vec![huge]).await; + assert!( + observer() + .chain_id(&url, Instant::now() + RPC_BUDGET) + .await + .unwrap_err() + .to_string() + .contains("1 MiB") + ); + } + + #[tokio::test] + async fn freshness_requires_available_full_window() { + let good = Reply { delay: Duration::ZERO, body: rpc(block(1, 'a')) }; + let url = server(vec![good.clone(), good.clone(), good.clone(), good.clone(), good]).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + assert!( + observer() + .fresh( + &url, + "rpc", + Duration::from_secs(1), + Duration::from_millis(10), + Instant::now() + RPC_BUDGET, + &mut state + ) + .await + .is_ok() + ); + + let url = server(vec![]).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + assert!( + observer() + .fresh( + &url, + "rpc", + Duration::from_secs(1), + Duration::from_millis(10), + Instant::now() + RPC_BUDGET, + &mut state + ) + .await + .is_err() + ); + assert_eq!(state.errors, 1); + + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + assert!( + observer() + .fresh( + "http://127.0.0.1:1", + "rpc", + Duration::from_secs(1), + Duration::from_millis(20), + Instant::now() + Duration::from_millis(5), + &mut state + ) + .await + .unwrap_err() + .to_string() + .contains("exceeds") + ); + } + + #[tokio::test] + async fn late_response_cannot_pass_and_connection_error_is_counted() { + let url = + server(vec![Reply { delay: Duration::from_millis(30), body: rpc(json!(1)) }]).await; + assert!( + observer().chain_id(&url, Instant::now() + Duration::from_millis(5)).await.is_err() + ); + + let mut samples = Vec::new(); + let result = observer() + .run( + &progress(Duration::from_millis(10), 1), + &BTreeMap::from([("rpc".into(), "http://127.0.0.1:1".into())]), + &mut samples, + Instant::now(), + Instant::now() + Duration::from_millis(20), + ) + .await; + assert_eq!(result.status, Status::Error); + assert!(result.rpc_errors > 0); + assert!(samples.iter().all(|sample| sample.number.is_none())); + } +} diff --git a/acceptance/src/cli.rs b/acceptance/src/cli.rs new file mode 100644 index 0000000000..74d834cb2d --- /dev/null +++ b/acceptance/src/cli.rs @@ -0,0 +1,513 @@ +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use clap::{Parser, Subcommand}; +use eyre::{Context, Result, bail}; +use serde_json::json; + +use crate::{ + AcceptanceOptions, AcceptanceRunner, Aggregate, CheckResult, ExpectedManifest, + ExpectedScenario, Provisioner, PublishArgs, Report, RunResult, ScenarioConfig, ScenarioResult, + StageResult, Status, +}; + +/// Process exit classification used by CI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ExitCode { + /// All checks passed. + Passed = 0, + /// One or more assertions failed. + Assertion = 1, + /// Invocation, schema, or configuration was invalid. + Config = 2, + /// Infrastructure prevented evaluation. + Infrastructure = 3, +} + +/// Acceptance command-line parser and executor. +#[derive(Debug, Parser)] +#[command(name = "base-acceptance")] +pub struct AcceptanceCli { + /// Operation to perform. + #[command(subcommand)] + pub command: AcceptanceCommand, +} + +/// Supported acceptance operations. +#[derive(Debug, Subcommand)] +pub enum AcceptanceCommand { + /// Parse and validate scenario files. + Validate { + /// Scenario paths. + #[arg(required = true)] + paths: Vec, + }, + /// Emit a resolved execution plan as JSON. + Plan { + /// Scenario paths. + #[arg(required = true)] + paths: Vec, + }, + /// Build an expected-shards manifest. + Manifest { + /// Scenario paths. + #[arg(required = true)] + paths: Vec, + /// Run identity. + #[arg(long)] + run_id: String, + /// Tested revision. + #[arg(long)] + tested_sha: String, + /// Destination JSON file. + #[arg(long)] + output: PathBuf, + }, + /// Provision and execute a scenario. + Run { + /// Scenario path. + path: PathBuf, + /// Output root. + #[arg(long)] + output: PathBuf, + /// Reuse built images without claiming log reuse. + #[arg(long)] + no_build: bool, + /// Run identity. + #[arg(long)] + run_id: Option, + /// Tested revision. + #[arg(long)] + tested_sha: Option, + /// Repository root. + #[arg(long)] + repo_root: Option, + }, + /// Attach to endpoints and execute checks without mutation. + Check { + /// Scenario path. + path: PathBuf, + /// Logical endpoint JSON map. + #[arg(long)] + endpoints: PathBuf, + /// Verified rollup JSON. + #[arg(long)] + rollup: Option, + /// Output root. + #[arg(long)] + output: PathBuf, + /// Run identity. + #[arg(long)] + run_id: Option, + /// Tested revision. + #[arg(long)] + tested_sha: Option, + }, + /// Render an existing portable result. + Report { + /// Input result.json. + result: PathBuf, + /// Output report directory. + #[arg(long)] + output: PathBuf, + }, + /// Strictly aggregate sharded results. + Aggregate { + /// Expected manifest JSON. + #[arg(long)] + expected: PathBuf, + /// Directory containing result bundles. + #[arg(long)] + results: PathBuf, + /// Output root. + #[arg(long)] + output: PathBuf, + }, + /// Remove resources recorded by an owned acceptance manifest. + Cleanup { + /// Ownership manifest produced by a run. + #[arg(long)] + manifest: PathBuf, + }, + /// Publish a trusted, marker-owned `GitHub` PR summary from result artifacts. + Publish(PublishArgs), +} + +/// Inputs for one run or read-only check execution. +#[derive(Debug)] +pub struct CliRun { + /// Scenario configuration path. + pub path: PathBuf, + /// Fresh output root. + pub output: PathBuf, + /// Optional explicit run identity. + pub run_id: Option, + /// Optional explicit tested revision. + pub tested_sha: Option, + /// Repository root. + pub repo_root: PathBuf, + /// Endpoints for read-only attach mode. + pub endpoints: Option>, + /// Original endpoint-map path for an accurate read-only reproduction command. + pub endpoint_file: Option, + /// Verified rollup configuration for attach mode. + pub rollup: Option, + /// Whether provisioning may build images. + pub build: bool, +} + +impl AcceptanceCli { + /// Executes the parsed command and returns the stable process code. + pub async fn execute(self) -> Result { + match self.command { + AcceptanceCommand::Validate { paths } => { + CliRun::load_all(&paths)?; + Ok(ExitCode::Passed) + } + AcceptanceCommand::Plan { paths } => { + println!("{}", serde_json::to_string_pretty(&CliRun::load_all(&paths)?)?); + Ok(ExitCode::Passed) + } + AcceptanceCommand::Manifest { paths, run_id, tested_sha, output } => { + let scenarios = CliRun::load_all(&paths)? + .into_iter() + .map(|scenario| ExpectedScenario { + id: scenario.id, + checks: scenario.checks.iter().map(|check| check.id().into()).collect(), + }) + .collect(); + let manifest = + ExpectedManifest { schema_version: 1, run_id, tested_sha, scenarios }; + fs::write(output, format!("{}\n", serde_json::to_string_pretty(&manifest)?))?; + Ok(ExitCode::Passed) + } + AcceptanceCommand::Run { path, output, no_build, run_id, tested_sha, repo_root } => { + let root = CliRun::resolve_repo(repo_root.as_deref().or_else(|| path.parent()))?; + CliRun { + path, + output, + run_id, + tested_sha, + repo_root: root, + endpoints: None, + endpoint_file: None, + rollup: None, + build: !no_build, + } + .execute() + .await + } + AcceptanceCommand::Check { path, endpoints, rollup, output, run_id, tested_sha } => { + let endpoint_file = endpoints.clone(); + let bytes = fs::read(&endpoints).wrap_err("read endpoint map")?; + let endpoints: BTreeMap = + serde_json::from_slice(&bytes).wrap_err("parse endpoint map")?; + let root = CliRun::resolve_repo(path.parent())?; + CliRun { + path, + output, + run_id, + tested_sha, + repo_root: root, + endpoints: Some(endpoints), + endpoint_file: Some(endpoint_file), + rollup, + build: false, + } + .execute() + .await + } + AcceptanceCommand::Report { result, output } => { + let run: RunResult = serde_json::from_slice(&Aggregate::bounded_read(&result)?)?; + Report::validate(&run)?; + CliRun::copy_report_evidence(&result, &output, &run)?; + Report::write(&run, &output)?; + Ok(CliRun::verdict(&run)) + } + AcceptanceCommand::Aggregate { expected, results, output } => { + let run = Aggregate::write(&expected, &results, &output)?; + Ok(CliRun::verdict(&run)) + } + AcceptanceCommand::Cleanup { manifest } => { + Provisioner::recover(&manifest).await?; + Ok(ExitCode::Passed) + } + AcceptanceCommand::Publish(args) => { + args.execute().await?; + Ok(ExitCode::Passed) + } + } + } +} + +impl CliRun { + /// Executes one scenario while preserving its invocation identity and start time. + pub async fn execute(self) -> Result { + if self.output.exists() { + let entries = fs::read_dir(&self.output)?.collect::>>()?; + let only_empty_report = entries.len() == 1 + && entries[0].file_name() == "report" + && entries[0].file_type()?.is_dir() + && fs::read_dir(entries[0].path())?.next().is_none(); + if !entries.is_empty() && !only_empty_report { + bail!("output already exists and is not empty: {}", self.output.display()); + } + } + + let started_at = Self::now(); + let config = ScenarioConfig::load(&self.path)?; + if let Some(endpoints) = &self.endpoints { + AcceptanceRunner::validate_endpoints(&config, endpoints)?; + if self.rollup.is_none() && config.checks.iter().any(|check| check.start().is_some()) { + bail!("fork-window checks in attach mode require --rollup"); + } + } + + fs::create_dir_all(&self.output)?; + let identity = self.run_id.unwrap_or_else(|| format!("local-{started_at}")); + let sha = match self.tested_sha { + Some(sha) => sha, + None => Self::git_revision(&self.repo_root)?, + }; + let reproduction = if let Some(path) = &self.endpoint_file { + format!( + "base-acceptance check {} --endpoints {}{} --output \"$(mktemp -d)\"", + Self::shell_quote(&self.path), + Self::shell_quote(path), + self.rollup.as_ref().map_or_else(String::new, |path| format!( + " --rollup {}", + Self::shell_quote(path) + )) + ) + } else { + format!( + "base-acceptance run {} --output \"$(mktemp -d)\"{}", + Self::shell_quote(&self.path), + if self.build { "" } else { " --no-build" } + ) + }; + let options = AcceptanceOptions { + repo_root: self.repo_root, + output: self.output.clone(), + endpoints: self.endpoints, + rollup: self.rollup, + build: self.build, + }; + let mut scenario = match AcceptanceRunner::run(config.clone(), options).await { + Ok(result) => result, + Err(error) => Self::fatal(&config, error.to_string()), + }; + scenario.reproduction = reproduction; + let run = RunResult { + schema_version: 1, + run_id: identity, + tested_sha: sha, + started_at_unix_ms: started_at, + scenarios: vec![scenario], + }; + Report::write(&run, &self.output.join("report"))?; + Ok(Self::verdict(&run)) + } + + /// Loads every supplied scenario configuration. + pub fn load_all(paths: &[PathBuf]) -> Result> { + paths.iter().map(ScenarioConfig::load).collect() + } + + /// Creates a truthful blocked result after fatal setup failure. + pub fn fatal(config: &ScenarioConfig, message: String) -> ScenarioResult { + let checks = config + .checks + .iter() + .map(|check| CheckResult { + id: check.id().into(), + kind: check.kind().into(), + status: Status::Blocked, + duration_ms: 0, + expected: json!({}), + observed: json!({}), + message: "fatal setup error prevented evaluation".into(), + next_step: "inspect setup diagnostic".into(), + samples: 0, + rpc_errors: 0, + evidence: Vec::new(), + }) + .collect(); + ScenarioResult { + id: config.id.clone(), + status: Status::Error, + duration_ms: 0, + config: serde_json::to_value(config).unwrap_or_else(|_| json!({})), + stages: vec![StageResult { + id: "setup".into(), + status: Status::Error, + duration_ms: 0, + message, + }], + checks, + samples: Vec::new(), + forks: Vec::new(), + diagnostics: Vec::new(), + reproduction: String::new(), + } + } + + /// Classifies infrastructure ahead of assertions when both occur. + pub fn verdict(run: &RunResult) -> ExitCode { + let infrastructure = run.scenarios.iter().any(|scenario| { + matches!(scenario.outcome(), Status::Error | Status::Blocked | Status::Cancelled) + }); + if infrastructure { + ExitCode::Infrastructure + } else if run + .scenarios + .iter() + .flat_map(|scenario| &scenario.checks) + .any(|check| check.status == Status::Failed) + { + ExitCode::Assertion + } else if run.passed() { + ExitCode::Passed + } else { + ExitCode::Infrastructure + } + } + + /// Finds the containing repository root. + pub fn resolve_repo(start: Option<&Path>) -> Result { + let start = start + .unwrap_or_else(|| Path::new(".")) + .canonicalize() + .wrap_err("resolve repository search path")?; + for ancestor in start.ancestors() { + if ancestor.join("Cargo.toml").is_file() + && (ancestor.join("docker-compose.yml").is_file() || ancestor.join(".git").exists()) + { + return Ok(ancestor.into()); + } + } + bail!("could not locate repository root") + } + + /// Reads the current Git revision and dirty state. + pub fn git_revision(root: &Path) -> Result { + let output = Command::new("git").args(["rev-parse", "HEAD"]).current_dir(root).output()?; + if !output.status.success() { + bail!("git rev-parse failed"); + } + + let mut sha = String::from_utf8(output.stdout)?.trim().to_owned(); + let dirty = + Command::new("git").args(["status", "--porcelain"]).current_dir(root).output()?; + if !dirty.stdout.is_empty() { + sha.push_str("-dirty"); + } + Ok(sha) + } + + /// Quotes one path for inert reproduction text. + pub fn shell_quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', "'\\''")) + } + + /// Returns Unix time in milliseconds. + pub fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64 + } + + /// Copies referenced evidence when rendering an existing portable result. + pub fn copy_report_evidence(result: &Path, output: &Path, run: &RunResult) -> Result<()> { + let source = result.parent().unwrap_or_else(|| Path::new(".")); + for evidence in run + .scenarios + .iter() + .flat_map(|scenario| &scenario.checks) + .flat_map(|check| &check.evidence) + { + Report::validate_evidence_path(evidence)?; + let from = source.join(evidence); + let destination = output.join(evidence); + let canonical_source = source.canonicalize()?; + let canonical_file = from + .canonicalize() + .wrap_err_with(|| format!("referenced evidence unavailable: {evidence}"))?; + if !canonical_file.starts_with(canonical_source) + || fs::symlink_metadata(&from)?.file_type().is_symlink() + { + bail!("unsafe evidence source: {evidence}"); + } + if destination.exists() && destination.canonicalize()? == canonical_file { + continue; + } + fs::create_dir_all(destination.parent().expect("evidence has parent"))?; + fs::copy(from, destination)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scenario(stage: Status, checks: &[Status]) -> ScenarioResult { + let config = ScenarioConfig::load("scenarios/smoke.toml").unwrap(); + let mut result = CliRun::fatal(&config, String::new()); + result.stages[0].status = stage; + result.checks.truncate(checks.len()); + for (check, status) in result.checks.iter_mut().zip(checks) { + check.status = *status; + } + result + } + + fn run(scenarios: Vec) -> RunResult { + RunResult { + schema_version: 1, + run_id: "run".into(), + tested_sha: "sha".into(), + started_at_unix_ms: 0, + scenarios, + } + } + + #[test] + fn verdict_reports_infrastructure_before_assertions() { + let passed = || scenario(Status::Passed, &[Status::Passed, Status::Passed]); + let failed = || scenario(Status::Passed, &[Status::Passed, Status::Failed]); + let errored = || scenario(Status::Error, &[Status::Blocked]); + for (scenarios, expected) in [ + (vec![passed()], ExitCode::Passed), + (vec![passed(), failed()], ExitCode::Assertion), + ( + vec![scenario(Status::Passed, &[Status::Failed, Status::Blocked])], + ExitCode::Assertion, + ), + (vec![failed(), errored()], ExitCode::Infrastructure), + (vec![scenario(Status::Passed, &[Status::Error])], ExitCode::Infrastructure), + (vec![scenario(Status::Cancelled, &[Status::Passed])], ExitCode::Infrastructure), + (vec![scenario(Status::Passed, &[])], ExitCode::Infrastructure), + (Vec::new(), ExitCode::Infrastructure), + ] { + assert_eq!(CliRun::verdict(&run(scenarios)), expected); + } + } + + #[test] + fn shell_quote_round_trips_through_a_posix_shell() { + for path in ["/tmp/plain", "/tmp/it's here", "/tmp/$(touch x) `y` \"z\""] { + let output = Command::new("/bin/sh") + .arg("-c") + .arg(format!("printf %s {}", CliRun::shell_quote(Path::new(path)))) + .output() + .unwrap(); + assert_eq!(String::from_utf8(output.stdout).unwrap(), path); + } + } +} diff --git a/acceptance/src/config.rs b/acceptance/src/config.rs new file mode 100644 index 0000000000..f4eb328437 --- /dev/null +++ b/acceptance/src/config.rs @@ -0,0 +1,905 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, + time::Duration, +}; + +use eyre::{Context, Result, bail}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::ForkBoundary; + +const FORKS: [&str; 5] = ["azul", "beryl", "cobalt", "denim", "zenith"]; +const MAX_ROLLUP_JSON_BYTES: u64 = 1024 * 1024; +const MAX_DESCRIPTION_BYTES: usize = 1024; +const MAX_CHECKS: usize = 100; + +/// A human-readable, positive duration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span(pub Duration); + +impl Serialize for Span { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&humantime::format_duration(self.0).to_string()) + } +} + +impl<'de> Deserialize<'de> for Span { + fn deserialize>(d: D) -> Result { + let value = String::deserialize(d)?; + humantime::parse_duration(&value).map(Self).map_err(serde::de::Error::custom) + } +} + +/// Strict version-one scenario document. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScenarioConfig { + /// Configuration schema version (currently one). + pub schema_version: u32, + /// Stable scenario identifier. + pub id: String, + /// Human-readable purpose. + pub description: String, + /// Overall execution deadline. + #[serde(default = "ScenarioConfig::default_timeout")] + pub timeout: Span, + /// Devnet topology and chain settings. + #[serde(default)] + pub devnet: DevnetConfig, + /// RPC readiness settings. + #[serde(default)] + pub readiness: ReadinessConfig, + /// Assertions to execute. + pub checks: Vec, +} + +impl ScenarioConfig { + /// Returns the default overall scenario timeout. + pub const fn default_timeout() -> Span { + Span(Duration::from_secs(600)) + } + + /// Loads strict TOML and validates it without contacting Docker or the network. + pub fn load(path: impl AsRef) -> Result { + let text = fs::read_to_string(path.as_ref()) + .wrap_err_with(|| format!("read {}", path.as_ref().display()))?; + let config: Self = toml::from_str(&text).wrap_err("parse scenario TOML")?; + config.validate()?; + Ok(config) + } + + /// Validates schema, schedules, endpoints and all observation bounds. + pub fn validate(&self) -> Result<()> { + if self.schema_version != 1 { + bail!("unsupported schema_version {}; expected 1", self.schema_version); + } + Self::validate_id(&self.id, "scenario")?; + if self.description.trim().is_empty() { + bail!("description must not be empty"); + } + if self.description.len() > MAX_DESCRIPTION_BYTES { + bail!("description must not exceed {MAX_DESCRIPTION_BYTES} bytes"); + } + Self::validate_duration(self.timeout.0, "scenario timeout", 1, 3600)?; + if self.devnet.topology != "single-sequencer" { + bail!("unsupported topology {}; HA is not version-one", self.devnet.topology); + } + if self.devnet.l1.chain_id == 0 || self.devnet.l2.chain_id == 0 { + bail!("chain IDs must be non-zero"); + } + if self.devnet.l2.verifier_l1_confirmations > 10_000 { + bail!("verifier_l1_confirmations must not exceed 10000"); + } + Self::validate_duration(self.devnet.l1.slot_duration.0, "L1 slot duration", 1, 60)?; + if self.devnet.l1.slot_duration.0.subsec_nanos() != 0 { + bail!("L1 slot duration must use whole seconds"); + } + Self::validate_duration_against( + self.readiness.timeout.0, + "readiness timeout", + Duration::from_secs(1), + self.timeout.0, + )?; + Self::validate_duration(self.readiness.request_timeout.0, "request timeout", 1, 30)?; + Self::validate_duration(self.readiness.poll_interval.0, "poll interval", 1, 30)?; + if self.readiness.request_timeout.0 > self.readiness.timeout.0 { + bail!("request timeout exceeds readiness timeout"); + } + if self.checks.is_empty() { + bail!("at least one check is required"); + } + if self.checks.len() > MAX_CHECKS { + bail!("no more than {MAX_CHECKS} checks are allowed"); + } + let mut ids = BTreeSet::new(); + let mut latest_after_fork = None; + for check in &self.checks { + Self::validate_id(check.id(), "check")?; + if !ids.insert(check.id().to_ascii_lowercase()) { + bail!("duplicate check id {}", check.id()); + } + check.validate(self)?; + if let Some(start) = check.start() { + let fork = start.before_fork.as_ref().or(start.after_fork.as_ref()); + let block = fork + .and_then(|fork| self.devnet.l2.forks.get(fork)) + .and_then(ForkActivation::block); + if start.after_fork.is_some() { + latest_after_fork = latest_after_fork.max(block); + } else if let Some(block) = block { + if block == 0 { + bail!("check {}: before_fork cannot target a genesis fork", check.id()); + } + if latest_after_fork.is_some_and(|after| after >= block) { + bail!( + "check {}: before_fork window closes before an earlier after_fork check", + check.id() + ); + } + } + } + } + let mut previous = None; + let mut prerequisite_disabled = false; + for name in FORKS { + let activation = self + .devnet + .l2 + .forks + .get(name) + .ok_or_else(|| eyre::eyre!("missing L2 fork {name}"))?; + if let ForkActivation::Disabled { disabled: false } = activation { + bail!("fork {name}: disabled must be true"); + } + if let ForkActivation::AtBlock { at_block } = activation { + if prerequisite_disabled { + bail!("enabled fork {name} has a disabled prerequisite"); + } + if let Some(prior) = previous + && *at_block < prior + { + bail!("fork {name} activates before its prerequisite"); + } + if name == "zenith" { + let denim = self.devnet.l2.forks["denim"] + .block() + .ok_or_else(|| eyre::eyre!("Zenith requires Denim"))?; + if (at_block - denim) % 5 != 0 { + bail!("post-Denim fork offsets must be divisible by five"); + } + } + previous = Some(*at_block); + } else { + prerequisite_disabled = true; + } + } + for name in self.devnet.l2.forks.keys() { + if !FORKS.contains(&name.as_str()) { + bail!("unsupported L2 fork {name}"); + } + } + Ok(()) + } + + /// Resolves and verifies configured fork activation timestamps from generated rollup JSON. + pub fn verified_forks(&self, rollup_path: &Path) -> Result> { + self.validate()?; + let metadata = fs::metadata(rollup_path)?; + if metadata.len() > MAX_ROLLUP_JSON_BYTES { + bail!("rollup JSON exceeds {MAX_ROLLUP_JSON_BYTES} bytes"); + } + let value: serde_json::Value = serde_json::from_slice(&fs::read(rollup_path)?)?; + if value.get("l1_chain_id").and_then(serde_json::Value::as_u64) + != Some(self.devnet.l1.chain_id) + { + bail!("generated L1 chain ID does not match scenario"); + } + if value.get("l2_chain_id").and_then(serde_json::Value::as_u64) + != Some(self.devnet.l2.chain_id) + { + bail!("generated L2 chain ID does not match scenario"); + } + let genesis = value + .pointer("/genesis/l2_time") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| eyre::eyre!("rollup genesis.l2_time is missing"))?; + let generated = value + .get("base") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| eyre::eyre!("rollup base schedule is missing"))?; + let denim_block = self.devnet.l2.forks.get("denim").and_then(ForkActivation::block); + let mut boundaries = Vec::new(); + for name in FORKS { + let block = self.devnet.l2.forks.get(name).and_then(ForkActivation::block); + let expected = block + .map(|block| { + if let Some(denim) = denim_block.filter(|denim| block > *denim) { + genesis + .checked_add( + denim + .checked_mul(2) + .ok_or_else(|| eyre::eyre!("fork timestamp overflow"))?, + ) + .and_then(|time| time.checked_add((block - denim) / 5)) + .ok_or_else(|| eyre::eyre!("fork timestamp overflow")) + } else { + genesis + .checked_add( + block + .checked_mul(2) + .ok_or_else(|| eyre::eyre!("fork timestamp overflow"))?, + ) + .ok_or_else(|| eyre::eyre!("fork timestamp overflow")) + } + }) + .transpose()?; + let actual = generated.get(name).and_then(serde_json::Value::as_u64); + if actual != expected { + bail!("generated {name} timestamp {actual:?} does not match expected {expected:?}"); + } + if let Some(activation_timestamp) = actual { + boundaries.push(ForkBoundary { + name: name.into(), + chain: "l2".into(), + activation_timestamp, + observed_block: None, + observed_elapsed_ms: None, + }); + } + } + Ok(boundaries) + } + + /// Validates a stable scenario or check identifier. + pub fn validate_id(id: &str, what: &str) -> Result<()> { + if id.is_empty() + || id.len() > 63 + || !id.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') + { + bail!("invalid {what} id {id:?}"); + } + Ok(()) + } + + /// Validates a duration against inclusive whole-second bounds. + pub fn validate_duration(value: Duration, name: &str, min: u64, max: u64) -> Result<()> { + Self::validate_duration_against( + value, + name, + Duration::from_secs(min), + Duration::from_secs(max), + ) + } + + /// Validates a duration against inclusive duration bounds. + pub fn validate_duration_against( + value: Duration, + name: &str, + min: Duration, + max: Duration, + ) -> Result<()> { + if value < min || value > max { + bail!("{name} must be between {min:?} and {max:?}"); + } + Ok(()) + } +} + +/// Devnet topology and chain configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevnetConfig { + /// Topology name. + #[serde(default = "DevnetConfig::default_topology")] + pub topology: String, + /// Layer-one settings. + #[serde(default)] + pub l1: L1Config, + /// Layer-two settings. + #[serde(default)] + pub l2: L2Config, +} + +impl DevnetConfig { + /// Returns the default topology. + pub fn default_topology() -> String { + "single-sequencer".into() + } +} + +impl Default for DevnetConfig { + fn default() -> Self { + Self { + topology: Self::default_topology(), + l1: L1Config::default(), + l2: L2Config::default(), + } + } +} + +/// Layer-one settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct L1Config { + /// Chain identifier. + #[serde(default = "L1Config::default_chain_id")] + pub chain_id: u64, + /// Slot duration. + #[serde(default = "L1Config::default_slot_duration")] + pub slot_duration: Span, +} + +impl L1Config { + /// Returns the default chain identifier. + pub const fn default_chain_id() -> u64 { + 1337 + } + + /// Returns the default slot duration. + pub const fn default_slot_duration() -> Span { + Span(Duration::from_secs(12)) + } +} + +impl Default for L1Config { + fn default() -> Self { + Self { chain_id: Self::default_chain_id(), slot_duration: Self::default_slot_duration() } + } +} + +/// Layer-two settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct L2Config { + /// Chain identifier. + #[serde(default = "L2Config::default_chain_id")] + pub chain_id: u64, + /// Required layer-one confirmations. + #[serde(default = "L2Config::default_confirmations")] + pub verifier_l1_confirmations: u64, + /// Fork activation schedule. + #[serde(default = "L2Config::default_forks", deserialize_with = "L2Config::merge_forks")] + pub forks: BTreeMap, +} + +impl L2Config { + /// Returns the default chain identifier. + pub const fn default_chain_id() -> u64 { + 84_538_453 + } + + /// Returns the default confirmation count. + pub const fn default_confirmations() -> u64 { + 15 + } + /// Merges a partial fork schedule over defaults. + pub fn merge_forks<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let supplied = BTreeMap::::deserialize(deserializer)?; + let mut forks = Self::default_forks(); + forks.extend(supplied); + Ok(forks) + } + + /// Returns the default fork schedule. + pub fn default_forks() -> BTreeMap { + [("azul", 20), ("beryl", 21), ("cobalt", 22), ("denim", 25)] + .into_iter() + .map(|(name, at_block)| (name.into(), ForkActivation::AtBlock { at_block })) + .chain([(String::from("zenith"), ForkActivation::Disabled { disabled: true })]) + .collect() + } +} + +impl Default for L2Config { + fn default() -> Self { + Self { + chain_id: Self::default_chain_id(), + verifier_l1_confirmations: Self::default_confirmations(), + forks: Self::default_forks(), + } + } +} + +/// Fork activation setting. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +pub enum ForkActivation { + /// Block activation. + AtBlock { + /// Activation block. + at_block: u64, + }, + /// Disabled activation. + Disabled { + /// Whether the fork is disabled. + disabled: bool, + }, +} + +impl ForkActivation { + /// Returns the activation block when enabled. + pub const fn block(&self) -> Option { + match self { + Self::AtBlock { at_block } => Some(*at_block), + Self::Disabled { .. } => None, + } + } +} + +/// Endpoint readiness settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReadinessConfig { + /// Overall readiness timeout. + #[serde(default = "ReadinessConfig::default_timeout")] + pub timeout: Span, + /// Per-request timeout. + #[serde(default = "ReadinessConfig::default_request_timeout")] + pub request_timeout: Span, + /// Poll interval. + #[serde(default = "ReadinessConfig::default_poll_interval")] + pub poll_interval: Span, +} + +impl ReadinessConfig { + /// Returns the default readiness timeout. + pub const fn default_timeout() -> Span { + Span(Duration::from_secs(240)) + } + + /// Returns the default request timeout. + pub const fn default_request_timeout() -> Span { + Span(Duration::from_secs(2)) + } + + /// Returns the default polling interval. + pub const fn default_poll_interval() -> Span { + Span(Duration::from_secs(1)) + } +} + +impl Default for ReadinessConfig { + fn default() -> Self { + Self { + timeout: Self::default_timeout(), + request_timeout: Self::default_request_timeout(), + poll_interval: Self::default_poll_interval(), + } + } +} + +/// A fork-relative check start condition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CheckStart { + /// Chain containing the fork. + pub chain: String, + /// Fork immediately before which to start. + #[serde(default)] + pub before_fork: Option, + /// Fork immediately after which to start. + #[serde(default)] + pub after_fork: Option, +} + +/// An acceptance assertion. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum AcceptanceCheck { + /// Checks a chain identifier. + ChainId { + /// Check identifier. + id: String, + /// Endpoint name. + endpoint: String, + /// Expected identifier. + expected: u64, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, + /// Checks head progress. + HeadProgress { + /// Check identifier. + id: String, + /// Endpoint name. + endpoint: String, + /// Minimum progress. + minimum_blocks: u64, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, + /// Checks head convergence. + HeadsConverge { + /// Check identifier. + id: String, + /// Endpoint names. + endpoints: Vec, + /// Head tag. + #[serde(default = "AcceptanceCheck::default_head")] + head: String, + /// Maximum lag. + max_lag_blocks: u64, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, + /// Checks safe-head progress. + SafeHeadProgress { + /// Check identifier. + id: String, + /// Endpoint name. + endpoint: String, + /// Minimum progress. + minimum_blocks: u64, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, + /// Checks head freshness. + HeadFresh { + /// Check identifier. + id: String, + /// Endpoint name. + endpoint: String, + /// Maximum head age. + maximum_age: Span, + /// Observation duration. + duration: Span, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, +} + +impl AcceptanceCheck { + /// Returns the default head tag. + pub fn default_head() -> String { + "latest".into() + } + + /// Returns the check identifier. + pub fn id(&self) -> &str { + match self { + Self::ChainId { id, .. } + | Self::HeadProgress { id, .. } + | Self::HeadsConverge { id, .. } + | Self::SafeHeadProgress { id, .. } + | Self::HeadFresh { id, .. } => id, + } + } + + /// Returns the check kind. + pub const fn kind(&self) -> &'static str { + match self { + Self::ChainId { .. } => "chain_id", + Self::HeadProgress { .. } => "head_progress", + Self::HeadsConverge { .. } => "heads_converge", + Self::SafeHeadProgress { .. } => "safe_head_progress", + Self::HeadFresh { .. } => "head_fresh", + } + } + + /// Returns the check timeout. + pub const fn timeout(&self) -> Duration { + match self { + Self::ChainId { timeout, .. } + | Self::HeadProgress { timeout, .. } + | Self::HeadsConverge { timeout, .. } + | Self::SafeHeadProgress { timeout, .. } + | Self::HeadFresh { timeout, .. } => timeout.0, + } + } + + /// Returns the optional start condition. + pub const fn start(&self) -> Option<&CheckStart> { + match self { + Self::ChainId { start, .. } + | Self::HeadProgress { start, .. } + | Self::HeadsConverge { start, .. } + | Self::SafeHeadProgress { start, .. } + | Self::HeadFresh { start, .. } => start.as_ref(), + } + } + + /// Validates the check against its containing scenario. + pub fn validate(&self, scenario: &ScenarioConfig) -> Result<()> { + ScenarioConfig::validate_duration_against( + self.timeout(), + "check timeout", + Duration::from_secs(1), + scenario.timeout.0, + )?; + let endpoints = match self { + Self::ChainId { endpoint, .. } + | Self::HeadProgress { endpoint, .. } + | Self::SafeHeadProgress { endpoint, .. } + | Self::HeadFresh { endpoint, .. } => vec![endpoint.as_str()], + Self::HeadsConverge { endpoints, head, .. } => { + if endpoints.len() < 2 { + bail!("heads_converge requires two endpoints"); + } + if !matches!(head.as_str(), "latest" | "safe" | "finalized") { + bail!("unsupported head tag {head}"); + } + let unique: BTreeSet<_> = endpoints.iter().collect(); + if unique.len() != endpoints.len() { + bail!("heads_converge endpoints must be distinct"); + } + endpoints.iter().map(String::as_str).collect() + } + }; + for endpoint in endpoints { + if !matches!(endpoint, "l1" | "builder" | "validator" | "rpc" | "shadow") { + bail!("unknown endpoint {endpoint}"); + } + } + if let Some(start) = self.start() { + if (start.before_fork.is_some()) == (start.after_fork.is_some()) { + bail!("start requires exactly one of before_fork/after_fork"); + } + if start.chain != "l2" { + bail!("configurable L1 forks (including Glamsterdam) are unsupported"); + } + let fork = start.before_fork.as_ref().or(start.after_fork.as_ref()).unwrap(); + if scenario.devnet.l2.forks.get(fork).and_then(ForkActivation::block).is_none() { + bail!("check references absent or disabled fork {fork}"); + } + } + if let Self::HeadFresh { maximum_age, duration, timeout, .. } = self { + ScenarioConfig::validate_duration_against( + maximum_age.0, + "freshness maximum age", + Duration::from_secs(1), + scenario.timeout.0, + )?; + if maximum_age.0.subsec_nanos() != 0 { + bail!("freshness maximum age must use whole seconds"); + } + ScenarioConfig::validate_duration_against( + duration.0, + "freshness duration", + Duration::from_secs(1), + timeout.0, + )?; + if duration.0 >= timeout.0 { + bail!("freshness duration must be shorter than check timeout"); + } + } + match self { + Self::HeadProgress { minimum_blocks: 0, .. } + | Self::SafeHeadProgress { minimum_blocks: 0, .. } => { + bail!("minimum_blocks must be non-zero"); + } + _ => {} + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MINIMAL_SCENARIO: &str = r#" +schema_version = 1 +id = "smoke" +description = "x" + +[[checks]] +id = "identity" +kind = "chain_id" +endpoint = "builder" +expected = 84538453 +timeout = "5s" +"#; + + #[test] + fn rejects_unknown_and_empty() { + assert!( + toml::from_str::( + "schema_version=1\nid='x'\ndescription='x'\nchecks=[]\nextra=1" + ) + .is_err() + ); + } + + #[test] + fn validates_minimal() { + let config: ScenarioConfig = toml::from_str(MINIMAL_SCENARIO).unwrap(); + assert!(config.validate().is_ok()); + } + + #[test] + fn partial_forks_retain_devnet_defaults() { + let config: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[devnet.l2.forks]\ndenim = {{ at_block = 30 }}\n" + )) + .unwrap(); + assert_eq!(config.devnet.l2.forks["azul"].block(), Some(20)); + assert_eq!(config.devnet.l2.forks["denim"].block(), Some(30)); + } + + #[test] + fn rejects_unknown_fork_and_false_disabled() { + let unknown: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[devnet.l2.forks]\nwat = {{ at_block = 30 }}\n" + )) + .unwrap(); + assert!(unknown.validate().is_err()); + let false_disabled: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[devnet.l2.forks]\nzenith = {{ disabled = false }}\n" + )) + .unwrap(); + assert!(false_disabled.validate().is_err()); + } + + #[test] + fn zenith_requires_enabled_ordered_denim() { + let mut config = ScenarioConfig::load("scenarios/smoke.toml").unwrap(); + config.devnet.l2.forks.insert("zenith".into(), ForkActivation::AtBlock { at_block: 24 }); + assert_eq!( + config.validate().unwrap_err().to_string(), + "fork zenith activates before its prerequisite" + ); + + for at_block in [25, 30] { + config.devnet.l2.forks.insert("zenith".into(), ForkActivation::AtBlock { at_block }); + config.validate().unwrap(); + } + config.devnet.l2.forks.insert("zenith".into(), ForkActivation::AtBlock { at_block: 26 }); + assert_eq!( + config.validate().unwrap_err().to_string(), + "post-Denim fork offsets must be divisible by five" + ); + + config.devnet.l2.forks.insert("denim".into(), ForkActivation::Disabled { disabled: true }); + assert_eq!( + config.validate().unwrap_err().to_string(), + "enabled fork zenith has a disabled prerequisite" + ); + } + + #[test] + fn rejects_wrong_generated_schedule() { + let config: ScenarioConfig = toml::from_str(MINIMAL_SCENARIO).unwrap(); + let temp = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp.path(), + r#"{ + "l1_chain_id": 1337, + "l2_chain_id": 84538453, + "genesis": { "l2_time": 100 }, + "base": { "azul": 141, "beryl": 142, "cobalt": 144, "denim": 150 } + }"#, + ) + .unwrap(); + assert_eq!( + config.verified_forks(temp.path()).unwrap_err().to_string(), + "generated azul timestamp Some(141) does not match expected Some(140)" + ); + } + + #[test] + fn verifies_post_denim_slot_offset() { + let mut config: ScenarioConfig = toml::from_str(MINIMAL_SCENARIO).unwrap(); + config.devnet.l2.forks.insert("zenith".into(), ForkActivation::AtBlock { at_block: 30 }); + let temp = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp.path(), + r#"{ + "l1_chain_id": 1337, + "l2_chain_id": 84538453, + "genesis": { "l2_time": 100 }, + "base": { + "azul": 140, + "beryl": 142, + "cobalt": 144, + "denim": 150, + "zenith": 151 + } + }"#, + ) + .unwrap(); + let forks = config.verified_forks(temp.path()).unwrap(); + assert_eq!(forks.last().unwrap().activation_timestamp, 151); + } + + #[test] + fn genesis_activation_uses_genesis_timestamp() { + let mut config: ScenarioConfig = toml::from_str(MINIMAL_SCENARIO).unwrap(); + config.devnet.l2.forks.insert("azul".into(), ForkActivation::AtBlock { at_block: 0 }); + let temp = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp.path(), + r#"{ + "l1_chain_id": 1337, + "l2_chain_id": 84538453, + "genesis": { "l2_time": 100 }, + "base": { "azul": 100, "beryl": 142, "cobalt": 144, "denim": 150 } + }"#, + ) + .unwrap(); + let forks = config.verified_forks(temp.path()).unwrap(); + assert_eq!(forks[0].name, "azul"); + assert_eq!(forks[0].activation_timestamp, 100); + } + + #[test] + fn before_fork_windows_must_be_reachable() { + let check = |id: &str, window: &str| { + format!( + "\n[[checks]]\nid = \"{id}\"\nkind = \"chain_id\"\nendpoint = \"builder\"\nexpected = 84538453\ntimeout = \"5s\"\nstart = {{ {window} = \"denim\", chain = \"l2\" }}\n" + ) + }; + let genesis: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}{}\n[devnet.l2.forks]\nazul = {{ at_block = 0 }}\nberyl = {{ at_block = 0 }}\ncobalt = {{ at_block = 0 }}\ndenim = {{ at_block = 0 }}\n", + check("before", "before_fork") + )) + .unwrap(); + assert_eq!( + genesis.validate().unwrap_err().to_string(), + "check before: before_fork cannot target a genesis fork" + ); + + let reordered: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}{}{}", + check("after", "after_fork"), + check("before", "before_fork") + )) + .unwrap(); + assert_eq!( + reordered.validate().unwrap_err().to_string(), + "check before: before_fork window closes before an earlier after_fork check" + ); + + let ordered: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}{}{}", + check("before", "before_fork"), + check("after", "after_fork") + )) + .unwrap(); + ordered.validate().unwrap(); + } + + #[test] + fn freshness_maximum_age_uses_positive_whole_seconds() { + for (maximum_age, error) in [ + ("0s", "freshness maximum age must be between 1s and 600s"), + ("1500ms", "freshness maximum age must use whole seconds"), + ] { + let config: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[[checks]]\nid = \"fresh\"\nkind = \"head_fresh\"\nendpoint = \"builder\"\nmaximum_age = \"{maximum_age}\"\nduration = \"5s\"\ntimeout = \"10s\"\n" + )) + .unwrap(); + assert_eq!(config.validate().unwrap_err().to_string(), error); + } + } + + #[test] + fn freshness_duration_must_be_shorter_than_timeout() { + let config: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[[checks]]\nid = \"fresh\"\nkind = \"head_fresh\"\nendpoint = \"builder\"\nmaximum_age = \"10s\"\nduration = \"5s\"\ntimeout = \"5s\"\n" + )) + .unwrap(); + assert_eq!( + config.validate().unwrap_err().to_string(), + "freshness duration must be shorter than check timeout" + ); + } +} diff --git a/acceptance/src/lib.rs b/acceptance/src/lib.rs new file mode 100644 index 0000000000..668500a641 --- /dev/null +++ b/acceptance/src/lib.rs @@ -0,0 +1,33 @@ +#![doc = include_str!("../README.md")] + +mod aggregate; +pub use aggregate::{Aggregate, ExpectedManifest, ExpectedScenario}; + +mod check; +pub use check::{ObservationState, ObservedBlock, RpcObserver}; + +mod cli; +pub use cli::{AcceptanceCli, AcceptanceCommand, CliRun, ExitCode}; + +mod config; +pub use config::{ + AcceptanceCheck, CheckStart, DevnetConfig, ForkActivation, L1Config, L2Config, ReadinessConfig, + ScenarioConfig, Span, +}; + +mod provision; +pub use provision::{EndpointMap, Ownership, Provisioner}; + +mod publish; +pub use publish::{CommentMetadata, PrPublisher, PublishArgs}; + +mod report; +pub use report::{Report, ReportCounts}; + +mod result; +pub use result::{ + CheckResult, ForkBoundary, HeadSample, RunResult, ScenarioResult, StageResult, Status, +}; + +mod runner; +pub use runner::{AcceptanceOptions, AcceptanceRunner}; diff --git a/acceptance/src/provision.rs b/acceptance/src/provision.rs new file mode 100644 index 0000000000..8128d23259 --- /dev/null +++ b/acceptance/src/provision.rs @@ -0,0 +1,561 @@ +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + process::Stdio, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use eyre::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::{io::AsyncReadExt, process::Command}; + +use crate::ScenarioConfig; + +/// Logical node roles mapped to host-accessible execution RPC endpoints. +pub type EndpointMap = BTreeMap; + +/// Recovery metadata for resources created by this runner. +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Ownership { + /// Canonical repository root supplying Compose configuration. + pub repo: PathBuf, + /// Canonical run output directory. + pub output: PathBuf, + /// Unique Compose project name. + pub project: String, +} + +/// Exclusive Compose lifecycle preserving pre-existing developer resources. +#[derive(Debug)] +pub struct Provisioner { + repo: PathBuf, + output: PathBuf, + project: String, + owned: bool, + private_owned: bool, + lock_handle: Option, +} + +impl Provisioner { + /// Creates an inert lifecycle owner; construction never touches Docker or a lock. + pub fn new(repo: PathBuf, output: PathBuf, id: &str) -> Self { + let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(); + Self { + repo, + output, + project: format!("base-acceptance-{id}-{nonce}"), + owned: false, + private_owned: false, + lock_handle: None, + } + } + + /// Generates and verifies fresh chain state, then starts its consumers. + pub async fn setup(&mut self, config: &ScenarioConfig, build: bool) -> Result { + self.repo = self.repo.canonicalize()?; + self.output = self.output.canonicalize()?; + self.acquire_lock()?; + let private = self.output.join("private"); + fs::create_dir(&private).wrap_err("acceptance requires a fresh private state directory")?; + self.private_owned = true; + Self::restrict(&private, true)?; + fs::create_dir(private.join("devnet"))?; + self.write_environment(config)?; + self.write_ports().await?; + self.preflight().await?; + if build { + let mut command = Command::new("docker"); + command + .args(["buildx", "bake", "--load", "-f", "etc/docker/docker-bake.hcl", "devnet"]) + .env("PROFILE", "dev") + .env("BASE_SUCCINCT_ELF_REQUIRE", "0") + .current_dir(&self.repo); + Self::command(&mut command, Duration::from_secs(1800)).await?; + self.compose(&["build", "setup-devnet"], Duration::from_secs(1800)).await?; + } + self.compose(&["pull", "--ignore-buildable"], Duration::from_secs(300)).await?; + let model = self.model().await?; + let mut images = BTreeSet::new(); + for service in model["services"] + .as_object() + .ok_or_else(|| eyre::eyre!("Compose services missing"))? + .values() + { + if service.get("profiles").is_none() + && let Some(image) = service["image"].as_str() + { + images.insert(image.to_owned()); + } + } + let mut provenance = Vec::new(); + for image in images { + let inspected = Self::command( + Command::new("docker").args([ + "image", + "inspect", + "--format", + "{{json .Id}}", + &image, + ]), + Duration::from_secs(15), + ) + .await?; + provenance.push(json!({ + "image": image, + "id": serde_json::from_str::(&inspected)?, + })); + } + fs::create_dir_all(self.output.join("report"))?; + fs::write( + self.output.join("report/environment.json"), + serde_json::to_vec_pretty(&json!({ + "images": provenance, + "built_by_this_invocation": build, + "project": self.project, + }))?, + )?; + let owner = Ownership { + repo: self.repo.clone(), + output: self.output.clone(), + project: self.project.clone(), + }; + fs::write(private.join("ownership.json"), serde_json::to_vec_pretty(&owner)?)?; + self.owned = true; + self.compose( + &[ + "up", + "--no-build", + "--pull", + "never", + "--exit-code-from", + "setup-devnet", + "setup-devnet", + ], + config.readiness.timeout.0, + ) + .await?; + self.verify_artifacts(config)?; + self.compose( + &[ + "up", + "-d", + "--no-build", + "--pull", + "never", + "--wait", + "--wait-timeout", + &config.readiness.timeout.0.as_secs().to_string(), + ], + config.readiness.timeout.0 + Duration::from_secs(15), + ) + .await?; + self.endpoints().await + } + + /// Exposes only execution RPCs on daemon-allocated localhost ports. + pub async fn write_ports(&self) -> Result<()> { + let model = self.model().await?; + let mut overlay = String::from("services:\n"); + for name in model["services"] + .as_object() + .ok_or_else(|| eyre::eyre!("Compose services missing"))? + .keys() + { + let port = match name.as_str() { + "l1-el" => Some(4545), + "base-builder" => Some(7545), + "base-client" => Some(8545), + "base-rpc" => Some(8645), + "base-shadow-validator" => Some(8845), + _ => None, + }; + overlay.push_str(&format!(" {}:\n ports: !override", serde_json::to_string(name)?)); + if let Some(port) = port { + overlay.push_str(&format!("\n - \"127.0.0.1::{port}\"\n")); + } else { + overlay.push_str(" []\n"); + } + } + fs::write(self.output.join("private/ports.yml"), overlay)?; + Ok(()) + } + + /// Resolves the published host endpoint for each logical role after startup. + pub async fn endpoints(&self) -> Result { + let mut endpoints = BTreeMap::new(); + for (role, service, port) in [ + ("l1", "l1-el", "4545"), + ("builder", "base-builder", "7545"), + ("validator", "base-client", "8545"), + ("rpc", "base-rpc", "8645"), + ("shadow", "base-shadow-validator", "8845"), + ] { + let address = self.compose(&["port", service, port], Duration::from_secs(10)).await?; + endpoints.insert(role.into(), format!("http://{}", address.trim())); + } + Ok(endpoints) + } + + /// Acquires a kernel lock; the stable lock file is never unlinked by any runner. + pub fn acquire_lock(&mut self) -> Result<()> { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + options.mode(0o600); + let mut lock = options.open(std::env::temp_dir().join("base-acceptance-docker.lock"))?; + lock.try_lock().wrap_err("another acceptance run owns the local Docker daemon")?; + lock.set_len(0)?; + writeln!(lock, "{}", std::process::id())?; + self.lock_handle = Some(lock); + Ok(()) + } + + /// Writes a private Compose environment with explicit scenario overrides. + pub fn write_environment(&self, config: &ScenarioConfig) -> Result<()> { + let root = self.output.join("private/devnet").canonicalize()?; + if root.to_string_lossy().contains(['\n', '\r', '$', '#', '"', '\'', ',']) { + bail!("unsupported characters in output directory"); + } + let mut env = fs::read_to_string(self.repo.join("etc/docker/devnet-env"))?; + env.push_str(&format!( + "\nDEVNET_ROOT={}\nPROFILE=dev\nBASE_SUCCINCT_ELF_REQUIRE=0\nL1_CHAIN_ID={}\nL2_CHAIN_ID={}\nL1_SLOT_DURATION_OVERRIDE={}\nBASE_NODE_VERIFIER_L1_CONFS={}\n", + root.display(), + config.devnet.l1.chain_id, + config.devnet.l2.chain_id, + config.devnet.l1.slot_duration.0.as_secs(), + config.devnet.l2.verifier_l1_confirmations, + )); + for (name, activation) in &config.devnet.l2.forks { + env.push_str(&format!( + "L2_BASE_{}_BLOCK={}\n", + name.to_ascii_uppercase(), + activation.block().map_or_else(String::new, |block| block.to_string()) + )); + } + let path = self.output.join("private/compose.env"); + fs::write(&path, env)?; + Self::restrict(&path, false) + } + + /// Rejects container name collisions without adopting existing resources. + /// Docker assigns and binds the dynamically allocated host ports at startup. + pub async fn preflight(&self) -> Result<()> { + let model = self.model().await?; + let existing = Self::command( + Command::new("docker").args(["ps", "-a", "--format", "{{.Names}}"]), + Duration::from_secs(15), + ) + .await?; + for service in model["services"] + .as_object() + .ok_or_else(|| eyre::eyre!("Compose services missing"))? + .values() + { + if service.get("profiles").is_some() { + continue; + } + if let Some(name) = service["container_name"].as_str() + && existing.lines().any(|line| line == name) + { + bail!( + "container {name} already exists; stop the developer devnet explicitly before acceptance" + ); + } + } + Ok(()) + } + + /// Resolves Compose configuration without persisting credential-bearing fields. + pub async fn model(&self) -> Result { + Ok(serde_json::from_str( + &self.compose(&["config", "--format", "json"], Duration::from_secs(15)).await?, + )?) + } + + /// Verifies generated identity and both representations of every L2 schedule. + pub fn verify_artifacts(&self, config: &ScenarioConfig) -> Result<()> { + let root = self.output.join("private/devnet"); + let rollup = root.join("l2/configs/rollup.json"); + let forks = config.verified_forks(&rollup)?; + let genesis: Value = + serde_json::from_slice(&fs::read(root.join("l2/configs/genesis.json"))?)?; + if genesis.pointer("/config/chainId").and_then(Value::as_u64) + != Some(config.devnet.l2.chain_id) + { + bail!("L2 genesis chain ID differs from scenario"); + } + let l1: Value = + serde_json::from_slice(&fs::read(root.join("l1/configs/el/genesis.json"))?)?; + if l1.pointer("/config/chainId").and_then(Value::as_u64) != Some(config.devnet.l1.chain_id) + { + bail!("L1 genesis chain ID differs from scenario"); + } + for name in config.devnet.l2.forks.keys() { + let actual = genesis.pointer(&format!("/config/base/{name}")).and_then(Value::as_u64); + let expected = + forks.iter().find(|fork| &fork.name == name).map(|fork| fork.activation_timestamp); + if actual != expected { + bail!("L2 genesis {name} schedule differs from verified rollup"); + } + } + Ok(()) + } + + /// Captures bounded sanitized logs, returning collection failures truthfully. + pub async fn collect_logs(&self) -> Result<()> { + if !self.owned { + return Ok(()); + } + let logs = self + .compose( + &["logs", "--no-color", "--timestamps", "--tail", "1000"], + Duration::from_secs(30), + ) + .await?; + let clean = self.sanitize(&logs); + let evidence = self.output.join("report/evidence"); + fs::create_dir_all(&evidence)?; + fs::write(evidence.join("compose.log"), clean)?; + Ok(()) + } + + /// Redacts configured secrets and strips control characters from diagnostics. + pub fn sanitize(&self, text: &str) -> String { + let mut clean = text.replace(&self.output.to_string_lossy().to_string(), ""); + if let Ok(env) = fs::read_to_string(self.output.join("private/compose.env")) { + for (key, value) in env.lines().filter_map(|line| line.split_once('=')) { + if (key.contains("KEY") + || key.contains("TOKEN") + || key.contains("SECRET") + || key.contains("PASSWORD")) + && value.len() > 8 + { + clean = clean.replace(value.trim_matches(['\'', '"']), "[REDACTED]"); + } + } + } + let mut characters = clean.chars().peekable(); + let mut output = String::new(); + while let Some(character) = characters.next() { + if character == '\u{1b}' && characters.peek() == Some(&'[') { + characters.next(); + for part in characters.by_ref() { + if ('@'..='~').contains(&part) { + break; + } + } + } else if !character.is_control() || matches!(character, '\n' | '\t') { + output.push(character); + } + if output.len() >= 2_000_000 { + break; + } + } + output + } + + /// Stops owned resources and deletes only this run's freshly generated state. + /// Removing root-owned state requires the local `devnet-setup:local-v2` image. + pub async fn cleanup(&mut self) -> Result<()> { + if self.owned { + self.compose( + &["down", "--volumes", "--remove-orphans", "--timeout", "10"], + Duration::from_secs(40), + ) + .await?; + let data = self.output.join("private/devnet"); + if data.exists() { + Self::command( + Command::new("docker").args([ + "run", + "--rm", + "--network", + "none", + "--entrypoint", + "/bin/sh", + "--mount", + &format!("type=bind,source={},target=/owned", data.display()), + "devnet-setup:local-v2", + "-c", + "find /owned -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +", + ]), + Duration::from_secs(30), + ) + .await?; + } + self.owned = false; + } + if self.private_owned { + fs::remove_dir_all(self.output.join("private"))?; + self.private_owned = false; + } + self.lock_handle.take(); + Ok(()) + } + + /// Recovers an interrupted local run using its private ownership manifest. + pub async fn recover(path: &Path) -> Result<()> { + let canonical = path.canonicalize()?; + let owner: Ownership = serde_json::from_slice(&fs::read(&canonical)?)?; + if canonical != owner.output.join("private/ownership.json") + || !owner.project.starts_with("base-acceptance-") + || !owner + .project + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + bail!("invalid ownership manifest"); + } + let mut provisioner = Self { + repo: owner.repo, + output: owner.output, + project: owner.project, + owned: true, + private_owned: true, + lock_handle: None, + }; + provisioner.acquire_lock()?; + provisioner.cleanup().await + } + + /// Invokes canonical Compose with scenario-sensitive ambient variables removed. + pub async fn compose(&self, args: &[&str], timeout: Duration) -> Result { + let env_file = self.output.join("private/compose.env"); + let mut command = Command::new("docker"); + command + .args(["compose", "--project-name", &self.project, "--env-file"]) + .arg(&env_file) + .arg("--file") + .arg(self.repo.join("etc/docker/docker-compose.yml")); + let ports = self.output.join("private/ports.yml"); + if ports.exists() { + command.arg("--file").arg(ports); + } + command.args(args).current_dir(&self.repo); + for line in fs::read_to_string(&env_file)?.lines() { + if let Some((key, _)) = line.split_once('=') { + command.env_remove(key.trim()); + } + } + for (key, _) in std::env::vars().filter(|(key, _)| { + key.starts_with("COMPOSE_") + || key.starts_with("L2_BASE_") + || key.starts_with("UPGRADE_SIGNAL_") + }) { + command.env_remove(key); + } + Self::command(&mut command, timeout) + .await + .map_err(|error| eyre::eyre!("{}", self.sanitize(&error.to_string()))) + } + + /// Executes a child with bounded retained output while draining both pipes. + pub async fn command(command: &mut Command, timeout: Duration) -> Result { + command.stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); + let mut child = command.spawn()?; + let stdout = child.stdout.take().ok_or_else(|| eyre::eyre!("stdout unavailable"))?; + let stderr = child.stderr.take().ok_or_else(|| eyre::eyre!("stderr unavailable"))?; + let (status, out, err) = tokio::time::timeout(timeout, async { + let (out, err, status) = + tokio::try_join!(Self::drain(stdout), Self::drain(stderr), child.wait())?; + Ok::<_, std::io::Error>((status, out, err)) + }) + .await + .wrap_err("command deadline elapsed")??; + if !status.success() { + bail!( + "command failed ({status}): {}", + String::from_utf8_lossy(&err).chars().take(4000).collect::() + ); + } + Ok(String::from_utf8_lossy(&out).into_owned()) + } + + /// Retains a bounded prefix and drains the remainder to avoid deadlocking a child. + pub async fn drain(mut input: impl tokio::io::AsyncRead + Unpin) -> std::io::Result> { + const LIMIT: usize = 2_000_000; + let mut output = Vec::new(); + let mut buffer = [0; 8192]; + loop { + let read = input.read(&mut buffer).await?; + if read == 0 { + break; + } + output.extend_from_slice(&buffer[..read.min(LIMIT.saturating_sub(output.len()))]); + } + Ok(output) + } + + /// Restricts private state on Unix, where the Compose devnet is supported. + pub fn restrict(path: &Path, directory: bool) -> Result<()> { + #[cfg(unix)] + fs::set_permissions( + path, + fs::Permissions::from_mode(if directory { 0o700 } else { 0o600 }), + )?; + Ok(()) + } + + /// Host endpoints of the canonical single-sequencer topology. + pub fn default_endpoints() -> EndpointMap { + [("l1", 4545), ("builder", 7545), ("validator", 8545), ("rpc", 8645), ("shadow", 8845)] + .into_iter() + .map(|(role, port)| (role.into(), format!("http://127.0.0.1:{port}"))) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contender_cannot_release_owner_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lock"); + let owner = File::create(&path).unwrap(); + owner.try_lock().unwrap(); + let contender = File::open(&path).unwrap(); + assert!(contender.try_lock().is_err()); + drop(contender); + assert!(File::open(&path).unwrap().try_lock().is_err()); + drop(owner); + File::open(&path).unwrap().try_lock().unwrap(); + } + + #[tokio::test] + async fn output_is_bounded() { + let data = vec![b'a'; 2_100_000]; + assert_eq!(Provisioner::drain(data.as_slice()).await.unwrap().len(), 2_000_000); + } + + #[tokio::test] + async fn inert_cleanup_preserves_existing_private_data() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("private")).unwrap(); + let sentinel = dir.path().join("private/sentinel"); + fs::write(&sentinel, "developer data").unwrap(); + let mut provisioner = Provisioner::new(PathBuf::new(), dir.path().into(), "test"); + provisioner.cleanup().await.unwrap(); + assert_eq!(fs::read_to_string(sentinel).unwrap(), "developer data"); + } + + #[test] + fn diagnostic_removes_ansi_and_configured_credentials() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("private")).unwrap(); + fs::write(dir.path().join("private/compose.env"), "PRIVATE_KEY=never-publish-this\n") + .unwrap(); + let provisioner = Provisioner::new(PathBuf::new(), dir.path().into(), "test"); + assert_eq!( + provisioner.sanitize("\x1b[31mERROR\x1b[0m never-publish-this\n"), + "ERROR [REDACTED]\n" + ); + } +} diff --git a/acceptance/src/publish.rs b/acceptance/src/publish.rs new file mode 100644 index 0000000000..61c1e271c3 --- /dev/null +++ b/acceptance/src/publish.rs @@ -0,0 +1,927 @@ +use std::{env, fmt::Write as _, path::PathBuf, time::Duration}; + +use chrono::DateTime; +use clap::Args; +use eyre::{Context, Result, ensure}; +use reqwest::{Client, Method, header, redirect::Policy}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{Aggregate, Report, ReportCounts, RunResult, Status}; + +const MARKER: &str = ""; +const META_PREFIX: &str = "").map(|(metadata, _)| metadata)) + .ok_or_else(|| eyre::eyre!("owned marker has invalid metadata"))?; + let metadata: Self = serde_json::from_str(encoded)?; + metadata.validate()?; + Ok(metadata) + } +} + +/// `GitHub` comment publisher with bounded requests and narrow comment ownership. +#[derive(Debug)] +pub struct PrPublisher { + /// Authenticated client; authorization headers must be marked sensitive. + pub client: Client, + /// Repository API URL (production always uses api.github.com). + pub api_url: String, + /// Pull-request number. + pub pr: u64, + /// Expected pull-request head revision. + pub head: String, + /// Expected pull-request base revision. + pub base: String, + /// Exact bot login allowed to own the comment. + pub bot_login: String, +} + +impl PublishArgs { + /// Publishes from trusted CI environment and artifact data, never executing artifact code. + pub async fn execute(self) -> Result<()> { + let metadata = CommentMetadata { + run_id: self.run_id.clone(), + attempt: self.attempt, + started_at: self.started_at.clone(), + }; + metadata.validate()?; + let repo = env::var("GITHUB_REPOSITORY")?; + let parts: Vec<_> = repo.split('/').collect(); + ensure!( + parts.len() == 2 + && parts.iter().all(|part| !part.is_empty() + && *part != "." + && *part != ".." + && part.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))), + "invalid repository" + ); + let tested_sha = env::var("TESTED_SHA")?; + PrPublisher::validate_sha(&tested_sha)?; + let event: Value = serde_json::from_slice(&Aggregate::bounded_read(&self.event)?)?; + let pr = event["pull_request"]["number"] + .as_u64() + .filter(|number| *number > 0) + .ok_or_else(|| eyre::eyre!("missing PR number"))?; + let head = event["pull_request"]["head"]["sha"].as_str().unwrap_or_default().to_owned(); + let base = event["pull_request"]["base"]["sha"].as_str().unwrap_or_default().to_owned(); + PrPublisher::validate_sha(&head)?; + PrPublisher::validate_sha(&base)?; + let run = self.load_result(&tested_sha).unwrap_or_else(|_| { + let mut run = Aggregate::fallback( + "Missing or invalid acceptance artifacts; inspect PR checks and report artifacts." + .into(), + ); + run.tested_sha.clone_from(&tested_sha); + run.run_id.clone_from(&self.run_id); + run + }); + let body = PrPublisher::body( + &run, + &format!("https://github.com/{repo}/pull/{pr}/checks"), + &metadata, + )?; + let mut authorization = + header::HeaderValue::from_str(&format!("Bearer {}", env::var("GITHUB_TOKEN")?))?; + authorization.set_sensitive(true); + let publisher = PrPublisher { + client: Client::builder() + .redirect(Policy::none()) + .timeout(Duration::from_secs(20)) + .user_agent("base-acceptance") + .default_headers(header::HeaderMap::from_iter([ + (header::AUTHORIZATION, authorization), + ( + header::ACCEPT, + header::HeaderValue::from_static("application/vnd.github+json"), + ), + ( + header::HeaderName::from_static("x-github-api-version"), + header::HeaderValue::from_static("2022-11-28"), + ), + ])) + .build()?, + api_url: format!("https://api.github.com/repos/{repo}"), + pr, + head, + base, + bot_login: env::var("BOT_LOGIN") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "depot-code-access[bot]".into()), + }; + println!( + "{}", + if publisher.publish(&metadata, &body).await? { + "published" + } else { + "stale run; publication skipped" + } + ); + Ok(()) + } + + /// Reuses portable schema validation and enforces exact artifact provenance and check sets. + pub fn load_result(&self, tested_sha: &str) -> Result { + PrPublisher::validate_sha(tested_sha)?; + let expected = Aggregate::load_expected(&self.expected)?; + let run: RunResult = serde_json::from_slice(&Aggregate::bounded_read(&self.result)?)?; + Report::validate(&run)?; + ensure!( + expected.run_id == self.run_id + && expected.tested_sha == tested_sha + && run.run_id == self.run_id + && run.tested_sha == tested_sha, + "artifact provenance mismatch" + ); + ensure!(expected.scenarios.len() == run.scenarios.len(), "scenario set mismatch"); + for (wanted, actual) in expected.scenarios.iter().zip(&run.scenarios) { + ensure!( + wanted.id == actual.id + && wanted + .checks + .iter() + .map(String::as_str) + .eq(actual.checks.iter().map(|check| check.id.as_str())), + "scenario/check set mismatch" + ); + } + Ok(run) + } +} + +impl PrPublisher { + /// Validates a full `GitHub` commit identity. + pub fn validate_sha(sha: &str) -> Result<()> { + ensure!( + sha.len() == 40 + && sha.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "invalid tested revision" + ); + Ok(()) + } + + /// Performs one bounded `GitHub` request without printing credentials or response bodies. + pub async fn request(&self, method: Method, path: &str, body: Option) -> Result { + let mut request = self.client.request(method, format!("{}{path}", self.api_url)); + if let Some(body) = body { + request = request.json(&body); + } + let mut response = request.send().await.wrap_err("GitHub request failed")?; + ensure!(response.status().is_success(), "GitHub HTTP status {}", response.status()); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + ensure!( + bytes.len().saturating_add(chunk.len()) <= 20 * 1024 * 1024, + "GitHub response exceeds 20 MiB" + ); + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).wrap_err("invalid GitHub JSON response") + } + + /// Rechecks both branch identities, including immediately before a write. + pub async fn current(&self) -> Result { + let item = self.request(Method::GET, &format!("/pulls/{}", self.pr), None).await?; + Ok(item["head"]["sha"].as_str() == Some(&self.head) + && item["base"]["sha"].as_str() == Some(&self.base)) + } + + /// Upserts exactly one marker-owned bot comment, refusing stale or ambiguous writes. + pub async fn publish(&self, metadata: &CommentMetadata, body: &str) -> Result { + metadata.validate()?; + Report::bounded("comment body", body, 60_000)?; + ensure!(body.starts_with(MARKER), "missing comment marker"); + if !self.current().await? { + return Ok(false); + } + let mut owned = None; + for page in 1..=MAX_PAGES { + let response = self + .request( + Method::GET, + &format!("/issues/{}/comments?per_page=100&page={page}", self.pr), + None, + ) + .await?; + let comments = + response.as_array().ok_or_else(|| eyre::eyre!("invalid comment page"))?; + ensure!(comments.len() <= 100, "oversized comment page"); + for comment in comments { + if comment["user"]["type"] == "Bot" + && comment["user"]["login"].as_str() == Some(&self.bot_login) + && comment["body"].as_str().is_some_and(|body| body.starts_with(MARKER)) + { + ensure!(owned.is_none(), "duplicate owned comments"); + owned = Some(comment.clone()); + } + } + if comments.len() < 100 { + break; + } + ensure!(page < MAX_PAGES, "comment pagination cap reached"); + } + if let Some(comment) = &owned { + let old = CommentMetadata::parse(comment["body"].as_str().unwrap_or_default())?; + if (old.run_id == metadata.run_id && old.attempt > metadata.attempt) + || (old.run_id != metadata.run_id && old.started_at >= metadata.started_at) + { + return Ok(false); + } + } + let (method, path) = if let Some(comment) = owned { + let id = comment["id"] + .as_u64() + .filter(|id| *id > 0) + .ok_or_else(|| eyre::eyre!("invalid owned comment id"))?; + (Method::PATCH, format!("/issues/comments/{id}")) + } else { + (Method::POST, format!("/issues/{}/comments", self.pr)) + }; + if !self.current().await? { + return Ok(false); + } + self.request(method, &path, Some(json!({ "body": body }))).await?; + Ok(true) + } + + /// Escapes untrusted content for both Markdown and embedded HTML summary text. + pub fn text(value: &str) -> String { + let mut out = String::new(); + for word in value.split_whitespace() { + if !out.is_empty() { + out.push(' '); + } + if word.to_ascii_lowercase().contains("http://") + || word.to_ascii_lowercase().contains("https://") + { + out.push_str("[link removed]"); + continue; + } + out.push_str(word); + } + let out: String = out.chars().take(2_000).collect(); + out.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('@', "@") + .replace('`', "`") + .replace('|', "|") + .replace('[', "[") + .replace(']', "]") + .replace('(', "(") + .replace(')', ")") + } + + /// Summarizes check counts with an inline, collapsed report of checks and lifecycle errors. + pub fn body(run: &RunResult, checks_url: &str, metadata: &CommentMetadata) -> Result { + Report::validate(run)?; + metadata.validate()?; + let counts = ReportCounts::from_run(run); + let total = + counts.passed + counts.failed + counts.error + counts.blocked + counts.cancelled; + let mut out = format!( + "{MARKER}\n{META_PREFIX}{} -->\n## Acceptance Tests: {}\n\n{}/{total} checks passed", + serde_json::to_string(metadata)?, + if run.passed() { "✅ Passed" } else { "❌ Not passed" }, + counts.passed, + ); + for (count, label) in [ + (counts.failed, "failed"), + (counts.error, "errored"), + (counts.blocked, "blocked"), + (counts.cancelled, "cancelled"), + ] { + if count > 0 { + write!(out, " · {count} {label}")?; + } + } + writeln!(out, " · [Checks & reports]({checks_url})\n")?; + + out.push_str("

\nAcceptance report\n\n"); + let mut omitted = 0; + for scenario in &run.scenarios { + let records = scenario + .stages + .iter() + .filter(|stage| stage.status != Status::Passed) + .map(|stage| (&stage.id, stage.status, &stage.message, None)) + .chain( + scenario + .checks + .iter() + .map(|check| (&check.id, check.status, &check.message, Some(check))), + ); + for (id, status, message, check) in records { + let mut summary: String = message.chars().take(300).collect(); + if summary.len() < message.len() { + summary.push('…'); + } + let mut record = format!( + "- **{} / {}** — {}: {}\n", + Self::text(&scenario.id), + Self::text(id), + status.label(), + Self::text(&summary), + ); + if let Some(check) = check { + writeln!( + record, + " - Expected: {}\n - Observed: {}", + Self::text(&Report::compact_json(&check.expected)), + Self::text(&Report::compact_json(&check.observed)), + )?; + if check.status != Status::Passed { + writeln!(record, " - Next step: {}", Self::text(&check.next_step))?; + } + } + // Leave room for the omission notice and closing tag below GitHub's limit. + if out.len() + record.len() > 50_000 { + omitted += 1; + } else { + out.push_str(&record); + } + } + } + if omitted > 0 { + writeln!( + out, + "\n{omitted} more results omitted due to comment size; see Checks & reports for the complete report." + )?; + } + out.push_str("\n
\n"); + Report::bounded("comment body", &out, 60_000)?; + Ok(out) + } +} + +#[cfg(test)] +mod tests { + //! HTTP fixtures exercise reqwest itself, including ordered reads before a write; + //! no internal trait is replaced by a hand-written mock. + + use std::fs; + + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + task::JoinHandle, + }; + + use super::*; + use crate::{ExpectedManifest, ExpectedScenario}; + + fn metadata() -> CommentMetadata { + CommentMetadata { + run_id: "run/attempt-2".into(), + attempt: 2, + started_at: "2026-01-02T00:00:00Z".into(), + } + } + + fn run() -> RunResult { + let mut run: RunResult = + serde_json::from_str(include_str!("../fixtures/reports/synthetic-mixed.json")).unwrap(); + run.run_id = metadata().run_id; + run.tested_sha = "a".repeat(40); + run + } + + fn current() -> Value { + json!({ + "head": { + "sha": "head", + }, + "base": { + "sha": "base", + }, + }) + } + + fn owned(metadata: &CommentMetadata) -> Value { + json!({ + "id": 777, + "user": { + "login": "depot-code-access[bot]", + "type": "Bot", + }, + "body": format!( + "{MARKER}\n{META_PREFIX}{} -->\nprior result", + serde_json::to_string(metadata).unwrap() + ), + }) + } + + async fn server(steps: Vec<(String, Value)>) -> (PrPublisher, JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let mut bodies = Vec::new(); + for (expected, reply) in steps { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let (end, length) = loop { + let mut buffer = [0; 4096]; + let read = stream.read(&mut buffer).await.unwrap(); + assert!(read > 0); + bytes.extend_from_slice(&buffer[..read]); + if let Some(end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]); + assert_eq!(headers.lines().next().unwrap(), format!("{expected} HTTP/1.1")); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|value| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + break (end + 4, length); + } + }; + while bytes.len() < end + length { + let mut buffer = [0; 4096]; + let read = stream.read(&mut buffer).await.unwrap(); + assert!(read > 0); + bytes.extend_from_slice(&buffer[..read]); + } + if length > 0 { + bodies.push(serde_json::from_slice(&bytes[end..end + length]).unwrap()); + } + let body = reply.to_string(); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + } + bodies + }); + ( + PrPublisher { + client: Client::builder().timeout(Duration::from_secs(2)).build().unwrap(), + api_url: format!("http://{address}"), + pr: 1, + head: "head".into(), + base: "base".into(), + bot_login: "depot-code-access[bot]".into(), + }, + handle, + ) + } + + #[test] + fn timestamp_and_metadata_validation_preserve_trusted_ordering() { + metadata().validate().unwrap(); + for invalid in [ + "", + "invalid", + "2026-02-30T00:00:00Z", + "2026-01-02T00:00:00", + "2026-1-2T00:00:00Z", + "2026-01-02T01:00:00+01:00", + "2026-01-02T00:00:00.123Z", + ] { + assert!( + CommentMetadata { started_at: invalid.into(), ..metadata() }.validate().is_err(), + "{invalid}" + ); + } + assert!(CommentMetadata { run_id: "-->@team".into(), ..metadata() }.validate().is_err()); + assert!(CommentMetadata { attempt: 0, ..metadata() }.validate().is_err()); + assert!(CommentMetadata::parse(MARKER).is_err()); + let parsed = CommentMetadata::parse(owned(&metadata())["body"].as_str().unwrap()).unwrap(); + assert_eq!(parsed.started_at, "2026-01-02T00:00:00Z"); + } + + #[tokio::test] + async fn invalid_timestamp_is_rejected_before_environment_or_artifact_access() { + let args = PublishArgs { + event: "missing".into(), + result: "missing".into(), + expected: "missing".into(), + run_id: "run".into(), + attempt: 1, + started_at: "invalid".into(), + }; + assert!(args.execute().await.unwrap_err().to_string().contains("started-at")); + } + + #[test] + fn artifact_schema_provenance_and_exact_check_sets_are_required() { + let directory = tempfile::tempdir().unwrap(); + let original = run(); + let args = PublishArgs { + event: PathBuf::new(), + result: directory.path().join("result.json"), + expected: directory.path().join("expected.json"), + run_id: original.run_id.clone(), + attempt: 2, + started_at: metadata().started_at, + }; + let expected = ExpectedManifest { + schema_version: 1, + run_id: original.run_id.clone(), + tested_sha: original.tested_sha.clone(), + scenarios: original + .scenarios + .iter() + .map(|scenario| ExpectedScenario { + id: scenario.id.clone(), + checks: scenario.checks.iter().map(|check| check.id.clone()).collect(), + }) + .collect(), + }; + fs::write(&args.expected, serde_json::to_vec(&expected).unwrap()).unwrap(); + assert!(args.load_result(&original.tested_sha).is_err()); + fs::write(&args.result, serde_json::to_vec(&original).unwrap()).unwrap(); + assert!(!args.load_result(&original.tested_sha).unwrap().passed()); + let mut variants = Vec::new(); + let mut stale = original.clone(); + stale.run_id = "other".into(); + variants.push(stale); + let mut stale = original.clone(); + stale.tested_sha = "b".repeat(40); + variants.push(stale); + let mut missing = original.clone(); + missing.scenarios.clear(); + variants.push(missing); + let mut duplicate = original.clone(); + duplicate.scenarios.push(original.scenarios[0].clone()); + variants.push(duplicate); + let mut wrong_check = original.clone(); + wrong_check.scenarios[0].checks[0].id = "unexpected".into(); + variants.push(wrong_check); + let mut bad_path = original.clone(); + bad_path.scenarios[0].checks[0].evidence = vec!["../secret".into()]; + variants.push(bad_path); + for invalid in variants { + fs::write(&args.result, serde_json::to_vec(&invalid).unwrap()).unwrap(); + assert!(args.load_result(&original.tested_sha).is_err()); + } + fs::write(&args.result, b"not json").unwrap(); + assert!(args.load_result(&original.tested_sha).is_err()); + } + + #[test] + fn aggregate_normalizes_reordered_shard_checks_for_publication() { + let directory = tempfile::tempdir().unwrap(); + let mut shard = run(); + let expected = ExpectedManifest { + schema_version: 1, + run_id: shard.run_id.clone(), + tested_sha: shard.tested_sha.clone(), + scenarios: shard + .scenarios + .iter() + .map(|scenario| ExpectedScenario { + id: scenario.id.clone(), + checks: scenario.checks.iter().map(|check| check.id.clone()).collect(), + }) + .collect(), + }; + let reordered = shard + .scenarios + .iter_mut() + .find(|scenario| scenario.checks.len() > 1) + .expect("fixture has a multi-check scenario"); + reordered.checks.reverse(); + + let expected_path = directory.path().join("expected.json"); + let results = directory.path().join("results"); + let output = directory.path().join("aggregate"); + fs::create_dir_all(results.join("shard")).unwrap(); + fs::write(&expected_path, serde_json::to_vec(&expected).unwrap()).unwrap(); + fs::write(results.join("shard/result.json"), serde_json::to_vec(&shard).unwrap()).unwrap(); + + Aggregate::write(&expected_path, &results, &output).unwrap(); + let args = PublishArgs { + event: PathBuf::new(), + result: output.join("result.json"), + expected: expected_path, + run_id: shard.run_id.clone(), + attempt: 2, + started_at: metadata().started_at, + }; + let loaded = args.load_result(&shard.tested_sha).unwrap(); + for (actual, wanted) in loaded.scenarios.iter().zip(&expected.scenarios) { + assert_eq!( + actual.checks.iter().map(|check| check.id.as_str()).collect::>(), + wanted.checks.iter().map(String::as_str).collect::>() + ); + } + } + + #[test] + fn passing_comment_keeps_summary_short_and_includes_a_collapsed_report() { + let mut run = run(); + run.scenarios.truncate(1); + let body = + PrPublisher::body(&run, "https://github.com/base/base/pull/1/checks", &metadata()) + .unwrap(); + let (summary, report) = body.split_once("
\n").unwrap(); + let visible: Vec<_> = summary.lines().filter(|line| !line.starts_with("\n## Acceptance: {}\n\nRun {} · revision {}\n\n| Passed | Failed | Error | Blocked | Cancelled |\n|---:|---:|---:|---:|---:|\n| {} | {} | {} | {} | {} |\n\n", + Self::markdown_text(ReportCounts::status_phrase(run)), + Self::markdown_code(&run.run_id), + Self::markdown_code(&run.tested_sha), + counts.passed, + counts.failed, + counts.error, + counts.blocked, + counts.cancelled + ); + let total = run.scenarios.iter().map(|s| s.checks.len()).sum::(); + let mut shown = 0; + for scenario in &run.scenarios { + let mut section = format!( + "{} — {}\n\n", + if scenario.outcome() == Status::Passed { "" } else { " open" }, + Self::escape_html(&scenario.id), + scenario.outcome().label() + ); + for check in &scenario.checks { + let line = format!( + "- **{}** ({}): {} — expected {}, observed {}. {}\n - Next step: {}\n - Reproduce: {}\n - Evidence: {}\n", + Self::markdown_text(&check.id), + Self::markdown_code(&check.kind), + check.status.label(), + Self::markdown_code(&Self::compact_json(&check.expected)), + Self::markdown_code(&Self::compact_json(&check.observed)), + Self::markdown_text(&check.message), + Self::markdown_text(&check.next_step), + Self::markdown_code(&scenario.reproduction), + if check.evidence.is_empty() { + "⚠ none recorded".into() + } else { + check + .evidence + .iter() + .map(|path| Self::markdown_code(path)) + .collect::>() + .join(", ") + } + ); + if out.len() + section.len() + line.len() + RESERVE > LIMIT { + break; + } + section.push_str(&line); + shown += 1; + } + section.push_str("\n
\n\n"); + if out.len() + section.len() + RESERVE > LIMIT { + break; + } + out.push_str(§ion); + } + if shown < total { + writeln!( + out, + "_{} check(s) omitted to keep this comment below 50 KiB; download the report bundle for complete results._", + total - shown + )?; + } + Ok(out) + } + + /// Writes the portable report bundle, leaving an existing evidence directory intact. + pub fn write(run: &RunResult, directory: &Path) -> Result<()> { + Self::validate(run)?; + fs::create_dir_all(directory).wrap_err("create report directory")?; + Self::validate_existing_evidence(run, directory)?; + for name in ["result.json", "report.html", "summary.md"] { + let destination = directory.join(name); + if destination + .symlink_metadata() + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + bail!("refusing to overwrite symlink report file {name}"); + } + } + let json = serde_json::to_string_pretty(run).wrap_err("serialize result")?; + let temporary = directory.join(format!(".result.json.{}.tmp", std::process::id())); + if temporary.symlink_metadata().is_ok() { + bail!("temporary result path already exists"); + } + fs::write(&temporary, format!("{json}\n")).wrap_err("write temporary result.json")?; + fs::rename(&temporary, directory.join("result.json")).wrap_err("commit result.json")?; + fs::write(directory.join("report.html"), Self::html(run)?).wrap_err("write report.html")?; + fs::write(directory.join("summary.md"), Self::markdown(run)?) + .wrap_err("write summary.md")?; + Ok(()) + } + + /// Validates reporting schema, resource bounds, identifiers, and artifact paths. + pub fn validate(run: &RunResult) -> Result<()> { + if run.schema_version != 1 { + bail!("unsupported result schema {}", run.schema_version); + } + Self::bounded("run_id", &run.run_id, 200)?; + Self::bounded("tested_sha", &run.tested_sha, 200)?; + if run.scenarios.is_empty() || run.scenarios.len() > 100 { + bail!("result must contain 1..=100 scenarios"); + } + let mut scenario_ids = HashSet::new(); + let mut total_checks = 0usize; + let mut total_samples = 0usize; + for scenario in &run.scenarios { + Self::valid_id("scenario", &scenario.id)?; + if !scenario_ids.insert(&scenario.id) { + bail!("duplicate scenario id {}", scenario.id); + } + if scenario.checks.len() > 1_000 || scenario.stages.len() > 100 { + bail!("scenario {} exceeds record bounds", scenario.id); + } + total_checks += scenario.checks.len(); + total_samples += scenario.samples.len(); + if total_checks > 5_000 || total_samples > 50_000 { + bail!("result exceeds reporting bounds"); + } + let mut stage_ids = HashSet::new(); + for stage in &scenario.stages { + Self::valid_id("stage", &stage.id)?; + Self::bounded("stage message", &stage.message, 65_536)?; + Self::reject_secret("stage message", &stage.message)?; + if !stage_ids.insert(&stage.id) { + bail!("duplicate stage id {}", stage.id); + } + } + let mut check_ids = HashSet::new(); + for check in &scenario.checks { + Self::valid_id("check", &check.id)?; + Self::bounded("check kind", &check.kind, 100)?; + for (name, value) in [("message", &check.message), ("next_step", &check.next_step)] + { + Self::bounded(name, value, 65_536)?; + Self::reject_secret(name, value)?; + } + if !check_ids.insert(&check.id) { + bail!("duplicate check id {}", check.id); + } + for path in &check.evidence { + Self::validate_evidence_path(path)?; + } + Self::validate_value(&check.expected, 0)?; + Self::validate_value(&check.observed, 0)?; + } + Self::validate_value(&scenario.config, 0)?; + Self::bounded("reproduction", &scenario.reproduction, 4_096)?; + Self::reject_secret("reproduction", &scenario.reproduction)?; + for diagnostic in &scenario.diagnostics { + Self::bounded("diagnostic", diagnostic, 65_536)?; + Self::reject_secret("diagnostic", diagnostic)?; + } + for fork in &scenario.forks { + Self::bounded("fork name", &fork.name, 200)?; + Self::bounded("fork chain", &fork.chain, 100)?; + if fork.observed_block.is_some() != fork.observed_elapsed_ms.is_some() { + bail!("fork {} has a partial observed boundary", fork.name); + } + } + for sample in &scenario.samples { + Self::bounded("endpoint", &sample.endpoint, 100)?; + if let Some(hash) = &sample.hash { + Self::bounded("sample hash", hash, 200)?; + } + if sample.number.is_none() && (sample.timestamp.is_some() || sample.hash.is_some()) + { + bail!("gap sample for {} contains block data", sample.endpoint); + } + } + } + Ok(()) + } + + /// Appends one semantic scenario section to an HTML document. + pub fn render_scenario(out: &mut String, scenario: &ScenarioResult) -> Result<()> { + let open = scenario.outcome() != Status::Passed; + write!( + out, + "
{} — {}

{} ms · recorded aggregate: {} · derived result: {}

", + Self::anchor(&scenario.id), + if open { " open" } else { "" }, + Self::escape_html(&scenario.id), + scenario.outcome().label(), + scenario.duration_ms, + scenario.status.label(), + scenario.outcome().label() + )?; + for check in &scenario.checks { + write!( + out, + "

{} {}

{}

Expected
{}
Observed
{}

Next step: {}

{} samples · {} RPC errors · {} ms

", + Self::anchor(&scenario.id), + Self::anchor(&check.id), + Self::escape_html(&check.id), + check.status.label(), + check.status.label(), + Self::escape_html(&check.message), + Self::escape_html(&Self::pretty_json(&check.expected)), + Self::escape_html(&Self::pretty_json(&check.observed)), + Self::escape_html(&check.next_step), + check.samples, + check.rpc_errors, + check.duration_ms + )?; + if !check.evidence.is_empty() { + out.push_str("

Evidence: "); + for (i, path) in check.evidence.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + write!( + out, + "{}", + Self::escape_html(path), + Self::escape_html(path) + )?; + } + out.push_str("

"); + } else { + out.push_str( + "

Evidence: none recorded

", + ); + } + out.push_str("
"); + } + if !scenario.samples.is_empty() { + out.push_str(&Self::head_chart(scenario)?); + } + out.push_str("
Accessible head sample data
"); + for sample in &scenario.samples { + write!( + out, + "", + Self::escape_html(&sample.endpoint), + sample.elapsed_ms, + Self::option_u64(sample.number), + Self::option_u64(sample.timestamp), + Self::escape_html(sample.hash.as_deref().unwrap_or("gap")) + )?; + } + out.push_str("
EndpointElapsed msHeightTimestampHash
{}{}{}{}{}
"); + write!( + out, + "
Configuration, provenance, diagnostics, and reproduction

Resolved configuration

{}
", + Self::escape_html(&Self::pretty_json(&scenario.config)) + )?; + if !scenario.forks.is_empty() { + out.push_str("

Fork boundaries

    "); + for fork in &scenario.forks { + write!( + out, + "
  • {} on {}: configured activation timestamp {}; {}.
  • ", + Self::escape_html(&fork.name), + Self::escape_html(&fork.chain), + fork.activation_timestamp, + match (fork.observed_block, fork.observed_elapsed_ms) { + (Some(block), Some(ms)) => format!( + "boundary crossing observed at block {block}, {ms} ms (not proof of fork behavior)" + ), + _ => "boundary crossing not observed".into(), + } + )?; + } + out.push_str("
"); + } + if !scenario.diagnostics.is_empty() { + out.push_str("

Collection limitations

    "); + for diagnostic in &scenario.diagnostics { + write!(out, "
  • {}
  • ", Self::escape_html(diagnostic))?; + } + out.push_str("
"); + } + write!( + out, + "

Inert reproduction command

{}
", + Self::escape_html(&scenario.reproduction) + )?; + Ok(()) + } + + /// Renders a fixed head-progress SVG with explicit sample gaps. + pub fn head_chart(scenario: &ScenarioResult) -> Result { + let numbered: Vec<_> = + scenario.samples.iter().filter_map(|s| s.number.map(|n| (s.elapsed_ms, n))).collect(); + if numbered.is_empty() { + return Ok("

Head progress

No successful head observations; all samples are explicit gaps.

".into()); + } + let min_x = 0; + let max_x = + scenario.samples.iter().map(|s| s.elapsed_ms).max().unwrap_or(min_x + 1).max(min_x + 1); + let min_y = numbered.iter().map(|(_, n)| *n).min().unwrap_or(0); + let max_y = + numbered.iter().map(|(_, n)| *n).max().unwrap_or(min_y).max(min_y.saturating_add(1)); + let mut endpoints: Vec<_> = scenario.samples.iter().map(|s| &s.endpoint).collect(); + endpoints.sort(); + endpoints.dedup(); + let colors = ["#69d391", "#8ec5ff", "#ffbd66", "#c4b5fd"]; + let mut svg = String::from( + "

Head progress

Height over elapsed time; lines stop at failed samples. Vertical separation at the same time is lag.

", + ); + write!( + svg, + "{min_x} ms{max_x} msblock {max_y}block {min_y}" + )?; + for (index, endpoint) in endpoints.iter().enumerate() { + let samples: Vec<_> = + scenario.samples.iter().filter(|s| &s.endpoint == *endpoint).collect(); + let mut segment = String::new(); + for sample in samples { + if let Some(number) = sample.number { + let x = Self::scale(sample.elapsed_ms, min_x, max_x, 60, 980); + let y = 300 - Self::scale(number, min_y, max_y, 0, 280); + if segment.is_empty() { + write!(segment, "M{x} {y}")?; + } else { + write!(segment, " L{x} {y}")?; + } + } else { + if !segment.is_empty() { + write!( + svg, + "", + colors[index % colors.len()], + segment + )?; + segment.clear(); + } + let x = Self::scale(sample.elapsed_ms, min_x, max_x, 60, 980); + write!( + svg, + "gap", + x + 3 + )?; + } + } + if !segment.is_empty() { + write!( + svg, + "", + colors[index % colors.len()], + segment + )?; + } + write!( + svg, + "{}", + 650 + (index % 2) * 170, + 25 + (index / 2) * 18, + Self::escape_html(endpoint) + )?; + } + for fork in &scenario.forks { + if let Some(ms) = fork.observed_elapsed_ms { + let x = Self::scale(ms, min_x, max_x, 60, 980); + write!( + svg, + "{} boundary observed", + x + 4, + Self::escape_html(&fork.name) + )?; + } + } + svg.push_str("
"); + Ok(svg) + } + + /// Renders a lifecycle table cell for matching stage records. + pub fn stage_cell(scenario: &ScenarioResult, needle: &str) -> String { + let stages: Vec<_> = scenario + .stages + .iter() + .filter(|stage| stage.id.to_ascii_lowercase().contains(needle)) + .collect(); + if stages.is_empty() { + return "— not recorded".into(); + } + let worst = stages + .iter() + .map(|stage| stage.status) + .find(|s| *s != Status::Passed) + .unwrap_or(Status::Passed); + let duration: u64 = stages.iter().map(|stage| stage.duration_ms).sum(); + let messages = stages + .iter() + .filter(|stage| !stage.message.is_empty()) + .map(|stage| Self::escape_html(&stage.message)) + .collect::>() + .join("; "); + format!( + "{} · {} ms{}", + worst.label(), + worst.label(), + duration, + if messages.is_empty() { + String::new() + } else { + format!("
{messages}") + } + ) + } + + /// Renders an accessible status table cell. + pub fn status_html(status: Status) -> String { + format!("{}", status.label(), status.label()) + } + + /// Serializes a JSON value for human-readable display. + pub fn pretty_json(value: &Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".into()) + } + + /// Serializes a JSON value on one line. + pub fn compact_json(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "null".into()) + } + + /// Formats an optional observation as a value or explicit gap. + pub fn option_u64(value: Option) -> String { + value.map_or_else(|| "gap".into(), |v| v.to_string()) + } + + /// Scales an integer into a bounded chart coordinate range. + pub fn scale(value: u64, min: u64, max: u64, low: u64, high: u64) -> u64 { + low + value.saturating_sub(min).saturating_mul(high - low) / max.saturating_sub(min).max(1) + } + + /// Converts a validated identifier to a stable HTML anchor fragment. + pub fn anchor(value: &str) -> String { + value + .bytes() + .map(|b| if b.is_ascii_alphanumeric() { (b as char).to_ascii_lowercase() } else { '-' }) + .collect() + } + + /// Escapes untrusted text for HTML text and quoted-attribute contexts. + pub fn escape_html(value: &str) -> String { + value + .chars() + .map(|c| match c { + '&' => "&".into(), + '<' => "<".into(), + '>' => ">".into(), + '"' => """.into(), + '\'' => "'".into(), + c if c.is_control() && !matches!(c, '\n' | '\t') => "�".into(), + c => c.to_string(), + }) + .collect() + } + + /// Escapes untrusted text used in Markdown content. + pub fn markdown_text(value: &str) -> String { + value + .chars() + .map(|c| match c { + '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '|' | '#' => format!("\\{c}"), + c if c.is_control() && !matches!(c, '\n' | '\t') => "�".into(), + c => c.to_string(), + }) + .collect() + } + + /// Renders untrusted text as portable inline code without Markdown delimiter ambiguity. + pub fn markdown_code(value: &str) -> String { + let delimiter = "`".repeat(value.split(|c| c != '`').map(str::len).max().unwrap_or(0) + 1); + let text: String = value + .chars() + .map(|c| { + if c.is_whitespace() { + ' ' + } else if c.is_control() { + '�' + } else { + c + } + }) + .collect(); + format!("{delimiter} {text} {delimiter}") + } + + /// Validates a bounded portable record identifier. + pub fn valid_id(kind: &str, value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 200 + || !value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + { + bail!("invalid {kind} id {value:?}"); + } + Ok(()) + } + + /// Rejects a string exceeding its byte budget. + pub fn bounded(name: &str, value: &str, maximum: usize) -> Result<()> { + if value.len() > maximum { + bail!("{name} exceeds {maximum} bytes"); + } + Ok(()) + } + + /// Validates a contained, URL-safe relative evidence path. + pub fn validate_evidence_path(value: &str) -> Result<()> { + Self::bounded("evidence path", value, 500)?; + let path = Path::new(value); + if value.is_empty() + || value.contains('\\') + || value.contains(['?', '#', '%']) + || path.is_absolute() + || !value.starts_with("evidence/") + || path.components().any(|c| !matches!(c, Component::Normal(_))) + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.')) + { + bail!("unsafe evidence path {value:?}"); + } + Ok(()) + } + + /// Rejects existing evidence links which resolve outside the report bundle. + pub fn validate_existing_evidence(run: &RunResult, directory: &Path) -> Result<()> { + let root = directory.canonicalize().wrap_err("canonicalize report directory")?; + for path in run + .scenarios + .iter() + .flat_map(|scenario| &scenario.checks) + .flat_map(|check| &check.evidence) + { + let candidate = directory.join(path); + if candidate.symlink_metadata().is_ok() { + let resolved = candidate + .canonicalize() + .wrap_err_with(|| format!("resolve evidence path {path}"))?; + if !resolved.starts_with(&root) { + bail!("evidence path escapes report directory: {path}"); + } + } + } + Ok(()) + } + + /// Rejects common credential forms; collection must still use allowlists. + pub fn reject_secret(name: &str, value: &str) -> Result<()> { + let lower = value.to_ascii_lowercase(); + let bearer_token = lower.find("bearer ").is_some_and(|start| { + value[start + 7..].split_whitespace().next().is_some_and(|token| { + token.len() >= 16 + && token.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') + }) + }) + }); + if lower.contains("begin private key") + || bearer_token + || lower.contains("://") + && value.split("://").nth(1).is_some_and(|tail| { + tail.split('/').next().is_some_and(|authority| authority.contains('@')) + }) + { + bail!("possible credential in {name}"); + } + Ok(()) + } + + /// Validates bounds and credential patterns recursively in JSON display data. + pub fn validate_value(value: &Value, depth: usize) -> Result<()> { + if depth > 20 { + bail!("JSON value nesting exceeds 20"); + } + match value { + Value::String(v) => { + Self::bounded("JSON string", v, 65_536)?; + Self::reject_secret("JSON string", v)?; + } + Value::Array(values) => { + if values.len() > 10_000 { + bail!("JSON array too large"); + } + for v in values { + Self::validate_value(v, depth + 1)?; + } + } + Value::Object(values) => { + if values.len() > 1_000 { + bail!("JSON object too large"); + } + for (k, v) in values { + Self::bounded("JSON key", k, 200)?; + Self::validate_value(v, depth + 1)?; + } + } + _ => {} + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use std::os::unix::fs::symlink; + + use serde_json::json; + + use super::*; + use crate::{CheckResult, ForkBoundary, HeadSample, ScenarioResult, StageResult}; + + fn run(status: Status) -> RunResult { + RunResult { + schema_version: 1, + run_id: "synthetic-test".into(), + tested_sha: "abc123".into(), + started_at_unix_ms: 1, + scenarios: vec![ScenarioResult { + id: "smoke".into(), + status: Status::Passed, + duration_ms: 10, + config: json!({ "network": "synthetic" }), + stages: vec![StageResult { + id: "setup".into(), + status: Status::Passed, + duration_ms: 2, + message: String::new(), + }], + checks: vec![CheckResult { + id: "heads".into(), + kind: "heads_converge".into(), + status, + duration_ms: 5, + expected: json!({ "max_lag": 5 }), + observed: json!({ "lag": 14 }), + message: "result".into(), + next_step: "inspect logs".into(), + samples: 2, + rpc_errors: 0, + evidence: vec!["evidence/heads.json".into()], + }], + samples: vec![ + HeadSample { + endpoint: "builder".into(), + elapsed_ms: 0, + number: Some(100), + timestamp: Some(1), + hash: Some("0xaa".into()), + }, + HeadSample { + endpoint: "builder".into(), + elapsed_ms: 10, + number: None, + timestamp: None, + hash: None, + }, + HeadSample { + endpoint: "builder".into(), + elapsed_ms: 20, + number: Some(102), + timestamp: Some(2), + hash: Some("0xbb".into()), + }, + ], + forks: vec![ForkBoundary { + name: "denim".into(), + chain: "l2".into(), + activation_timestamp: 2, + observed_block: Some(102), + observed_elapsed_ms: Some(20), + }], + diagnostics: vec![], + reproduction: "base-acceptance run synthetic.toml".into(), + }], + } + } + + #[test] + fn derives_counts_and_ignores_supplied_status() { + let mut value = run(Status::Failed); + value.scenarios[0].status = Status::Passed; + assert_eq!( + ReportCounts::from_run(&value), + ReportCounts { failed: 1, ..Default::default() } + ); + assert!(Report::html(&value).unwrap().contains("FAILED")); + } + + #[test] + fn escapes_hostile_content_in_both_formats() { + let mut value = run(Status::Failed); + value.scenarios[0].checks[0].message = "