diff --git a/Cargo.lock b/Cargo.lock index 8b120441..e0e7919a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,18 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "alloy-eip7928" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6adac476434bf024279164dcdca299309f0c7d1e3557024eb7a83f8d9d01c6b5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + [[package]] name = "alloy-eips" version = "1.1.2" @@ -1507,6 +1519,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "base-fbal" +version = "0.2.1" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-macro", + "alloy-sol-types", + "eyre", + "op-revm", + "reth-evm", + "reth-optimism-chainspec", + "reth-optimism-evm", + "revm", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "base-flashtypes" version = "0.2.1" @@ -1530,10 +1564,6 @@ dependencies = [ "reth", ] -[[package]] -name = "base-reth-fbal" -version = "0.2.1" - [[package]] name = "base-reth-flashblocks" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 6d99fbcc..8fd8d856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,9 +97,11 @@ revm = { version = "31.0.2", default-features = false } revm-bytecode = { version = "7.1.1", default-features = false } # alloy +alloy-rlp = "0.3.10" alloy-trie = "0.9.1" alloy-eips = "1.0.41" alloy-serde = "1.0.41" +alloy-eip7928 = "0.3.0" alloy-genesis = "1.0.41" alloy-signer-local = "1.0.41" alloy-hardforks = "0.4.4" @@ -123,6 +125,9 @@ 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 } +# op-revm +op-revm = { version = "12.0.2", default-features = false } + # tokio tokio = "1.48.0" tokio-stream = "0.1.17" diff --git a/crates/fbal/Cargo.toml b/crates/fbal/Cargo.toml index ced261b8..7ea41d74 100644 --- a/crates/fbal/Cargo.toml +++ b/crates/fbal/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "base-reth-fbal" +name = "base-fbal" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -12,5 +12,25 @@ description = "FBAL library crate" workspace = true [dependencies] +alloy-primitives.workspace = true +alloy-eip7928 = {workspace = true, features = ["serde", "rlp"]} +alloy-rlp = {workspace = true, features = ["derive"]} +tracing.workspace = true +revm.workspace = true +serde.workspace = true [dev-dependencies] +op-revm.workspace = true +eyre.workspace = true +reth-optimism-chainspec.workspace = true +reth-optimism-evm.workspace = true +alloy-consensus.workspace = true +alloy-contract.workspace = true +alloy-sol-macro = { workspace = true, features = ["json"] } +alloy-sol-types.workspace = true +reth-evm.workspace = true +serde_json.workspace = true + +[[test]] +name = "builder" +path = "tests/builder/main.rs" diff --git a/crates/fbal/README.md b/crates/fbal/README.md new file mode 100644 index 00000000..8366c9b8 --- /dev/null +++ b/crates/fbal/README.md @@ -0,0 +1,42 @@ +# `base-fbal` + +A library to build and process Flashblock-level Access Lists (FBALs). + +## Overview + +This crate provides types and utilities for tracking account and storage changes during EVM transaction execution, producing access lists that can be used by downstream consumers to understand exactly what state was read or modified. + +- `FBALBuilderDb` - A database wrapper that tracks reads and writes during transaction execution. +- `FlashblockAccessListBuilder` - A builder pattern for constructing access lists from tracked changes. +- `FlashblockAccessList` - The final access list containing all account changes, storage changes, and metadata. + +## Usage + +Wrap your database with `FBALBuilderDb`, execute transactions, then call `finish()` to retrieve the builder: + +```rust,ignore +use base_fbal::{FBALBuilderDb, FlashblockAccessList}; +use revm::database::InMemoryDB; + +// Create a wrapped database +let db = InMemoryDB::default(); +let mut fbal_db = FBALBuilderDb::new(db); + +// Execute transactions, calling set_index() before each one +for (i, tx) in transactions.into_iter().enumerate() { + fbal_db.set_index(i as u64); + // ... execute transaction with fbal_db ... + fbal_db.commit(state_changes); +} + +// Build the access list +let builder = fbal_db.finish()?; +let access_list = builder.build(0, max_tx_index); +``` + +## Features + +- Tracks balance, nonce, and code changes per account +- Tracks storage slot reads and writes +- Associates each change with its transaction index +- Produces RLP-encodable access lists with a commitment hash diff --git a/crates/fbal/src/builder.rs b/crates/fbal/src/builder.rs new file mode 100644 index 00000000..6ff68628 --- /dev/null +++ b/crates/fbal/src/builder.rs @@ -0,0 +1,118 @@ +use std::u64; + +use alloy_eip7928::{ + AccountChanges, BalanceChange, CodeChange, NonceChange, SlotChanges, StorageChange, +}; +use alloy_primitives::{Address, U256}; +use revm::{ + primitives::{HashMap, HashSet}, + state::Bytecode, +}; + +use crate::FlashblockAccessList; + +/// A builder type for [`FlashblockAccessList`] +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct FlashblockAccessListBuilder { + /// Mapping from Address -> [`AccountChangesBuilder`] + pub changes: HashMap, +} + +impl FlashblockAccessListBuilder { + /// Creates a new [`FlashblockAccessListBuilder`] + pub fn new() -> Self { + Self { changes: Default::default() } + } + + /// Merges another [`FlashblockAccessListBuilder`] with this one + pub fn merge(&mut self, other: Self) { + for (address, changes) in other.changes.into_iter() { + self.changes + .entry(address) + .and_modify(|prev| prev.merge(changes.clone())) + .or_insert(changes); + } + } + + /// Consumes the builder and produces a [`FlashblockAccessList`] + pub fn build(self, min_tx_index: u64, max_tx_index: u64) -> FlashblockAccessList { + let mut changes: Vec<_> = self.changes.into_iter().map(|(k, v)| v.build(k)).collect(); + changes.sort_unstable_by_key(|a| a.address); + + FlashblockAccessList::build(changes, min_tx_index, max_tx_index) + } +} + +/// A builder type for [`AccountChanges`] +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct AccountChangesBuilder { + /// Mapping from Storage Slot -> (Transaction Index -> New Value) + pub storage_changes: HashMap>, + /// Set of storage slots + pub storage_reads: HashSet, + /// Mapping from Transaction Index -> New Balance + pub balance_changes: HashMap, + /// Mapping from Transaction Index -> New Nonce + pub nonce_changes: HashMap, + /// Mapping from Transaction Index -> New Code + pub code_changes: HashMap, +} + +impl AccountChangesBuilder { + /// Merges another [`AccountChangesBuilder`] with this one + pub fn merge(&mut self, other: Self) { + for (slot, sc) in other.storage_changes { + self.storage_changes + .entry(slot) + .and_modify(|prev| prev.extend(sc.clone())) + .or_insert(sc); + } + self.storage_reads.extend(other.storage_reads); + self.balance_changes.extend(other.balance_changes); + self.nonce_changes.extend(other.nonce_changes); + self.code_changes.extend(other.code_changes); + } + + /// Consumes the builder and produces [`AccountChanges`] + pub fn build(mut self, address: Address) -> AccountChanges { + AccountChanges { + address, + storage_changes: self + .storage_changes + .drain() + .map(|(slot, sc)| SlotChanges { + slot: slot.into(), + changes: sc + .into_iter() + .map(|(tx_idx, val)| StorageChange { + block_access_index: tx_idx, + new_value: val.into(), + }) + .collect(), + }) + .collect(), + storage_reads: self.storage_reads.into_iter().collect(), + balance_changes: self + .balance_changes + .into_iter() + .map(|(tx_idx, val)| BalanceChange { + block_access_index: tx_idx, + post_balance: val, + }) + .collect(), + nonce_changes: self + .nonce_changes + .into_iter() + .map(|(tx_idx, val)| NonceChange { block_access_index: tx_idx, new_nonce: val }) + .collect(), + code_changes: self + .code_changes + .into_iter() + .map(|(tx_idx, bc)| CodeChange { + block_access_index: tx_idx, + new_code: bc.original_bytes(), + }) + .collect(), + } + } +} diff --git a/crates/fbal/src/db.rs b/crates/fbal/src/db.rs new file mode 100644 index 00000000..dfacef22 --- /dev/null +++ b/crates/fbal/src/db.rs @@ -0,0 +1,172 @@ +use alloy_primitives::{Address, B256}; +use revm::{ + Database, DatabaseCommit, + primitives::{HashMap, KECCAK_EMPTY, StorageKey, StorageValue}, + state::{Account, AccountInfo, Bytecode}, +}; +use tracing::error; + +use crate::builder::FlashblockAccessListBuilder; + +/// A [`Database`] implementation that builds an access list based on reads and writes +/// Use [`FBALBuilderDb::finish`] to build and retrieve the access list +#[derive(Debug)] +pub struct FBALBuilderDb +where + DB: DatabaseCommit + Database, +{ + /// Underlying CacheDB + db: DB, + /// Transaction index of the txn being currently executed + index: u64, + /// Builder for the access list + access_list: FlashblockAccessListBuilder, + /// The most recent error generated during a commit attempt + /// We need to store this as [`DatabaseCommit`] does not return an error + /// and we need to return it on [`FBALBuilderDb::finish`] as that implies + /// we weren't able to construct the access list properly + error: Option<::Error>, +} + +impl FBALBuilderDb +where + DB: DatabaseCommit + Database, +{ + /// Creates a new instance of [`FBALBuilderDb`] with the given underlying database + pub fn new(db: DB) -> Self { + Self { db, index: 0, access_list: Default::default(), error: None } + } + + /// Returns a reference to the underlying database + pub fn db(&self) -> &DB { + &self.db + } + + /// Returns a mutable reference to the underlying database + pub fn db_mut(&mut self) -> &mut DB { + &mut self.db + } + + /// Sets the transaction index of the txn being currently executed + pub fn set_index(&mut self, index: u64) { + self.index = index; + } + + /// Attempts to commit the changes to the underlying database + /// as well as applies account/storage changes to the access list builder + fn try_commit( + &mut self, + changes: HashMap, + ) -> Result<(), ::Error> { + for (address, account) in changes.iter() { + let account_changes = self.access_list.changes.entry(*address).or_default(); + + // Update balance, nonce, and code + match self.db.basic(*address)? { + Some(prev) => { + if prev.balance != account.info.balance { + account_changes.balance_changes.insert(self.index, account.info.balance); + } + + if prev.nonce != account.info.nonce { + account_changes.nonce_changes.insert(self.index, account.info.nonce); + } + + if prev.code_hash != account.info.code_hash { + let bytecode = match account.info.code.clone() { + Some(code) => code, + None => self.db.code_by_hash(account.info.code_hash)?, + }; + account_changes.code_changes.insert(self.index, bytecode); + } + } + None => { + // For new accounts, only record changes if they differ from defaults + if !account.info.balance.is_zero() { + account_changes.balance_changes.insert(self.index, account.info.balance); + } + if account.info.nonce != 0 { + account_changes.nonce_changes.insert(self.index, account.info.nonce); + } + // Only record code changes if the account actually has code + if account.info.code_hash != KECCAK_EMPTY { + let bytecode = match account.info.code.clone() { + Some(code) => code, + None => self.db.code_by_hash(account.info.code_hash)?, + }; + account_changes.code_changes.insert(self.index, bytecode); + } + } + } + + // Update storage + for (slot, value) in account.storage.iter() { + let prev = value.original_value; + let new = value.present_value; + + if prev != new { + account_changes + .storage_changes + .entry(*slot) + .or_default() + .insert(self.index, new); + } + } + } + + self.db.commit(changes); + Ok(()) + } + + /// Consumes the database and returns the access list back as well as the most recent + /// error during commiting if any + pub fn finish(self) -> Result::Error> { + if let Some(e) = self.error { + return Err(e); + } + + Ok(self.access_list) + } +} + +impl Database for FBALBuilderDb +where + DB: DatabaseCommit + Database, +{ + type Error = ::Error; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + self.access_list.changes.entry(address).or_default(); + self.db.basic(address) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + self.db.code_by_hash(code_hash) + } + + fn storage( + &mut self, + address: Address, + index: StorageKey, + ) -> Result { + let account = self.access_list.changes.entry(address).or_default(); + account.storage_reads.insert(index); + self.db.storage(address, index) + } + + fn block_hash(&mut self, number: u64) -> Result { + self.db.block_hash(number) + } +} + +impl DatabaseCommit for FBALBuilderDb +where + DB: DatabaseCommit + Database, +{ + fn commit(&mut self, changes: HashMap) { + if let Err(e) = self.try_commit(changes) { + error!("Failed to commit changes via FBALBuilderDb: {:?}", e); + self.error = Some(e); + } + } +} diff --git a/crates/fbal/src/lib.rs b/crates/fbal/src/lib.rs index f83db7d5..806445f3 100644 --- a/crates/fbal/src/lib.rs +++ b/crates/fbal/src/lib.rs @@ -1,3 +1,12 @@ -//! FBAL library crate +#![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))] -#![warn(missing_docs)] +mod builder; +mod db; +mod types; + +pub use builder::{AccountChangesBuilder, FlashblockAccessListBuilder}; +pub use db::FBALBuilderDb; +pub use types::FlashblockAccessList; diff --git a/crates/fbal/src/types.rs b/crates/fbal/src/types.rs new file mode 100644 index 00000000..8e57da55 --- /dev/null +++ b/crates/fbal/src/types.rs @@ -0,0 +1,36 @@ +use alloy_eip7928::AccountChanges; +use alloy_primitives::{B256, keccak256}; +use alloy_rlp::{Encodable, RlpDecodable, RlpEncodable}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Default, Deserialize, Serialize, RlpEncodable, RlpDecodable)] +/// FlashblockAccessList represents the access list for a single flashblock +pub struct FlashblockAccessList { + /// All the account changes in this access list + pub account_changes: Vec, + /// Minimum txn index from the full block that's included in this access list + pub min_tx_index: u64, + /// Maximum txn index from the full block that's included in this access list + pub max_tx_index: u64, + /// keccak256 hash of the RLP-encoded account changes list + pub fal_hash: B256, +} + +impl FlashblockAccessList { + /// Builds a new FlashblockAccessList from the given account changes + pub fn build( + account_changes: Vec, + min_tx_index: u64, + max_tx_index: u64, + ) -> Self { + let mut encoded = Vec::new(); + account_changes.encode(&mut encoded); + + FlashblockAccessList { + account_changes, + min_tx_index, + max_tx_index, + fal_hash: keccak256(encoded), + } + } +} diff --git a/crates/fbal/tests/builder/delegatecall.rs b/crates/fbal/tests/builder/delegatecall.rs new file mode 100644 index 00000000..97c02dcd --- /dev/null +++ b/crates/fbal/tests/builder/delegatecall.rs @@ -0,0 +1,247 @@ +//! Tests for DELEGATECALL storage pattern tracking + +use std::collections::HashMap; + +use super::{ + AccountInfo, BASE_SEPOLIA_CHAIN_ID, Bytecode, IntoAddress, Logic, Logic2, ONE_ETHER, + OpTransaction, Proxy, SolCall, TxEnv, TxKind, U256, execute_txns_build_access_list, +}; + +#[test] +/// Tests that DELEGATECALL storage changes are tracked on the calling contract (Proxy), +/// not the logic contract +fn test_delegatecall_storage_tracked_on_caller() { + let sender = U256::from(0xDEAD).into_address(); + let logic_addr = U256::from(0xBEEF).into_address(); + let proxy_addr = U256::from(0xCAFE).into_address(); + + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + + // Deploy logic contract first + overrides.insert( + logic_addr, + AccountInfo::default().with_code(Bytecode::new_raw(Logic::DEPLOYED_BYTECODE.clone())), + ); + + // Deploy proxy contract with logic as implementation + // We need to set up the proxy's storage slot 0 to point to logic + overrides.insert( + proxy_addr, + AccountInfo::default().with_code(Bytecode::new_raw(Proxy::DEPLOYED_BYTECODE.clone())), + ); + + // Call setValue(42) through the proxy + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(proxy_addr)) + .data(Logic::setValueCall { v: U256::from(42) }.abi_encode().into()) + .nonce(0) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(200_000), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list( + vec![tx], + Some(overrides), + Some(HashMap::from([(proxy_addr, HashMap::from([(U256::ZERO, logic_addr.into_word())]))])), + ) + .expect("access list build should succeed"); + + // Verify that proxy is in touched accounts + let proxy_changes = access_list + .account_changes + .iter() + .find(|ac| ac.address == proxy_addr) + .expect("Proxy should be in account changes"); + + // Verify storage change for slot 1 is on the PROXY, not the logic contract + // Slot 1 is where `value` is stored + let slot_1 = U256::from(1); + let has_slot_1_change = proxy_changes.storage_changes.iter().any(|sc| sc.slot == slot_1); + assert!(has_slot_1_change, "Proxy should have storage change for slot 1 (value)"); + + // Verify logic contract has NO storage changes (it's just providing code) + let logic_changes = access_list.account_changes.iter().find(|ac| ac.address == logic_addr); + if let Some(logic) = logic_changes { + assert!(logic.storage_changes.is_empty(), "Logic contract should have no storage changes"); + } +} + +#[test] +/// Tests that DELEGATECALL storage reads are tracked on the calling contract +fn test_delegatecall_read_tracked_on_caller() { + let sender = U256::from(0xDEAD).into_address(); + let logic_addr = U256::from(0xBEEF).into_address(); + let proxy_addr = U256::from(0xCAFE).into_address(); + + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + overrides.insert( + logic_addr, + AccountInfo::default().with_code(Bytecode::new_raw(Logic::DEPLOYED_BYTECODE.clone())), + ); + overrides.insert( + proxy_addr, + AccountInfo::default().with_code(Bytecode::new_raw(Proxy::DEPLOYED_BYTECODE.clone())), + ); + + // First set a value, then read it + let set_tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(proxy_addr)) + .data(Logic::setValueCall { v: U256::from(42) }.abi_encode().into()) + .nonce(0) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(200_000), + ) + .build_fill(); + + let get_tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(proxy_addr)) + .data(Logic::getValueCall {}.abi_encode().into()) + .nonce(1) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(200_000), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list( + vec![set_tx, get_tx], + Some(overrides), + Some(HashMap::from([(proxy_addr, HashMap::from([(U256::ZERO, logic_addr.into_word())]))])), + ) + .expect("access list build should succeed"); + + // Verify proxy has storage reads recorded + let proxy_changes = access_list + .account_changes + .iter() + .find(|ac| ac.address == proxy_addr) + .expect("Proxy should be in account changes"); + + // Slot 1 should have been read (for getValue) + let slot_1 = U256::from(1); + let has_slot_1_read = proxy_changes.storage_reads.iter().any(|sr| *sr == slot_1); + assert!(has_slot_1_read, "Proxy should have storage read for slot 1"); + + // Verify both addresses are in touched accounts + assert!( + access_list.account_changes.iter().any(|ac| ac.address == proxy_addr), + "Proxy should be in touched accounts" + ); + assert!( + access_list.account_changes.iter().any(|ac| ac.address == logic_addr), + "Logic should be in touched accounts" + ); +} + +#[test] +/// Tests chained DELEGATECALL: Proxy -> Logic2 -> Logic +/// Storage changes should still be tracked on the original Proxy +fn test_delegatecall_chain() { + let sender = U256::from(0xDEAD).into_address(); + let logic_addr = U256::from(0xBEEF).into_address(); + let logic2_addr = U256::from(0xFACE).into_address(); + let proxy_addr = U256::from(0xCAFE).into_address(); + + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + overrides.insert( + logic_addr, + AccountInfo::default().with_code(Bytecode::new_raw(Logic::DEPLOYED_BYTECODE.clone())), + ); + overrides.insert( + logic2_addr, + AccountInfo::default().with_code(Bytecode::new_raw(Logic2::DEPLOYED_BYTECODE.clone())), + ); + overrides.insert( + proxy_addr, + AccountInfo::default().with_code(Bytecode::new_raw(Proxy::DEPLOYED_BYTECODE.clone())), + ); + + // Storage overrides: + // - Proxy slot 0 = logic2_addr (implementation) + // - Proxy slot 3 = logic_addr (nextLogic) - Because Logic2's chainedDelegatecall + // runs in Proxy's context, it reads nextLogic from Proxy's storage slot 3 + let storage_overrides = HashMap::from([( + proxy_addr, + HashMap::from([ + (U256::ZERO, logic2_addr.into_word()), + (U256::from(3), logic_addr.into_word()), + ]), + )]); + + // Call chainedDelegatecall with setValue(99) encoded + let inner_call = Logic::setValueCall { v: U256::from(99) }.abi_encode(); + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(proxy_addr)) + .data( + Logic2::chainedDelegatecallCall { data: inner_call.into() }.abi_encode().into(), + ) + .nonce(0) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(300_000), + ) + .build_fill(); + + let access_list = + execute_txns_build_access_list(vec![tx], Some(overrides), Some(storage_overrides)) + .expect("access list build should succeed"); + + // Verify all three addresses are in touched accounts + assert!( + access_list.account_changes.iter().any(|ac| ac.address == proxy_addr), + "Proxy should be in touched accounts" + ); + assert!( + access_list.account_changes.iter().any(|ac| ac.address == logic2_addr), + "Logic2 should be in touched accounts" + ); + assert!( + access_list.account_changes.iter().any(|ac| ac.address == logic_addr), + "Logic should be in touched accounts" + ); + + // Storage changes should be on the PROXY (the original caller) + let proxy_changes = access_list + .account_changes + .iter() + .find(|ac| ac.address == proxy_addr) + .expect("Proxy should have account changes"); + + let slot_1 = U256::from(1); + let has_value_change = proxy_changes.storage_changes.iter().any(|sc| sc.slot == slot_1); + assert!(has_value_change, "Proxy should have storage change for slot 1 (value)"); + + // Logic contracts should have no storage changes + if let Some(logic2) = access_list.account_changes.iter().find(|ac| ac.address == logic2_addr) { + assert!(logic2.storage_changes.is_empty(), "Logic2 should have no storage changes"); + } + if let Some(logic) = access_list.account_changes.iter().find(|ac| ac.address == logic_addr) { + assert!(logic.storage_changes.is_empty(), "Logic should have no storage changes"); + } +} diff --git a/crates/fbal/tests/builder/deployment.rs b/crates/fbal/tests/builder/deployment.rs new file mode 100644 index 00000000..ea25add6 --- /dev/null +++ b/crates/fbal/tests/builder/deployment.rs @@ -0,0 +1,210 @@ +//! Tests for CREATE/CREATE2 contract deployment tracking in the access list + +use std::collections::HashMap; + +use super::{ + AccountInfo, B256, BASE_SEPOLIA_CHAIN_ID, Bytecode, ContractFactory, IntoAddress, ONE_ETHER, + OpTransaction, SimpleStorage, SolCall, TxEnv, TxKind, U256, execute_txns_build_access_list, +}; + +#[test] +/// Tests that contract deployment via CREATE is tracked in the access list +/// Verifies: +/// - Factory contract address is in touched accounts +/// - Newly deployed contract address is in touched accounts +/// - Code change is recorded for the new contract +/// - Nonce change is recorded for the factory (CREATE increments nonce) +fn test_create_deployment_tracked() { + let sender = U256::from(0xDEAD).into_address(); + let factory = U256::from(0xFAC0).into_address(); + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + overrides.insert( + factory, + AccountInfo::default() + .with_code(Bytecode::new_raw(ContractFactory::DEPLOYED_BYTECODE.clone())), + ); + + // Deploy SimpleStorage via CREATE + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(factory)) + .data( + ContractFactory::deployWithCreateCall { + bytecode: SimpleStorage::BYTECODE.to_vec().into(), + } + .abi_encode() + .into(), + ) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(500_000), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list(vec![tx], Some(overrides), None) + .expect("access list build should succeed"); + + // Verify factory is in the access list + let factory_entry = access_list.account_changes.iter().find(|ac| ac.address == factory); + assert!(factory_entry.is_some(), "Factory should be in access list"); + + // The factory's nonce should change (CREATE increments deployer nonce) + let factory_changes = factory_entry.unwrap(); + assert!(!factory_changes.nonce_changes.is_empty(), "Factory nonce should change due to CREATE"); + + // Find the deployed contract - it should have a code change + let deployed_entry = access_list + .account_changes + .iter() + .find(|ac| !ac.code_changes.is_empty() && ac.address != factory); + assert!(deployed_entry.is_some(), "Deployed contract should have code change"); + + let deployed_changes = deployed_entry.unwrap(); + assert_eq!(deployed_changes.code_changes.len(), 1, "Should have exactly one code change"); + + // Verify the deployed bytecode matches SimpleStorage's deployed bytecode + let code_change = &deployed_changes.code_changes[0]; + assert!(!code_change.new_code.is_empty(), "Deployed code should not be empty"); +} + +#[test] +/// Tests that contract deployment via CREATE2 is tracked in the access list +/// Verifies: +/// - Factory contract address is in touched accounts +/// - Deployed address (deterministic) is in touched accounts +/// - Code change is recorded with correct bytecode +fn test_create2_deployment_tracked() { + let sender = U256::from(0xDEAD).into_address(); + let factory = U256::from(0xFAC0).into_address(); + let salt = B256::from(U256::from(12345)); + + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + overrides.insert( + factory, + AccountInfo::default() + .with_code(Bytecode::new_raw(ContractFactory::DEPLOYED_BYTECODE.clone())), + ); + + // Deploy SimpleStorage via CREATE2 + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(factory)) + .data( + ContractFactory::deployWithCreate2Call { + bytecode: SimpleStorage::BYTECODE.to_vec().into(), + salt, + } + .abi_encode() + .into(), + ) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(500_000), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list(vec![tx], Some(overrides), None) + .expect("access list build should succeed"); + + // Verify factory is in the access list + let factory_entry = access_list.account_changes.iter().find(|ac| ac.address == factory); + assert!(factory_entry.is_some(), "Factory should be in access list"); + + // Find the deployed contract - it should have a code change + let deployed_entry = access_list + .account_changes + .iter() + .find(|ac| !ac.code_changes.is_empty() && ac.address != factory); + assert!(deployed_entry.is_some(), "Deployed contract should have code change"); + + let deployed_changes = deployed_entry.unwrap(); + assert_eq!(deployed_changes.code_changes.len(), 1, "Should have exactly one code change"); + + // Verify the deployed bytecode is present + let code_change = &deployed_changes.code_changes[0]; + assert!(!code_change.new_code.is_empty(), "Deployed code should not be empty"); +} + +#[test] +/// Tests that deploying a contract and immediately calling it tracks both operations +/// Verifies: +/// - Both the factory and deployed contract are tracked +/// - Code change for deployment is recorded +/// - Storage change from the call is recorded on the new contract's address +fn test_create_and_immediate_call() { + let sender = U256::from(0xDEAD).into_address(); + let factory = U256::from(0xFAC0).into_address(); + + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + overrides.insert( + factory, + AccountInfo::default() + .with_code(Bytecode::new_raw(ContractFactory::DEPLOYED_BYTECODE.clone())), + ); + + // Deploy SimpleStorage and immediately call setValue(42) + let set_value_calldata = SimpleStorage::setValueCall { v: U256::from(42) }.abi_encode(); + + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(factory)) + .data( + ContractFactory::deployAndCallCall { + bytecode: SimpleStorage::BYTECODE.to_vec().into(), + callData: set_value_calldata.into(), + } + .abi_encode() + .into(), + ) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(500_000), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list(vec![tx], Some(overrides), None) + .expect("access list build should succeed"); + + // Verify factory is in the access list + let factory_entry = access_list.account_changes.iter().find(|ac| ac.address == factory); + assert!(factory_entry.is_some(), "Factory should be in access list"); + + // Find the deployed contract - it should have both code change AND storage change + let deployed_entry = access_list + .account_changes + .iter() + .find(|ac| !ac.code_changes.is_empty() && ac.address != factory); + assert!(deployed_entry.is_some(), "Deployed contract should have code change"); + + let deployed_changes = deployed_entry.unwrap(); + + // Verify code change exists + assert_eq!(deployed_changes.code_changes.len(), 1, "Should have exactly one code change"); + + // Verify storage change exists (from setValue(42)) + // SimpleStorage stores `value` at slot 0 + assert!( + !deployed_changes.storage_changes.is_empty(), + "Should have storage change from setValue call" + ); + + // Verify the storage slot is 0 and value is 42 + let storage_change = &deployed_changes.storage_changes[0]; + assert_eq!(storage_change.slot, U256::ZERO, "Storage slot should be 0"); + assert_eq!(storage_change.changes[0].new_value, U256::from(42), "Storage value should be 42"); +} diff --git a/crates/fbal/tests/builder/main.rs b/crates/fbal/tests/builder/main.rs new file mode 100644 index 00000000..c6c09ad8 --- /dev/null +++ b/crates/fbal/tests/builder/main.rs @@ -0,0 +1,127 @@ +//! Tests for ensuring the access list is built properly + +use std::{collections::HashMap, sync::Arc}; + +use alloy_consensus::Header; +pub use alloy_primitives::{Address, B256, TxKind, U256}; +use alloy_sol_macro::sol; +pub use alloy_sol_types::SolCall; +use base_fbal::FBALBuilderDb; +pub use base_fbal::FlashblockAccessList; +pub use eyre::Result; +pub use op_revm::OpTransaction; +use reth_evm::{ConfigureEvm, Evm}; +use reth_optimism_chainspec::OpChainSpec; +use reth_optimism_evm::OpEvmConfig; +use revm::{DatabaseCommit, context::result::ResultAndState, database::InMemoryDB}; +pub use revm::{ + context::TxEnv, + interpreter::instructions::utility::IntoAddress, + primitives::ONE_ETHER, + state::{AccountInfo, Bytecode}, +}; + +mod delegatecall; +mod deployment; +mod storage; +mod transfers; + +sol!( + #[sol(rpc)] + AccessListContract, + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../test-utils/contracts/out/AccessList.sol/AccessList.json" + ) +); + +sol!( + #[sol(rpc)] + ContractFactory, + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../test-utils/contracts/out/ContractFactory.sol/ContractFactory.json" + ) +); + +sol!( + #[sol(rpc)] + SimpleStorage, + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../test-utils/contracts/out/ContractFactory.sol/SimpleStorage.json" + ) +); + +sol!( + #[sol(rpc)] + Proxy, + concat!(env!("CARGO_MANIFEST_DIR"), "/../test-utils/contracts/out/Proxy.sol/Proxy.json") +); + +sol!( + #[sol(rpc)] + Logic, + concat!(env!("CARGO_MANIFEST_DIR"), "/../test-utils/contracts/out/Proxy.sol/Logic.json") +); + +sol!( + #[sol(rpc)] + Logic2, + concat!(env!("CARGO_MANIFEST_DIR"), "/../test-utils/contracts/out/Proxy.sol/Logic2.json") +); + +/// Chain ID for Base Sepolia +pub const BASE_SEPOLIA_CHAIN_ID: u64 = 84532; + +/// Executes a list of transactions and builds a FlashblockAccessList tracking all +/// account and storage changes across all transactions. +/// +/// Uses a single FBALBuilderDb instance that wraps the underlying InMemoryDB, +/// calling set_index() before each transaction to track which txn caused which change. +pub fn execute_txns_build_access_list( + txs: Vec>, + acc_overrides: Option>, + storage_overrides: Option>>, +) -> Result { + let chain_spec = Arc::new(OpChainSpec::from_genesis( + serde_json::from_str(include_str!("../../../test-utils/assets/genesis.json")).unwrap(), + )); + let evm_config = OpEvmConfig::optimism(chain_spec.clone()); + let header = Header { base_fee_per_gas: Some(0), ..chain_spec.genesis_header().clone() }; + + // Set up the underlying InMemoryDB with any overrides + let mut db = InMemoryDB::default(); + if let Some(overrides) = acc_overrides { + for (address, info) in overrides { + db.insert_account_info(address, info); + } + } + if let Some(storage) = storage_overrides { + for (address, slots) in storage { + for (slot, value) in slots { + db.insert_account_storage(address, slot, U256::from_be_bytes(value.0)).unwrap(); + } + } + } + + // Create a single FBALBuilderDb that wraps the InMemoryDB for all transactions + let mut fbal_db = FBALBuilderDb::new(db); + let max_tx_index = txs.len().saturating_sub(1); + + for (i, tx) in txs.into_iter().enumerate() { + // Set the transaction index before executing each transaction + fbal_db.set_index(i as u64); + + let evm_env = evm_config.evm_env(&header).unwrap(); + let mut evm = evm_config.evm_with_env(&mut fbal_db, evm_env); + let ResultAndState { state, .. } = evm.transact(tx).unwrap(); + + // Commit the state changes to our FBALBuilderDb + fbal_db.commit(state); + } + + // Finish and build the access list + let access_list_builder = fbal_db.finish()?; + Ok(access_list_builder.build(0, max_tx_index as u64)) +} diff --git a/crates/fbal/tests/builder/storage.rs b/crates/fbal/tests/builder/storage.rs new file mode 100644 index 00000000..98ba221b --- /dev/null +++ b/crates/fbal/tests/builder/storage.rs @@ -0,0 +1,229 @@ +//! Tests for SLOAD/SSTORE tracking in the access list + +use std::collections::HashMap; + +use super::{ + AccessListContract, AccountInfo, BASE_SEPOLIA_CHAIN_ID, Bytecode, IntoAddress, ONE_ETHER, + OpTransaction, SolCall, TxEnv, TxKind, U256, execute_txns_build_access_list, +}; + +#[test] +/// Tests that we can SLOAD a zero-value from a freshly deployed contract's state +fn test_sload_zero_value() { + let sender = U256::from(0xDEAD).into_address(); + let contract = U256::from(0xCAFE).into_address(); + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + overrides.insert( + contract, + AccountInfo::default() + .with_code(Bytecode::new_raw(AccessListContract::DEPLOYED_BYTECODE.clone())), + ); + + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(contract)) + .data(AccessListContract::valueCall {}.abi_encode().into()) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(100_000), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list(vec![tx], Some(overrides), None) + .expect("access list build should succeed"); + + // Verify contract is in the access list + let contract_entry = access_list.account_changes.iter().find(|ac| ac.address == contract); + assert!(contract_entry.is_some(), "Contract should be in access list"); + + // Verify storage read is recorded (slot 0 for `value`) + let contract_changes = contract_entry.unwrap(); + let slot_0 = U256::ZERO; + let has_storage_read = contract_changes.storage_reads.iter().any(|sr| *sr == slot_0); + assert!(has_storage_read, "Contract should have storage read for slot 0 (value)"); +} + +#[test] +/// Tests that we can SSTORE and later SLOAD one value from a contract's state +fn test_update_one_value() { + let sender = U256::from(0xDEAD).into_address(); + let contract = U256::from(0xCAFE).into_address(); + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + overrides.insert( + contract, + AccountInfo::default() + .with_code(Bytecode::new_raw(AccessListContract::DEPLOYED_BYTECODE.clone())), + ); + + let mut txs = Vec::new(); + txs.push( + OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(contract)) + .data( + AccessListContract::updateValueCall { newValue: U256::from(42) } + .abi_encode() + .into(), + ) + .nonce(0) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(100_000), + ) + .build_fill(), + ); + txs.push( + OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(contract)) + .data(AccessListContract::valueCall {}.abi_encode().into()) + .nonce(1) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(100_000), + ) + .build_fill(), + ); + + let access_list = execute_txns_build_access_list(txs, Some(overrides), None) + .expect("access list build should succeed"); + + // Verify contract is in the access list + let contract_entry = access_list.account_changes.iter().find(|ac| ac.address == contract); + assert!(contract_entry.is_some(), "Contract should be in access list"); + + let contract_changes = contract_entry.unwrap(); + + // Verify storage write at slot 0 with new value 42 at tx_index 0 + let slot_0 = U256::ZERO; + let storage_change = contract_changes.storage_changes.iter().find(|sc| sc.slot == slot_0); + assert!(storage_change.is_some(), "Contract should have storage change for slot 0"); + + let slot_change = storage_change.unwrap(); + assert!( + slot_change.changes.iter().any(|c| c.block_access_index == 0), + "Storage change should be at tx_index 0" + ); + assert!( + slot_change.changes.iter().any(|c| c.new_value == U256::from(42)), + "Storage value should be 42" + ); + + // Verify storage read is recorded + let has_storage_read = contract_changes.storage_reads.iter().any(|sr| *sr == slot_0); + assert!(has_storage_read, "Contract should have storage read for slot 0"); +} + +#[test] +/// Ensures that storage reads that read the same slot multiple times are deduped properly +fn test_multi_sload_same_slot() { + let sender = U256::from(0xDEAD).into_address(); + let contract = U256::from(0xCAFE).into_address(); + + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + overrides.insert( + contract, + AccountInfo::default() + .with_code(Bytecode::new_raw(AccessListContract::DEPLOYED_BYTECODE.clone())), + ); + + // getAB reads both `a` and `b` which are packed in slot 1 + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(contract)) + .data(AccessListContract::getABCall {}.abi_encode().into()) + .nonce(0) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(100_000), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list(vec![tx], Some(overrides), None) + .expect("access list build should succeed"); + + // Verify contract is in the access list + let contract_entry = access_list.account_changes.iter().find(|ac| ac.address == contract); + assert!(contract_entry.is_some(), "Contract should be in access list"); + + let contract_changes = contract_entry.unwrap(); + + // Verify storage reads exist - `a` and `b` are packed in slot 1 + // The slot should only appear once even if read multiple times + let slot_1 = U256::from(1); + let slot_1_reads: Vec<_> = + contract_changes.storage_reads.iter().filter(|sr| **sr == slot_1).collect(); + assert_eq!(slot_1_reads.len(), 1, "Slot 1 should only appear once in storage_reads (deduped)"); +} + +#[test] +/// Ensures that storage writes that update multiple slots are recorded properly +fn test_multi_sstore() { + let sender = U256::from(0xDEAD).into_address(); + let contract = U256::from(0xCAFE).into_address(); + + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + overrides.insert( + contract, + AccountInfo::default() + .with_code(Bytecode::new_raw(AccessListContract::DEPLOYED_BYTECODE.clone())), + ); + + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(contract)) + .data( + AccessListContract::insertMultipleCall { + keys: vec![U256::from(0), U256::from(1)], + values: vec![U256::from(84), U256::from(53)], + } + .abi_encode() + .into(), + ) + .nonce(0) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(100_000), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list(vec![tx], Some(overrides), None) + .expect("access list build should succeed"); + + // Verify contract is in the access list + let contract_entry = access_list.account_changes.iter().find(|ac| ac.address == contract); + assert!(contract_entry.is_some(), "Contract should be in access list"); + + let contract_changes = contract_entry.unwrap(); + + // Verify we have storage changes for the mapping slots + // The mapping `data` is at slot 3, so keys hash to keccak256(key . slot) + assert!( + contract_changes.storage_changes.len() >= 2, + "Contract should have at least 2 storage changes for the mapping writes" + ); +} diff --git a/crates/fbal/tests/builder/transfers.rs b/crates/fbal/tests/builder/transfers.rs new file mode 100644 index 00000000..87157f5f --- /dev/null +++ b/crates/fbal/tests/builder/transfers.rs @@ -0,0 +1,168 @@ +//! Tests for ETH transfer tracking in the access list + +use std::collections::HashMap; + +use super::{ + AccountInfo, BASE_SEPOLIA_CHAIN_ID, IntoAddress, ONE_ETHER, OpTransaction, TxEnv, TxKind, U256, + execute_txns_build_access_list, +}; + +#[test] +/// Tests that the system precompiles get included in the access list +fn test_precompiles() { + let base_tx = + TxEnv::builder().chain_id(Some(BASE_SEPOLIA_CHAIN_ID)).gas_limit(50_000).gas_price(0); + let tx = OpTransaction::builder().base(base_tx).build_fill(); + let access_list = execute_txns_build_access_list(vec![tx], None, None) + .expect("access list build should succeed"); + + // Verify we got an access list (precompiles/system contracts should be touched) + assert!(!access_list.account_changes.is_empty(), "Access list should not be empty"); +} + +#[test] +/// Tests that a single ETH transfer is included in the access list +fn test_single_transfer() { + let sender = U256::from(0xDEAD).into_address(); + let recipient = U256::from(0xBEEF).into_address(); + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(recipient)) + .value(U256::from(1_000_000)) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(21_100), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list(vec![tx], Some(overrides), None) + .expect("access list build should succeed"); + + // Verify sender is in the access list with balance and nonce changes + let sender_entry = access_list.account_changes.iter().find(|ac| ac.address == sender); + assert!(sender_entry.is_some(), "Sender should be in access list"); + let sender_changes = sender_entry.unwrap(); + assert!(!sender_changes.balance_changes.is_empty(), "Sender should have balance change"); + assert!(!sender_changes.nonce_changes.is_empty(), "Sender should have nonce change"); + + // Verify recipient is in the access list with balance change + let recipient_entry = access_list.account_changes.iter().find(|ac| ac.address == recipient); + assert!(recipient_entry.is_some(), "Recipient should be in access list"); + let recipient_changes = recipient_entry.unwrap(); + assert!(!recipient_changes.balance_changes.is_empty(), "Recipient should have balance change"); +} + +#[test] +/// Ensures that when gas is paid, the appropriate balance changes are included +/// Sender balance is deducted as (fee paid + value) +/// Fee Vault/Beneficiary address earns fee paid +fn test_gas_included_in_balance_change() { + let sender = U256::from(0xDEAD).into_address(); + let recipient = U256::from(0xBEEF).into_address(); + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .kind(TxKind::Call(recipient)) + .value(U256::from(1_000_000)) + .gas_price(1000) + .gas_priority_fee(Some(1_000)) + .max_fee_per_gas(1_000) + .gas_limit(21_100), + ) + .build_fill(); + + let access_list = execute_txns_build_access_list(vec![tx], Some(overrides), None) + .expect("access list build should succeed"); + + // Verify sender has balance change reflecting gas + value + let sender_entry = access_list.account_changes.iter().find(|ac| ac.address == sender); + assert!(sender_entry.is_some(), "Sender should be in access list"); + let sender_changes = sender_entry.unwrap(); + assert!(!sender_changes.balance_changes.is_empty(), "Sender should have balance change"); + + // Verify there's a fee vault/beneficiary that received the gas payment + // In OP stack this is typically the sequencer fee vault + let fee_recipients: Vec<_> = access_list + .account_changes + .iter() + .filter(|ac| ac.address != sender && ac.address != recipient) + .filter(|ac| !ac.balance_changes.is_empty()) + .collect(); + assert!(!fee_recipients.is_empty(), "There should be a fee recipient with balance change"); +} + +#[test] +/// Ensures that multiple transfers between the same sender/recipient +/// in a single direction are all processed correctly +fn test_multiple_transfers() { + let sender = U256::from(0xDEAD).into_address(); + let recipient = U256::from(0xBEEF).into_address(); + let mut overrides = HashMap::new(); + overrides.insert(sender, AccountInfo::from_balance(U256::from(ONE_ETHER))); + + let mut txs = Vec::new(); + for i in 0..10 { + let tx = OpTransaction::builder() + .base( + TxEnv::builder() + .caller(sender) + .chain_id(Some(BASE_SEPOLIA_CHAIN_ID)) + .nonce(i) + .kind(TxKind::Call(recipient)) + .value(U256::from(1_000_000)) + .gas_price(0) + .gas_priority_fee(None) + .max_fee_per_gas(0) + .gas_limit(21_100), + ) + .build_fill(); + txs.push(tx); + } + + let access_list = execute_txns_build_access_list(txs, Some(overrides), None) + .expect("access list build should succeed"); + + // Verify sender has 10 nonce changes (one per tx) + let sender_entry = access_list.account_changes.iter().find(|ac| ac.address == sender); + assert!(sender_entry.is_some(), "Sender should be in access list"); + let sender_changes = sender_entry.unwrap(); + assert_eq!( + sender_changes.nonce_changes.len(), + 10, + "Sender should have 10 nonce changes (one per tx)" + ); + assert_eq!( + sender_changes.balance_changes.len(), + 10, + "Sender should have 10 balance changes (one per tx)" + ); + + // Verify recipient has 10 balance changes (one per tx) + let recipient_entry = access_list.account_changes.iter().find(|ac| ac.address == recipient); + assert!(recipient_entry.is_some(), "Recipient should be in access list"); + let recipient_changes = recipient_entry.unwrap(); + assert_eq!( + recipient_changes.balance_changes.len(), + 10, + "Recipient should have 10 balance changes (one per tx)" + ); + + // Verify tx indices are tracked correctly (0 through 9) + let tx_indices: Vec<_> = + sender_changes.nonce_changes.iter().map(|nc| nc.block_access_index).collect(); + for i in 0..10u64 { + assert!(tx_indices.contains(&i), "Tx index {} should be present in nonce changes", i); + } +} diff --git a/crates/test-utils/contracts/src/AccessList.sol b/crates/test-utils/contracts/src/AccessList.sol new file mode 100644 index 00000000..761f4a54 --- /dev/null +++ b/crates/test-utils/contracts/src/AccessList.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract AccessList { + uint256 public value; + + uint128 public a; + uint128 public b; + + mapping(uint256 => uint256) public data; + + function updateValue(uint256 newValue) public { + value = newValue; + } + + function updateA(uint128 newA) public { + a = newA; + } + + function updateB(uint128 newB) public { + b = newB; + } + + function insertMultiple( + uint256[] calldata keys, + uint256[] calldata values + ) public { + require( + keys.length == values.length, + "Keys and values length mismatch" + ); + for (uint256 i = 0; i < keys.length; i++) { + data[keys[i]] = values[i]; + } + } + + function getAB() public view returns (uint128, uint128) { + return (a, b); + } + + function getMultiple( + uint256[] calldata keys + ) public view returns (uint256[] memory) { + uint256[] memory results = new uint256[](keys.length); + for (uint256 i = 0; i < keys.length; i++) { + results[i] = data[keys[i]]; + } + return results; + } +} diff --git a/crates/test-utils/contracts/src/ContractFactory.sol b/crates/test-utils/contracts/src/ContractFactory.sol new file mode 100644 index 00000000..6b41669b --- /dev/null +++ b/crates/test-utils/contracts/src/ContractFactory.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title ContractFactory +/// @notice Factory contract for testing CREATE and CREATE2 deployment tracking +contract ContractFactory { + event Created(address indexed deployed); + + /// @notice Deploy a contract using CREATE opcode + /// @param bytecode The bytecode to deploy + /// @return addr The deployed contract address + function deployWithCreate( + bytes memory bytecode + ) public returns (address addr) { + assembly { + addr := create(0, add(bytecode, 0x20), mload(bytecode)) + } + require(addr != address(0), "CREATE failed"); + emit Created(addr); + } + + /// @notice Deploy a contract using CREATE2 opcode + /// @param bytecode The bytecode to deploy + /// @param salt The salt for deterministic address calculation + /// @return addr The deployed contract address + function deployWithCreate2( + bytes memory bytecode, + bytes32 salt + ) public returns (address addr) { + assembly { + addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt) + } + require(addr != address(0), "CREATE2 failed"); + emit Created(addr); + } + + /// @notice Deploy a contract and immediately call it + /// @param bytecode The bytecode to deploy + /// @param callData The call data to execute on the deployed contract + /// @return addr The deployed contract address + /// @return result The result of the call + function deployAndCall( + bytes memory bytecode, + bytes memory callData + ) public returns (address addr, bytes memory result) { + assembly { + addr := create(0, add(bytecode, 0x20), mload(bytecode)) + } + require(addr != address(0), "CREATE failed"); + (bool success, bytes memory data) = addr.call(callData); + require(success, "Call failed"); + result = data; + emit Created(addr); + } +} + +/// @title SimpleStorage +/// @notice Simple contract for testing deployment and storage operations +contract SimpleStorage { + uint256 public value; + + function setValue(uint256 v) public { + value = v; + } + + function getValue() public view returns (uint256) { + return value; + } +} diff --git a/crates/test-utils/contracts/src/Proxy.sol b/crates/test-utils/contracts/src/Proxy.sol new file mode 100644 index 00000000..00cb7070 --- /dev/null +++ b/crates/test-utils/contracts/src/Proxy.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Proxy { + // Storage layout must match Logic contract + address public implementation; // slot 0 + uint256 public value; // slot 1 + uint256 public value2; // slot 2 + + constructor(address _impl) { + implementation = _impl; + } + + fallback() external payable { + address impl = implementation; + assembly { + calldatacopy(0, 0, calldatasize()) + let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) + returndatacopy(0, 0, returndatasize()) + switch result + case 0 { + revert(0, returndatasize()) + } + default { + return(0, returndatasize()) + } + } + } + + receive() external payable {} +} + +contract Logic { + // Storage layout must match Proxy contract + address public implementation; // slot 0 - unused but must be here + uint256 public value; // slot 1 + uint256 public value2; // slot 2 + + function setValue(uint256 v) public { + value = v; + } + + function setValue2(uint256 v) public { + value2 = v; + } + + function getValue() public view returns (uint256) { + return value; + } + + function setBoth(uint256 v1, uint256 v2) public { + value = v1; + value2 = v2; + } +} + +contract Logic2 { + address public implementation; + uint256 public value; + uint256 public value2; + address public nextLogic; + + function setNextLogic(address _next) public { + nextLogic = _next; + } + + function chainedDelegatecall( + bytes memory data + ) public returns (bytes memory) { + (bool success, bytes memory result) = nextLogic.delegatecall(data); + require(success, "Chained delegatecall failed"); + return result; + } +}