From 2b1ceb72a2500f92b2ffe82719bfdfbe390f2b86 Mon Sep 17 00:00:00 2001 From: Andreas Bigger Date: Wed, 7 Jan 2026 12:58:33 -0500 Subject: [PATCH 1/4] feat(bundles): new bundles crate :sparkles: --- Cargo.lock | 17 +++ Cargo.toml | 5 +- crates/bundles/Cargo.toml | 39 +++++ crates/bundles/README.md | 56 ++++++++ crates/bundles/src/accepted.rs | 179 +++++++++++++++++++++++ crates/bundles/src/bundle.rs | 127 +++++++++++++++++ crates/bundles/src/cancel.rs | 106 ++++++++++++++ crates/bundles/src/lib.rs | 25 ++++ crates/bundles/src/meter.rs | 197 ++++++++++++++++++++++++++ crates/bundles/src/parsed.rs | 159 +++++++++++++++++++++ crates/bundles/src/test_utils.rs | 140 ++++++++++++++++++ crates/bundles/src/traits.rs | 235 +++++++++++++++++++++++++++++++ 12 files changed, 1284 insertions(+), 1 deletion(-) create mode 100644 crates/bundles/Cargo.toml create mode 100644 crates/bundles/README.md create mode 100644 crates/bundles/src/accepted.rs create mode 100644 crates/bundles/src/bundle.rs create mode 100644 crates/bundles/src/cancel.rs create mode 100644 crates/bundles/src/lib.rs create mode 100644 crates/bundles/src/meter.rs create mode 100644 crates/bundles/src/parsed.rs create mode 100644 crates/bundles/src/test_utils.rs create mode 100644 crates/bundles/src/traits.rs diff --git a/Cargo.lock b/Cargo.lock index adcf97d7..8826fe35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1490,6 +1490,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "base-bundles" +version = "0.2.1" +dependencies = [ + "alloy-consensus", + "alloy-primitives", + "alloy-provider", + "alloy-serde", + "alloy-signer-local", + "op-alloy-consensus", + "op-alloy-flz", + "op-alloy-rpc-types", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "base-flashtypes" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7c74d179..a485fc53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ codegen-units = 1 [workspace.dependencies] # local +base-bundles = { path = "crates/bundles" } base-fbal = { path = "crates/fbal" } base-reth-cli = { path = "crates/cli" } base-reth-rpc = { path = "crates/rpc" } @@ -103,6 +104,7 @@ alloy-trie = "0.9.1" alloy-eips = "1.0.41" alloy-serde = "1.0.41" alloy-genesis = "1.0.41" +alloy-signer-local = "1.0.41" alloy-hardforks = "0.4.4" alloy-provider = "1.0.41" alloy-contract = "1.0.41" @@ -116,12 +118,13 @@ alloy-rpc-types-eth = "1.0.41" alloy-rpc-types-engine = "1.0.41" # op-alloy -alloy-op-evm = { version = "0.23.3", default-features = false } +op-alloy-flz = "0.13.1" op-alloy-network = "0.22.0" op-alloy-rpc-types = "0.22.0" op-alloy-consensus = "0.22.0" op-alloy-rpc-jsonrpsee = "0.22.0" op-alloy-rpc-types-engine = "0.22.0" +alloy-op-evm = { version = "0.23.3", default-features = false } # tokio tokio = "1.48.0" diff --git a/crates/bundles/Cargo.toml b/crates/bundles/Cargo.toml new file mode 100644 index 00000000..f39ceaec --- /dev/null +++ b/crates/bundles/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "base-bundles" +description = "Bundle types for transaction bundles" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[features] +test-utils = [ "dep:alloy-signer-local", "dep:op-alloy-rpc-types" ] + +[dependencies] +# alloy +alloy-serde.workspace = true +alloy-primitives = { workspace = true, features = ["serde"] } +alloy-consensus = { workspace = true, features = ["std"] } +alloy-provider = { workspace = true, features = ["reqwest"] } + +# op-alloy +op-alloy-flz.workspace = true +op-alloy-consensus = { workspace = true, features = ["std", "k256", "serde"] } + +# misc +uuid = { workspace = true, features = ["v4", "serde"] } +serde = { workspace = true, features = ["std", "derive"] } + +# test-utils (optional) +alloy-signer-local = { workspace = true, optional = true } +op-alloy-rpc-types = { workspace = true, features = ["std"], optional = true } + +[dev-dependencies] +serde_json = { workspace = true, features = ["std"] } +alloy-signer-local.workspace = true +op-alloy-rpc-types = { workspace = true, features = ["std"] } diff --git a/crates/bundles/README.md b/crates/bundles/README.md new file mode 100644 index 00000000..1875dbba --- /dev/null +++ b/crates/bundles/README.md @@ -0,0 +1,56 @@ +# `base-bundles` + +CI +MIT License + +Types for transaction bundles used in Base's flashblocks infrastructure. Provides types for raw bundles, parsed bundles with decoded transactions, accepted bundles with metering data, and bundle cancellation. + +## Overview + +- **`Bundle`**: Raw bundle type for API requests, mirrors the `eth_sendBundle` format with support for flashblock targeting. +- **`ParsedBundle`**: Decoded bundle with recovered transaction signers, created from raw bundles. +- **`AcceptedBundle`**: Validated and metered bundle ready for inclusion, includes simulation results. +- **`MeterBundleResponse`**: Simulation response containing gas usage, coinbase diff, and per-transaction results. +- **`CancelBundle`**: Request type for cancelling a bundle by its replacement UUID. + +## Usage + +Add the dependency to your `Cargo.toml`: + +```toml +[dependencies] +base-bundles = { git = "https://github.com/base/node-reth" } +``` + +Parse and validate a bundle: + +```rust,ignore +use base_bundles::{Bundle, ParsedBundle, AcceptedBundle, MeterBundleResponse}; + +// Decode a raw bundle into a parsed bundle with recovered signers +let bundle: Bundle = serde_json::from_str(json)?; +let parsed: ParsedBundle = bundle.try_into()?; + +// After metering, create an accepted bundle +let meter_response: MeterBundleResponse = simulate_bundle(&parsed); +let accepted = AcceptedBundle::new(parsed, meter_response); +``` + +Use bundle extension traits for utility methods: + +```rust,ignore +use base_bundles::{ParsedBundle, BundleExtensions}; + +let parsed: ParsedBundle = bundle.try_into()?; + +// Compute bundle hash, get transaction hashes, senders, gas limits, and DA size +let hash = parsed.bundle_hash(); +let tx_hashes = parsed.txn_hashes(); +let senders = parsed.senders(); +let total_gas = parsed.gas_limit(); +let da_bytes = parsed.da_size(); +``` + +## License + +Licensed under the MIT license, as found in [`LICENSE`](../../LICENSE). diff --git a/crates/bundles/src/accepted.rs b/crates/bundles/src/accepted.rs new file mode 100644 index 00000000..1d5d80ae --- /dev/null +++ b/crates/bundles/src/accepted.rs @@ -0,0 +1,179 @@ +//! Accepted bundle type that has been validated and metered. + +use alloy_consensus::transaction::Recovered; +use alloy_primitives::TxHash; +use op_alloy_consensus::OpTxEnvelope; +use uuid::Uuid; + +use crate::{MeterBundleResponse, ParsedBundle}; + +/// `AcceptedBundle` is the type that is sent over the wire after validation. +/// +/// This represents a bundle that has been decoded, validated, and metered. +#[derive(Debug, Clone)] +pub struct AcceptedBundle { + /// Unique identifier for this bundle instance. + pub uuid: Uuid, + + /// Decoded and recovered transactions. + pub txs: Vec>, + + /// The target block number for inclusion. + pub block_number: u64, + + /// Minimum flashblock number for inclusion. + pub flashblock_number_min: Option, + + /// Maximum flashblock number for inclusion. + pub flashblock_number_max: Option, + + /// Minimum timestamp for inclusion. + pub min_timestamp: Option, + + /// Maximum timestamp for inclusion. + pub max_timestamp: Option, + + /// Transaction hashes that are allowed to revert. + pub reverting_tx_hashes: Vec, + + /// UUID for bundle replacement (if this bundle replaces another). + pub replacement_uuid: Option, + + /// Transaction hashes that should be dropped from the pool. + pub dropping_tx_hashes: Vec, + + /// Metering response from bundle simulation. + pub meter_bundle_response: MeterBundleResponse, +} + +impl AcceptedBundle { + /// Creates a new accepted bundle from a parsed bundle and metering response. + pub fn new(bundle: ParsedBundle, meter_bundle_response: MeterBundleResponse) -> Self { + Self { + uuid: bundle.replacement_uuid.unwrap_or_else(Uuid::new_v4), + txs: bundle.txs, + block_number: bundle.block_number, + flashblock_number_min: bundle.flashblock_number_min, + flashblock_number_max: bundle.flashblock_number_max, + min_timestamp: bundle.min_timestamp, + max_timestamp: bundle.max_timestamp, + reverting_tx_hashes: bundle.reverting_tx_hashes, + replacement_uuid: bundle.replacement_uuid, + dropping_tx_hashes: bundle.dropping_tx_hashes, + meter_bundle_response, + } + } + + /// Returns the unique identifier of this bundle. + pub const fn uuid(&self) -> &Uuid { + &self.uuid + } +} + +impl From for ParsedBundle { + fn from(accepted_bundle: AcceptedBundle) -> Self { + Self { + txs: accepted_bundle.txs, + block_number: accepted_bundle.block_number, + flashblock_number_min: accepted_bundle.flashblock_number_min, + flashblock_number_max: accepted_bundle.flashblock_number_max, + min_timestamp: accepted_bundle.min_timestamp, + max_timestamp: accepted_bundle.max_timestamp, + reverting_tx_hashes: accepted_bundle.reverting_tx_hashes, + replacement_uuid: accepted_bundle.replacement_uuid, + dropping_tx_hashes: accepted_bundle.dropping_tx_hashes, + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::U256; + use alloy_provider::network::eip2718::Encodable2718; + use alloy_signer_local::PrivateKeySigner; + + use super::*; + use crate::{ + Bundle, + test_utils::{create_test_meter_bundle_response, create_transaction}, + }; + + #[test] + fn test_accepted_bundle_new() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx_hash = tx.tx_hash(); + let tx_bytes = tx.encoded_2718(); + + let bundle = Bundle { txs: vec![tx_bytes.into()], block_number: 100, ..Default::default() }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + let meter_response = create_test_meter_bundle_response(); + let accepted = AcceptedBundle::new(parsed, meter_response); + + assert!(!accepted.uuid().is_nil()); + assert!(accepted.replacement_uuid.is_none()); + assert_eq!(accepted.txs.len(), 1); + assert_eq!(accepted.txs[0].tx_hash(), tx_hash); + assert_eq!(accepted.block_number, 100); + } + + #[test] + fn test_accepted_bundle_with_replacement_uuid() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx_bytes = tx.encoded_2718(); + + let uuid = Uuid::new_v4(); + let bundle = Bundle { + txs: vec![tx_bytes.into()], + block_number: 100, + replacement_uuid: Some(uuid.to_string()), + ..Default::default() + }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + let meter_response = create_test_meter_bundle_response(); + let accepted = AcceptedBundle::new(parsed, meter_response); + + assert_eq!(*accepted.uuid(), uuid); + assert_eq!(accepted.replacement_uuid, Some(uuid)); + } + + #[test] + fn test_accepted_bundle_to_parsed_bundle() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx_hash = tx.tx_hash(); + let tx_bytes = tx.encoded_2718(); + + let bundle = Bundle { + txs: vec![tx_bytes.into()], + block_number: 100, + flashblock_number_min: Some(1), + flashblock_number_max: Some(5), + min_timestamp: Some(1000), + max_timestamp: Some(2000), + ..Default::default() + }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + let meter_response = create_test_meter_bundle_response(); + let accepted = AcceptedBundle::new(parsed, meter_response); + + let back_to_parsed: ParsedBundle = accepted.into(); + assert_eq!(back_to_parsed.txs.len(), 1); + assert_eq!(back_to_parsed.txs[0].tx_hash(), tx_hash); + assert_eq!(back_to_parsed.block_number, 100); + assert_eq!(back_to_parsed.flashblock_number_min, Some(1)); + assert_eq!(back_to_parsed.flashblock_number_max, Some(5)); + assert_eq!(back_to_parsed.min_timestamp, Some(1000)); + assert_eq!(back_to_parsed.max_timestamp, Some(2000)); + } +} diff --git a/crates/bundles/src/bundle.rs b/crates/bundles/src/bundle.rs new file mode 100644 index 00000000..45d91b71 --- /dev/null +++ b/crates/bundles/src/bundle.rs @@ -0,0 +1,127 @@ +//! Raw bundle type for API requests. + +use alloy_primitives::{Bytes, TxHash}; +use serde::{Deserialize, Serialize}; + +/// `Bundle` is the type that mirrors `EthSendBundle` and is used for the API. +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Bundle { + /// The raw transaction bytes in the bundle. + pub txs: Vec, + + /// The target block number for inclusion. + #[serde(with = "alloy_serde::quantity")] + pub block_number: u64, + + /// Minimum flashblock number for inclusion. + #[serde( + default, + deserialize_with = "alloy_serde::quantity::opt::deserialize", + skip_serializing_if = "Option::is_none" + )] + pub flashblock_number_min: Option, + + /// Maximum flashblock number for inclusion. + #[serde( + default, + deserialize_with = "alloy_serde::quantity::opt::deserialize", + skip_serializing_if = "Option::is_none" + )] + pub flashblock_number_max: Option, + + /// Minimum timestamp for inclusion. + #[serde( + default, + deserialize_with = "alloy_serde::quantity::opt::deserialize", + skip_serializing_if = "Option::is_none" + )] + pub min_timestamp: Option, + + /// Maximum timestamp for inclusion. + #[serde( + default, + deserialize_with = "alloy_serde::quantity::opt::deserialize", + skip_serializing_if = "Option::is_none" + )] + pub max_timestamp: Option, + + /// Transaction hashes that are allowed to revert. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reverting_tx_hashes: Vec, + + /// UUID for bundle replacement. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replacement_uuid: Option, + + /// Transaction hashes that should be dropped from the pool. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dropping_tx_hashes: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bundle_default() { + let bundle = Bundle::default(); + assert!(bundle.txs.is_empty()); + assert_eq!(bundle.block_number, 0); + assert!(bundle.flashblock_number_min.is_none()); + assert!(bundle.flashblock_number_max.is_none()); + assert!(bundle.min_timestamp.is_none()); + assert!(bundle.max_timestamp.is_none()); + assert!(bundle.reverting_tx_hashes.is_empty()); + assert!(bundle.replacement_uuid.is_none()); + assert!(bundle.dropping_tx_hashes.is_empty()); + } + + #[test] + fn test_bundle_serialization() { + let bundle = Bundle { + txs: vec![], + block_number: 12345, + flashblock_number_min: Some(1), + flashblock_number_max: Some(5), + min_timestamp: Some(1000), + max_timestamp: Some(2000), + reverting_tx_hashes: vec![], + replacement_uuid: Some("test-uuid".to_string()), + dropping_tx_hashes: vec![], + }; + + let json = serde_json::to_string(&bundle).unwrap(); + assert!(json.contains("\"blockNumber\":\"0x3039\"")); + // Optional fields serialize as integers, not hex + assert!(json.contains("\"flashblockNumberMin\":1")); + assert!(json.contains("\"flashblockNumberMax\":5")); + assert!(json.contains("\"replacementUuid\":\"test-uuid\"")); + + let deserialized: Bundle = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, bundle); + } + + #[test] + fn test_bundle_deserialization_minimal() { + let json = r#"{ + "txs": [], + "blockNumber": "0x1" + }"#; + + let bundle: Bundle = serde_json::from_str(json).unwrap(); + assert_eq!(bundle.block_number, 1); + assert!(bundle.flashblock_number_min.is_none()); + assert!(bundle.flashblock_number_max.is_none()); + assert!(bundle.min_timestamp.is_none()); + assert!(bundle.max_timestamp.is_none()); + } + + #[test] + fn test_bundle_clone_and_eq() { + let bundle = Bundle { txs: vec![], block_number: 100, ..Default::default() }; + + let cloned = bundle.clone(); + assert_eq!(bundle, cloned); + } +} diff --git a/crates/bundles/src/cancel.rs b/crates/bundles/src/cancel.rs new file mode 100644 index 00000000..da2e7976 --- /dev/null +++ b/crates/bundles/src/cancel.rs @@ -0,0 +1,106 @@ +//! Types for bundle cancellation and identification. + +use alloy_primitives::B256; +use serde::{Deserialize, Serialize}; + +/// Response containing a bundle hash. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BundleHash { + /// The hash identifying the bundle. + pub bundle_hash: B256, +} + +/// Request to cancel a bundle by its replacement UUID. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CancelBundle { + /// The replacement UUID of the bundle to cancel. + pub replacement_uuid: String, +} + +#[cfg(test)] +mod tests { + use alloy_primitives::b256; + + use super::*; + + #[test] + fn test_bundle_hash_serialization() { + let hash = BundleHash { + bundle_hash: b256!( + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + ), + }; + + let json = serde_json::to_string(&hash).unwrap(); + assert!(json.contains( + "\"bundleHash\":\"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\"" + )); + + let deserialized: BundleHash = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, hash); + } + + #[test] + fn test_bundle_hash_deserialization() { + let json = r#"{"bundleHash":"0x0000000000000000000000000000000000000000000000000000000000000001"}"#; + let hash: BundleHash = serde_json::from_str(json).unwrap(); + assert_eq!( + hash.bundle_hash, + b256!("0x0000000000000000000000000000000000000000000000000000000000000001") + ); + } + + #[test] + fn test_cancel_bundle_serialization() { + let cancel = + CancelBundle { replacement_uuid: "550e8400-e29b-41d4-a716-446655440000".to_string() }; + + let json = serde_json::to_string(&cancel).unwrap(); + assert!(json.contains("\"replacementUuid\":\"550e8400-e29b-41d4-a716-446655440000\"")); + + let deserialized: CancelBundle = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, cancel); + } + + #[test] + fn test_cancel_bundle_deserialization() { + let json = r#"{"replacementUuid":"test-uuid-12345"}"#; + let cancel: CancelBundle = serde_json::from_str(json).unwrap(); + assert_eq!(cancel.replacement_uuid, "test-uuid-12345"); + } + + #[test] + fn test_bundle_hash_debug() { + let hash = BundleHash { bundle_hash: B256::default() }; + let debug = format!("{:?}", hash); + assert!(debug.contains("BundleHash")); + } + + #[test] + fn test_cancel_bundle_debug() { + let cancel = CancelBundle { replacement_uuid: "test".to_string() }; + let debug = format!("{:?}", cancel); + assert!(debug.contains("CancelBundle")); + assert!(debug.contains("test")); + } + + #[test] + fn test_bundle_hash_clone() { + let hash = BundleHash { + bundle_hash: b256!( + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + ), + }; + let cloned = hash.clone(); + assert_eq!(hash, cloned); + } + + #[test] + fn test_cancel_bundle_clone() { + let cancel = CancelBundle { replacement_uuid: "my-uuid".to_string() }; + let cloned = cancel.clone(); + assert_eq!(cancel, cloned); + } +} diff --git a/crates/bundles/src/lib.rs b/crates/bundles/src/lib.rs new file mode 100644 index 00000000..b96a2be8 --- /dev/null +++ b/crates/bundles/src/lib.rs @@ -0,0 +1,25 @@ +#![doc = include_str!("../README.md")] +#![doc(issue_tracker_base_url = "https://github.com/base/node-reth/issues/")] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod accepted; +pub use accepted::AcceptedBundle; + +mod bundle; +pub use bundle::Bundle; + +mod cancel; +pub use cancel::{BundleHash, CancelBundle}; + +mod meter; +pub use meter::{MeterBundleResponse, TransactionResult}; + +mod parsed; +pub use parsed::ParsedBundle; + +mod traits; +pub use traits::{BundleExtensions, BundleTxs}; + +#[cfg(any(test, feature = "test-utils"))] +pub mod test_utils; diff --git a/crates/bundles/src/meter.rs b/crates/bundles/src/meter.rs new file mode 100644 index 00000000..f237eea2 --- /dev/null +++ b/crates/bundles/src/meter.rs @@ -0,0 +1,197 @@ +//! Metering response types for bundle simulation. + +use alloy_primitives::{Address, B256, TxHash, U256}; +use serde::{Deserialize, Serialize}; + +/// Result of simulating a single transaction within a bundle. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TransactionResult { + /// Change in coinbase balance after this transaction. + pub coinbase_diff: U256, + /// ETH explicitly sent to coinbase (e.g., via direct transfer). + pub eth_sent_to_coinbase: U256, + /// Sender address of the transaction. + pub from_address: Address, + /// Gas fees paid by this transaction. + pub gas_fees: U256, + /// Gas price of the transaction. + pub gas_price: U256, + /// Gas used by the transaction. + pub gas_used: u64, + /// Recipient address (None for contract creation). + pub to_address: Option
, + /// Hash of the transaction. + pub tx_hash: TxHash, + /// Value transferred in the transaction. + pub value: U256, + /// Time spent executing this transaction in microseconds. + pub execution_time_us: u128, +} + +/// Response from simulating a bundle. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct MeterBundleResponse { + /// Effective gas price of the bundle. + pub bundle_gas_price: U256, + /// Hash of the bundle (keccak256 of concatenated tx hashes). + pub bundle_hash: B256, + /// Total change in coinbase balance. + pub coinbase_diff: U256, + /// Total ETH sent directly to coinbase. + pub eth_sent_to_coinbase: U256, + /// Total gas fees paid. + pub gas_fees: U256, + /// Results for each transaction in the bundle. + pub results: Vec, + /// Block number used for simulation state. + pub state_block_number: u64, + /// Flashblock index used for simulation state. + #[serde( + default, + deserialize_with = "alloy_serde::quantity::opt::deserialize", + skip_serializing_if = "Option::is_none" + )] + pub state_flashblock_index: Option, + /// Total gas used by all transactions. + pub total_gas_used: u64, + /// Total execution time in microseconds. + pub total_execution_time_us: u128, +} + +#[cfg(test)] +mod tests { + use alloy_primitives::address; + + use super::*; + + #[test] + fn test_transaction_result_serialization() { + let result = TransactionResult { + coinbase_diff: U256::from(100), + eth_sent_to_coinbase: U256::from(0), + from_address: address!("0x1111111111111111111111111111111111111111"), + gas_fees: U256::from(21000), + gas_price: U256::from(1_000_000_000), + gas_used: 21000, + to_address: Some(address!("0x2222222222222222222222222222222222222222")), + tx_hash: B256::default(), + value: U256::from(1_000_000_000_000_000_000u64), + execution_time_us: 500, + }; + + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"fromAddress\":\"0x1111111111111111111111111111111111111111\"")); + assert!(json.contains("\"toAddress\":\"0x2222222222222222222222222222222222222222\"")); + assert!(json.contains("\"gasUsed\":21000")); + + let deserialized: TransactionResult = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, result); + } + + #[test] + fn test_transaction_result_contract_creation() { + let result = TransactionResult { + coinbase_diff: U256::from(100), + eth_sent_to_coinbase: U256::from(0), + from_address: address!("0x1111111111111111111111111111111111111111"), + gas_fees: U256::from(100000), + gas_price: U256::from(1_000_000_000), + gas_used: 100000, + to_address: None, + tx_hash: B256::default(), + value: U256::ZERO, + execution_time_us: 1000, + }; + + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"toAddress\":null")); + + let deserialized: TransactionResult = serde_json::from_str(&json).unwrap(); + assert!(deserialized.to_address.is_none()); + } + + #[test] + fn test_meter_bundle_response_default() { + let response = MeterBundleResponse::default(); + assert_eq!(response.bundle_gas_price, U256::ZERO); + assert_eq!(response.coinbase_diff, U256::ZERO); + assert!(response.results.is_empty()); + assert_eq!(response.state_block_number, 0); + assert!(response.state_flashblock_index.is_none()); + assert_eq!(response.total_gas_used, 0); + } + + #[test] + fn test_meter_bundle_response_serialization() { + let response = MeterBundleResponse { + bundle_gas_price: U256::from(1000000000), + bundle_hash: B256::default(), + coinbase_diff: U256::from(100), + eth_sent_to_coinbase: U256::from(0), + gas_fees: U256::from(100), + results: vec![], + state_block_number: 12345, + state_flashblock_index: Some(42), + total_gas_used: 21000, + total_execution_time_us: 1000, + }; + + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"stateFlashblockIndex\":42")); + assert!(json.contains("\"stateBlockNumber\":12345")); + + let deserialized: MeterBundleResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.state_flashblock_index, Some(42)); + assert_eq!(deserialized.state_block_number, 12345); + } + + #[test] + fn test_meter_bundle_response_without_flashblock_index() { + let response = MeterBundleResponse { + bundle_gas_price: U256::from(1000000000), + bundle_hash: B256::default(), + coinbase_diff: U256::from(100), + eth_sent_to_coinbase: U256::from(0), + gas_fees: U256::from(100), + results: vec![], + state_block_number: 12345, + state_flashblock_index: None, + total_gas_used: 21000, + total_execution_time_us: 1000, + }; + + let json = serde_json::to_string(&response).unwrap(); + assert!(!json.contains("stateFlashblockIndex")); + assert!(json.contains("\"stateBlockNumber\":12345")); + + let deserialized: MeterBundleResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.state_flashblock_index, None); + assert_eq!(deserialized.state_block_number, 12345); + } + + #[test] + fn test_meter_bundle_response_deserialization_without_flashblock() { + let json = r#"{ + "bundleGasPrice": "1000000000", + "bundleHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "coinbaseDiff": "100", + "ethSentToCoinbase": "0", + "gasFees": "100", + "results": [], + "stateBlockNumber": 12345, + "totalGasUsed": 21000, + "totalExecutionTimeUs": 1000, + "stateRootTimeUs": 500 + }"#; + + let deserialized: MeterBundleResponse = serde_json::from_str(json).unwrap(); + assert_eq!(deserialized.bundle_gas_price, U256::from(1000000000)); + assert_eq!(deserialized.coinbase_diff, U256::from(100)); + assert_eq!(deserialized.eth_sent_to_coinbase, U256::from(0)); + assert_eq!(deserialized.state_flashblock_index, None); + assert_eq!(deserialized.state_block_number, 12345); + assert_eq!(deserialized.total_gas_used, 21000); + } +} diff --git a/crates/bundles/src/parsed.rs b/crates/bundles/src/parsed.rs new file mode 100644 index 00000000..e6ce5448 --- /dev/null +++ b/crates/bundles/src/parsed.rs @@ -0,0 +1,159 @@ +//! Parsed bundle type with decoded transactions. + +use alloy_consensus::transaction::{Recovered, SignerRecoverable}; +use alloy_primitives::TxHash; +use alloy_provider::network::eip2718::Decodable2718; +use op_alloy_consensus::OpTxEnvelope; +use uuid::Uuid; + +use crate::Bundle; + +/// `ParsedBundle` is the type that contains utility methods for the `Bundle` type. +/// +/// Unlike [`Bundle`], this type has decoded transactions with recovered signers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedBundle { + /// Decoded and recovered transactions. + pub txs: Vec>, + /// The target block number for inclusion. + pub block_number: u64, + /// Minimum flashblock number for inclusion. + pub flashblock_number_min: Option, + /// Maximum flashblock number for inclusion. + pub flashblock_number_max: Option, + /// Minimum timestamp for inclusion. + pub min_timestamp: Option, + /// Maximum timestamp for inclusion. + pub max_timestamp: Option, + /// Transaction hashes that are allowed to revert. + pub reverting_tx_hashes: Vec, + /// UUID for bundle replacement. + pub replacement_uuid: Option, + /// Transaction hashes that should be dropped from the pool. + pub dropping_tx_hashes: Vec, +} + +impl TryFrom for ParsedBundle { + type Error = String; + + fn try_from(bundle: Bundle) -> Result { + let txs: Vec> = bundle + .txs + .into_iter() + .map(|tx| { + OpTxEnvelope::decode_2718_exact(tx.iter().as_slice()) + .map_err(|e| format!("Failed to decode transaction: {e:?}")) + .and_then(|tx| { + tx.try_into_recovered().map_err(|e| { + format!("Failed to convert transaction to recovered: {e:?}") + }) + }) + }) + .collect::>, String>>()?; + + let uuid = bundle + .replacement_uuid + .map(|x| Uuid::parse_str(x.as_ref())) + .transpose() + .map_err(|e| format!("Invalid UUID: {e:?}"))?; + + Ok(Self { + txs, + block_number: bundle.block_number, + flashblock_number_min: bundle.flashblock_number_min, + flashblock_number_max: bundle.flashblock_number_max, + min_timestamp: bundle.min_timestamp, + max_timestamp: bundle.max_timestamp, + reverting_tx_hashes: bundle.reverting_tx_hashes, + replacement_uuid: uuid, + dropping_tx_hashes: bundle.dropping_tx_hashes, + }) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::U256; + use alloy_provider::network::eip2718::Encodable2718; + use alloy_signer_local::PrivateKeySigner; + + use super::*; + use crate::test_utils::create_transaction; + + #[test] + fn test_parsed_bundle_from_bundle() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx_bytes = tx.encoded_2718(); + + let bundle = Bundle { + txs: vec![tx_bytes.into()], + block_number: 100, + flashblock_number_min: Some(1), + flashblock_number_max: Some(5), + min_timestamp: Some(1000), + max_timestamp: Some(2000), + reverting_tx_hashes: vec![], + replacement_uuid: None, + dropping_tx_hashes: vec![], + }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + assert_eq!(parsed.txs.len(), 1); + assert_eq!(parsed.block_number, 100); + assert_eq!(parsed.flashblock_number_min, Some(1)); + assert_eq!(parsed.flashblock_number_max, Some(5)); + assert_eq!(parsed.min_timestamp, Some(1000)); + assert_eq!(parsed.max_timestamp, Some(2000)); + assert!(parsed.replacement_uuid.is_none()); + } + + #[test] + fn test_parsed_bundle_with_uuid() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx_bytes = tx.encoded_2718(); + + let uuid = Uuid::new_v4(); + let bundle = Bundle { + txs: vec![tx_bytes.into()], + block_number: 100, + replacement_uuid: Some(uuid.to_string()), + ..Default::default() + }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + assert_eq!(parsed.replacement_uuid, Some(uuid)); + } + + #[test] + fn test_parsed_bundle_invalid_tx() { + let bundle = Bundle { + txs: vec![vec![0x00, 0x01, 0x02].into()], + block_number: 100, + ..Default::default() + }; + + let result: Result = bundle.try_into(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to decode transaction")); + } + + #[test] + fn test_parsed_bundle_invalid_uuid() { + let bundle = Bundle { + txs: vec![], + block_number: 100, + replacement_uuid: Some("not-a-valid-uuid".to_string()), + ..Default::default() + }; + + let result: Result = bundle.try_into(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid UUID")); + } +} diff --git a/crates/bundles/src/test_utils.rs b/crates/bundles/src/test_utils.rs new file mode 100644 index 00000000..1d93b529 --- /dev/null +++ b/crates/bundles/src/test_utils.rs @@ -0,0 +1,140 @@ +//! Test utilities for bundle types. + +use alloy_consensus::SignableTransaction; +use alloy_primitives::{Address, B256, Bytes, TxHash, U256, b256, bytes}; +use alloy_provider::network::{TxSignerSync, eip2718::Encodable2718}; +use alloy_signer_local::PrivateKeySigner; +use op_alloy_consensus::OpTxEnvelope; +use op_alloy_rpc_types::OpTransactionRequest; + +use crate::{AcceptedBundle, Bundle, MeterBundleResponse}; + +/// Sample transaction data from basescan. +/// +/// +pub const TXN_DATA: Bytes = bytes!( + "0x02f88f8221058304b6b3018315fb3883124f80948ff2f0a8d017c79454aa28509a19ab9753c2dd1480a476d58e1a0182426068c9ea5b00000000000000000002f84f00000000083e4fda54950000c080a086fbc7bbee41f441fb0f32f7aa274d2188c460fe6ac95095fa6331fa08ec4ce7a01aee3bcc3c28f7ba4e0c24da9ae85e9e0166c73cabb42c25ff7b5ecd424f3105" +); + +/// Hash of the sample transaction. +pub const TXN_HASH: TxHash = + b256!("0x4f7ddfc911f5cf85dd15a413f4cbb2a0abe4f1ff275ed13581958c0bcf043c5e"); + +/// Creates a test bundle from the sample transaction data. +pub fn create_bundle_from_txn_data() -> AcceptedBundle { + AcceptedBundle::new( + Bundle { txs: vec![TXN_DATA], ..Default::default() }.try_into().unwrap(), + create_test_meter_bundle_response(), + ) +} + +/// Creates a signed transaction with the given parameters. +pub fn create_transaction( + from: PrivateKeySigner, + nonce: u64, + to: Address, + value: U256, +) -> OpTxEnvelope { + let mut txn = OpTransactionRequest::default() + .value(value) + .gas_limit(21_000) + .max_fee_per_gas(200) + .max_priority_fee_per_gas(100) + .from(from.address()) + .to(to) + .nonce(nonce) + .build_typed_tx() + .unwrap(); + + let sig = from.sign_transaction_sync(&mut txn).unwrap(); + OpTxEnvelope::Eip1559(txn.eip1559().cloned().unwrap().into_signed(sig)) +} + +/// Creates a test bundle with the given transactions and parameters. +pub fn create_test_bundle( + txns: Vec, + block_number: Option, + min_timestamp: Option, + max_timestamp: Option, +) -> AcceptedBundle { + let txs = txns.iter().map(|t| t.encoded_2718().into()).collect(); + + let bundle = Bundle { + txs, + block_number: block_number.unwrap_or(0), + min_timestamp, + max_timestamp, + ..Default::default() + }; + let meter_bundle_response = create_test_meter_bundle_response(); + + AcceptedBundle::new(bundle.try_into().unwrap(), meter_bundle_response) +} + +/// Creates a default meter bundle response for testing. +pub fn create_test_meter_bundle_response() -> MeterBundleResponse { + MeterBundleResponse { + bundle_gas_price: U256::from(0), + bundle_hash: B256::default(), + coinbase_diff: U256::from(0), + eth_sent_to_coinbase: U256::from(0), + gas_fees: U256::from(0), + results: vec![], + state_block_number: 0, + state_flashblock_index: None, + total_gas_used: 0, + total_execution_time_us: 0, + } +} + +#[cfg(test)] +mod tests { + use alloy_consensus::Transaction; + + use super::*; + use crate::traits::BundleExtensions; + + #[test] + fn test_create_bundle_from_txn_data() { + let bundle = create_bundle_from_txn_data(); + assert_eq!(bundle.txs.len(), 1); + assert_eq!(bundle.txn_hashes()[0], TXN_HASH); + } + + #[test] + fn test_create_transaction() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 5, bob.address(), U256::from(1000)); + + assert_eq!(tx.gas_limit(), 21_000); + } + + #[test] + fn test_create_test_bundle() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx1 = create_transaction(alice.clone(), 1, bob.address(), U256::from(100)); + let tx2 = create_transaction(alice.clone(), 2, bob.address(), U256::from(200)); + + let bundle = create_test_bundle(vec![tx1, tx2], Some(100), Some(1000), Some(2000)); + + assert_eq!(bundle.txs.len(), 2); + assert_eq!(bundle.block_number, 100); + assert_eq!(bundle.min_timestamp, Some(1000)); + assert_eq!(bundle.max_timestamp, Some(2000)); + } + + #[test] + fn test_create_test_meter_bundle_response() { + let response = create_test_meter_bundle_response(); + + assert_eq!(response.bundle_gas_price, U256::ZERO); + assert_eq!(response.coinbase_diff, U256::ZERO); + assert!(response.results.is_empty()); + assert_eq!(response.state_block_number, 0); + assert!(response.state_flashblock_index.is_none()); + } +} diff --git a/crates/bundles/src/traits.rs b/crates/bundles/src/traits.rs new file mode 100644 index 00000000..bdb7d3a3 --- /dev/null +++ b/crates/bundles/src/traits.rs @@ -0,0 +1,235 @@ +//! Traits for bundle operations. + +use alloy_consensus::{Transaction, transaction::Recovered}; +use alloy_primitives::{Address, B256, TxHash, keccak256}; +use alloy_provider::network::eip2718::Encodable2718; +use op_alloy_consensus::OpTxEnvelope; +use op_alloy_flz::tx_estimated_size_fjord_bytes; + +use crate::{AcceptedBundle, ParsedBundle}; + +/// Trait for types that contain bundle transactions. +pub trait BundleTxs { + /// Returns a reference to the transactions in the bundle. + fn transactions(&self) -> &Vec>; +} + +/// Extension trait providing utility methods for bundle types. +pub trait BundleExtensions { + /// Computes the bundle hash (keccak256 of concatenated transaction hashes). + fn bundle_hash(&self) -> B256; + /// Returns the transaction hashes in the bundle. + fn txn_hashes(&self) -> Vec; + /// Returns the sender addresses of all transactions. + fn senders(&self) -> Vec
; + /// Returns the total gas limit of all transactions. + fn gas_limit(&self) -> u64; + /// Returns the estimated DA size in bytes (Fjord estimation). + fn da_size(&self) -> u64; +} + +impl BundleExtensions for T { + fn bundle_hash(&self) -> B256 { + let parsed = self.transactions(); + let mut concatenated = Vec::new(); + for tx in parsed { + concatenated.extend_from_slice(tx.tx_hash().as_slice()); + } + keccak256(&concatenated) + } + + fn txn_hashes(&self) -> Vec { + self.transactions().iter().map(|t| t.tx_hash()).collect() + } + + fn senders(&self) -> Vec
{ + self.transactions().iter().map(|t| t.signer()).collect() + } + + fn gas_limit(&self) -> u64 { + self.transactions().iter().map(|t| t.gas_limit()).sum() + } + + fn da_size(&self) -> u64 { + self.transactions().iter().map(|t| tx_estimated_size_fjord_bytes(&t.encoded_2718())).sum() + } +} + +impl BundleTxs for ParsedBundle { + fn transactions(&self) -> &Vec> { + &self.txs + } +} + +impl BundleTxs for AcceptedBundle { + fn transactions(&self) -> &Vec> { + &self.txs + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Keccak256, U256}; + use alloy_provider::network::eip2718::Encodable2718; + use alloy_signer_local::PrivateKeySigner; + + use super::*; + use crate::{ + Bundle, + test_utils::{create_test_meter_bundle_response, create_transaction}, + }; + + #[test] + fn test_bundle_hash_single_tx() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx_bytes = tx.encoded_2718(); + + let bundle = + Bundle { txs: vec![tx_bytes.clone().into()], block_number: 1, ..Default::default() }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + + let expected_hash = { + let mut hasher = Keccak256::default(); + hasher.update(keccak256(&tx_bytes)); + hasher.finalize() + }; + + assert_eq!(parsed.bundle_hash(), expected_hash); + } + + #[test] + fn test_bundle_hash_multiple_txs() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx1 = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx2 = create_transaction(alice.clone(), 2, bob.address(), U256::from(20_000)); + let tx1_bytes = tx1.encoded_2718(); + let tx2_bytes = tx2.encoded_2718(); + + let bundle = Bundle { + txs: vec![tx1_bytes.clone().into(), tx2_bytes.clone().into()], + block_number: 1, + ..Default::default() + }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + + let expected_hash = { + let mut hasher = Keccak256::default(); + hasher.update(keccak256(&tx1_bytes)); + hasher.update(keccak256(&tx2_bytes)); + hasher.finalize() + }; + + assert_eq!(parsed.bundle_hash(), expected_hash); + } + + #[test] + fn test_txn_hashes() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx1 = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx2 = create_transaction(alice.clone(), 2, bob.address(), U256::from(20_000)); + let tx1_hash = tx1.tx_hash(); + let tx2_hash = tx2.tx_hash(); + let tx1_bytes = tx1.encoded_2718(); + let tx2_bytes = tx2.encoded_2718(); + + let bundle = Bundle { + txs: vec![tx1_bytes.into(), tx2_bytes.into()], + block_number: 1, + ..Default::default() + }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + let hashes = parsed.txn_hashes(); + + assert_eq!(hashes.len(), 2); + assert_eq!(hashes[0], tx1_hash); + assert_eq!(hashes[1], tx2_hash); + } + + #[test] + fn test_senders() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx_bytes = tx.encoded_2718(); + + let bundle = Bundle { txs: vec![tx_bytes.into()], block_number: 1, ..Default::default() }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + let senders = parsed.senders(); + + assert_eq!(senders.len(), 1); + assert_eq!(senders[0], alice.address()); + } + + #[test] + fn test_gas_limit() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx1 = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx2 = create_transaction(alice.clone(), 2, bob.address(), U256::from(20_000)); + let tx1_bytes = tx1.encoded_2718(); + let tx2_bytes = tx2.encoded_2718(); + + let bundle = Bundle { + txs: vec![tx1_bytes.into(), tx2_bytes.into()], + block_number: 1, + ..Default::default() + }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + + // Each transaction has gas limit of 21000 + assert_eq!(parsed.gas_limit(), 42000); + } + + #[test] + fn test_da_size() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx_bytes = tx.encoded_2718(); + + let bundle = Bundle { txs: vec![tx_bytes.into()], block_number: 1, ..Default::default() }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + + // DA size should be > 0 for any transaction + assert!(parsed.da_size() > 0); + } + + #[test] + fn test_accepted_bundle_traits() { + let alice = PrivateKeySigner::random(); + let bob = PrivateKeySigner::random(); + + let tx = create_transaction(alice.clone(), 1, bob.address(), U256::from(10_000)); + let tx_hash = tx.tx_hash(); + let tx_bytes = tx.encoded_2718(); + + let bundle = Bundle { txs: vec![tx_bytes.into()], block_number: 1, ..Default::default() }; + + let parsed: ParsedBundle = bundle.try_into().unwrap(); + let accepted = AcceptedBundle::new(parsed.clone(), create_test_meter_bundle_response()); + + // Both should have the same bundle hash + assert_eq!(parsed.bundle_hash(), accepted.bundle_hash()); + assert_eq!(parsed.txn_hashes(), accepted.txn_hashes()); + assert_eq!(parsed.senders(), accepted.senders()); + assert_eq!(parsed.gas_limit(), accepted.gas_limit()); + assert_eq!(parsed.da_size(), accepted.da_size()); + assert_eq!(accepted.txn_hashes()[0], tx_hash); + } +} From c279a41b0d3c54d6d2fff9f386cffecec19a5c93 Mon Sep 17 00:00:00 2001 From: Andreas Bigger Date: Wed, 7 Jan 2026 13:08:51 -0500 Subject: [PATCH 2/4] feat(bundles): new bundles crate :sparkles: --- crates/rpc/tests/eip7702_tests.rs | 112 ++++-------------------------- 1 file changed, 12 insertions(+), 100 deletions(-) diff --git a/crates/rpc/tests/eip7702_tests.rs b/crates/rpc/tests/eip7702_tests.rs index f168991a..85875c9a 100644 --- a/crates/rpc/tests/eip7702_tests.rs +++ b/crates/rpc/tests/eip7702_tests.rs @@ -3,7 +3,7 @@ //! These tests verify that EIP-7702 authorization and delegation //! transactions work correctly in the pending/flashblocks state. -use alloy_consensus::{Receipt, SignableTransaction, TxEip1559, TxEip7702}; +use alloy_consensus::{SignableTransaction, TxEip1559, TxEip7702}; use alloy_eips::{eip2718::Encodable2718, eip7702::Authorization}; use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_provider::Provider; @@ -12,13 +12,10 @@ use base_flashtypes::{ ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1, Flashblock, Metadata, }; use base_reth_test_utils::{ - Account, FlashblocksHarness, L1_BLOCK_INFO_DEPOSIT_TX, L1_BLOCK_INFO_DEPOSIT_TX_HASH, - Minimal7702Account, SignerSync, + Account, FlashblocksHarness, L1_BLOCK_INFO_DEPOSIT_TX, Minimal7702Account, SignerSync, }; use eyre::Result; -use op_alloy_consensus::OpDepositReceipt; use op_alloy_network::ReceiptResponse; -use reth_optimism_primitives::OpReceipt; /// Cumulative gas used after the base flashblock (deposit tx + contract deployment) /// This value must be used as the starting point for subsequent flashblocks. @@ -29,7 +26,6 @@ struct TestSetup { harness: FlashblocksHarness, account_contract_address: Address, account_deploy_tx: Bytes, - account_deploy_hash: B256, } impl TestSetup { @@ -39,10 +35,10 @@ impl TestSetup { // Deploy Minimal7702Account contract let deploy_data = Minimal7702Account::BYTECODE.to_vec(); - let (account_deploy_tx, account_contract_address, account_deploy_hash) = + let (account_deploy_tx, account_contract_address, _) = deployer.create_deployment_tx(Bytes::from(deploy_data), 0)?; - Ok(Self { harness, account_contract_address, account_deploy_tx, account_deploy_hash }) + Ok(Self { harness, account_contract_address, account_deploy_tx }) } async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { @@ -155,38 +151,11 @@ fn create_base_flashblock(setup: &TestSetup) -> Flashblock { transactions: vec![L1_BLOCK_INFO_DEPOSIT_TX.clone(), setup.account_deploy_tx.clone()], ..Default::default() }, - metadata: Metadata { - block_number: 1, - receipts: { - let mut receipts = alloy_primitives::map::HashMap::default(); - receipts.insert( - L1_BLOCK_INFO_DEPOSIT_TX_HASH, - OpReceipt::Deposit(OpDepositReceipt { - inner: Receipt { - status: true.into(), - cumulative_gas_used: 10000, - logs: vec![], - }, - deposit_nonce: Some(4012991u64), - deposit_receipt_version: None, - }), - ); - receipts.insert( - setup.account_deploy_hash, - OpReceipt::Eip1559(Receipt { - status: true.into(), - cumulative_gas_used: 500000, - logs: vec![], - }), - ); - receipts - }, - new_account_balances: alloy_primitives::map::HashMap::default(), - }, + metadata: Metadata { block_number: 1 }, } } -fn create_eip7702_flashblock(eip7702_tx: Bytes, tx_hash: B256, cumulative_gas: u64) -> Flashblock { +fn create_eip7702_flashblock(eip7702_tx: Bytes, cumulative_gas: u64) -> Flashblock { Flashblock { payload_id: alloy_rpc_types_engine::PayloadId::new([0; 8]), index: 1, @@ -202,22 +171,7 @@ fn create_eip7702_flashblock(eip7702_tx: Bytes, tx_hash: B256, cumulative_gas: u logs_bloom: Default::default(), withdrawals_root: Default::default(), }, - metadata: Metadata { - block_number: 1, - receipts: { - let mut receipts = alloy_primitives::map::HashMap::default(); - receipts.insert( - tx_hash, - OpReceipt::Eip7702(Receipt { - status: true.into(), - cumulative_gas_used: cumulative_gas, - logs: vec![], - }), - ); - receipts - }, - new_account_balances: alloy_primitives::map::HashMap::default(), - }, + metadata: Metadata { block_number: 1 }, } } @@ -252,8 +206,7 @@ async fn test_eip7702_delegation_in_pending_flashblock() -> Result<()> { // Create flashblock with the EIP-7702 transaction // Cumulative gas must continue from where the base flashblock left off - let eip7702_flashblock = - create_eip7702_flashblock(eip7702_tx, tx_hash, BASE_CUMULATIVE_GAS + 50000); + let eip7702_flashblock = create_eip7702_flashblock(eip7702_tx, BASE_CUMULATIVE_GAS + 50000); setup.send_flashblock(eip7702_flashblock).await?; // Query pending transaction to verify it was included @@ -307,7 +260,6 @@ async fn test_eip7702_multiple_delegations_same_flashblock() -> Result<()> { // Create flashblock with both transactions // Cumulative gas must continue from where the base flashblock left off - let alice_cumulative = BASE_CUMULATIVE_GAS + 50000; let bob_cumulative = BASE_CUMULATIVE_GAS + 100000; let flashblock = Flashblock { payload_id: alloy_rpc_types_engine::PayloadId::new([0; 8]), @@ -324,30 +276,7 @@ async fn test_eip7702_multiple_delegations_same_flashblock() -> Result<()> { logs_bloom: Default::default(), withdrawals_root: Default::default(), }, - metadata: Metadata { - block_number: 1, - receipts: { - let mut receipts = alloy_primitives::map::HashMap::default(); - receipts.insert( - tx_hash_alice, - OpReceipt::Eip7702(Receipt { - status: true.into(), - cumulative_gas_used: alice_cumulative, - logs: vec![], - }), - ); - receipts.insert( - tx_hash_bob, - OpReceipt::Eip7702(Receipt { - status: true.into(), - cumulative_gas_used: bob_cumulative, - logs: vec![], - }), - ); - receipts - }, - new_account_balances: alloy_primitives::map::HashMap::default(), - }, + metadata: Metadata { block_number: 1 }, }; setup.send_flashblock(flashblock).await?; @@ -388,8 +317,7 @@ async fn test_eip7702_pending_receipt() -> Result<()> { let tx_hash = alloy_primitives::keccak256(&eip7702_tx); // Cumulative gas must continue from where the base flashblock left off - let eip7702_flashblock = - create_eip7702_flashblock(eip7702_tx, tx_hash, BASE_CUMULATIVE_GAS + 50000); + let eip7702_flashblock = create_eip7702_flashblock(eip7702_tx, BASE_CUMULATIVE_GAS + 50000); setup.send_flashblock(eip7702_flashblock).await?; // Query receipt from pending state @@ -430,8 +358,7 @@ async fn test_eip7702_delegation_then_execution() -> Result<()> { ); let delegation_hash = alloy_primitives::keccak256(&delegation_tx); - let delegation_flashblock = - create_eip7702_flashblock(delegation_tx, delegation_hash, delegation_cumulative); + let delegation_flashblock = create_eip7702_flashblock(delegation_tx, delegation_cumulative); setup.send_flashblock(delegation_flashblock).await?; // Second flashblock: execute through delegated account @@ -467,22 +394,7 @@ async fn test_eip7702_delegation_then_execution() -> Result<()> { logs_bloom: Default::default(), withdrawals_root: Default::default(), }, - metadata: Metadata { - block_number: 1, - receipts: { - let mut receipts = alloy_primitives::map::HashMap::default(); - receipts.insert( - execution_hash, - OpReceipt::Eip1559(Receipt { - status: true.into(), - cumulative_gas_used: execution_cumulative, - logs: vec![], - }), - ); - receipts - }, - new_account_balances: alloy_primitives::map::HashMap::default(), - }, + metadata: Metadata { block_number: 1 }, }; setup.send_flashblock(execution_flashblock).await?; From 5df90d4d58992e0167057b5d7ba5049dc5414332 Mon Sep 17 00:00:00 2001 From: Andreas Bigger Date: Wed, 7 Jan 2026 13:12:58 -0500 Subject: [PATCH 3/4] chore: restore eip7702_tests.rs to main version --- crates/rpc/tests/eip7702_tests.rs | 112 ++++++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 12 deletions(-) diff --git a/crates/rpc/tests/eip7702_tests.rs b/crates/rpc/tests/eip7702_tests.rs index 85875c9a..f168991a 100644 --- a/crates/rpc/tests/eip7702_tests.rs +++ b/crates/rpc/tests/eip7702_tests.rs @@ -3,7 +3,7 @@ //! These tests verify that EIP-7702 authorization and delegation //! transactions work correctly in the pending/flashblocks state. -use alloy_consensus::{SignableTransaction, TxEip1559, TxEip7702}; +use alloy_consensus::{Receipt, SignableTransaction, TxEip1559, TxEip7702}; use alloy_eips::{eip2718::Encodable2718, eip7702::Authorization}; use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_provider::Provider; @@ -12,10 +12,13 @@ use base_flashtypes::{ ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1, Flashblock, Metadata, }; use base_reth_test_utils::{ - Account, FlashblocksHarness, L1_BLOCK_INFO_DEPOSIT_TX, Minimal7702Account, SignerSync, + Account, FlashblocksHarness, L1_BLOCK_INFO_DEPOSIT_TX, L1_BLOCK_INFO_DEPOSIT_TX_HASH, + Minimal7702Account, SignerSync, }; use eyre::Result; +use op_alloy_consensus::OpDepositReceipt; use op_alloy_network::ReceiptResponse; +use reth_optimism_primitives::OpReceipt; /// Cumulative gas used after the base flashblock (deposit tx + contract deployment) /// This value must be used as the starting point for subsequent flashblocks. @@ -26,6 +29,7 @@ struct TestSetup { harness: FlashblocksHarness, account_contract_address: Address, account_deploy_tx: Bytes, + account_deploy_hash: B256, } impl TestSetup { @@ -35,10 +39,10 @@ impl TestSetup { // Deploy Minimal7702Account contract let deploy_data = Minimal7702Account::BYTECODE.to_vec(); - let (account_deploy_tx, account_contract_address, _) = + let (account_deploy_tx, account_contract_address, account_deploy_hash) = deployer.create_deployment_tx(Bytes::from(deploy_data), 0)?; - Ok(Self { harness, account_contract_address, account_deploy_tx }) + Ok(Self { harness, account_contract_address, account_deploy_tx, account_deploy_hash }) } async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { @@ -151,11 +155,38 @@ fn create_base_flashblock(setup: &TestSetup) -> Flashblock { transactions: vec![L1_BLOCK_INFO_DEPOSIT_TX.clone(), setup.account_deploy_tx.clone()], ..Default::default() }, - metadata: Metadata { block_number: 1 }, + metadata: Metadata { + block_number: 1, + receipts: { + let mut receipts = alloy_primitives::map::HashMap::default(); + receipts.insert( + L1_BLOCK_INFO_DEPOSIT_TX_HASH, + OpReceipt::Deposit(OpDepositReceipt { + inner: Receipt { + status: true.into(), + cumulative_gas_used: 10000, + logs: vec![], + }, + deposit_nonce: Some(4012991u64), + deposit_receipt_version: None, + }), + ); + receipts.insert( + setup.account_deploy_hash, + OpReceipt::Eip1559(Receipt { + status: true.into(), + cumulative_gas_used: 500000, + logs: vec![], + }), + ); + receipts + }, + new_account_balances: alloy_primitives::map::HashMap::default(), + }, } } -fn create_eip7702_flashblock(eip7702_tx: Bytes, cumulative_gas: u64) -> Flashblock { +fn create_eip7702_flashblock(eip7702_tx: Bytes, tx_hash: B256, cumulative_gas: u64) -> Flashblock { Flashblock { payload_id: alloy_rpc_types_engine::PayloadId::new([0; 8]), index: 1, @@ -171,7 +202,22 @@ fn create_eip7702_flashblock(eip7702_tx: Bytes, cumulative_gas: u64) -> Flashblo logs_bloom: Default::default(), withdrawals_root: Default::default(), }, - metadata: Metadata { block_number: 1 }, + metadata: Metadata { + block_number: 1, + receipts: { + let mut receipts = alloy_primitives::map::HashMap::default(); + receipts.insert( + tx_hash, + OpReceipt::Eip7702(Receipt { + status: true.into(), + cumulative_gas_used: cumulative_gas, + logs: vec![], + }), + ); + receipts + }, + new_account_balances: alloy_primitives::map::HashMap::default(), + }, } } @@ -206,7 +252,8 @@ async fn test_eip7702_delegation_in_pending_flashblock() -> Result<()> { // Create flashblock with the EIP-7702 transaction // Cumulative gas must continue from where the base flashblock left off - let eip7702_flashblock = create_eip7702_flashblock(eip7702_tx, BASE_CUMULATIVE_GAS + 50000); + let eip7702_flashblock = + create_eip7702_flashblock(eip7702_tx, tx_hash, BASE_CUMULATIVE_GAS + 50000); setup.send_flashblock(eip7702_flashblock).await?; // Query pending transaction to verify it was included @@ -260,6 +307,7 @@ async fn test_eip7702_multiple_delegations_same_flashblock() -> Result<()> { // Create flashblock with both transactions // Cumulative gas must continue from where the base flashblock left off + let alice_cumulative = BASE_CUMULATIVE_GAS + 50000; let bob_cumulative = BASE_CUMULATIVE_GAS + 100000; let flashblock = Flashblock { payload_id: alloy_rpc_types_engine::PayloadId::new([0; 8]), @@ -276,7 +324,30 @@ async fn test_eip7702_multiple_delegations_same_flashblock() -> Result<()> { logs_bloom: Default::default(), withdrawals_root: Default::default(), }, - metadata: Metadata { block_number: 1 }, + metadata: Metadata { + block_number: 1, + receipts: { + let mut receipts = alloy_primitives::map::HashMap::default(); + receipts.insert( + tx_hash_alice, + OpReceipt::Eip7702(Receipt { + status: true.into(), + cumulative_gas_used: alice_cumulative, + logs: vec![], + }), + ); + receipts.insert( + tx_hash_bob, + OpReceipt::Eip7702(Receipt { + status: true.into(), + cumulative_gas_used: bob_cumulative, + logs: vec![], + }), + ); + receipts + }, + new_account_balances: alloy_primitives::map::HashMap::default(), + }, }; setup.send_flashblock(flashblock).await?; @@ -317,7 +388,8 @@ async fn test_eip7702_pending_receipt() -> Result<()> { let tx_hash = alloy_primitives::keccak256(&eip7702_tx); // Cumulative gas must continue from where the base flashblock left off - let eip7702_flashblock = create_eip7702_flashblock(eip7702_tx, BASE_CUMULATIVE_GAS + 50000); + let eip7702_flashblock = + create_eip7702_flashblock(eip7702_tx, tx_hash, BASE_CUMULATIVE_GAS + 50000); setup.send_flashblock(eip7702_flashblock).await?; // Query receipt from pending state @@ -358,7 +430,8 @@ async fn test_eip7702_delegation_then_execution() -> Result<()> { ); let delegation_hash = alloy_primitives::keccak256(&delegation_tx); - let delegation_flashblock = create_eip7702_flashblock(delegation_tx, delegation_cumulative); + let delegation_flashblock = + create_eip7702_flashblock(delegation_tx, delegation_hash, delegation_cumulative); setup.send_flashblock(delegation_flashblock).await?; // Second flashblock: execute through delegated account @@ -394,7 +467,22 @@ async fn test_eip7702_delegation_then_execution() -> Result<()> { logs_bloom: Default::default(), withdrawals_root: Default::default(), }, - metadata: Metadata { block_number: 1 }, + metadata: Metadata { + block_number: 1, + receipts: { + let mut receipts = alloy_primitives::map::HashMap::default(); + receipts.insert( + execution_hash, + OpReceipt::Eip1559(Receipt { + status: true.into(), + cumulative_gas_used: execution_cumulative, + logs: vec![], + }), + ); + receipts + }, + new_account_balances: alloy_primitives::map::HashMap::default(), + }, }; setup.send_flashblock(execution_flashblock).await?; From efa3445d0eb228bccb767bdc52f57df79b50ef97 Mon Sep 17 00:00:00 2001 From: Andreas Bigger Date: Wed, 7 Jan 2026 13:54:37 -0500 Subject: [PATCH 4/4] fix: update eip7702_tests.rs for Metadata struct changes Remove receipts and new_account_balances fields from Metadata instantiations after these fields were removed from the struct. Also clean up now-unused imports and variables. --- crates/rpc/tests/eip7702_tests.rs | 112 ++++-------------------------- 1 file changed, 12 insertions(+), 100 deletions(-) diff --git a/crates/rpc/tests/eip7702_tests.rs b/crates/rpc/tests/eip7702_tests.rs index f168991a..85875c9a 100644 --- a/crates/rpc/tests/eip7702_tests.rs +++ b/crates/rpc/tests/eip7702_tests.rs @@ -3,7 +3,7 @@ //! These tests verify that EIP-7702 authorization and delegation //! transactions work correctly in the pending/flashblocks state. -use alloy_consensus::{Receipt, SignableTransaction, TxEip1559, TxEip7702}; +use alloy_consensus::{SignableTransaction, TxEip1559, TxEip7702}; use alloy_eips::{eip2718::Encodable2718, eip7702::Authorization}; use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_provider::Provider; @@ -12,13 +12,10 @@ use base_flashtypes::{ ExecutionPayloadBaseV1, ExecutionPayloadFlashblockDeltaV1, Flashblock, Metadata, }; use base_reth_test_utils::{ - Account, FlashblocksHarness, L1_BLOCK_INFO_DEPOSIT_TX, L1_BLOCK_INFO_DEPOSIT_TX_HASH, - Minimal7702Account, SignerSync, + Account, FlashblocksHarness, L1_BLOCK_INFO_DEPOSIT_TX, Minimal7702Account, SignerSync, }; use eyre::Result; -use op_alloy_consensus::OpDepositReceipt; use op_alloy_network::ReceiptResponse; -use reth_optimism_primitives::OpReceipt; /// Cumulative gas used after the base flashblock (deposit tx + contract deployment) /// This value must be used as the starting point for subsequent flashblocks. @@ -29,7 +26,6 @@ struct TestSetup { harness: FlashblocksHarness, account_contract_address: Address, account_deploy_tx: Bytes, - account_deploy_hash: B256, } impl TestSetup { @@ -39,10 +35,10 @@ impl TestSetup { // Deploy Minimal7702Account contract let deploy_data = Minimal7702Account::BYTECODE.to_vec(); - let (account_deploy_tx, account_contract_address, account_deploy_hash) = + let (account_deploy_tx, account_contract_address, _) = deployer.create_deployment_tx(Bytes::from(deploy_data), 0)?; - Ok(Self { harness, account_contract_address, account_deploy_tx, account_deploy_hash }) + Ok(Self { harness, account_contract_address, account_deploy_tx }) } async fn send_flashblock(&self, flashblock: Flashblock) -> Result<()> { @@ -155,38 +151,11 @@ fn create_base_flashblock(setup: &TestSetup) -> Flashblock { transactions: vec![L1_BLOCK_INFO_DEPOSIT_TX.clone(), setup.account_deploy_tx.clone()], ..Default::default() }, - metadata: Metadata { - block_number: 1, - receipts: { - let mut receipts = alloy_primitives::map::HashMap::default(); - receipts.insert( - L1_BLOCK_INFO_DEPOSIT_TX_HASH, - OpReceipt::Deposit(OpDepositReceipt { - inner: Receipt { - status: true.into(), - cumulative_gas_used: 10000, - logs: vec![], - }, - deposit_nonce: Some(4012991u64), - deposit_receipt_version: None, - }), - ); - receipts.insert( - setup.account_deploy_hash, - OpReceipt::Eip1559(Receipt { - status: true.into(), - cumulative_gas_used: 500000, - logs: vec![], - }), - ); - receipts - }, - new_account_balances: alloy_primitives::map::HashMap::default(), - }, + metadata: Metadata { block_number: 1 }, } } -fn create_eip7702_flashblock(eip7702_tx: Bytes, tx_hash: B256, cumulative_gas: u64) -> Flashblock { +fn create_eip7702_flashblock(eip7702_tx: Bytes, cumulative_gas: u64) -> Flashblock { Flashblock { payload_id: alloy_rpc_types_engine::PayloadId::new([0; 8]), index: 1, @@ -202,22 +171,7 @@ fn create_eip7702_flashblock(eip7702_tx: Bytes, tx_hash: B256, cumulative_gas: u logs_bloom: Default::default(), withdrawals_root: Default::default(), }, - metadata: Metadata { - block_number: 1, - receipts: { - let mut receipts = alloy_primitives::map::HashMap::default(); - receipts.insert( - tx_hash, - OpReceipt::Eip7702(Receipt { - status: true.into(), - cumulative_gas_used: cumulative_gas, - logs: vec![], - }), - ); - receipts - }, - new_account_balances: alloy_primitives::map::HashMap::default(), - }, + metadata: Metadata { block_number: 1 }, } } @@ -252,8 +206,7 @@ async fn test_eip7702_delegation_in_pending_flashblock() -> Result<()> { // Create flashblock with the EIP-7702 transaction // Cumulative gas must continue from where the base flashblock left off - let eip7702_flashblock = - create_eip7702_flashblock(eip7702_tx, tx_hash, BASE_CUMULATIVE_GAS + 50000); + let eip7702_flashblock = create_eip7702_flashblock(eip7702_tx, BASE_CUMULATIVE_GAS + 50000); setup.send_flashblock(eip7702_flashblock).await?; // Query pending transaction to verify it was included @@ -307,7 +260,6 @@ async fn test_eip7702_multiple_delegations_same_flashblock() -> Result<()> { // Create flashblock with both transactions // Cumulative gas must continue from where the base flashblock left off - let alice_cumulative = BASE_CUMULATIVE_GAS + 50000; let bob_cumulative = BASE_CUMULATIVE_GAS + 100000; let flashblock = Flashblock { payload_id: alloy_rpc_types_engine::PayloadId::new([0; 8]), @@ -324,30 +276,7 @@ async fn test_eip7702_multiple_delegations_same_flashblock() -> Result<()> { logs_bloom: Default::default(), withdrawals_root: Default::default(), }, - metadata: Metadata { - block_number: 1, - receipts: { - let mut receipts = alloy_primitives::map::HashMap::default(); - receipts.insert( - tx_hash_alice, - OpReceipt::Eip7702(Receipt { - status: true.into(), - cumulative_gas_used: alice_cumulative, - logs: vec![], - }), - ); - receipts.insert( - tx_hash_bob, - OpReceipt::Eip7702(Receipt { - status: true.into(), - cumulative_gas_used: bob_cumulative, - logs: vec![], - }), - ); - receipts - }, - new_account_balances: alloy_primitives::map::HashMap::default(), - }, + metadata: Metadata { block_number: 1 }, }; setup.send_flashblock(flashblock).await?; @@ -388,8 +317,7 @@ async fn test_eip7702_pending_receipt() -> Result<()> { let tx_hash = alloy_primitives::keccak256(&eip7702_tx); // Cumulative gas must continue from where the base flashblock left off - let eip7702_flashblock = - create_eip7702_flashblock(eip7702_tx, tx_hash, BASE_CUMULATIVE_GAS + 50000); + let eip7702_flashblock = create_eip7702_flashblock(eip7702_tx, BASE_CUMULATIVE_GAS + 50000); setup.send_flashblock(eip7702_flashblock).await?; // Query receipt from pending state @@ -430,8 +358,7 @@ async fn test_eip7702_delegation_then_execution() -> Result<()> { ); let delegation_hash = alloy_primitives::keccak256(&delegation_tx); - let delegation_flashblock = - create_eip7702_flashblock(delegation_tx, delegation_hash, delegation_cumulative); + let delegation_flashblock = create_eip7702_flashblock(delegation_tx, delegation_cumulative); setup.send_flashblock(delegation_flashblock).await?; // Second flashblock: execute through delegated account @@ -467,22 +394,7 @@ async fn test_eip7702_delegation_then_execution() -> Result<()> { logs_bloom: Default::default(), withdrawals_root: Default::default(), }, - metadata: Metadata { - block_number: 1, - receipts: { - let mut receipts = alloy_primitives::map::HashMap::default(); - receipts.insert( - execution_hash, - OpReceipt::Eip1559(Receipt { - status: true.into(), - cumulative_gas_used: execution_cumulative, - logs: vec![], - }), - ); - receipts - }, - new_account_balances: alloy_primitives::map::HashMap::default(), - }, + metadata: Metadata { block_number: 1 }, }; setup.send_flashblock(execution_flashblock).await?;