Skip to content
16 changes: 8 additions & 8 deletions crates/evm/src/block/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ where
&mut self,
tx: &MorphTxEnvelope,
sender: Address,
hardfork: MorphHardfork,
) -> Result<Option<MorphReceiptTxFields>, BlockExecutionError> {
if !tx.is_morph_tx() {
return Ok(None);
Expand Down Expand Up @@ -169,12 +168,13 @@ where

let token_info = match self.evm.cached_token_fee_info() {
Some(info) => Some(info),
None => {
TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork)
.map_err(|e| {
BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}"))
})?
}
// Only `price_ratio` and `scale` are read below, and both come straight from
// registry storage. `load_storage_only` reads exactly that and never builds a
// temporary EVM to resolve a balance this receipt has no use for.
None => TokenFeeInfo::load_storage_only(self.evm.db_mut(), fee_token_id, sender)
.map_err(|e| {
BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}"))
})?,
};

Ok(token_info.map(|info| MorphReceiptTxFields {
Expand Down Expand Up @@ -300,7 +300,7 @@ where
// are tracing-only — the trait API no longer permits us to surface errors
// from `commit_transaction`.
let (tx, signer) = recovered.into_parts();
let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer, self.hardfork) {
let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer) {
Ok(fields) => fields,
Err(err) => {
tracing::error!(
Expand Down
10 changes: 7 additions & 3 deletions crates/node/src/components/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ where
// Use in-memory blob store (Morph doesn't support EIP-4844 blobs)
let blob_store = InMemoryBlobStore::default();

// Build the Morph-specific EVM config for the validator
// Build the Morph-specific EVM config for the validator and the maintenance task
let morph_evm_config =
MorphEvmConfig::new(ctx.chain_spec(), morph_evm::MorphEvmFactory::default());

// Build the transaction validator with Morph-specific checks
let validator = TransactionValidationTaskExecutor::eth_builder(
ctx.provider().clone(),
morph_evm_config,
morph_evm_config.clone(),
)
.with_max_tx_input_bytes(ctx.config().txpool.max_tx_input_bytes)
.with_local_transactions_config(pool_config.local_transactions_config.clone())
Expand Down Expand Up @@ -88,7 +88,11 @@ where
// cannot track (reth only tracks ETH balance via SenderInfo)
ctx.task_executor().spawn_critical_task(
"txpool maintenance - morph pool",
morph_txpool::maintain_morph_pool(pool.clone(), ctx.provider().clone()),
morph_txpool::maintain_morph_pool(
pool.clone(),
ctx.provider().clone(),
morph_evm_config,
),
);

info!(target: "morph::node", "Transaction pool initialized");
Expand Down
160 changes: 144 additions & 16 deletions crates/revm/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ use crate::{
error::MorphHaltReason,
evm::MorphContext,
l1block::L1BlockInfo,
token_fee::{TokenRegistryEntry, compute_mapping_slot_for_address, encode_balance_of_calldata},
token_fee::{
TokenFeeInfo, TokenRegistryEntry, compute_mapping_slot_for_address,
encode_balance_of_calldata, read_balance_from_storage,
},
tx::MorphTxExt,
};

Expand Down Expand Up @@ -555,11 +558,7 @@ where

let hardfork = *evm.ctx_ref().cfg().spec();

let token_fee_info = token_registry_entry.load_for_caller(
evm.ctx_mut().journal_mut().db_mut(),
caller_addr,
hardfork,
)?;
let token_fee_info = load_token_fee_info(evm, token_registry_entry, caller_addr)?;

let beneficiary = evm.ctx_ref().block().beneficiary();
let rlp_bytes = evm.ctx_ref().tx().rlp_bytes.clone().unwrap_or_default();
Expand Down Expand Up @@ -826,41 +825,99 @@ where
let mut h = MorphEvmHandler::<DB, I>::new();
let init_and_floor_gas = InitialAndFloorGas::new(0, 0);
let mut gas = h.tx_gas(evm, &init_and_floor_gas);
// A database failure inside the frame is recorded on the context and surfaces as a halt,
// not as an `Err`. Running the frame group directly skips the step that normally converts
// it, so an I/O failure would otherwise be indistinguishable from the token reverting.
debug_assert!(
evm.ctx_ref().error.is_ok(),
"context error must be taken before evm_call"
);
// `execution` owns this checkpoint: it commits once the runtime gas phase is done, or
// unwinds to it when that phase runs out of gas. The `None` arm is only reachable
// under EIP-2780 (AMSTERDAM), which Morph never enables, so it is unreachable today;
// it is kept faithful to upstream so a future hardfork mapping cannot silently skip it.
let checkpoint = evm.ctx().journal_mut().checkpoint();
match h.execution(evm, checkpoint, &mut gas)? {
let result = match h.execution(evm, checkpoint, &mut gas)? {
Some(res) => Ok(res),
None => h.runtime_oog_result(evm, &init_and_floor_gas, &mut gas),
}
};
revm::context_interface::context::take_error::<
EVMError<DB::Error, MorphInvalidTransaction>,
DB::Error,
>(&mut evm.ctx_mut().error)?;
result
}

/// Query ERC20 `balanceOf(address)` via an internal EVM call.
///
/// Uses [`with_evm_snapshot`] to match go-ethereum's StaticCall semantics:
/// all state changes and `evm.tx` mutations are reverted after the call.
fn evm_call_balance_of<DB, I>(evm: &mut MorphEvm<DB, I>, token: Address, account: Address) -> U256
pub(crate) fn evm_call_balance_of<DB, I>(
evm: &mut MorphEvm<DB, I>,
token: Address,
account: Address,
) -> Result<U256, EVMError<DB::Error, MorphInvalidTransaction>>
where
DB: alloy_evm::Database,
{
with_evm_snapshot(evm, |evm| {
let calldata = encode_balance_of_calldata(account);
match evm_call(evm, Address::ZERO, token, calldata) {
// go-ethereum passes the queried account as the caller
// (`sender := vm.AccountRef(userAddress)`, core/token_gas.go:109).
match evm_call(evm, account, token, calldata) {
Ok(ref result) if result.instruction_result().is_ok() => {
let output = &result.interpreter_result().output;
if output.len() >= 32 {
Ok(if output.len() >= 32 {
U256::from_be_slice(&output[..32])
} else {
U256::ZERO
}
})
}
_ => U256::ZERO,
// The token reverted or returned nothing usable: a zero balance, which the
// caller turns into the same rejection go-ethereum reaches by erroring out of
// `buyAltTokenGas` (core/state_transition.go:314).
Ok(_) => Ok(U256::ZERO),
// A failed state read is not an answer about the balance, and must never be
// turned into one: it would make an I/O failure change the block's outcome.
Err(err @ EVMError::Database(_)) => Err(err),
Err(_) => Ok(U256::ZERO),
}
})
}

/// Resolves the caller's fee-token balance against the **executing** EVM.
///
/// go-ethereum reads it through `st.evm` (`GetAltTokenBalanceHybrid`, core/token_gas.go:43),
/// so the `balanceOf` call sees the real block context, the real chain config and the user as
/// `msg.sender`. Building a throwaway EVM here instead would answer under
/// `BlockEnv::default()` and `CfgEnv::default()` — block 0, timestamp 1, chain id 1, zero
/// coinbase and base fee — with `SYSTEM_ADDRESS` as the sender and a 30M gas limit in place
/// of go-ethereum's 200k. For any token whose `balanceOf` reads that context the two clients
/// would charge different fees for the same transaction.
fn load_token_fee_info<DB, I>(
evm: &mut MorphEvm<DB, I>,
entry: TokenRegistryEntry,
caller: Address,
) -> Result<TokenFeeInfo, EVMError<DB::Error, MorphInvalidTransaction>>
where
DB: alloy_evm::Database,
{
let balance = match entry.balance_slot() {
// Slot mode is a plain storage read with no environment to get wrong. It goes
// through the database rather than the journal deliberately: the journal is empty
// at this point in the transaction, and an `sload` here would warm a slot that the
// fee deduction below is careful to leave cold.
Some(slot) => read_balance_from_storage(
evm.ctx_mut().journal_mut().db_mut(),
entry.token_address(),
caller,
slot,
)?,
None => evm_call_balance_of(evm, entry.token_address(), caller)?,
};
Ok(entry.into_fee_info(caller, balance))
}

/// Matches go-ethereum's `transferAltTokenByEVM` validation:
/// 1. Checks EVM call succeeded (no revert)
/// 2. Validates ABI-decoded bool return value (supports old tokens with no return data)
Expand All @@ -887,7 +944,7 @@ where
// This uses with_evm_snapshot internally, so evm.tx is safe.
let from_balance_before = match from_balance_before {
Some(b) => b,
None => evm_call_balance_of(evm, token_address, from),
None => evm_call_balance_of(evm, token_address, from)?,
};

with_evm_checkpoint(evm, |evm| {
Expand Down Expand Up @@ -919,7 +976,7 @@ where

// Verify sender balance changed by the expected amount, matching go-ethereum.
// evm_call_balance_of uses with_evm_snapshot, so evm.tx is safe here too.
let from_balance_after = evm_call_balance_of(evm, token_address, from);
let from_balance_after = evm_call_balance_of(evm, token_address, from)?;

// Verify sender balance decreased by exactly the transfer amount.
// Matches go-ethereum's transferAltTokenByEVM which always checks this,
Expand Down Expand Up @@ -1104,6 +1161,77 @@ mod tests {
}
}

/// `<opcode> PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN` — a `balanceOf` that reports one piece
/// of its environment instead of a balance, so a call made under the wrong environment
/// shows up in the value that comes back.
fn code_returning(opcode: u8) -> Bytes {
Bytes::from(vec![opcode, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3])
}

fn insert_contract(db: &mut CacheDB<EmptyDB>, address: Address, code: Bytes) {
db.insert_account_info(
address,
AccountInfo {
code_hash: keccak256(code.as_ref()),
code: Some(Bytecode::new_raw(code)),
..Default::default()
},
);
}

/// Loads token 1's registry entry and resolves `caller`'s balance against `evm`.
fn probe_fee_token_balance(db: CacheDB<EmptyDB>, block: BlockEnv, caller: Address) -> U256 {
let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Emerald), NoOpInspector);
evm.block = MorphBlockEnv { inner: block };

let entry = TokenRegistryEntry::load(evm.ctx_mut().journal_mut().db_mut(), 1)
.unwrap()
.unwrap();
load_token_fee_info(&mut evm, entry, caller)
.unwrap()
.balance
}

#[test]
fn fee_token_balance_is_read_under_the_executing_block_environment() {
const TIMESTAMP: u64 = 1_767_765_600;
let token = address!("5300000000000000000000000000000000000042");
let caller = address!("1000000000000000000000000000000000000001");

let mut db = CacheDB::new(EmptyDB::default());
insert_test_fee_token(&mut db, 1, token, true);
insert_contract(&mut db, token, code_returning(0x42)); // TIMESTAMP

let balance = probe_fee_token_balance(
db,
BlockEnv {
timestamp: U256::from(TIMESTAMP),
..Default::default()
},
caller,
);

// `BlockEnv::default()` reports timestamp 1, which is what a throwaway EVM would
// have answered with regardless of the block being executed.
assert_eq!(balance, U256::from(TIMESTAMP));
}

#[test]
fn fee_token_balance_query_names_the_queried_account_as_the_caller() {
let token = address!("5300000000000000000000000000000000000042");
let caller = address!("1000000000000000000000000000000000000001");

let mut db = CacheDB::new(EmptyDB::default());
insert_test_fee_token(&mut db, 1, token, true);
insert_contract(&mut db, token, code_returning(0x33)); // CALLER

let balance = probe_fee_token_balance(db, BlockEnv::default(), caller);

// go-ethereum queries as the account being asked about, not as the zero address and
// not as `SYSTEM_ADDRESS`.
assert_eq!(balance, U256::from_be_bytes(caller.into_word().0));
}

fn insert_test_fee_token(
db: &mut CacheDB<EmptyDB>,
token_id: u16,
Expand Down Expand Up @@ -1432,7 +1560,7 @@ mod tests {
inner: BlockEnv::default(),
};

let balance = evm_call_balance_of(&mut evm, token, account);
let balance = evm_call_balance_of(&mut evm, token, account).unwrap();

assert_eq!(balance, U256::from(42));
let slot_state = evm
Expand Down
4 changes: 2 additions & 2 deletions crates/revm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ pub use l1block::{
};
pub use precompiles::MorphPrecompiles;
pub use token_fee::{
L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, compute_mapping_slot,
compute_mapping_slot_for_address, encode_balance_of_calldata, query_erc20_balance,
L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, compute_mapping_slot,
compute_mapping_slot_for_address, encode_balance_of_calldata,
};
pub use tx::{MorphTxEnv, MorphTxExt};
Loading