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; + } +}