diff --git a/crates/context/src/journal/inner.rs b/crates/context/src/journal/inner.rs index a8755aa274..216eed2923 100644 --- a/crates/context/src/journal/inner.rs +++ b/crates/context/src/journal/inner.rs @@ -12,10 +12,11 @@ use context_interface::{ use core::mem; use database_interface::Database; use primitives::{ + eip7708::{ETH_TRANSFER_LOG_ADDRESS, ETH_TRANSFER_LOG_TOPIC, SELFDESTRUCT_TO_SELF_LOG_TOPIC}, hardfork::SpecId::{self, *}, hash_map::Entry, hints_util::unlikely, - Address, HashMap, Log, StorageKey, StorageValue, B256, KECCAK_EMPTY, U256, + Address, Bytes, HashMap, Log, LogData, StorageKey, StorageValue, B256, KECCAK_EMPTY, U256, }; use state::{Account, EvmState, TransientStorage}; use std::vec::Vec; @@ -357,6 +358,9 @@ impl JournalInner { self.journal .push(ENTRY::balance_transfer(from, to, balance)); + // EIP-7708: emit ETH transfer log + self.eip7708_transfer_log(from, to, balance); + None } @@ -429,6 +433,11 @@ impl JournalInner { // saved even empty. Self::touch_account(last_journal, target_address, target_acc); + // If balance is zero, we don't need to add any journal entries or emit any logs. + if balance.is_zero() { + return Ok(checkpoint); + } + // Add balance to created account, as we already have target here. let Some(new_balance) = target_acc.info.balance.checked_add(balance) else { self.checkpoint_revert(checkpoint); @@ -443,6 +452,9 @@ impl JournalInner { // add journal entry of transferred balance last_journal.push(ENTRY::balance_transfer(caller, target_address, balance)); + // EIP-7708: emit ETH transfer log + self.eip7708_transfer_log(caller, target_address, balance); + Ok(checkpoint) } @@ -534,6 +546,15 @@ impl JournalInner { let journal_entry = if acc.is_created_locally() || !is_cancun_enabled { acc.mark_selfdestructed_locally(); acc.info.balance = U256::ZERO; + + // EIP-7708: emit appropriate log for selfdestruct + if target != address { + // Transfer log for balance transferred to different address + self.eip7708_transfer_log(address, target, balance); + } else { + // Selfdestruct to self log + self.eip7708_selfdestruct_to_self_log(address, balance); + } Some(ENTRY::account_destroyed( address, target, @@ -542,6 +563,9 @@ impl JournalInner { )) } else if address != target { acc.info.balance = U256::ZERO; + // EIP-7708: emit appropriate log for selfdestruct + // Transfer log for balance transferred to different address + self.eip7708_transfer_log(address, target, balance); Some(ENTRY::balance_transfer(address, target, balance)) } else { // State is not changed: @@ -923,6 +947,66 @@ impl JournalInner { pub fn log(&mut self, log: Log) { self.logs.push(log); } + + /// Creates and pushes an EIP-7708 ETH transfer log. + /// + /// This emits a LOG3 with the Transfer event signature, matching ERC-20 transfer events. + /// Only emitted if EIP-7708 is enabled (Amsterdam and later) and balance is non-zero. + /// + /// [EIP-7708](https://eips.ethereum.org/EIPS/eip-7708) + #[inline] + pub fn eip7708_transfer_log(&mut self, from: Address, to: Address, balance: U256) { + // Only emit log if EIP-7708 is enabled and balance is non-zero + if !self.spec.is_enabled_in(AMSTERDAM) || balance.is_zero() { + return; + } + + // Create LOG3 with Transfer(address,address,uint256) event signature + // Topic[0]: Transfer event signature + // Topic[1]: from address (zero-padded to 32 bytes) + // Topic[2]: to address (zero-padded to 32 bytes) + // Data: amount in wei (big-endian uint256) + let topics = std::vec![ + ETH_TRANSFER_LOG_TOPIC, + B256::left_padding_from(from.as_slice()), + B256::left_padding_from(to.as_slice()), + ]; + let data = Bytes::copy_from_slice(&balance.to_be_bytes::<32>()); + + self.logs.push(Log { + address: ETH_TRANSFER_LOG_ADDRESS, + data: LogData::new(topics, data).expect("3 topics is valid"), + }); + } + + /// Creates and pushes an EIP-7708 selfdestruct-to-self log. + /// + /// This emits a LOG2 when a contract self-destructs to itself. + /// Only emitted if EIP-7708 is enabled (Amsterdam and later) and balance is non-zero. + /// + /// [EIP-7708](https://eips.ethereum.org/EIPS/eip-7708) + #[inline] + pub fn eip7708_selfdestruct_to_self_log(&mut self, address: Address, balance: U256) { + // Only emit log if EIP-7708 is enabled and balance is non-zero + if !self.spec.is_enabled_in(AMSTERDAM) || balance.is_zero() { + return; + } + + // Create LOG2 with SelfBalanceLog(address,uint256) event signature + // Topic[0]: SelfBalanceLog event signature + // Topic[1]: account address (zero-padded to 32 bytes) + // Data: amount in wei (big-endian uint256) + let topics = std::vec![ + SELFDESTRUCT_TO_SELF_LOG_TOPIC, + B256::left_padding_from(address.as_slice()), + ]; + let data = Bytes::copy_from_slice(&balance.to_be_bytes::<32>()); + + self.logs.push(Log { + address: ETH_TRANSFER_LOG_ADDRESS, + data: LogData::new(topics, data).expect("2 topics is valid"), + }); + } } #[cfg(test)] diff --git a/crates/ee-tests/src/revm_tests.rs b/crates/ee-tests/src/revm_tests.rs index fc66a77dfe..0a1a0cd1e5 100644 --- a/crates/ee-tests/src/revm_tests.rs +++ b/crates/ee-tests/src/revm_tests.rs @@ -283,3 +283,408 @@ fn test_disable_balance_check() { let expected_balance = U256::ZERO; assert_eq!(returned_balance, expected_balance); } + +// ============================================================================ +// EIP-7708: ETH transfers emit a log +// ============================================================================ + +use revm::primitives::eip7708::{ + ETH_TRANSFER_LOG_ADDRESS, ETH_TRANSFER_LOG_TOPIC, SELFDESTRUCT_TO_SELF_LOG_TOPIC, +}; +use revm::primitives::B256; + +/// Test EIP-7708 transfer log emission for transaction value transfer +#[test] +fn test_eip7708_transfer_log_tx_value() { + let recipient = address!("0xc000000000000000000000000000000000000001"); + + let mut evm = Context::mainnet() + .with_cfg(CfgEnv::new_with_spec(SpecId::AMSTERDAM)) + .with_db(BenchmarkDB::new_bytecode(Bytecode::new())) + .build_mainnet(); + + let tx_value = U256::from(1_000_000_000_000_000u128); // 0.001 ETH (within balance) + + let result = evm + .transact_one( + TxEnv::builder_for_bench() + .to(recipient) + .value(tx_value) + .gas_limit(100_000) + .gas_price(0) // Zero gas price to avoid balance issues + .build_fill(), + ) + .unwrap(); + + assert!(result.is_success()); + + // Verify that a transfer log was emitted + let logs = result.logs(); + assert_eq!(logs.len(), 1, "Expected 1 transfer log"); + + let log = &logs[0]; + assert_eq!(log.address, ETH_TRANSFER_LOG_ADDRESS); + assert_eq!(log.data.topics().len(), 3); + assert_eq!(log.data.topics()[0], ETH_TRANSFER_LOG_TOPIC); + assert_eq!( + log.data.topics()[1], + B256::left_padding_from(BENCH_CALLER.as_slice()) + ); + assert_eq!( + log.data.topics()[2], + B256::left_padding_from(recipient.as_slice()) + ); + assert_eq!(log.data.data.as_ref(), &tx_value.to_be_bytes::<32>()); +} + +/// Test that no transfer log is emitted for zero value transfer +#[test] +fn test_eip7708_no_log_for_zero_value() { + let recipient = address!("0xc000000000000000000000000000000000000001"); + + let mut evm = Context::mainnet() + .with_cfg(CfgEnv::new_with_spec(SpecId::AMSTERDAM)) + .with_db(BenchmarkDB::new_bytecode(Bytecode::new())) + .build_mainnet(); + + let result = evm + .transact_one( + TxEnv::builder_for_bench() + .to(recipient) + .value(U256::ZERO) + .gas_limit(100_000) + .gas_price(0) + .build_fill(), + ) + .unwrap(); + + assert!(result.is_success()); + + // No logs should be emitted for zero value transfer + let logs = result.logs(); + assert_eq!(logs.len(), 0, "Expected no logs for zero value transfer"); +} + +/// Test that no transfer log is emitted before AMSTERDAM +#[test] +fn test_eip7708_no_log_before_amsterdam() { + let recipient = address!("0xc000000000000000000000000000000000000001"); + + let mut evm = Context::mainnet() + .with_cfg(CfgEnv::new_with_spec(SpecId::OSAKA)) // Before AMSTERDAM + .with_db(BenchmarkDB::new_bytecode(Bytecode::new())) + .build_mainnet(); + + let tx_value = U256::from(1_000_000_000_000_000u128); // 0.001 ETH (within balance) + + let result = evm + .transact_one( + TxEnv::builder_for_bench() + .to(recipient) + .value(tx_value) + .gas_limit(100_000) + .gas_price(0) + .build_fill(), + ) + .unwrap(); + + assert!(result.is_success()); + + // No logs should be emitted before AMSTERDAM + let logs = result.logs(); + assert_eq!(logs.len(), 0, "Expected no logs before AMSTERDAM"); +} + +/// Bytecode that selfdestructs to the caller (different address) +const SELFDESTRUCT_TO_CALLER_BYTECODE: &[u8] = &[ + opcode::CALLER, // Push caller address + opcode::SELFDESTRUCT, + opcode::STOP, +]; + +/// Test EIP-7708 transfer log emission for selfdestruct to different address +#[test] +fn test_eip7708_selfdestruct_to_different_address() { + let mut evm = Context::mainnet() + .with_cfg(CfgEnv::new_with_spec(SpecId::AMSTERDAM)) + .with_db(BenchmarkDB::new_bytecode(Bytecode::new_legacy( + SELFDESTRUCT_TO_CALLER_BYTECODE.into(), + ))) + .build_mainnet(); + + // Execute selfdestruct - the contract at BENCH_TARGET will selfdestruct to BENCH_CALLER + // After Cancun (and Amsterdam), SELFDESTRUCT only destroys if created in same tx, + // but it still transfers balance, so we should see a transfer log. + let result = evm + .transact_one( + TxEnv::builder_for_bench() + .gas_limit(100_000) + .gas_price(0) + .build_fill(), + ) + .unwrap(); + + assert!(result.is_success()); + + // There should be two logs: + // 1. Transfer log for initial tx value transfer from BENCH_CALLER to BENCH_TARGET + // 2. Transfer log for selfdestruct balance transfer from BENCH_TARGET to BENCH_CALLER + let logs = result.logs(); + + // Find the selfdestruct transfer log (from BENCH_TARGET to BENCH_CALLER) + let selfdestruct_log = logs.iter().find(|log| { + log.data.topics().len() == 3 + && log.data.topics()[0] == ETH_TRANSFER_LOG_TOPIC + && log.data.topics()[1] == B256::left_padding_from(BENCH_TARGET.as_slice()) + }); + + assert!( + selfdestruct_log.is_some(), + "Expected selfdestruct transfer log" + ); + let log = selfdestruct_log.unwrap(); + assert_eq!(log.address, ETH_TRANSFER_LOG_ADDRESS); + assert_eq!( + log.data.topics()[2], + B256::left_padding_from(BENCH_CALLER.as_slice()) + ); +} + +/// Init code that selfdestructs to itself during construction +/// This triggers the selfdestruct-to-self scenario where a newly created +/// contract (is_created_locally = true) selfdestructs to itself. +const SELFDESTRUCT_TO_SELF_INIT_CODE: &[u8] = &[ + opcode::ADDRESS, // Push contract's own address + opcode::SELFDESTRUCT, +]; + +/// Test EIP-7708 selfdestruct-to-self log emission +/// This test creates a contract with value that selfdestructs to itself during construction, +/// which should emit a SelfBalanceLog since the contract is created in the same tx. +#[test] +fn test_eip7708_selfdestruct_to_self() { + let mut evm = Context::mainnet() + .with_cfg(CfgEnv::new_with_spec(SpecId::AMSTERDAM)) + .with_db(BenchmarkDB::new_bytecode(Bytecode::new())) + .build_mainnet(); + + let create_value = U256::from(1_000_000u128); // 1M wei + + // Create a contract with value that selfdestructs to itself during init + let result = evm + .transact_one( + TxEnv::builder_for_bench() + .kind(TxKind::Create) + .data(SELFDESTRUCT_TO_SELF_INIT_CODE.into()) + .value(create_value) + .gas_limit(100_000) + .gas_price(0) + .build_fill(), + ) + .unwrap(); + + assert!(result.is_success(), "Transaction should succeed"); + + // Find the selfdestruct-to-self log + let logs = result.logs(); + let selfdestruct_to_self_log = logs.iter().find(|log| { + log.data.topics().len() == 2 && log.data.topics()[0] == SELFDESTRUCT_TO_SELF_LOG_TOPIC + }); + + assert!( + selfdestruct_to_self_log.is_some(), + "Expected selfdestruct-to-self log, got logs: {:?}", + logs + ); + let log = selfdestruct_to_self_log.unwrap(); + assert_eq!(log.address, ETH_TRANSFER_LOG_ADDRESS); + // The log data should contain the create value + assert_eq!(log.data.data.as_ref(), &create_value.to_be_bytes::<32>()); +} + +/// Bytecode that performs a CALL with value to a specific address +#[allow(clippy::vec_init_then_push)] +fn call_with_value_bytecode(target: [u8; 20], value: U256) -> Bytecode { + // CALL(gas, addr, value, argsOffset, argsSize, retOffset, retSize) + let mut bytecode = Vec::new(); + + // Push return size (0) + bytecode.push(opcode::PUSH1); + bytecode.push(0); + + // Push return offset (0) + bytecode.push(opcode::PUSH1); + bytecode.push(0); + + // Push args size (0) + bytecode.push(opcode::PUSH1); + bytecode.push(0); + + // Push args offset (0) + bytecode.push(opcode::PUSH1); + bytecode.push(0); + + // Push value (32 bytes) + let value_bytes = value.to_be_bytes::<32>(); + bytecode.push(opcode::PUSH32); + bytecode.extend_from_slice(&value_bytes); + + // Push target address (20 bytes) + bytecode.push(opcode::PUSH20); + bytecode.extend_from_slice(&target); + + // Push gas (use all remaining gas) + bytecode.push(opcode::GAS); + + // Execute CALL + bytecode.push(opcode::CALL); + + // Clean up stack + bytecode.push(opcode::POP); + + // Stop + bytecode.push(opcode::STOP); + + Bytecode::new_legacy(bytecode.into()) +} + +/// Test EIP-7708 transfer log emission for CALL with value +#[test] +fn test_eip7708_call_with_value() { + let call_target = address!("0xd000000000000000000000000000000000000001"); + let call_value = U256::from(1_000_000u128); // Small value (1M wei) + + let bytecode = call_with_value_bytecode(call_target.into_array(), call_value); + + let mut evm = Context::mainnet() + .with_cfg(CfgEnv::new_with_spec(SpecId::AMSTERDAM)) + .with_db(BenchmarkDB::new_bytecode(bytecode)) + .build_mainnet(); + + let result = evm + .transact_one( + TxEnv::builder_for_bench() + .gas_limit(200_000) + .gas_price(0) + .build_fill(), + ) + .unwrap(); + + assert!(result.is_success(), "Transaction should succeed"); + + // There should be transfer logs: + // Transfer from BENCH_TARGET to call_target (CALL value transfer) + let logs = result.logs(); + + // Find the CALL transfer log (from BENCH_TARGET to call_target) + let call_log = logs.iter().find(|log| { + log.data.topics().len() == 3 + && log.data.topics()[0] == ETH_TRANSFER_LOG_TOPIC + && log.data.topics()[1] == B256::left_padding_from(BENCH_TARGET.as_slice()) + && log.data.topics()[2] == B256::left_padding_from(call_target.as_slice()) + }); + + assert!( + call_log.is_some(), + "Expected CALL transfer log, got logs: {:?}", + logs + ); + let log = call_log.unwrap(); + assert_eq!(log.address, ETH_TRANSFER_LOG_ADDRESS); + assert_eq!(log.data.data.as_ref(), &call_value.to_be_bytes::<32>()); +} + +/// Bytecode that creates a contract with initial value +#[allow(clippy::vec_init_then_push)] +fn create_with_value_bytecode(init_code: &[u8], value: U256) -> Bytecode { + // CREATE(value, offset, length) + let mut bytecode = Vec::new(); + + // First, store init_code in memory + // PUSH init_code bytes + for (i, byte) in init_code.iter().enumerate() { + bytecode.push(opcode::PUSH1); + bytecode.push(*byte); + bytecode.push(opcode::PUSH1); + bytecode.push(i as u8); + bytecode.push(opcode::MSTORE8); + } + + // Push length + bytecode.push(opcode::PUSH1); + bytecode.push(init_code.len() as u8); + + // Push offset (0) + bytecode.push(opcode::PUSH1); + bytecode.push(0); + + // Push value (32 bytes) + let value_bytes = value.to_be_bytes::<32>(); + bytecode.push(opcode::PUSH32); + bytecode.extend_from_slice(&value_bytes); + + // Execute CREATE + bytecode.push(opcode::CREATE); + + // Clean up stack + bytecode.push(opcode::POP); + + // Stop + bytecode.push(opcode::STOP); + + Bytecode::new_legacy(bytecode.into()) +} + +/// Simple init code that just returns nothing (creates empty contract) +const SIMPLE_INIT_CODE: &[u8] = &[ + opcode::PUSH1, + 0, // length + opcode::PUSH1, + 0, // offset + opcode::RETURN, +]; + +/// Test EIP-7708 transfer log emission for CREATE with value +#[test] +fn test_eip7708_create_with_value() { + let create_value = U256::from(1_000_000u128); // Small value (1M wei) + + let bytecode = create_with_value_bytecode(SIMPLE_INIT_CODE, create_value); + + let mut evm = Context::mainnet() + .with_cfg(CfgEnv::new_with_spec(SpecId::AMSTERDAM)) + .with_db(BenchmarkDB::new_bytecode(bytecode)) + .build_mainnet(); + + let result = evm + .transact_one( + TxEnv::builder_for_bench() + .gas_limit(200_000) + .gas_price(0) + .build_fill(), + ) + .unwrap(); + + assert!(result.is_success(), "Transaction should succeed"); + + // There should be transfer logs: + // Transfer from BENCH_TARGET to created address (CREATE value transfer) + let logs = result.logs(); + + // Find the CREATE transfer log (from BENCH_TARGET to some address) + let create_log = logs.iter().find(|log| { + log.data.topics().len() == 3 + && log.data.topics()[0] == ETH_TRANSFER_LOG_TOPIC + && log.data.topics()[1] == B256::left_padding_from(BENCH_TARGET.as_slice()) + && log.data.topics()[2] != B256::left_padding_from(BENCH_CALLER.as_slice()) + }); + + assert!( + create_log.is_some(), + "Expected CREATE transfer log, got logs: {:?}", + logs + ); + let log = create_log.unwrap(); + assert_eq!(log.address, ETH_TRANSFER_LOG_ADDRESS); + assert_eq!(log.data.data.as_ref(), &create_value.to_be_bytes::<32>()); +} diff --git a/crates/primitives/src/eip7708.rs b/crates/primitives/src/eip7708.rs new file mode 100644 index 0000000000..9ba3397e96 --- /dev/null +++ b/crates/primitives/src/eip7708.rs @@ -0,0 +1,28 @@ +//! Constants for [EIP-7708](https://eips.ethereum.org/EIPS/eip-7708): ETH transfers emit a log. +//! +//! This EIP specifies that all ETH transfers (transactions, CALL, SELFDESTRUCT) emit a log, +//! making ETH transfers trackable like ERC-20 tokens. + +use alloy_primitives::{address, b256, Address, B256}; + +/// The system address used as the log emitter for ETH transfer events. +/// +/// This matches the ERC-20 Transfer event format but uses a system address +/// as the emitter since no contract actually emits these logs. +pub const ETH_TRANSFER_LOG_ADDRESS: Address = + address!("0xfffffffffffffffffffffffffffffffffffffffe"); + +/// The topic hash for the Transfer event: `keccak256("Transfer(address,address,uint256)")`. +/// +/// This is the same topic used by ERC-20 tokens for transfer events, ensuring +/// compatibility with existing indexing and tracking infrastructure. +pub const ETH_TRANSFER_LOG_TOPIC: B256 = + b256!("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"); + +/// The topic hash for self-destruct to self events. +/// +/// This is emitted when a contract self-destructs to itself, which doesn't +/// involve an actual transfer but still needs to be logged. +/// `keccak256("SelfBalanceLog(address,uint256)")` +pub const SELFDESTRUCT_TO_SELF_LOG_TOPIC: B256 = + b256!("0x4bfaba3443c1a1836cd362418edc679fc96cae8449cbefccb6457cdf2c943083"); diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index 1504880dbf..373f089647 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -20,6 +20,7 @@ pub mod eip170; pub mod eip3860; pub mod eip4844; pub mod eip7702; +pub mod eip7708; pub mod eip7823; pub mod eip7825; pub mod eip7907;