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`
+
+
+
+
+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);
+ }
+}
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?;