diff --git a/Cargo.lock b/Cargo.lock index d930672..bfc0b09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6438,7 +6438,6 @@ dependencies = [ "ark-serialize 0.5.0", "arrayref", "aurora-engine-modexp", - "blst", "c-kzg", "cfg-if", "k256", diff --git a/Cargo.toml b/Cargo.toml index 883cb0a..52f1199 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,12 @@ publish = false [workspace] resolver = "3" members = [ - "crates/chainspec", - "crates/evm", - "crates/payload/builder", - "crates/payload/types", - "crates/primitives", - "crates/revm", + "crates/chainspec", + "crates/evm", + "crates/payload/builder", + "crates/payload/types", + "crates/primitives", + "crates/revm", ] [workspace.lints] @@ -95,7 +95,9 @@ reth-revm = { git = "https://github.com/paradigmxyz/reth", rev = "64909d3", feat "std", "optional-checks", ] } -revm = { version = "33.1.0", features = ["optional_fee_charge"] } +revm = { version = "33.1.0", features = [ + "optional_fee_charge", +], default-features = false } alloy = { version = "1.1.3", default-features = false } alloy-consensus = { version = "1.1.3", default-features = false } diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 2e367f0..b972a78 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -1,5 +1,5 @@ use alloy_consensus::{Signed, TransactionEnvelope, TxEip1559, TxEip2930, TxEip7702, TxLegacy}; -use alloy_primitives::{B256, Bytes}; +use alloy_primitives::{B256, Bytes, U256}; use alloy_rlp::BytesMut; use crate::{TxAltFee, TxL1Msg}; @@ -70,6 +70,22 @@ impl MorphTxEnvelope { } Bytes(bytes.freeze()) } + + /// Returns the fee token id if this is an AltFee transaction. + pub fn fee_token_id(&self) -> Option { + match self { + Self::AltFee(tx) => Some(tx.tx().fee_token_id), + _ => None, + } + } + + /// Returns the fee limit if this is an AltFee transaction. + pub fn fee_limit(&self) -> Option { + match self { + Self::AltFee(tx) => Some(tx.tx().fee_limit), + _ => None, + } + } } impl reth_primitives_traits::InMemorySize for MorphTxEnvelope { diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 41fd53c..0ce1d80 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -15,10 +15,20 @@ pub enum MorphInvalidTransaction { #[error("Token with ID {0} is not registered")] TokenNotRegistered(u16), + /// Token ID 0 not supported for gas payment. + #[error("Token ID 0 is not supported for gas payment")] + TokenIdZeroNotSupported, + /// Token is not active for gas payment. #[error("Token with ID {0} is not active for gas payment")] TokenNotActive(u16), + #[error("Token transfer failed: {reason}")] + TokenTransferFailed { + /// Token transfer failure reason. + reason: String, + }, + /// Insufficient token balance for gas payment. #[error( "Insufficient token balance for gas payment: required {required}, available {available}" diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 17b9665..5c25a37 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1,21 +1,21 @@ //! Morph EVM Handler implementation. -use std::fmt::Debug; - -use alloy_primitives::U256; +use alloy_primitives::{Address, Bytes, U256}; +use morph_primitives::L1_TX_TYPE_ID; use revm::{ + ExecuteEvm, context::{ Cfg, ContextTr, JournalTr, Transaction, result::{EVMError, ExecutionResult, InvalidTransaction}, }, context_interface::Block, - handler::{EvmTr, FrameTr, Handler, MainnetHandler, pre_execution, validation}, + handler::{EvmTr, FrameTr, Handler, MainnetHandler, post_execution, pre_execution, validation}, inspector::{Inspector, InspectorHandler}, - interpreter::{InitialAndFloorGas, interpreter::EthInterpreter}, + interpreter::{Gas, InitialAndFloorGas, interpreter::EthInterpreter}, }; use crate::{ - MorphEvm, MorphInvalidTransaction, + MorphEvm, MorphInvalidTransaction, MorphTxEnv, error::MorphHaltReason, evm::MorphContext, l1block::L1BlockInfo, @@ -115,16 +115,22 @@ where fn reimburse_caller( &self, evm: &mut Self::Evm, - _exec_result: &mut <::Frame as FrameTr>::FrameResult, + exec_result: &mut <::Frame as FrameTr>::FrameResult, ) -> Result<(), Self::Error> { // For L1 message transactions, no reimbursement is needed if evm.ctx_ref().tx().is_l1_msg() { return Ok(()); } - // For Morph L2, we don't reimburse caller - // The L2 execution fee is handled by the sequencer - // L1 data fee is a fixed cost that is not refunded + // Check if transaction is AltFeeTx (tx_type 0x7F) which uses token fee + if evm.ctx_ref().tx().is_alt_fee_tx() { + // Get fee_token_id directly from MorphTxEnv + let token_id = evm.ctx_ref().tx().fee_token_id.unwrap_or_default(); + return self.reimburse_caller_token_fee(evm, exec_result.gas(), token_id); + } + + // Standard ETH-based fee handling + post_execution::reimburse_caller(evm.ctx(), exec_result.gas(), U256::ZERO)?; Ok(()) } @@ -167,12 +173,10 @@ where // Calculate L1 data fee based on full RLP-encoded transaction let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); - // Get mutable access to context components + // Get mutable access to journal components let journal = evm.ctx().journal_mut(); - let gas_spent = exec_result.gas().spent(); - let gas_refunded = exec_result.gas().refunded() as u64; - let gas_used = gas_spent - gas_refunded; + let gas_used = exec_result.gas().used(); let execution_fee = U256::from(effective_gas_price).saturating_mul(U256::from(gas_used)); @@ -298,20 +302,88 @@ where /// Validate and deduct token-based gas fees. /// /// This handles gas payment using ERC20 tokens instead of ETH. - fn validate_and_deduct_token_fee( + fn reimburse_caller_token_fee( &self, evm: &mut MorphEvm, + gas: &Gas, token_id: u16, ) -> Result<(), EVMError> { // Get caller address - let caller_addr = evm.ctx_ref().tx().caller(); + let caller = evm.ctx_ref().tx().caller(); // Get coinbase address let beneficiary = evm.ctx_ref().block().beneficiary(); + let basefee = evm.ctx.block().basefee() as u128; + let effective_gas_price = evm.ctx.tx().effective_gas_price(basefee); + + let reimburse_eth = U256::from( + effective_gas_price.saturating_mul((gas.remaining() + gas.refunded() as u64) as u128), + ); + + if reimburse_eth.is_zero() { + return Ok(()); + } // Fetch token fee info from Token Registry - let token_fee_info = - TokenFeeInfo::try_fetch(evm.ctx_mut().db_mut(), token_id, caller_addr)? - .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; + let token_fee_info = TokenFeeInfo::try_fetch(evm.ctx_mut().db_mut(), token_id, caller)? + .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; + + // Check if token is active + if !token_fee_info.is_active { + return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); + } + + // Calculate token amount required for total fee + let token_amount_required = token_fee_info.calculate_token_amount(reimburse_eth); + + // Get mutable access to journal components + let journal = evm.ctx().journal_mut(); + + if let Some(balance_slot) = token_fee_info.balance_slot { + // Transfer with token slot. + let _ = transfer_erc20_with_slot( + journal, + beneficiary, + caller, + token_fee_info.token_address, + token_amount_required, + balance_slot, + )?; + } else { + // Transfer with evm call. + transfer_erc20_with_evm( + evm, + beneficiary, + token_fee_info.caller, + token_fee_info.token_address, + token_amount_required, + )?; + } + Ok(()) + } + + /// Validate and deduct token-based gas fees. + /// + /// This handles gas payment using ERC20 tokens instead of ETH. + fn validate_and_deduct_token_fee( + &self, + evm: &mut MorphEvm, + token_id: u16, + ) -> Result<(), EVMError> { + // Token ID 0 not supported for gas payment. + if token_id == 0 { + return Err(MorphInvalidTransaction::TokenIdZeroNotSupported.into()); + } + + let (block, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); + + // Get caller address + let caller_addr = tx.caller(); + // Get coinbase address + let beneficiary = block.beneficiary(); + + // Fetch token fee info from Token Registry + let token_fee_info = TokenFeeInfo::try_fetch(journal.db_mut(), token_id, caller_addr)? + .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; // Check if token is active if !token_fee_info.is_active { @@ -319,16 +391,14 @@ where } // Get the current hardfork for L1 fee calculation - let hardfork = evm.ctx_ref().cfg().spec(); + let hardfork = cfg.spec(); // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; + let l1_block_info = L1BlockInfo::try_fetch(journal.db_mut(), hardfork)?; // Get RLP-encoded transaction bytes for L1 fee calculation // This represents the full transaction data posted to L1 for data availability - let rlp_bytes = evm - .ctx_ref() - .tx() + let rlp_bytes = tx .rlp_bytes .as_ref() .map(|b| b.as_ref()) @@ -338,8 +408,8 @@ where let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); // Calculate L2 gas fee (in ETH) - let gas_limit = evm.ctx_ref().tx().gas_limit(); - let gas_price = evm.ctx_ref().tx().gas_price(); + let gas_limit = tx.gas_limit(); + let gas_price = tx.gas_price(); let l2_gas_fee = U256::from(gas_limit).saturating_mul(U256::from(gas_price)); // Total fee in ETH @@ -348,55 +418,80 @@ where // Calculate token amount required for total fee let token_amount_required = token_fee_info.calculate_token_amount(total_eth_fee); + // Determine fee limit + let mut fee_limit = tx.fee_limit.unwrap_or_default(); + if fee_limit.is_zero() || fee_limit > token_fee_info.balance { + fee_limit = token_fee_info.balance + } + // Check if caller has sufficient token balance - if token_fee_info.balance < token_amount_required { + if fee_limit < token_amount_required { return Err(MorphInvalidTransaction::InsufficientTokenBalance { required: token_amount_required, - available: token_fee_info.balance, + available: fee_limit, } .into()); } - // Get mutable access to context components - let (_, tx, cfg, journal, _, _) = evm.ctx().all_mut(); - - // First, deduct token fee from caller's ERC20 balance - // This updates the ERC20 token's storage directly if let Some(balance_slot) = token_fee_info.balance_slot { - // Sub amount - let token_storage_slot = get_mapping_account_slot(balance_slot, caller_addr); - let new_token_balance = token_fee_info.balance.saturating_sub(token_amount_required); - journal.sstore( + // Transfer with token slot. + // Ensure token account is loaded into the journal state, because `sload`/`sstore` + // assume the account is present. + let _ = journal.load_account_mut(token_fee_info.token_address)?; + journal.touch(token_fee_info.token_address); + let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( + journal, + caller_addr, + beneficiary, token_fee_info.token_address, - token_storage_slot, - new_token_balance, + token_amount_required, + balance_slot, )?; - - // Add amount - let token_storage_slot = get_mapping_account_slot(balance_slot, beneficiary); - let balance = journal - .sload(token_fee_info.token_address, token_storage_slot) - .unwrap_or_default(); - journal.sstore( + // We don't want the fee-token account/slots we touched during validation to become + // warm for the rest of the transaction execution. + if let Some(token_acc) = journal.state.get_mut(&token_fee_info.token_address) { + token_acc.mark_cold(); + if let Some(slot) = token_acc.storage.get_mut(&from_storage_slot) { + slot.mark_cold(); + } + if let Some(slot) = token_acc.storage.get_mut(&to_storage_slot) { + slot.mark_cold(); + } + } + } else { + // Transfer with evm call. + let tx_origin = evm.tx.clone(); + transfer_erc20_with_evm( + evm, + token_fee_info.caller, beneficiary, - token_storage_slot, - balance.saturating_sub(token_amount_required), + token_fee_info.token_address, + token_amount_required, )?; + // restore the original transaction + evm.tx = tx_origin; } + let (_, tx, cfg, journal, _, _) = evm.ctx().all_mut(); + + // Extract the required tx fields (Copy) before mutating accounts. + let caller_addr = tx.caller(); + let nonce = tx.nonce(); + let is_call = tx.kind().is_call(); + // Load caller's account for nonce/code validation - let mut caller = journal.load_account_with_code_mut(tx.caller())?.data; + let mut caller = journal.load_account_with_code_mut(caller_addr)?.data; // Validate account nonce and code (EIP-3607) pre_execution::validate_account_nonce_and_code( &caller.info, - tx.nonce(), + nonce, cfg.is_eip3607_disabled(), cfg.is_nonce_check_disabled(), )?; // Bump nonce for calls (CREATE nonce is bumped in make_create_frame) - if tx.kind().is_call() { + if is_call { caller.bump_nonce(); } @@ -404,6 +499,99 @@ where } } +/// Performs an ERC20 balance transfer by directly `sload`/`sstore`-ing the token contract storage +/// using the known `balance` mapping base slot, returning the computed storage slots for `from`/`to`. +fn transfer_erc20_with_slot( + journal: &mut revm::Journal, + from: Address, + to: Address, + token: Address, + token_amount: U256, + token_balance_slot: U256, +) -> Result<(U256, U256), EVMError<::Error, MorphInvalidTransaction>> +where + DB: alloy_evm::Database, +{ + // Sub amount + let from_storage_slot = get_mapping_account_slot(token_balance_slot, from); + let balance = journal.sload(token, from_storage_slot)?; + journal.sstore( + token, + from_storage_slot, + balance.saturating_sub(token_amount), + )?; + + // Add amount + let to_storage_slot = get_mapping_account_slot(token_balance_slot, to); + let balance = journal.sload(token, to_storage_slot)?; + journal.sstore(token, to_storage_slot, balance.saturating_add(token_amount))?; + Ok((from_storage_slot, to_storage_slot)) +} + +/// Gas limit for ERC20 transfer calls. +const TRANSFER_GAS_LIMIT: u64 = 200000; + +/// Transfers ERC20 tokens by executing a `transfer(address,uint256)` call via the EVM. +fn transfer_erc20_with_evm( + evm: &mut MorphEvm, + caller: Address, + to: Address, + token_address: Address, + token_amount: U256, +) -> Result<(), EVMError> +where + DB: alloy_evm::Database, +{ + let calldata = build_transfer_calldata(to, token_amount); + + let tx_env = revm::context::TxEnv { + caller, + gas_limit: TRANSFER_GAS_LIMIT, + kind: token_address.into(), + data: calldata, + tx_type: L1_TX_TYPE_ID, // Mark as L1 message to skip gas validation + ..Default::default() + }; + + let tx = MorphTxEnv { + inner: tx_env, + rlp_bytes: None, + ..Default::default() + }; + match evm.transact_one(tx) { + Ok(result) => { + if !result.is_success() { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("{result:?}"), + } + .into()); + } + } + Err(e) => { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("Error: {e:?}"), + } + .into()); + } + }; + Ok(()) +} + +/// Build the calldata for ERC20 transfer(address,amount) call. +/// +/// Method signature: `transfer(address,amount) -> 0xa9059cbb` +fn build_transfer_calldata(to: Address, token_amount: alloy_primitives::Uint<256, 4>) -> Bytes { + let method_id = [0xa9u8, 0x05, 0x9c, 0xbb]; + // Encode calldata: method_id + padded to address + amount + let mut calldata = Vec::with_capacity(68); + calldata.extend_from_slice(&method_id); + let mut address_bytes = [0u8; 32]; + address_bytes[12..32].copy_from_slice(to.as_slice()); + calldata.extend_from_slice(&address_bytes); + calldata.extend_from_slice(&token_amount.to_be_bytes::<32>()); + Bytes::from(calldata) +} + /// Calculate the new balance after deducting L2 fees and L1 data fee. /// /// This is a Morph-specific version of `pre_execution::calculate_caller_fee` that diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index 94f1ae5..ff516c4 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -7,12 +7,11 @@ use alloy_evm::Database; use alloy_primitives::{Address, Bytes, TxKind, U256, address, keccak256}; +use morph_chainspec::hardfork::MorphHardfork; use morph_primitives::L1_TX_TYPE_ID; use revm::{ - ExecuteEvm, Inspector, - context::TxEnv, - context_interface::{ContextTr, result::EVMError}, - handler::EvmTr, + ExecuteEvm, Inspector, context::TxEnv, context_interface::result::EVMError, + inspector::NoOpInspector, }; use crate::evm::MorphContext; @@ -142,9 +141,15 @@ impl TokenFeeInfo { } // token_amount = eth_amount * scale / price_ratio - eth_amount + let (token_amount, remainder) = eth_amount .saturating_mul(self.scale) - .wrapping_div(self.price_ratio) + .div_rem(self.price_ratio); + // If there's a remainder, round up by adding 1 + if !remainder.is_zero() { + token_amount.saturating_add(U256::from(1)) + } else { + token_amount + } } /// Check if the caller has sufficient token balance for the given ETH amount. @@ -188,7 +193,7 @@ fn load_mapping_value( } /// Gas limit for ERC20 balance query calls. -const BALANCE_OF_GAS_LIMIT: u64 = 1_000_000; +const BALANCE_OF_GAS_LIMIT: u64 = 200000; /// Get ERC20 token balance for an account (storage-only version). /// @@ -207,14 +212,29 @@ pub fn get_erc20_balance( if let Some(slot) = token_balance_slot { let mut data = [0u8; 32]; data[12..32].copy_from_slice(account.as_slice()); - if let Ok(balance) = load_mapping_value(db, token, slot, data.to_vec()) { - return Ok(balance); + load_mapping_value(db, token, slot, data.to_vec()) + } else { + // For the EVM fallback we construct a temporary MorphEvm instance. + // + // Notes: + // - `MorphContext::new` requires a hardfork/spec parameter. + // - We pass `&mut DB` as the context database type (so we don't move `db`). + // - `NoOpInspector` satisfies the `Inspector` bound without adding side effects. + let db: &mut dyn Database = db; + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::Curie), + NoOpInspector {}, + ); + evm.cfg.disable_balance_check = true; + + match get_erc20_balance_with_evm(&mut evm, token, account) { + Ok(balance) => Ok(balance), + Err(EVMError::Database(db_err)) => Err(db_err), + // For non-database EVM errors, fall back to zero (matches original behavior). + Err(_) => Ok(U256::ZERO), } } - - // If balance slot is not available, return zero. - // Use get_erc20_balance_with_evm for EVM call fallback. - Ok(U256::ZERO) } /// Get ERC20 token balance for an account with EVM call fallback. @@ -226,22 +246,11 @@ pub fn get_erc20_balance_with_evm( evm: &mut MorphEvm, token: Address, account: Address, - token_balance_slot: Option, ) -> Result> where DB: Database, I: Inspector>, { - // First try storage-based lookup - if let Some(slot) = token_balance_slot { - let mut data = [0u8; 32]; - data[12..32].copy_from_slice(account.as_slice()); - let storage_slot = get_mapping_slot(slot, data.to_vec()); - if let Ok(balance) = evm.ctx_mut().db_mut().storage(token, storage_slot) { - return Ok(balance); - } - } - // Fallback: Execute EVM call to balanceOf(address) let calldata = build_balance_of_calldata(account); diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index ad72b11..cc5d235 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -5,6 +5,7 @@ use alloy_consensus::{EthereumTxEnvelope, Transaction as AlloyTransaction, TxEip4844}; use alloy_eips::eip2718::Encodable2718; use alloy_eips::eip2930::AccessList; +use alloy_eips::eip7702::RecoveredAuthority; use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; use alloy_rlp::Decodable; use morph_primitives::{ALT_FEE_TX_TYPE_ID, L1_TX_TYPE_ID, MorphTxEnvelope, TxAltFee}; @@ -86,10 +87,13 @@ impl MorphTxEnv { let tx_type: u8 = tx.tx_type().into(); // Extract fee_token_id for AltFeeTx (type 0x7F) - let fee_token_id = if tx_type == ALT_FEE_TX_TYPE_ID { - extract_fee_token_id_from_rlp(&rlp_bytes) + let fee_token_info = if tx_type == ALT_FEE_TX_TYPE_ID { + ( + extract_fee_token_id_from_rlp(&rlp_bytes), + extract_fee_limit_from_rlp(&rlp_bytes), + ) } else { - 0 + (0, U256::default()) }; // Build TxEnv from the transaction @@ -110,13 +114,27 @@ impl MorphTxEnv { .map(|h| h.to_vec()) .unwrap_or_default(), max_fee_per_blob_gas: AlloyTransaction::max_fee_per_blob_gas(tx).unwrap_or(0), - authorization_list: Default::default(), + authorization_list: tx + .authorization_list() + .unwrap_or_default() + .iter() + .map(|auth| { + let authority = auth + .recover_authority() + .map_or(RecoveredAuthority::Invalid, RecoveredAuthority::Valid); + Either::Right(RecoveredAuthorization::new_unchecked( + auth.inner().clone(), + authority, + )) + }) + .collect(), }; // Use builder pattern to set Morph-specific fields Self::new(inner) .with_rlp_bytes(rlp_bytes) - .with_fee_token_id(fee_token_id) + .with_fee_token_id(fee_token_info.0) + .with_fee_limit(fee_token_info.1) } } @@ -136,6 +154,22 @@ fn extract_fee_token_id_from_rlp(rlp_bytes: &Bytes) -> u16 { .unwrap_or(0) } +/// Extract fee_limit from RLP-encoded AltFeeTx bytes. +/// +/// The bytes should be EIP-2718 encoded (type byte + RLP payload). +/// Returns 0 if decoding fails. +fn extract_fee_limit_from_rlp(rlp_bytes: &Bytes) -> U256 { + if rlp_bytes.is_empty() { + return U256::default(); + } + + // Skip the type byte (0x7F) and decode the AltFeeTx + let payload = &rlp_bytes[1..]; + TxAltFee::decode(&mut &payload[..]) + .map(|tx| tx.fee_limit) + .unwrap_or_default() +} + impl Deref for MorphTxEnv { type Target = TxEnv;