diff --git a/Cargo.lock b/Cargo.lock
index 485c65f6..8563df10 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -236,6 +236,17 @@ dependencies = [
"thiserror 2.0.17",
]
+[[package]]
+name = "alloy-eip7928"
+version = "0.1.0"
+source = "git+https://github.com/alloy-rs/eips#2197bdb48ae842b00f43b2cb29d59f20b29ec419"
+dependencies = [
+ "alloy-primitives",
+ "alloy-rlp",
+ "borsh",
+ "serde",
+]
+
[[package]]
name = "alloy-eips"
version = "1.1.3"
@@ -1566,15 +1577,31 @@ dependencies = [
]
[[package]]
-name = "base-reth-cli"
+name = "base-fbal"
version = "0.2.1"
dependencies = [
- "reth",
+ "alloy-consensus",
+ "alloy-contract",
+ "alloy-eip7928",
+ "alloy-primitives",
+ "alloy-rlp",
+ "alloy-sol-macro",
+ "alloy-sol-types",
+ "op-revm",
+ "reth-evm",
+ "reth-optimism-chainspec",
+ "reth-optimism-evm",
+ "revm",
+ "serde",
+ "serde_json",
]
[[package]]
-name = "base-reth-fbal"
+name = "base-reth-cli"
version = "0.2.1"
+dependencies = [
+ "reth",
+]
[[package]]
name = "base-reth-flashblocks"
diff --git a/Cargo.toml b/Cargo.toml
index 3ecc1505..92a01d92 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -100,6 +100,7 @@ revm-bytecode = { version = "7.1.1", default-features = false }
# alloy
alloy-trie = "0.9.1"
alloy-eips = "1.0.41"
+alloy-eip7928 = { git = "https://github.com/alloy-rs/eips", version = "0.1.0" }
alloy-serde = "1.0.41"
alloy-genesis = "1.0.41"
alloy-hardforks = "0.4.4"
@@ -109,6 +110,7 @@ alloy-sol-types = "1.4.1"
alloy-sol-macro = "1.4.1"
alloy-primitives = "1.4.1"
alloy-consensus = "1.0.41"
+alloy-rlp = "0.3.10"
alloy-rpc-types = "1.0.41"
alloy-rpc-client = "1.0.41"
alloy-rpc-types-eth = "1.0.41"
@@ -121,6 +123,9 @@ op-alloy-consensus = "0.22.0"
op-alloy-rpc-jsonrpsee = "0.22.0"
op-alloy-rpc-types-engine = "0.22.0"
+# 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..bc1f4cda 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,21 @@ description = "FBAL library crate"
workspace = true
[dependencies]
+alloy-primitives.workspace = true
+alloy-eip7928 = {workspace = true, features = ["serde", "rlp"]}
+alloy-rlp = {workspace = true, features = ["derive"]}
+
+revm.workspace = true
+
+serde.workspace = true
[dev-dependencies]
+alloy-consensus.workspace = true
+alloy-contract.workspace = true
+alloy-sol-macro = { workspace = true, features = ["json"] }
+alloy-sol-types.workspace = true
+op-revm.workspace = true
+reth-evm.workspace = true
+reth-optimism-chainspec.workspace = true
+reth-optimism-evm.workspace = true
+serde_json.workspace = true
diff --git a/crates/fbal/README.md b/crates/fbal/README.md
new file mode 100644
index 00000000..e69de29b
diff --git a/crates/fbal/src/inspector.rs b/crates/fbal/src/inspector.rs
new file mode 100644
index 00000000..25bc16d5
--- /dev/null
+++ b/crates/fbal/src/inspector.rs
@@ -0,0 +1,82 @@
+use alloy_primitives::{Address, FixedBytes, map::foldhash::HashMap};
+use revm::{
+ Inspector,
+ bytecode::opcode,
+ context::ContextTr,
+ inspector::JournalExt,
+ interpreter::{
+ CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter,
+ interpreter_types::{InputsTr, Jumps},
+ },
+};
+
+/// Inspector that tracks all accounts and storage slots touched during execution.
+#[derive(Debug, Default)]
+pub struct TouchedAccountsInspector {
+ /// Map of touched accounts to the storage slots accessed.
+ pub touched_accounts: HashMap
>>,
+ // pub account_changes: HashMap,
+}
+
+impl Inspector for TouchedAccountsInspector
+where
+ CTX: ContextTr,
+{
+ fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) {
+ match interp.bytecode.opcode() {
+ opcode::SLOAD => {
+ let slot = interp.stack.peek(0).expect("should be able to load slot");
+ let contract = interp.input.target_address();
+ self.touched_accounts.entry(contract).or_default().push(slot.into());
+ }
+ opcode::SSTORE => {
+ // TODO: This is likely not needed as it can be gotten from the block executor
+ //
+ // let slot = interp.stack.peek(0).expect("should be able to load slot");
+ // let new_value = interp.stack.peek(1).expect("should be able to load new value");
+ // let contract = interp.input.target_address();
+ }
+ opcode::EXTCODECOPY | opcode::EXTCODEHASH | opcode::EXTCODESIZE | opcode::BALANCE => {
+ let slot = interp.stack.peek(0).expect("should be able to load slot");
+ let addr = Address::from_word(slot.into());
+ println!("EXTCODECOPY | EXT CODE HASH | EXT CODE SIZE | BALANCE {}", addr);
+ self.touched_accounts.entry(addr).or_default();
+ }
+ opcode::DELEGATECALL | opcode::CALL | opcode::STATICCALL | opcode::CALLCODE => {
+ let addr_slot = interp.stack.peek(1).expect("should be able to load slot");
+ let addr = Address::from_word(addr_slot.into());
+ println!("DELEGATECALL | CALL | STATICCALL | CALLCODE {}", addr);
+ self.touched_accounts.entry(addr).or_default();
+ }
+
+ _ => {}
+ }
+ }
+
+ fn call(&mut self, _context: &mut CTX, inputs: &mut CallInputs) -> Option {
+ self.touched_accounts.entry(inputs.target_address).or_default();
+ self.touched_accounts.entry(inputs.caller).or_default();
+
+ // let caller_account = context.journal_mut().load_account(inputs.caller).ok()?;
+ // let is_eoa_caller = caller_account.info.is_empty_code_hash();
+ // if is_eoa_caller {
+ // caller_entry.nonce_changes.push(NonceChange::new(0, caller_account.info.nonce));
+ // }
+
+ None
+ }
+
+ fn create(&mut self, _context: &mut CTX, _inputs: &mut CreateInputs) -> Option {
+ // let nonce = context.journal_mut().load_account(inputs.caller).ok()?.data.info.nonce;
+ // self.account_changes.entry(inputs.caller).or_default();
+
+ // let deployed_contract = inputs.created_address(nonce);
+ // self.account_changes
+ // .entry(deployed_contract)
+ // .or_default()
+ // .code_changes
+ // .push(CodeChange::new(0, inputs.init_code.clone()));
+
+ None
+ }
+}
diff --git a/crates/fbal/src/lib.rs b/crates/fbal/src/lib.rs
index f83db7d5..9d5cbf74 100644
--- a/crates/fbal/src/lib.rs
+++ b/crates/fbal/src/lib.rs
@@ -1,3 +1,10 @@
-//! 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 inspector;
+mod types;
+
+pub use inspector::TouchedAccountsInspector;
+pub use types::FlashblockAccessList;
diff --git a/crates/fbal/src/types.rs b/crates/fbal/src/types.rs
new file mode 100644
index 00000000..ee1be057
--- /dev/null
+++ b/crates/fbal/src/types.rs
@@ -0,0 +1,48 @@
+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 {
+ /// Merge account changes from a new transaction into the access list
+ pub fn merge_account_changes(&mut self, extension: Vec) {
+ for new in extension.iter() {
+ let mut found = false;
+ for old in self.account_changes.iter_mut() {
+ if old.address() == new.address() {
+ old.storage_changes.extend(new.storage_changes.clone());
+ old.storage_reads.extend(new.storage_reads.clone());
+ old.balance_changes.extend(new.balance_changes.clone());
+ old.nonce_changes.extend(new.nonce_changes.clone());
+ old.code_changes.extend(new.code_changes.clone());
+ found = true;
+ break;
+ }
+ }
+
+ if !found {
+ self.account_changes.push(new.clone());
+ }
+ }
+ }
+
+ /// Finalize the access list by computing the hash of the RLP-encoded account changes list
+ pub fn finalize(&mut self) {
+ let mut encoded = Vec::new();
+ self.account_changes.encode(&mut encoded);
+ self.fal_hash = keccak256(encoded);
+ }
+}
diff --git a/crates/fbal/tests/builder/deployment.rs b/crates/fbal/tests/builder/deployment.rs
new file mode 100644
index 00000000..f934fe45
--- /dev/null
+++ b/crates/fbal/tests/builder/deployment.rs
@@ -0,0 +1,226 @@
+//! Tests for CREATE/CREATE2 contract deployment tracking in the access list
+
+use std::collections::HashMap;
+
+use alloy_primitives::{B256, TxKind, U256};
+use alloy_sol_types::SolCall;
+use op_revm::OpTransaction;
+use revm::{
+ context::TxEnv,
+ interpreter::instructions::utility::IntoAddress,
+ primitives::ONE_ETHER,
+ state::{AccountInfo, Bytecode},
+};
+
+use super::{
+ BASE_SEPOLIA_CHAIN_ID, ContractFactory, SimpleStorage, 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));
+
+ // 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");
+
+ dbg!(&access_list);
+}
+
+#[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));
+
+ // 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");
+
+ dbg!(&access_list);
+}
+
+#[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));
+
+ // 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, B256::ZERO, "Storage slot should be 0");
+ assert_eq!(
+ storage_change.changes[0].new_value,
+ B256::from(U256::from(42)),
+ "Storage value should be 42"
+ );
+
+ dbg!(&access_list);
+}
diff --git a/crates/fbal/tests/builder/main.rs b/crates/fbal/tests/builder/main.rs
new file mode 100644
index 00000000..cf90291f
--- /dev/null
+++ b/crates/fbal/tests/builder/main.rs
@@ -0,0 +1,160 @@
+//! Tests for ensuring the access list is built properly
+
+use std::{collections::HashMap, sync::Arc};
+
+use alloy_consensus::Header;
+use alloy_eip7928::{
+ AccountChanges, BalanceChange, CodeChange, EMPTY_BLOCK_ACCESS_LIST_HASH, NonceChange,
+ SlotChanges, StorageChange,
+};
+use alloy_primitives::{Address, B256};
+use alloy_sol_macro::sol;
+use base_fbal::{FlashblockAccessList, TouchedAccountsInspector};
+use op_revm::OpTransaction;
+use reth_evm::{ConfigureEvm, Evm};
+use reth_optimism_chainspec::OpChainSpec;
+use reth_optimism_evm::OpEvmConfig;
+use revm::{
+ DatabaseCommit, DatabaseRef,
+ context::{TxEnv, result::ResultAndState},
+ database::InMemoryDB,
+ primitives::KECCAK_EMPTY,
+ state::AccountInfo,
+};
+
+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"
+ )
+);
+
+const BASE_SEPOLIA_CHAIN_ID: u64 = 84532;
+
+fn execute_txns_build_access_list(
+ txs: Vec>,
+ acc_overrides: Option>,
+) -> FlashblockAccessList {
+ 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() };
+ let mut db = InMemoryDB::default();
+ if let Some(overrides) = acc_overrides {
+ for (address, info) in overrides {
+ db.insert_account_info(address, info);
+ }
+ }
+
+ let mut access_list = FlashblockAccessList {
+ min_tx_index: 0,
+ max_tx_index: (txs.len() - 1) as u64,
+ account_changes: vec![],
+ fal_hash: EMPTY_BLOCK_ACCESS_LIST_HASH,
+ };
+
+ for (idx, tx) in txs.into_iter().enumerate() {
+ let inspector = TouchedAccountsInspector::default();
+ let evm_env = evm_config.evm_env(&header).unwrap();
+ let mut evm = evm_config.evm_with_env_and_inspector(db, evm_env, inspector);
+ let ResultAndState { state, .. } = evm.transact(tx).unwrap();
+
+ let mut initial_accounts = HashMap::new();
+ for (address, _) in &state {
+ let initial_account = evm.db_mut().load_account(*address).map(|a| a.info());
+ _ = match initial_account {
+ Ok(Some(info)) => initial_accounts.insert(*address, info),
+ _ => None,
+ };
+ }
+
+ let mut account_changes: HashMap = HashMap::new();
+ for (address, slots) in evm.inspector_mut().touched_accounts.iter() {
+ let change = AccountChanges::new(*address).extend_storage_reads(slots.iter().cloned());
+ account_changes.insert(*address, change);
+ }
+
+ for (address, account) in &state {
+ let initial_account = initial_accounts.get(address);
+ let entry = account_changes.entry(*address).or_insert(AccountChanges::new(*address));
+
+ let initial_balance = initial_account.map(|a| a.balance).unwrap_or_default();
+ let initial_nonce = initial_account.map(|a| a.nonce).unwrap_or_default();
+ let initial_code_hash = initial_account.map(|a| a.code_hash()).unwrap_or(KECCAK_EMPTY);
+
+ if initial_balance != account.info.balance {
+ entry.balance_changes.push(BalanceChange::new(idx as u64, account.info.balance));
+ }
+
+ if initial_nonce != account.info.nonce {
+ entry.nonce_changes.push(NonceChange::new(idx as u64, account.info.nonce));
+ }
+
+ if initial_code_hash != account.info.code_hash() {
+ let bytecode = match account.info.code.clone() {
+ Some(code) => code,
+ None => evm.db_mut().code_by_hash_ref(account.info.code_hash()).unwrap(),
+ };
+ entry.code_changes.push(CodeChange::new(idx as u64, bytecode.bytes()));
+ }
+
+ // TODO: This currently does not check if a storage key already exists within `storage_changes`
+ // for a given account, and instead adds a new `SlotChanges` struct for the same storage key
+ account.storage.iter().for_each(|(key, value)| {
+ let previous_value = evm.db_mut().storage_ref(*address, *key);
+ match previous_value {
+ Ok(prev) => {
+ if prev != value.present_value {
+ entry.storage_changes.push(SlotChanges::new(
+ B256::from(*key),
+ vec![StorageChange::new(
+ idx as u64,
+ B256::from(value.present_value()),
+ )],
+ ));
+ }
+ }
+ Err(_) => {
+ entry.storage_changes.push(SlotChanges::new(
+ B256::from(*key),
+ vec![StorageChange::new(idx as u64, B256::from(value.present_value()))],
+ ));
+ }
+ }
+ });
+ }
+
+ evm.db_mut().commit(state);
+ db = evm.into_db();
+
+ access_list.merge_account_changes(account_changes.values().cloned().collect());
+ }
+
+ access_list.finalize();
+ access_list
+}
diff --git a/crates/fbal/tests/builder/storage.rs b/crates/fbal/tests/builder/storage.rs
new file mode 100644
index 00000000..4f7ed78a
--- /dev/null
+++ b/crates/fbal/tests/builder/storage.rs
@@ -0,0 +1,175 @@
+//! Tests for SLOAD/SSTORE tracking in the access list
+
+use std::collections::HashMap;
+
+use alloy_primitives::{TxKind, U256};
+use alloy_sol_types::SolCall;
+use op_revm::OpTransaction;
+use revm::{
+ context::TxEnv,
+ interpreter::instructions::utility::IntoAddress,
+ primitives::ONE_ETHER,
+ state::{AccountInfo, Bytecode},
+};
+
+use super::{AccessListContract, BASE_SEPOLIA_CHAIN_ID, 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));
+ dbg!(access_list);
+}
+
+#[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));
+ dbg!(access_list);
+}
+
+#[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())),
+ );
+
+ 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));
+ // TODO: dedup storage_reads
+ dbg!(access_list);
+}
+
+#[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));
+ dbg!(access_list);
+}
diff --git a/crates/fbal/tests/builder/transfers.rs b/crates/fbal/tests/builder/transfers.rs
new file mode 100644
index 00000000..175e42d0
--- /dev/null
+++ b/crates/fbal/tests/builder/transfers.rs
@@ -0,0 +1,108 @@
+//! Tests for ETH transfer tracking in the access list
+
+use std::collections::HashMap;
+
+use alloy_primitives::{TxKind, U256};
+use op_revm::OpTransaction;
+use revm::{
+ context::TxEnv, interpreter::instructions::utility::IntoAddress, primitives::ONE_ETHER,
+ state::AccountInfo,
+};
+
+use super::{BASE_SEPOLIA_CHAIN_ID, 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);
+ dbg!(access_list);
+}
+
+#[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));
+ dbg!(access_list);
+}
+
+#[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));
+ dbg!(access_list);
+}
+
+#[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));
+ dbg!(access_list);
+}
diff --git a/crates/test-utils/assets/genesis.json b/crates/test-utils/assets/genesis.json
index dde20c5e..fdced589 100644
--- a/crates/test-utils/assets/genesis.json
+++ b/crates/test-utils/assets/genesis.json
@@ -1,107 +1,107 @@
{
- "config": {
- "chainId": 84532,
- "homesteadBlock": 0,
- "eip150Block": 0,
- "eip155Block": 0,
- "eip158Block": 0,
- "byzantiumBlock": 0,
- "constantinopleBlock": 0,
- "petersburgBlock": 0,
- "istanbulBlock": 0,
- "muirGlacierBlock": 0,
- "berlinBlock": 0,
- "londonBlock": 0,
- "arrowGlacierBlock": 0,
- "grayGlacierBlock": 0,
- "mergeNetsplitBlock": 0,
- "bedrockBlock": 0,
- "regolithTime": 0,
- "canyonTime": 0,
- "ecotoneTime": 0,
- "fjordTime": 0,
- "graniteTime": 0,
- "isthmusTime": 0,
- "jovianTime": 0,
- "pragueTime": 0,
- "terminalTotalDifficulty": 0,
- "terminalTotalDifficultyPassed": true,
- "optimism": {
- "eip1559Elasticity": 6,
- "eip1559Denominator": 50
- }
- },
- "nonce": "0x0",
- "timestamp": "0x0",
- "extraData": "0x00",
- "gasLimit": "0x1c9c380",
- "difficulty": "0x0",
- "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
- "coinbase": "0x0000000000000000000000000000000000000000",
- "alloc": {
- "0x14dc79964da2c08b23698b3d3cc7ca32193d9955": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x1cbd3b2770909d4e10f157cabc84c7264073c9ec": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x2546bcd3c84621e976d8185a91a922ae77ecec30": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x70997970c51812dc3a010c7d01b50e0d17dc79c8": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x71be63f3384f5fb98995898a86b02fb2426c5788": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x8626f6940e2eb28930efb4cef49b2d1f2c9c1199": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x90f79bf6eb2c4f870365e785982e1f101e93b906": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x976ea74026e726554db657fa54763abd0c3a0aa9": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0x9c41de96b2088cdc640c6182dfcf5491dc574a57": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0xa0ee7a142d267c1f36714e4a8f75612f20a79720": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0xbcd4042de499d14e55001ccbb24a551f3b954096": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0xbda5747bfd65f08deb54cb465eb87d40e51b197e": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0xcd3b766ccdd6ae721141f452c550ca635964ce71": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0xdd2fd4581271e230360230f9337d5c0430bf44c0": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0xdf3e18d64bc6a983f673ab319ccae4f1a57c7097": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266": {
- "balance": "0xd3c21bcecceda1000000"
- },
- "0xfabb0ac9d68b0b445fb7357272ff202c5651694a": {
- "balance": "0xd3c21bcecceda1000000"
- }
- },
- "number": "0x0"
+ "config": {
+ "chainId": 84532,
+ "homesteadBlock": 0,
+ "eip150Block": 0,
+ "eip155Block": 0,
+ "eip158Block": 0,
+ "byzantiumBlock": 0,
+ "constantinopleBlock": 0,
+ "petersburgBlock": 0,
+ "istanbulBlock": 0,
+ "muirGlacierBlock": 0,
+ "berlinBlock": 0,
+ "londonBlock": 0,
+ "arrowGlacierBlock": 0,
+ "grayGlacierBlock": 0,
+ "mergeNetsplitBlock": 0,
+ "bedrockBlock": 0,
+ "regolithTime": 0,
+ "canyonTime": 0,
+ "ecotoneTime": 0,
+ "fjordTime": 0,
+ "graniteTime": 0,
+ "isthmusTime": 0,
+ "jovianTime": 0,
+ "pragueTime": 0,
+ "terminalTotalDifficulty": 0,
+ "terminalTotalDifficultyPassed": true,
+ "optimism": {
+ "eip1559Elasticity": 6,
+ "eip1559Denominator": 50
+ }
+ },
+ "nonce": "0x0",
+ "timestamp": "0x0",
+ "extraData": "0x00",
+ "gasLimit": "0x1c9c380",
+ "difficulty": "0x0",
+ "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
+ "coinbase": "0x4200000000000000000000000000000000000011",
+ "alloc": {
+ "0x14dc79964da2c08b23698b3d3cc7ca32193d9955": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x1cbd3b2770909d4e10f157cabc84c7264073c9ec": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x2546bcd3c84621e976d8185a91a922ae77ecec30": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x70997970c51812dc3a010c7d01b50e0d17dc79c8": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x71be63f3384f5fb98995898a86b02fb2426c5788": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x8626f6940e2eb28930efb4cef49b2d1f2c9c1199": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x90f79bf6eb2c4f870365e785982e1f101e93b906": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x976ea74026e726554db657fa54763abd0c3a0aa9": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0x9c41de96b2088cdc640c6182dfcf5491dc574a57": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0xa0ee7a142d267c1f36714e4a8f75612f20a79720": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0xbcd4042de499d14e55001ccbb24a551f3b954096": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0xbda5747bfd65f08deb54cb465eb87d40e51b197e": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0xcd3b766ccdd6ae721141f452c550ca635964ce71": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0xdd2fd4581271e230360230f9337d5c0430bf44c0": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0xdf3e18d64bc6a983f673ab319ccae4f1a57c7097": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266": {
+ "balance": "0xd3c21bcecceda1000000"
+ },
+ "0xfabb0ac9d68b0b445fb7357272ff202c5651694a": {
+ "balance": "0xd3c21bcecceda1000000"
+ }
+ },
+ "number": "0x0"
}
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..5acec2eb
--- /dev/null
+++ b/crates/test-utils/contracts/src/ContractFactory.sol
@@ -0,0 +1,64 @@
+// 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;
+ }
+}