From 5b6c2d87677fc595111c3322fe243ba58e443a89 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 17:12:34 +0800 Subject: [PATCH 01/17] feat: support MorphTx v2 with EIP-7702 authorization lists Add MorphTx version 2 (0x7f || 0x02 || rlp), which carries an EIP-7702 authorization list on top of the v1 fields and is activated by the new Onyx timestamp fork. - primitives: encode the authorization list after memo in both the payload and the signature hash; an empty list is valid and behaves like v1, a non-empty list forbids CREATE, v0/v1 must not carry one; the Compact codec stays backward compatible; JSON always emits authorizationList for v2 ([] when empty) and never for v0/v1 - chainspec: add the Onyx hardfork (onyxTime), mapped to OSAKA - consensus/txpool: reject v2 before Onyx; the upstream pool's authority and delegation limits apply to v2 through Transaction::authorization_list - revm: apply v2 authorization lists through the same path and refund accounting as 0x04, enforce the static EIP-7702 rules, and size the L1 data fee of simulated transactions with the list - rpc: build v2 from requests carrying authorizations and reject invalid combinations as parameter errors - statetest: model MorphTx with authorizations as v2, add the onyx fork --- Cargo.lock | 1 + bin/morph-statetest/Cargo.toml | 1 + bin/morph-statetest/src/schema.rs | 91 +- crates/chainspec/src/genesis.rs | 30 +- crates/chainspec/src/hardfork.rs | 52 +- crates/chainspec/src/spec.rs | 60 +- crates/consensus/src/validation.rs | 232 ++++- crates/evm/src/block/receipt.rs | 1 + crates/node/src/test_utils.rs | 88 +- crates/node/tests/assets/test-genesis.json | 1 + crates/node/tests/it/hardfork.rs | 25 + crates/node/tests/it/helpers.rs | 21 + crates/node/tests/it/morph_tx.rs | 772 +++++++++++++- crates/node/tests/it/rpc.rs | 291 +++++- .../src/transaction/morph_transaction.rs | 977 ++++++++++++++++-- crates/revm/src/error.rs | 17 + crates/revm/src/handler.rs | 651 +++++++++++- crates/revm/src/precompiles.rs | 2 +- crates/revm/src/tx.rs | 209 +++- crates/rpc/src/eth/transaction.rs | 493 ++++++++- crates/txpool/src/morph_tx_validation.rs | 120 ++- crates/txpool/src/transaction.rs | 1 + crates/txpool/src/validator.rs | 1 + 23 files changed, 3990 insertions(+), 147 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 94036ec6..c943baa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5501,6 +5501,7 @@ dependencies = [ "eyre", "morph-chainspec", "morph-evm", + "morph-primitives", "morph-revm", "revm", "revm-statetest-types", diff --git a/bin/morph-statetest/Cargo.toml b/bin/morph-statetest/Cargo.toml index 44e0192b..a4e687de 100644 --- a/bin/morph-statetest/Cargo.toml +++ b/bin/morph-statetest/Cargo.toml @@ -16,6 +16,7 @@ eyre.workspace = true morph-chainspec.workspace = true morph-evm.workspace = true morph-revm.workspace = true +morph-primitives.workspace = true revm = { workspace = true, features = ["tracer"] } revm-statetest-types.workspace = true serde.workspace = true diff --git a/bin/morph-statetest/src/schema.rs b/bin/morph-statetest/src/schema.rs index 6f0904d6..1670ff83 100644 --- a/bin/morph-statetest/src/schema.rs +++ b/bin/morph-statetest/src/schema.rs @@ -1,4 +1,5 @@ use morph_chainspec::hardfork::MorphHardfork; +use morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_2; use morph_revm::{MorphTxEnv, MorphTxExt}; use revm::{ context::{BlockEnv, CfgEnv, TransactionType, TxEnv}, @@ -282,6 +283,11 @@ impl MorphTransactionParts { let mut tx = MorphTxEnv::new(inner); if let Some(version) = self.version { tx = tx.with_version(version); + } else if tx.is_morph_tx() && self.authorization_list.is_some() { + // A MorphTx carrying an authorization list can only be V2; model it + // as such instead of leaving the version unset (which the handler + // treats as V0 and rejects). + tx = tx.with_version(MORPH_TX_VERSION_2); } if let Some(fee_token_id) = self.fee_token_id { tx = tx.with_fee_token_id(fee_token_id); @@ -358,7 +364,10 @@ pub fn parse_fork(name: &str) -> Result { "morph203" | "morph-203" => Ok(MorphHardfork::Morph203), "viridian" | "prague" => Ok(MorphHardfork::Viridian), "emerald" => Ok(MorphHardfork::Emerald), - "jade" | "osaka" => Ok(MorphHardfork::Jade), + "jade" => Ok(MorphHardfork::Jade), + // OSAKA is the spec level of the latest Morph fork, so the generic + // Ethereum name maps to it (matches `MorphHardfork::from(SpecId::OSAKA)`). + "onyx" | "osaka" => Ok(MorphHardfork::Onyx), "cancun" => Ok(MorphHardfork::Morph203), _ => Err(SchemaError::UnknownFork(name.to_string())), } @@ -487,6 +496,86 @@ mod tests { ); } + #[test] + fn morph_tx_with_authorization_list_is_modelled_as_v2() { + let suite: MorphTestSuite = serde_json::from_str( + r#"{ + "case": { + "env": { + "currentChainID": "0x1", + "currentCoinbase": "0x0000000000000000000000000000000000000000", + "currentDifficulty": "0x0", + "currentGasLimit": "0x989680", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1" + }, + "pre": {}, + "transaction": { + "type": "0x7f", + "nonce": "0x0", + "gasLimit": ["0x186a0"], + "to": "0x00000000000000000000000000000000000000f1", + "value": ["0x0"], + "data": ["0x"], + "accessLists": [null], + "maxFeePerGas": "0x10", + "maxPriorityFeePerGas": "0x1", + "feeTokenID": "0x1", + "feeLimit": "0x3e8", + "authorizationList": [{ + "chainId": "0x1", + "address": "0x4242424242424242424242424242424242424242", + "nonce": "0x0", + "yParity": "0x0", + "r": "0x1", + "s": "0x2" + }], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Onyx": [{ + "indexes": { "data": 0, "gas": 0, "value": 0 }, + "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logs": "0x0000000000000000000000000000000000000000000000000000000000000000", + "expectException": null + }] + } + } + }"#, + ) + .expect("suite should parse"); + + let unit = suite.0.values().next().unwrap(); + let post = &unit.post["Onyx"][0]; + let tx = unit + .morph_tx_env(post, MorphHardfork::Onyx) + .expect("tx env should build"); + + assert!(tx.is_morph_tx()); + assert_eq!(tx.version, Some(MORPH_TX_VERSION_2)); + assert_eq!(tx.fee_token_id, Some(1)); + assert_eq!(tx.authorization_list.len(), 1); + + // The fallback L1 fee bytes must be the V2 envelope (0x7f || 0x02 || rlp) + // and carry the authorization list: the delegate address appears verbatim. + let encoded = tx.rlp_bytes.expect("fallback L1 fee bytes"); + assert_eq!(encoded[0], 0x7f); + assert_eq!(encoded[1], MORPH_TX_VERSION_2); + let delegate = [0x42u8; 20]; + assert!( + encoded.windows(20).any(|window| window == delegate), + "L1 fee sizing bytes must include the authorization list" + ); + } + + #[test] + fn parse_fork_maps_onyx_and_osaka() { + assert_eq!(parse_fork("Onyx").unwrap(), MorphHardfork::Onyx); + assert_eq!(parse_fork("osaka").unwrap(), MorphHardfork::Onyx); + assert_eq!(parse_fork("jade").unwrap(), MorphHardfork::Jade); + } + #[test] fn blob_tx_without_txbytes_errors_instead_of_silently_zeroing_l1_fee() { let suite: MorphTestSuite = serde_json::from_str( diff --git a/crates/chainspec/src/genesis.rs b/crates/chainspec/src/genesis.rs index 98a2b93e..c37448f3 100644 --- a/crates/chainspec/src/genesis.rs +++ b/crates/chainspec/src/genesis.rs @@ -40,7 +40,7 @@ impl TryFrom<&OtherFields> for MorphGenesisInfo { /// the Morph hardforks were activated. /// /// Note: Bernoulli and Curie use block-based activation, while Morph203, Viridian, -/// Emerald, and Jade use timestamp-based activation (matching go-ethereum behavior). +/// Emerald, Jade, and Onyx use timestamp-based activation (matching go-ethereum behavior). #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MorphHardforkInfo { @@ -62,6 +62,9 @@ pub struct MorphHardforkInfo { /// Jade hardfork timestamp. #[serde(skip_serializing_if = "Option::is_none")] pub jade_fork_time: Option, + /// Onyx hardfork timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub onyx_time: Option, } impl MorphHardforkInfo { @@ -136,7 +139,8 @@ mod tests { "morph203Time": 3000, "viridianTime": 4000, "emeraldTime": 5000, - "jadeForkTime": 6000 + "jadeForkTime": 6000, + "onyxTime": 7000 } "#; @@ -152,10 +156,32 @@ mod tests { viridian_time: Some(4000), emerald_time: Some(5000), jade_fork_time: Some(6000), + onyx_time: Some(7000), } ); } + #[test] + fn test_extract_morph_hardfork_info_without_onyx() { + // Genesis files scheduled through Jade (current mainnet/hoodi) must keep parsing. + let genesis_info = r#" + { + "bernoulliBlock": 0, + "curieBlock": 100, + "morph203Time": 3000, + "viridianTime": 4000, + "emeraldTime": 5000, + "jadeForkTime": 6000 + } + "#; + + let others: OtherFields = serde_json::from_str(genesis_info).unwrap(); + let hardfork_info = MorphHardforkInfo::extract_from(&others).unwrap(); + + assert_eq!(hardfork_info.jade_fork_time, Some(6000)); + assert_eq!(hardfork_info.onyx_time, None); + } + #[test] fn test_extract_morph_chain_config() { let config_str = r#" diff --git a/crates/chainspec/src/hardfork.rs b/crates/chainspec/src/hardfork.rs index e13a4b9f..b5ddf384 100644 --- a/crates/chainspec/src/hardfork.rs +++ b/crates/chainspec/src/hardfork.rs @@ -29,7 +29,7 @@ //! ## Current State //! //! Bernoulli and Curie use block-based activation, while Morph203, Viridian, -//! Emerald, and Jade use timestamp-based activation. +//! Emerald, Jade, and Onyx use timestamp-based activation. use alloy_evm::revm::primitives::hardfork::SpecId; use alloy_hardforks::hardfork; @@ -39,7 +39,7 @@ hardfork!( /// Morph-specific hardforks for network upgrades. /// /// Note: Bernoulli and Curie use block-based activation, while Morph203, Viridian, - /// Emerald, and Jade use timestamp-based activation (matching go-ethereum behavior). + /// Emerald, Jade, and Onyx use timestamp-based activation (matching go-ethereum behavior). #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Default)] MorphHardfork { @@ -56,6 +56,10 @@ hardfork!( /// Jade hardfork (timestamp-based). #[default] Jade, + /// Onyx hardfork (timestamp-based). + /// + /// Activates MorphTx version 2, which carries an EIP-7702 authorization list. + Onyx, } ); @@ -95,6 +99,12 @@ impl MorphHardfork { pub fn is_jade(self) -> bool { self >= Self::Jade } + + /// Returns `true` if this hardfork is Onyx or later. + #[inline] + pub fn is_onyx(self) -> bool { + self >= Self::Onyx + } } /// Trait for querying Morph-specific hardfork activations. @@ -141,12 +151,20 @@ pub trait MorphHardforks: EthereumHardforks { .active_at_timestamp(timestamp) } + /// Convenience method to check if Onyx hardfork is active at a given timestamp. + fn is_onyx_active_at_timestamp(&self, timestamp: u64) -> bool { + self.morph_fork_activation(MorphHardfork::Onyx) + .active_at_timestamp(timestamp) + } + /// Retrieves the latest Morph hardfork active at a given block and timestamp. /// /// Note: This method checks both block-based (Bernoulli, Curie) and - /// timestamp-based (Morph203, Viridian, Emerald, Jade) hardforks. + /// timestamp-based (Morph203, Viridian, Emerald, Jade, Onyx) hardforks. fn morph_hardfork_at(&self, block_number: u64, timestamp: u64) -> MorphHardfork { - if self.is_jade_active_at_timestamp(timestamp) { + if self.is_onyx_active_at_timestamp(timestamp) { + MorphHardfork::Onyx + } else if self.is_jade_active_at_timestamp(timestamp) { MorphHardfork::Jade } else if self.is_emerald_active_at_timestamp(timestamp) { MorphHardfork::Emerald @@ -169,14 +187,14 @@ impl From for SpecId { /// The mapping must match go-ethereum Morph's EVM instruction sets: /// - Bernoulli/Curie/Morph203 = CANCUN gas tables (MCOPY, TSTORE/TLOAD, transient storage) /// - Viridian = PRAGUE (adds EIP-7702 delegation designator) - /// - Emerald/Jade = OSAKA (adds EIP-7939 CLZ opcode) + /// - Emerald/Jade/Onyx = OSAKA (adds EIP-7939 CLZ opcode) fn from(value: MorphHardfork) -> Self { match value { MorphHardfork::Bernoulli | MorphHardfork::Curie | MorphHardfork::Morph203 => { Self::CANCUN } MorphHardfork::Viridian => Self::PRAGUE, - MorphHardfork::Emerald | MorphHardfork::Jade => Self::OSAKA, + MorphHardfork::Emerald | MorphHardfork::Jade | MorphHardfork::Onyx => Self::OSAKA, } } } @@ -189,7 +207,7 @@ impl From for MorphHardfork { /// latest hardfork for the given spec level. fn from(spec: SpecId) -> Self { if spec.is_enabled_in(SpecId::OSAKA) { - Self::Jade + Self::Onyx } else if spec.is_enabled_in(SpecId::PRAGUE) { Self::Viridian } else { @@ -216,6 +234,7 @@ mod tests { assert_eq!(SpecId::from(MorphHardfork::Viridian), SpecId::PRAGUE); assert_eq!(SpecId::from(MorphHardfork::Emerald), SpecId::OSAKA); assert_eq!(SpecId::from(MorphHardfork::Jade), SpecId::OSAKA); + assert_eq!(SpecId::from(MorphHardfork::Onyx), SpecId::OSAKA); } #[test] @@ -227,6 +246,7 @@ mod tests { MorphHardfork::Viridian, MorphHardfork::Emerald, MorphHardfork::Jade, + MorphHardfork::Onyx, ]; for fork in forks { @@ -289,7 +309,15 @@ mod tests { fn test_specid_to_morph_hardfork_mapping() { assert_eq!(MorphHardfork::from(SpecId::CANCUN), MorphHardfork::Morph203); assert_eq!(MorphHardfork::from(SpecId::PRAGUE), MorphHardfork::Viridian); - assert_eq!(MorphHardfork::from(SpecId::OSAKA), MorphHardfork::Jade); + assert_eq!(MorphHardfork::from(SpecId::OSAKA), MorphHardfork::Onyx); + } + + #[test] + fn test_is_onyx() { + assert!(MorphHardfork::Onyx.is_onyx()); + assert!(MorphHardfork::Onyx.is_jade()); + assert!(!MorphHardfork::Jade.is_onyx()); + assert!(!MorphHardfork::Emerald.is_onyx()); } /// SpecIds below CANCUN should map to Morph203 (the latest CANCUN-level hardfork). @@ -313,8 +341,12 @@ mod tests { let spec = SpecId::from(MorphHardfork::Bernoulli); assert_eq!(MorphHardfork::from(spec), MorphHardfork::Morph203); - // Emerald -> OSAKA -> Jade (latest OSAKA hardfork) + // Emerald -> OSAKA -> Onyx (latest OSAKA hardfork) let spec = SpecId::from(MorphHardfork::Emerald); - assert_eq!(MorphHardfork::from(spec), MorphHardfork::Jade); + assert_eq!(MorphHardfork::from(spec), MorphHardfork::Onyx); + + // Jade -> OSAKA -> Onyx (latest OSAKA hardfork) + let spec = SpecId::from(MorphHardfork::Jade); + assert_eq!(MorphHardfork::from(spec), MorphHardfork::Onyx); } } diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 6477747c..1776323d 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -105,12 +105,13 @@ fn build_hardforks(genesis: &Genesis, chain_info: &MorphGenesisInfo) -> ChainHar .into_iter() .filter_map(|(fork, block)| block.map(|b| (fork, ForkCondition::Block(b)))); - // Morph timestamp-based hardforks (Morph203, Viridian, Emerald, Jade) + // Morph timestamp-based hardforks (Morph203, Viridian, Emerald, Jade, Onyx) let time_forks = vec![ (MorphHardfork::Morph203, hardfork_info.morph203_time), (MorphHardfork::Viridian, hardfork_info.viridian_time), (MorphHardfork::Emerald, hardfork_info.emerald_time), (MorphHardfork::Jade, hardfork_info.jade_fork_time), + (MorphHardfork::Onyx, hardfork_info.onyx_time), ] .into_iter() .filter_map(|(fork, time)| time.map(|t| (fork, ForkCondition::Timestamp(t)))); @@ -644,6 +645,63 @@ mod tests { ); } + #[test] + fn test_onyx_activation_from_genesis() { + let genesis_json = json!({ + "config": { + "chainId": 1337, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "mergeNetsplitBlock": 0, + "terminalTotalDifficulty": 0, + "terminalTotalDifficultyPassed": true, + "shanghaiTime": 0, + "cancunTime": 0, + "bernoulliBlock": 0, + "curieBlock": 0, + "morph203Time": 0, + "viridianTime": 0, + "emeraldTime": 0, + "jadeForkTime": 6000, + "onyxTime": 7000, + "morph": {} + }, + "alloc": {} + }); + + let genesis: Genesis = + serde_json::from_value(genesis_json).expect("genesis should be valid"); + let chainspec = MorphChainSpec::from(genesis); + + assert_eq!( + chainspec.fork(MorphHardfork::Onyx), + ForkCondition::Timestamp(7000) + ); + assert!(!chainspec.is_onyx_active_at_timestamp(6999)); + assert!(chainspec.is_onyx_active_at_timestamp(7000)); + + // Onyx must be reported as the latest fork once active, and must not + // shadow Jade before its own activation. + assert_eq!(chainspec.morph_hardfork_at(1, 6000), MorphHardfork::Jade); + assert_eq!(chainspec.morph_hardfork_at(1, 7000), MorphHardfork::Onyx); + } + + #[test] + fn test_onyx_absent_from_genesis_never_activates() { + // The bundled mainnet/hoodi chainspecs are scheduled through Jade only. + let chainspec = MorphChainSpec::from(create_test_genesis()); + assert!(!chainspec.is_onyx_active_at_timestamp(0)); + assert!(!chainspec.is_onyx_active_at_timestamp(u64::MAX)); + } + #[test] fn test_chainspec_from_genesis() { let genesis_json = json!({ diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 063d7db5..30412e52 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -46,7 +46,7 @@ use morph_chainspec::{ }; use morph_primitives::{ Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope, - transaction::morph_transaction::MORPH_TX_VERSION_1, + transaction::morph_transaction::{MORPH_TX_VERSION_1, MORPH_TX_VERSION_2}, }; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom}; use reth_consensus_common::validation::{ @@ -320,7 +320,10 @@ impl Consensus for MorphConsensus { let is_jade = self .chain_spec .is_jade_active_at_timestamp(block.header().timestamp()); - validate_morph_txs(&block.body().transactions, is_emerald, is_jade)?; + let is_onyx = self + .chain_spec + .is_onyx_active_at_timestamp(block.header().timestamp()); + validate_morph_txs(&block.body().transactions, is_emerald, is_jade, is_onyx)?; // Validate L1 messages ordering and internal consistency with header. // This is the body-level half of L1 validation; it verifies that the L1 @@ -643,15 +646,18 @@ fn validate_l1_messages_in_block( /// /// Performs three checks per MorphTx: /// 1. **Type hardfork gate**: rejects MorphTx before the Emerald fork is active -/// 2. **Version hardfork gate**: rejects V1 transactions before the Jade fork is active +/// 2. **Version hardfork gate**: rejects V1 transactions before the Jade fork is +/// active and V2 transactions before the Onyx fork is active /// 3. **Field validation**: delegates to [`TxMorph::validate()`] for version-specific -/// field constraints, memo length, and gas price ordering +/// field constraints (including the V2 authorization-list rules), memo length, +/// and gas price ordering /// /// See [`TxMorph::validate()`] for the detailed per-version rules. fn validate_morph_txs( txs: &[MorphTxEnvelope], is_emerald: bool, is_jade: bool, + is_onyx: bool, ) -> Result<(), ConsensusError> { for tx in txs { let morph_tx = match tx { @@ -673,6 +679,13 @@ fn validate_morph_txs( ))); } + // Reject MorphTx V2 (EIP-7702 authorization list) before Onyx fork. + if !is_onyx && morph_tx.version == MORPH_TX_VERSION_2 { + return Err(ConsensusError::other(MorphConsensusError::InvalidBody( + "MorphTx version 2 is not yet active (onyx fork not reached)".into(), + ))); + } + // Reuse primitive-layer validation (version, fee_token_id, reference, // memo length, fee_limit constraints, gas price ordering). if let Err(reason) = morph_tx.validate() { @@ -1756,6 +1769,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Bytes::new(), }; MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -1783,6 +1797,7 @@ mod tests { fee_limit: U256::ZERO, reference: Some(B256::repeat_byte(0xab)), memo: Some(Bytes::from_static(b"test-memo")), + authorization_list: Vec::new(), input: Bytes::new(), }; MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -1796,7 +1811,7 @@ mod tests { fn test_validate_morph_tx_v0_valid() { // V0 with fee_token_id > 0 and no reference/memo let txs = [create_morph_tx_v0(1)]; - let result = validate_morph_txs(&txs, true, false); + let result = validate_morph_txs(&txs, true, false, false); assert!(result.is_ok()); } @@ -1804,7 +1819,7 @@ mod tests { fn test_validate_morph_tx_v0_zero_fee_token_rejected() { // V0 with fee_token_id == 0 should be rejected let txs = [create_morph_tx_v0(0)]; - let result = validate_morph_txs(&txs, true, false); + let result = validate_morph_txs(&txs, true, false, false); assert!(result.is_err()); assert!( result @@ -1833,6 +1848,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: Some(B256::repeat_byte(0x01)), // V0 should not have reference memo: None, + authorization_list: Vec::new(), input: Bytes::new(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -1842,7 +1858,7 @@ mod tests { )); let txs = [envelope]; - let result = validate_morph_txs(&txs, true, false); + let result = validate_morph_txs(&txs, true, false, false); assert!(result.is_err()); assert!( result @@ -1856,7 +1872,7 @@ mod tests { fn test_validate_morph_tx_v1_before_jade_rejected() { // V1 before jade fork should be rejected let txs = [create_morph_tx_v1(1)]; - let result = validate_morph_txs(&txs, true, false); + let result = validate_morph_txs(&txs, true, false, false); assert!(result.is_err()); assert!( result @@ -1870,10 +1886,195 @@ mod tests { fn test_validate_morph_tx_v1_after_jade_valid() { // V1 after jade fork should pass let txs = [create_morph_tx_v1(1)]; - let result = validate_morph_txs(&txs, true, true); + let result = validate_morph_txs(&txs, true, true, true); assert!(result.is_ok()); } + fn sample_authorization() -> alloy_eips::eip7702::SignedAuthorization { + alloy_eips::eip7702::Authorization { + chain_id: U256::from(1337), + address: Address::repeat_byte(0x42), + nonce: 0, + } + .into_signed(Signature::new(U256::from(1), U256::from(2), false)) + } + + fn create_morph_tx_v2_with( + version: u8, + authorization_list: Vec, + to: alloy_primitives::TxKind, + ) -> MorphTxEnvelope { + use morph_primitives::TxMorph; + + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 100_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to, + value: U256::ZERO, + access_list: Default::default(), + version, + fee_token_id: 0, + fee_limit: U256::ZERO, + reference: Some(B256::repeat_byte(0xab)), + memo: Some(Bytes::from_static(b"test-memo")), + authorization_list, + input: Bytes::from_static(&[0x60, 0x80]), + }; + MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + )) + } + + fn create_morph_tx_v2() -> MorphTxEnvelope { + create_morph_tx_v2_with( + MORPH_TX_VERSION_2, + vec![sample_authorization()], + alloy_primitives::TxKind::Call(Address::repeat_byte(0x01)), + ) + } + + #[test] + fn test_validate_morph_tx_v2_before_onyx_rejected() { + let txs = [create_morph_tx_v2()]; + let result = validate_morph_txs(&txs, true, true, false); + assert!( + result + .unwrap_err() + .to_string() + .contains("onyx fork not reached") + ); + } + + #[test] + fn test_validate_morph_tx_v2_after_onyx_valid() { + let txs = [create_morph_tx_v2()]; + assert!(validate_morph_txs(&txs, true, true, true).is_ok()); + } + + /// A V2 with an empty list is valid after Onyx (and still Onyx-gated). + #[test] + fn test_validate_morph_tx_v2_empty_authorization_list_accepted() { + let txs = [create_morph_tx_v2_with( + MORPH_TX_VERSION_2, + vec![], + alloy_primitives::TxKind::Call(Address::repeat_byte(0x01)), + )]; + assert!(validate_morph_txs(&txs, true, true, true).is_ok()); + assert!( + validate_morph_txs(&txs, true, true, false) + .unwrap_err() + .to_string() + .contains("onyx fork not reached") + ); + } + + #[test] + fn test_validate_morph_tx_v2_create_with_authorizations_rejected() { + let txs = [create_morph_tx_v2_with( + MORPH_TX_VERSION_2, + vec![sample_authorization()], + alloy_primitives::TxKind::Create, + )]; + let err = validate_morph_txs(&txs, true, true, true) + .unwrap_err() + .to_string(); + assert!( + err.contains("version 2 MorphTx with an authorization list cannot create a contract"), + "unexpected error: {err}" + ); + } + + /// Without authorizations a V2 may create a contract, exactly like V1. + #[test] + fn test_validate_morph_tx_v2_create_without_authorizations_accepted() { + let txs = [create_morph_tx_v2_with( + MORPH_TX_VERSION_2, + vec![], + alloy_primitives::TxKind::Create, + )]; + assert!(validate_morph_txs(&txs, true, true, true).is_ok()); + } + + #[test] + fn test_validate_morph_tx_v1_with_authorization_list_rejected() { + let txs = [create_morph_tx_v2_with( + MORPH_TX_VERSION_1, + vec![sample_authorization()], + alloy_primitives::TxKind::Call(Address::repeat_byte(0x01)), + )]; + let err = validate_morph_txs(&txs, true, true, true) + .unwrap_err() + .to_string(); + assert!( + err.contains("version 1 MorphTx does not support authorization list"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_validate_block_pre_execution_rejects_v2_without_onyx() { + // `create_test_chainspec` schedules forks through Jade only. + let consensus = MorphConsensus::new(create_test_chainspec()); + let block = create_sealed_block(0, vec![create_morph_tx_v2()]); + + let err = consensus + .validate_block_pre_execution(&block) + .unwrap_err() + .to_string(); + assert!( + err.contains("onyx fork not reached"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_validate_block_pre_execution_uses_chainspec_onyx_activation() { + let genesis_json = serde_json::json!({ + "config": { + "chainId": 1337, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "bernoulliBlock": 0, + "curieBlock": 0, + "morph203Time": 0, + "viridianTime": 0, + "emeraldTime": 0, + "jadeForkTime": 0, + "onyxTime": 1000, + "morph": {} + }, + "alloc": {} + }); + let genesis: Genesis = serde_json::from_value(genesis_json).unwrap(); + let consensus = MorphConsensus::new(Arc::new(MorphChainSpec::from(genesis))); + + let before = create_sealed_block(999, vec![create_morph_tx_v2()]); + let err = consensus + .validate_block_pre_execution(&before) + .unwrap_err() + .to_string(); + assert!( + err.contains("onyx fork not reached"), + "unexpected error: {err}" + ); + + let after = create_sealed_block(1000, vec![create_morph_tx_v2()]); + assert!(consensus.validate_block_pre_execution(&after).is_ok()); + } + #[test] fn test_validate_block_pre_execution_uses_chainspec_jade_activation() { let consensus = MorphConsensus::new(create_test_chainspec()); @@ -2008,6 +2209,7 @@ mod tests { fee_limit: U256::from(100u64), // non-zero with fee_token_id=0 reference: None, memo: None, + authorization_list: Vec::new(), input: Bytes::new(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -2017,7 +2219,7 @@ mod tests { )); let txs = [envelope]; - let result = validate_morph_txs(&txs, true, true); + let result = validate_morph_txs(&txs, true, true, true); assert!(result.is_err()); assert!( result @@ -2046,6 +2248,7 @@ mod tests { fee_limit: U256::from(100u64), reference: None, memo: Some(Bytes::from(vec![0xab; MAX_MEMO_LENGTH + 1])), // too long + authorization_list: Vec::new(), input: Bytes::new(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -2055,7 +2258,7 @@ mod tests { )); let txs = [envelope]; - let result = validate_morph_txs(&txs, true, true); + let result = validate_morph_txs(&txs, true, true, true); assert!(result.is_err()); assert!( result @@ -2084,6 +2287,7 @@ mod tests { fee_limit: U256::from(100u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Bytes::new(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -2093,7 +2297,7 @@ mod tests { )); let txs = [envelope]; - let result = validate_morph_txs(&txs, true, true); + let result = validate_morph_txs(&txs, true, true, true); assert!(result.is_err()); assert!( result @@ -2107,7 +2311,7 @@ mod tests { fn test_validate_morph_txs_skips_non_morph_tx() { // Regular transactions should be skipped entirely let txs = [create_regular_tx(), create_l1_msg_tx(0)]; - let result = validate_morph_txs(&txs, false, false); + let result = validate_morph_txs(&txs, false, false, false); assert!(result.is_ok()); } @@ -2119,7 +2323,7 @@ mod tests { create_regular_tx(), create_morph_tx_v0(1), ]; - let result = validate_morph_txs(&txs, true, false); + let result = validate_morph_txs(&txs, true, false, false); assert!(result.is_ok()); } diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 347c8265..298ef3bd 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -340,6 +340,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), input: alloy_primitives::Bytes::new(), }; MorphTxEnvelope::Morph(Signed::new_unhashed(tx, Signature::test_signature())) diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs index b61b5809..f4773119 100644 --- a/crates/node/src/test_utils.rs +++ b/crates/node/src/test_utils.rs @@ -76,13 +76,18 @@ pub enum HardforkSchedule { #[default] AllActive, - /// Jade is NOT active; all other forks are active at t=0. + /// Onyx is NOT active; all other forks are active at t=0. + /// + /// Use this to test pre-Onyx behavior: MorphTx v2 (authorization list) rejected. + PreOnyx, + + /// Jade and Onyx are NOT active; all other forks are active at t=0. /// /// Use this to test pre-Jade behavior: state root validation skipped, /// MorphTx v1 rejected, etc. PreJade, - /// Viridian, Emerald, and Jade are NOT active; all earlier forks are at t=0. + /// Viridian, Emerald, Jade, and Onyx are NOT active; all earlier forks are at t=0. /// /// Use this to test pre-Viridian behavior: EIP-7702 rejected, etc. PreViridian, @@ -107,7 +112,7 @@ impl HardforkSchedule { /// used to determine which forks are currently active on those networks. fn reference_genesis_json(&self) -> Option<&'static str> { match self { - Self::AllActive | Self::PreJade | Self::PreViridian => None, + Self::AllActive | Self::PreOnyx | Self::PreJade | Self::PreViridian => None, Self::Hoodi => Some(include_str!("../../chainspec/res/genesis/hoodi.json")), Self::Mainnet => Some(include_str!("../../chainspec/res/genesis/mainnet.json")), } @@ -116,7 +121,8 @@ impl HardforkSchedule { /// Apply this schedule's fork timestamps to a mutable genesis JSON value. /// /// - `AllActive`: no changes (test genesis already has all forks at 0) - /// - `PreJade`: set `jadeForkTime` to `u64::MAX` + /// - `PreOnyx`: set `onyxTime` to `u64::MAX` + /// - `PreJade`: set `jadeForkTime` and `onyxTime` to `u64::MAX` /// - `Hoodi`/`Mainnet`: compare each `*Time` key against the reference network; /// forks active now → 0, forks not yet active → `u64::MAX`. /// Block-based forks (`*Block`) are always kept at 0. @@ -125,16 +131,23 @@ impl HardforkSchedule { Self::AllActive => { // nothing to do — test genesis has all forks at 0 } + Self::PreOnyx => { + // Disable only Onyx; all other forks remain at 0. + let config = genesis["config"].as_object_mut().expect("genesis.config"); + config.insert("onyxTime".to_string(), serde_json::json!(u64::MAX)); + } Self::PreJade => { - // Disable only Jade; all other forks remain at 0. + // Disable Jade and everything after it; all earlier forks remain at 0. let config = genesis["config"].as_object_mut().expect("genesis.config"); config.insert("jadeForkTime".to_string(), serde_json::json!(u64::MAX)); + config.insert("onyxTime".to_string(), serde_json::json!(u64::MAX)); } Self::PreViridian => { let config = genesis["config"].as_object_mut().expect("genesis.config"); config.insert("viridianTime".to_string(), serde_json::json!(u64::MAX)); config.insert("emeraldTime".to_string(), serde_json::json!(u64::MAX)); config.insert("jadeForkTime".to_string(), serde_json::json!(u64::MAX)); + config.insert("onyxTime".to_string(), serde_json::json!(u64::MAX)); } Self::Hoodi | Self::Mainnet => { let reference_json = self.reference_genesis_json().unwrap(); @@ -909,6 +922,7 @@ pub struct MorphTxBuilder { access_list: alloy_eips::eip2930::AccessList, reference: Option, memo: Option, + authorization_list: Vec, } impl MorphTxBuilder { @@ -933,9 +947,41 @@ impl MorphTxBuilder { access_list: Default::default(), reference: None, memo: None, + authorization_list: Vec::new(), } } + /// Configure as MorphTx **v2** with ETH fee payment (fee_token_id = 0). + /// + /// Add EIP-7702 authorizations with [`Self::with_authorization_list`]; + /// without any the transaction behaves exactly like v1. + pub fn with_v2_eth_fee(mut self) -> Self { + self.version = 2; + self.fee_token_id = 0; + self.fee_limit = U256::ZERO; + self + } + + /// Configure as MorphTx **v2** with ERC20 fee payment. + pub fn with_v2_token_fee(mut self, fee_token_id: u16) -> Self { + assert!(fee_token_id > 0, "v2 ERC20 fee requires fee_token_id > 0"); + self.version = 2; + self.fee_token_id = fee_token_id; + self.fee_limit = U256::from(100_000_000_000_000_000_000u128); // 100 tokens + self + } + + /// Set the EIP-7702 authorization list (v2 only; may be empty). + /// + /// Build tuples with [`sign_authorization`]. + pub fn with_authorization_list( + mut self, + authorization_list: Vec, + ) -> Self { + self.authorization_list = authorization_list; + self + } + /// Configure as MorphTx **v0** with ERC20 fee payment. /// /// - `fee_token_id` must be > 0 (v0 requires ERC20 fee) @@ -990,6 +1036,13 @@ impl MorphTxBuilder { self } + /// Make this a contract creation with the given init code. + pub fn with_create(mut self, init_code: impl Into) -> Self { + self.to = TxKind::Create; + self.input = init_code.into(); + self + } + /// Set the ETH value to transfer. pub fn with_value(mut self, value: U256) -> Self { self.value = value; @@ -1052,6 +1105,7 @@ impl MorphTxBuilder { fee_limit: self.fee_limit, reference: self.reference, memo: self.memo, + authorization_list: self.authorization_list, input: self.input, }; @@ -1064,3 +1118,27 @@ impl MorphTxBuilder { Ok(envelope.encoded_2718().into()) } } + +/// Signs an EIP-7702 authorization tuple delegating `authority` (the signer) +/// to `delegate`, for use in `0x04` or MorphTx v2 authorization lists. +/// +/// `nonce` must be the authority's nonce at the time the tuple is applied: +/// for a self-delegating sender that is `tx.nonce + 1`. +pub fn sign_authorization( + signer: &PrivateKeySigner, + chain_id: u64, + delegate: Address, + nonce: u64, +) -> eyre::Result { + use alloy_signer::SignerSync; + + let authorization = alloy_eips::eip7702::Authorization { + chain_id: U256::from(chain_id), + address: delegate, + nonce, + }; + let auth_sig = signer + .sign_hash_sync(&authorization.signature_hash()) + .map_err(|e| eyre::eyre!("auth signing failed: {e}"))?; + Ok(authorization.into_signed(auth_sig)) +} diff --git a/crates/node/tests/assets/test-genesis.json b/crates/node/tests/assets/test-genesis.json index 09cc751b..9d801601 100644 --- a/crates/node/tests/assets/test-genesis.json +++ b/crates/node/tests/assets/test-genesis.json @@ -20,6 +20,7 @@ "viridianTime": 0, "emeraldTime": 0, "jadeForkTime": 0, + "onyxTime": 0, "morph": { "feeVaultAddress": "0x530000000000000000000000000000000000000a" } diff --git a/crates/node/tests/it/hardfork.rs b/crates/node/tests/it/hardfork.rs index cef86694..121861e0 100644 --- a/crates/node/tests/it/hardfork.rs +++ b/crates/node/tests/it/hardfork.rs @@ -63,6 +63,31 @@ async fn pre_jade_chain_advances() -> eyre::Result<()> { Ok(()) } +/// With Onyx disabled (pre-Onyx schedule), blocks are still built correctly. +/// +/// Only MorphTx v2 is gated on Onyx; everything else behaves as under Jade. +#[tokio::test(flavor = "multi_thread")] +async fn pre_onyx_chain_advances() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreOnyx) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(3, &mut node, wallet).await?; + assert_eq!(payloads.len(), 3); + + for (i, payload) in payloads.iter().enumerate() { + let block = payload.block(); + assert_eq!(block.header().inner.number, (i + 1) as u64); + } + + Ok(()) +} + /// Verify that an empty block can be produced under pre-Jade schedule. #[tokio::test(flavor = "multi_thread")] async fn pre_jade_empty_block() -> eyre::Result<()> { diff --git a/crates/node/tests/it/helpers.rs b/crates/node/tests/it/helpers.rs index ceb66cd4..1e80e204 100644 --- a/crates/node/tests/it/helpers.rs +++ b/crates/node/tests/it/helpers.rs @@ -525,3 +525,24 @@ pub(crate) async fn expect_payload_build_failure( } } } + +/// Init code that deploys `runtime` as-is (CODECOPY + RETURN; runtime must be < 256 bytes). +pub(crate) fn init_code_for(runtime: &[u8]) -> Vec { + assert!(runtime.len() < 256, "runtime must fit a PUSH1 length"); + let len = runtime.len() as u8; + // PUSH1 len PUSH1 12 PUSH1 0 CODECOPY PUSH1 len PUSH1 0 RETURN — 12 bytes, runtime at offset 12. + let mut code = vec![ + 0x60, len, 0x60, 0x0c, 0x60, 0x00, 0x39, 0x60, len, 0x60, 0x00, 0xf3, + ]; + code.extend_from_slice(runtime); + code +} + +/// Runtime that returns the 32-byte word `0x42`. +pub(crate) const RETURN_WORD_42_RUNTIME: &[u8] = + &[0x60, 0x42, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3]; + +/// Runtime that emits `LOG0` with the 32-byte word `0x42` as data, then stops. +pub(crate) const LOG_WORD_42_RUNTIME: &[u8] = &[ + 0x60, 0x42, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xa0, 0x00, +]; diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs index 97cef34d..4bca3e5b 100644 --- a/crates/node/tests/it/morph_tx.rs +++ b/crates/node/tests/it/morph_tx.rs @@ -14,7 +14,10 @@ //! with 1000 tokens pre-funded for test account 0 and 1 use alloy_primitives::{Address, B256, Bytes, U256}; -use morph_node::test_utils::{HardforkSchedule, MorphTxBuilder, TEST_TOKEN_ID, TestNodeBuilder}; +use morph_node::test_utils::{ + HardforkSchedule, MorphTxBuilder, TEST_TOKEN_ID, TestNodeBuilder, sign_authorization, + wallet_at_index, +}; use reth_payload_primitives::BuiltPayload; // ============================================================================= @@ -745,3 +748,770 @@ async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { Ok(()) } + +// ============================================================================= +// MorphTx v2 (EIP-7702 authorization list) — Onyx gating and delegation +// ============================================================================= + +/// Asserts that `authority` is delegated to `delegate` (`0xef0100 || delegate`) +/// and returns its nonce. +fn assert_delegated( + state: &dyn reth_provider::StateProvider, + authority: Address, + delegate: Address, +) -> eyre::Result { + let account = state + .basic_account(&authority)? + .ok_or_else(|| eyre::eyre!("authority account {authority} must exist"))?; + let code = state + .account_code(&authority)? + .ok_or_else(|| eyre::eyre!("delegation designator must be written"))?; + let code_bytes = code.original_bytes(); + assert_eq!( + &code_bytes[..3], + &[0xef, 0x01, 0x00], + "authority code must be an EIP-7702 delegation designator" + ); + assert_eq!( + &code_bytes[3..], + delegate.as_slice(), + "delegation must point at the authorized address" + ); + Ok(account.nonce) +} + +/// MorphTx v2 with ETH fee applies its authorization list exactly like an +/// EIP-7702 transaction: the authority is delegated, its nonce is consumed, +/// and the receipt reports version 2. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_eth_fee_applies_delegation() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + + // Account 1 authorizes a delegation to 0x42; account 0 carries it in a MorphTx v2. + let authority_signer = wallet_at_index(1, chain_id); + let authority = authority_signer.address(); + let delegate = Address::with_last_byte(0x42); + let authorization = sign_authorization(&authority_signer, chain_id, delegate, 0)?; + + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_authorization_list(vec![authorization]) + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + let block = payload.block(); + assert_eq!( + block.body().transactions.len(), + 1, + "MorphTx v2 should be included in block" + ); + let tx = block.body().transactions.first().unwrap(); + assert!(tx.is_morph_tx()); + assert_eq!(tx.version(), Some(2)); + + let receipt = node + .inner + .provider + .receipt_by_hash(*tx.tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status(), "MorphTx v2 call must succeed"); + // Intrinsic gas: 21_000 base + 25_000 per authorization = 46_000. The + // authority already exists in genesis, so the EIP-7702 refund of 12_500 + // applies, capped by EIP-3529 at gas_used / 5 = 9_200 → 36_800. + assert_eq!( + receipt.cumulative_gas_used(), + 36_800, + "ETH-fee path must settle the EIP-7702 refund like 0x04" + ); + let morph_primitives::MorphReceipt::Morph(morph_receipt) = &receipt else { + panic!("expected a Morph receipt"); + }; + assert_eq!(morph_receipt.version, Some(2)); + + let state = node.inner.provider.latest()?; + let nonce = assert_delegated(&*state, authority, delegate)?; + assert_eq!(nonce, 1, "delegation consumes the authority nonce"); + + Ok(()) +} + +/// Two tuples for two different authorities are both applied; the intrinsic +/// gas and the refund scale with the list length (refund capped at gas/5). +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_applies_multiple_authorities_in_one_tx() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + + let authority_1 = wallet_at_index(1, chain_id); + let authority_2 = wallet_at_index(2, chain_id); + let delegate_1 = Address::with_last_byte(0x42); + let delegate_2 = Address::with_last_byte(0x43); + let authorizations = vec![ + sign_authorization(&authority_1, chain_id, delegate_1, 0)?, + sign_authorization(&authority_2, chain_id, delegate_2, 0)?, + ]; + + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_authorization_list(authorizations) + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + let tx = payload.block().body().transactions.first().unwrap(); + let receipt = node + .inner + .provider + .receipt_by_hash(*tx.tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status()); + // 21_000 + 2 × 25_000 = 71_000; refund 2 × 12_500 = 25_000 capped at 71_000 / 5 = 14_200. + assert_eq!(receipt.cumulative_gas_used(), 56_800); + + let state = node.inner.provider.latest()?; + assert_eq!( + assert_delegated(&*state, authority_1.address(), delegate_1)?, + 1 + ); + assert_eq!( + assert_delegated(&*state, authority_2.address(), delegate_2)?, + 1 + ); + + Ok(()) +} + +/// A sender delegating itself must sign the tuple with `tx.nonce + 1`, since +/// the transaction nonce is consumed before the list is applied. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_sender_self_delegation_uses_nonce_plus_one() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use reth_provider::StateProviderFactory; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + let delegate = Address::with_last_byte(0x42); + + let authorization = sign_authorization(&wallet.inner, chain_id, delegate, 1)?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_authorization_list(vec![authorization]) + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 1); + + let state = node.inner.provider.latest()?; + let nonce = assert_delegated(&*state, sender, delegate)?; + assert_eq!(nonce, 2, "tx nonce + authorization nonce both consumed"); + + Ok(()) +} + +/// MorphTx v2 with ERC20 fee: the delegation is applied and the fee (including +/// the per-authorization intrinsic gas) is charged in tokens. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_token_fee_applies_delegation_and_charges_tokens() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + + let sender = wallet.inner.address(); + let token_addr = morph_node::test_utils::TEST_TOKEN_ADDRESS; + let fee_vault = alloy_primitives::address!("530000000000000000000000000000000000000a"); + let bal_slot = token_balance_slot(sender); + let fee_vault_slot = token_balance_slot(fee_vault); + let state_before = node.inner.provider.latest()?; + let bal_before = state_before + .storage(token_addr, bal_slot)? + .unwrap_or_default(); + let fee_vault_before = state_before + .storage(token_addr, fee_vault_slot)? + .unwrap_or_default(); + + let authority_signer = wallet_at_index(1, chain_id); + let authority = authority_signer.address(); + let delegate = Address::with_last_byte(0x42); + let authorization = sign_authorization(&authority_signer, chain_id, delegate, 0)?; + + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_token_fee(TEST_TOKEN_ID) + .with_authorization_list(vec![authorization]) + .with_to(Address::with_last_byte(0x99)) + .with_fees(20_000_000_000, 20_000_000_000) + .build_signed()?; + + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + let block = payload.block(); + assert_eq!(block.body().transactions.len(), 1); + let tx = block.body().transactions.first().unwrap(); + assert_eq!(tx.fee_token_id(), Some(TEST_TOKEN_ID)); + + let receipt = node + .inner + .provider + .receipt_by_hash(*tx.tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status()); + // Intrinsic gas: 21_000 base + 25_000 per authorization = 46_000. The + // authority already exists in genesis, so the EIP-7702 refund of 12_500 + // applies, capped by EIP-3529 at gas_used / 5 = 9_200 → 36_800. + assert_eq!( + receipt.cumulative_gas_used(), + 36_800, + "gas used must include the per-authorization intrinsic cost minus the capped refund" + ); + let morph_primitives::MorphReceipt::Morph(morph_receipt) = &receipt else { + panic!("expected a Morph receipt"); + }; + assert_eq!(morph_receipt.version, Some(2)); + assert_eq!(morph_receipt.fee_token_id, Some(TEST_TOKEN_ID)); + + let state = node.inner.provider.latest()?; + assert_delegated(&*state, authority, delegate)?; + let bal_after = state.storage(token_addr, bal_slot)?.unwrap_or_default(); + let fee_vault_after = state + .storage(token_addr, fee_vault_slot)? + .unwrap_or_default(); + assert!( + bal_after < bal_before, + "token balance must decrease (fee paid in tokens)" + ); + + // The fixture token converts 1:1, so the net token fee must be exactly the + // post-refund gas used × gas price + L1 data fee: the EIP-7702 refund has + // to flow through the token reimbursement path, not only the ETH one. + let scale = U256::from(1_000_000_000_000_000_000u128); + assert_eq!(morph_receipt.fee_rate, Some(scale)); + assert_eq!(morph_receipt.token_scale, Some(scale)); + let fee_vault_delta = fee_vault_after - fee_vault_before; + assert_eq!( + fee_vault_delta, + U256::from(36_800u64) * U256::from(20_000_000_000u64) + morph_receipt.l1_fee, + "net token fee must equal post-refund gas used × price plus the L1 data fee" + ); + assert_eq!( + bal_before - bal_after, + fee_vault_delta, + "sender loses exactly the net token fee" + ); + + Ok(()) +} + +/// A V2 call that reverts still applies the delegation (it is applied before +/// the call frame, like 0x04) and still pays the token fee. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_reverting_call_still_applies_delegation_and_charges_tokens() -> eyre::Result<()> +{ + reth_tracing::init_test_tracing(); + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use morph_node::test_utils::make_deploy_tx; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + let token_addr = morph_node::test_utils::TEST_TOKEN_ADDRESS; + let bal_slot = token_balance_slot(sender); + + // Block 1: deploy a contract whose runtime always reverts. + let deploy_tx = make_deploy_tx(chain_id, wallet.inner.clone(), 0, RUNTIME_REVERT_INIT)?; + node.rpc.inject_tx(deploy_tx).await?; + node.advance_block().await?; + let revert_contract = Address::create(&sender, 0); + + let bal_before = node + .inner + .provider + .latest()? + .storage(token_addr, bal_slot)? + .unwrap_or_default(); + + // Block 2: V2 token-fee call into the reverting contract, carrying a delegation. + let authority_signer = wallet_at_index(1, chain_id); + let authority = authority_signer.address(); + let delegate = Address::with_last_byte(0x42); + let authorization = sign_authorization(&authority_signer, chain_id, delegate, 0)?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 1) + .with_v2_token_fee(TEST_TOKEN_ID) + .with_authorization_list(vec![authorization]) + .with_to(revert_contract) + .with_gas_limit(100_000) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + + let tx = payload.block().body().transactions.first().unwrap(); + let receipt = node + .inner + .provider + .receipt_by_hash(*tx.tx_hash())? + .expect("receipt must exist"); + assert!(!receipt.status(), "call must revert"); + + let state = node.inner.provider.latest()?; + assert_eq!( + assert_delegated(&*state, authority, delegate)?, + 1, + "delegation survives the reverted call" + ); + let bal_after = state.storage(token_addr, bal_slot)?.unwrap_or_default(); + assert!( + bal_after < bal_before, + "token fee is still charged when the call reverts" + ); + + Ok(()) +} + +/// After a self-delegation the sender's account carries code; both fee paths +/// must keep accepting its MorphTxs (EIP-3607 exempts delegation designators). +#[tokio::test(flavor = "multi_thread")] +async fn delegated_sender_can_keep_sending_morph_txs() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + let delegate = Address::with_last_byte(0x42); + + // Block 1: self-delegate (tx nonce 0, authorization nonce 1). + let authorization = sign_authorization(&wallet.inner, chain_id, delegate, 1)?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_authorization_list(vec![authorization]) + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + node.advance_block().await?; + let state = node.inner.provider.latest()?; + assert_eq!(assert_delegated(&*state, sender, delegate)?, 2); + + // Block 2: ETH-fee MorphTx v1 from the delegated sender. + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 2) + .with_v1_eth_fee() + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 1); + let receipt = node + .inner + .provider + .receipt_by_hash(*payload.block().body().transactions[0].tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status()); + + // Block 3: token-fee MorphTx v0 from the delegated sender. + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 3) + .with_v0_token_fee(TEST_TOKEN_ID) + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 1); + let receipt = node + .inner + .provider + .receipt_by_hash(*payload.block().body().transactions[0].tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status()); + + let state = node.inner.provider.latest()?; + assert_eq!( + assert_delegated(&*state, sender, delegate)?, + 4, + "delegation stays in place across later transactions" + ); + + Ok(()) +} + +/// The pool's EIP-7702 authority tracking applies to MorphTx v2: an authority +/// that already has more in-flight transactions than the delegated slot limit +/// cannot be referenced by a new authorization (`AuthorityReserved`). +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_authorization_for_busy_authority_is_rejected_by_pool() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use morph_node::test_utils::make_transfer_tx; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + + // Account 1 has two transactions in flight (above the default slot limit of 1). + let authority_signer = wallet_at_index(1, chain_id); + for nonce in 0..2 { + let raw_tx = make_transfer_tx(chain_id, authority_signer.clone(), nonce).await; + node.rpc.inject_tx(raw_tx).await?; + } + + // Account 0 now tries to carry a delegation signed by account 1. + let authorization = sign_authorization( + &authority_signer, + chain_id, + Address::with_last_byte(0x42), + 2, + )?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_authorization_list(vec![authorization]) + .build_signed()?; + + let err = node + .rpc + .inject_tx(raw_tx) + .await + .expect_err("authorization for an authority with two in-flight txs must be rejected"); + assert!( + err.to_string().contains("authority already reserved"), + "unexpected error: {err}" + ); + + Ok(()) +} + +/// A pending MorphTx v2 authorization reserves the authority: the authority may +/// keep only the delegated in-flight slot limit (1) of its own transactions. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_pending_authorization_limits_authority_inflight_txs() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use morph_node::test_utils::make_transfer_tx; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + + // Account 0's pending V2 carries a delegation signed by account 1. + let authority_signer = wallet_at_index(1, chain_id); + let authorization = sign_authorization( + &authority_signer, + chain_id, + Address::with_last_byte(0x42), + 0, + )?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_authorization_list(vec![authorization]) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + // Account 1 may still use its single delegated slot ... + let first = make_transfer_tx(chain_id, authority_signer.clone(), 0).await; + node.rpc.inject_tx(first).await?; + + // ... but not a second in-flight transaction. + let second = make_transfer_tx(chain_id, authority_signer.clone(), 1).await; + let err = node + .rpc + .inject_tx(second) + .await + .expect_err("second in-flight tx from a pending authority must be rejected"); + assert!( + err.to_string() + .contains("in-flight transaction limit reached"), + "unexpected error: {err}" + ); + + Ok(()) +} + +/// MorphTx v2 is rejected by the pool while Onyx is not active. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_rejected_before_onyx() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreOnyx) + .build() + .await?; + let node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + + let authority_signer = wallet_at_index(1, chain_id); + let authorization = sign_authorization( + &authority_signer, + chain_id, + Address::with_last_byte(0x42), + 0, + )?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_authorization_list(vec![authorization]) + .build_signed()?; + + let result = node.rpc.inject_tx(raw_tx).await; + assert!( + result.is_err(), + "MorphTx v2 should be rejected by pool before Onyx" + ); + + Ok(()) +} + +/// MorphTx v1 keeps working after Onyx (only v2 is new). +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v1_still_accepted_after_onyx() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v1_eth_fee() + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 1); + + Ok(()) +} + +/// MorphTx v2 without authorizations is accepted and executes exactly like a +/// v1 transaction: plain call cost, no delegation, receipt `version` 0x2, and +/// `authorizationList: []` in the RPC transaction object. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_without_authorizations_executes_like_v1() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use jsonrpsee::core::client::ClientT; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let sender = wallet.inner.address(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let block = payload.block(); + assert_eq!( + block.body().transactions.len(), + 1, + "MorphTx v2 without authorizations should be included in block" + ); + let tx = block.body().transactions.first().unwrap(); + assert!(tx.is_morph_tx()); + assert_eq!(tx.version(), Some(2)); + + let receipt = node + .inner + .provider + .receipt_by_hash(*tx.tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status(), "plain v2 call must succeed"); + assert_eq!( + receipt.cumulative_gas_used(), + 21_000, + "no authorizations: plain call cost, no 7702 gas or refund" + ); + let morph_primitives::MorphReceipt::Morph(morph_receipt) = &receipt else { + panic!("expected a Morph receipt"); + }; + assert_eq!(morph_receipt.version, Some(2)); + + // Nothing was delegated: the sender stays a plain EOA. + let state = node.inner.provider.latest()?; + assert!( + state + .account_code(&sender)? + .is_none_or(|code| code.is_empty()), + "sender must not carry any code" + ); + + // RPC transaction object: version 0x2 and an explicit empty list, the same + // shape go-ethereum returns. + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + let rpc_tx: serde_json::Value = client + .request("eth_getTransactionByHash", (*tx.tx_hash(),)) + .await?; + assert_eq!(rpc_tx["type"].as_str(), Some("0x7f")); + assert_eq!(rpc_tx["version"].as_str(), Some("0x2")); + assert_eq!( + rpc_tx["authorizationList"], + serde_json::json!([]), + "an empty v2 list is emitted as [] in the RPC transaction object" + ); + + Ok(()) +} + +/// Without authorizations a v2 keeps v1's ability to create contracts: the +/// no-CREATE rule only applies to a non-empty authorization list, and the same +/// CREATE with an authorization attached is rejected by the pool. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_without_authorizations_can_create_contract() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use super::helpers::{RETURN_WORD_42_RUNTIME, init_code_for}; + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_create(init_code_for(RETURN_WORD_42_RUNTIME)) + .with_gas_limit(200_000) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx = payload + .block() + .body() + .transactions + .first() + .expect("v2 CREATE without authorizations should be included"); + assert_eq!(tx.version(), Some(2)); + let receipt = node + .inner + .provider + .receipt_by_hash(*tx.tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status(), "v2 CREATE must succeed"); + + let contract = sender.create(0); + let state = node.inner.provider.latest()?; + let code = state + .account_code(&contract)? + .expect("contract code must be deployed"); + assert_eq!(code.original_bytes().as_ref(), RETURN_WORD_42_RUNTIME); + + // The same CREATE carrying an authorization is rejected up front. + let authority_signer = wallet_at_index(1, chain_id); + let authorization = sign_authorization( + &authority_signer, + chain_id, + Address::with_last_byte(0x42), + 0, + )?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 1) + .with_v2_eth_fee() + .with_create(init_code_for(RETURN_WORD_42_RUNTIME)) + .with_gas_limit(200_000) + .with_authorization_list(vec![authorization]) + .build_signed()?; + let err = node + .rpc + .inject_tx(raw_tx) + .await + .expect_err("v2 CREATE with authorizations must be rejected"); + assert!( + err.to_string().contains("cannot create a contract"), + "unexpected error: {err}" + ); + + Ok(()) +} + +/// A self-delegating V2 whose call targets the sender itself runs the delegate's +/// code in the same transaction: the list is applied (after the tx nonce bump) +/// before the call frame, so the sender already carries `0xef0100 || delegate` +/// when it is called, and the delegate's log is emitted from the sender's address. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_self_delegation_executes_delegate_code_in_same_tx() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use super::helpers::{LOG_WORD_42_RUNTIME, init_code_for}; + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use morph_node::test_utils::make_deploy_tx; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + + // Block 1: deploy the logging delegate. + let deploy_tx = make_deploy_tx( + chain_id, + wallet.inner.clone(), + 0, + init_code_for(LOG_WORD_42_RUNTIME), + )?; + node.rpc.inject_tx(deploy_tx).await?; + node.advance_block().await?; + let delegate = Address::create(&sender, 0); + + // Block 2: tx nonce 1, authorization nonce 2, call the sender itself. + let authorization = sign_authorization(&wallet.inner, chain_id, delegate, 2)?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 1) + .with_v2_eth_fee() + .with_authorization_list(vec![authorization]) + .with_to(sender) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 1); + + let receipt = node + .inner + .provider + .receipt_by_hash(*payload.block().body().transactions[0].tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status(), "delegated code must run successfully"); + let logs = receipt.logs(); + assert_eq!( + logs.len(), + 1, + "delegate code must have run inside the same tx" + ); + assert_eq!( + logs[0].address, sender, + "delegated code executes in the sender's own context" + ); + assert_eq!( + logs[0].data.data.as_ref(), + U256::from(0x42u64).to_be_bytes::<32>() + ); + + // deploy (0 → 1), V2 tx nonce (1 → 2), self-authorization (2 → 3) + let state = node.inner.provider.latest()?; + assert_eq!(assert_delegated(&*state, sender, delegate)?, 3); + + Ok(()) +} diff --git a/crates/node/tests/it/rpc.rs b/crates/node/tests/it/rpc.rs index a55232cb..f2bae6cf 100644 --- a/crates/node/tests/it/rpc.rs +++ b/crates/node/tests/it/rpc.rs @@ -9,7 +9,8 @@ use alloy_primitives::{Address, B256, Bytes, Sealable, TxKind, U256}; use alloy_signer::SignerSync; use jsonrpsee::core::client::ClientT; use morph_node::test_utils::{ - MorphTestNode, MorphTxBuilder, TEST_TOKEN_ID, TestNodeBuilder, advance_chain, make_transfer_tx, + HardforkSchedule, MorphTestNode, MorphTxBuilder, TEST_TOKEN_ID, TestNodeBuilder, advance_chain, + make_transfer_tx, sign_authorization, wallet_at_index, }; use morph_primitives::MorphTxEnvelope; use reth_payload_primitives::BuiltPayload; @@ -507,6 +508,294 @@ async fn transaction_by_hash_exposes_morph_fields_over_rpc() -> eyre::Result<()> Ok(()) } +/// `eth_getTransactionByHash` exposes the MorphTx v2 authorization list with the +/// same tuple shape as an EIP-7702 transaction. +#[tokio::test(flavor = "multi_thread")] +async fn transaction_by_hash_exposes_authorization_list_for_morph_tx_v2() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + + let authority_signer = wallet_at_index(1, chain_id); + let delegate = Address::with_last_byte(0x42); + let authorization = sign_authorization(&authority_signer, chain_id, delegate, 0)?; + let expected_tuple = serde_json::to_value(&authorization)?; + + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_token_fee(TEST_TOKEN_ID) + .with_authorization_list(vec![authorization]) + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx_hash = *payload + .block() + .body() + .transactions + .first() + .unwrap() + .tx_hash(); + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + + let tx: Value = client + .request("eth_getTransactionByHash", (tx_hash,)) + .await?; + + assert_eq!(tx["type"].as_str(), Some("0x7f")); + assert_eq!(tx["version"].as_str(), Some("0x2")); + assert_eq!(tx["feeTokenID"].as_str(), Some("0x1")); + let list = tx["authorizationList"] + .as_array() + .expect("MorphTx v2 must expose authorizationList"); + assert_eq!(list.len(), 1); + for key in ["chainId", "address", "nonce", "yParity", "r", "s"] { + assert_eq!( + list[0][key], expected_tuple[key], + "authorization tuple `{key}` must match the 0x04 JSON shape" + ); + } + + // Receipt shape is unchanged: only `version` moves to 0x2. + let receipt: Value = client + .request("eth_getTransactionReceipt", (tx_hash,)) + .await?; + assert_eq!(receipt["type"].as_str(), Some("0x7f")); + assert_eq!(receipt["version"].as_str(), Some("0x2")); + assert!(receipt.get("authorizationList").is_none()); + + Ok(()) +} + +/// `eth_estimateGas` for a MorphTx v2 request executes with the authorization +/// list, so the estimate covers the 25 000 gas per authorization on top of the +/// plain-call cost. +#[tokio::test(flavor = "multi_thread")] +async fn estimate_gas_for_morph_tx_v2_includes_authorization_gas() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + + // Produce a block so the L1 gas oracle state (genesis alloc) is live. + advance_chain(1, &mut node, wallet_to_arc(wallet)).await?; + + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + + let authority_signer = wallet_at_index(1, chain_id); + let authorization = sign_authorization( + &authority_signer, + chain_id, + Address::with_last_byte(0x42), + 0, + )?; + + let base_request = serde_json::json!({ + "from": sender, + "to": Address::with_last_byte(0x99), + "value": "0x0", + "maxFeePerGas": "0x4a817c800", + "maxPriorityFeePerGas": "0x4a817c800", + }); + + let mut v1_request = base_request.clone(); + v1_request["version"] = serde_json::json!("0x1"); + let v1_estimate: alloy_primitives::U64 = + client.request("eth_estimateGas", (v1_request,)).await?; + + let mut v2_request = base_request; + v2_request["version"] = serde_json::json!("0x2"); + v2_request["authorizationList"] = serde_json::json!([serde_json::to_value(&authorization)?]); + let v2_estimate: alloy_primitives::U64 = client + .request("eth_estimateGas", (v2_request.clone(),)) + .await?; + + assert!( + v1_estimate.to::() >= 21_000, + "v1 estimate: {v1_estimate}" + ); + assert!( + v2_estimate.to::() >= v1_estimate.to::() + 25_000, + "v2 estimate {v2_estimate} must add the per-authorization intrinsic gas over v1 {v1_estimate}" + ); + + // `eth_call` takes the same V2 shape (fee charge disabled, list still applied). + let call_result: Value = client + .request("eth_call", (v2_request.clone(), "latest")) + .await?; + assert_eq!(call_result.as_str(), Some("0x")); + + // Explicit v2 without authorizations (`[]` or no key at all) is a valid v2 + // with an empty list: it costs exactly what the v1 estimate costs. + let mut empty_v2_request = v2_request; + empty_v2_request["authorizationList"] = serde_json::json!([]); + let empty_v2_estimate: alloy_primitives::U64 = client + .request("eth_estimateGas", (empty_v2_request.clone(),)) + .await?; + assert_eq!( + empty_v2_estimate, v1_estimate, + "v2 without authorizations must cost the same gas as v1" + ); + empty_v2_request + .as_object_mut() + .unwrap() + .remove("authorizationList"); + let absent_list_estimate: alloy_primitives::U64 = client + .request("eth_estimateGas", (empty_v2_request.clone(),)) + .await?; + assert_eq!(absent_list_estimate, v1_estimate); + let call_result: Value = client + .request("eth_call", (empty_v2_request, "latest")) + .await?; + assert_eq!(call_result.as_str(), Some("0x")); + + Ok(()) +} + +/// Simulation is not fork-gated, exactly like V1 (geth only gates +/// `setDefaults`, i.e. the send paths): before Onyx `eth_estimateGas` and +/// `eth_call` still simulate a V2 request, while sending the same transaction +/// is rejected by the pool. +#[tokio::test(flavor = "multi_thread")] +async fn simulation_of_morph_tx_v2_is_not_fork_gated_before_onyx() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreOnyx) + .build() + .await?; + let node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + + let authority_signer = wallet_at_index(1, chain_id); + let authorization = sign_authorization( + &authority_signer, + chain_id, + Address::with_last_byte(0x42), + 0, + )?; + + let request = serde_json::json!({ + "from": sender, + "to": Address::with_last_byte(0x99), + "value": "0x0", + "maxFeePerGas": "0x4a817c800", + "maxPriorityFeePerGas": "0x4a817c800", + "version": "0x2", + "authorizationList": [serde_json::to_value(&authorization)?], + }); + let estimate: alloy_primitives::U64 = client + .request("eth_estimateGas", (request.clone(),)) + .await?; + assert!( + estimate.to::() >= 21_000 + 25_000, + "pre-Onyx estimate must still price the authorization: {estimate}" + ); + let call_result: Value = client.request("eth_call", (request, "latest")).await?; + assert_eq!(call_result.as_str(), Some("0x")); + + // Sending the same transaction is where the fork gate lives. + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_authorization_list(vec![authorization]) + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + let err = node + .rpc + .inject_tx(raw_tx) + .await + .expect_err("MorphTx v2 must be rejected by the pool before Onyx"); + assert!( + err.to_string().contains("not yet active"), + "unexpected error: {err}" + ); + + Ok(()) +} + +/// `eth_call` applies a self-delegating V2 authorization list before the call, +/// with the same nonce rule as real execution: the sender's nonce is bumped +/// first, so the tuple must carry `state nonce + 1`. Calling the sender itself +/// then executes the delegate's code; a tuple signed with the current state +/// nonce is skipped and the call hits an EOA (empty return). +#[tokio::test(flavor = "multi_thread")] +async fn eth_call_applies_self_delegation_for_morph_tx_v2() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use super::helpers::{RETURN_WORD_42_RUNTIME, init_code_for}; + use morph_node::test_utils::make_deploy_tx; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + + // Block 1: deploy the returning delegate; the sender's on-chain nonce is now 1. + let deploy_tx = make_deploy_tx( + chain_id, + wallet.inner.clone(), + 0, + init_code_for(RETURN_WORD_42_RUNTIME), + )?; + node.rpc.inject_tx(deploy_tx).await?; + node.advance_block().await?; + let delegate = Address::create(&sender, 0); + + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + + let call_with_auth_nonce = |auth_nonce: u64| -> eyre::Result { + let authorization = sign_authorization(&wallet.inner, chain_id, delegate, auth_nonce)?; + Ok(serde_json::json!({ + "from": sender, + "to": sender, + "nonce": "0x1", + "version": "0x2", + "maxFeePerGas": "0x4a817c800", + "maxPriorityFeePerGas": "0x4a817c800", + "authorizationList": [serde_json::to_value(&authorization)?], + })) + }; + + // state nonce 1 → bumped to 2 before the list is applied → tuple nonce 2 applies. + let result: Value = client + .request("eth_call", (call_with_auth_nonce(2)?, "latest")) + .await?; + assert_eq!( + result.as_str(), + Some(format!("0x{:064x}", 0x42).as_str()), + "the call must execute the delegate's code via the sender" + ); + + // A tuple signed with the un-bumped nonce is skipped: the sender stays an EOA. + let result: Value = client + .request("eth_call", (call_with_auth_nonce(1)?, "latest")) + .await?; + assert_eq!(result.as_str(), Some("0x")); + + // Nothing leaked from the simulation into the canonical state. + let state = node.inner.provider.latest()?; + assert!( + state + .account_code(&sender)? + .is_none_or(|code| code.is_empty()) + ); + + Ok(()) +} + /// Produces a simple one-transaction block on the standard Jade profile and returns the /// node and identifiers needed by the replay-based debug / trace RPCs. async fn build_standard_jade_block_for_debug_trace() -> eyre::Result<(MorphTestNode, B256, B256)> { diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index e60943df..bf236ca6 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -5,6 +5,12 @@ //! - ERC20 tokens for gas payment instead of native ETH //! - Transaction reference for indexing/lookup //! - Memo field for arbitrary data +//! - EIP-7702 authorization list (version 2, Onyx onwards) +//! +//! Wire formats (after the `0x7F` type byte): +//! - V0: `RLP([chainId, nonce, gasTipCap, gasFeeCap, gas, to, value, data, accessList, feeTokenID, feeLimit, V, R, S])` +//! - V1: `0x01 || RLP([..., feeTokenID, feeLimit, reference, memo, V, R, S])` +//! - V2: `0x02 || RLP([..., feeTokenID, feeLimit, reference, memo, authorizationList, V, R, S])` //! //! Reference: @@ -28,6 +34,16 @@ pub const MORPH_TX_VERSION_0: u8 = 0; /// MorphTx version 1: includes Version, Reference, Memo fields. pub const MORPH_TX_VERSION_1: u8 = 1; +/// MorphTx version 2: V1 fields plus an EIP-7702 authorization list. +/// +/// The list may be empty, in which case the transaction behaves exactly like a +/// V1 transaction (only the wire version byte and the empty list field differ). +/// The authorization tuples use the standard EIP-7702 structure, encoding and +/// signing domain (`keccak256(0x05 || rlp([chainId, address, nonce]))`), so +/// authority recovery, intrinsic gas and delegation semantics are identical to +/// the `0x04` SetCode transaction. +pub const MORPH_TX_VERSION_2: u8 = 2; + /// Maximum length of the memo field in bytes. pub const MAX_MEMO_LENGTH: usize = 64; @@ -75,8 +91,11 @@ pub struct MorphTxFields { /// - Memo field for arbitrary data /// /// Reference: +/// +/// JSON serialization is implemented by hand (see the `Serialize` impl below) +/// because whether `authorizationList` is emitted depends on the version. #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] pub struct TxMorph { /// EIP-155: Simple replay attack protection. @@ -129,14 +148,7 @@ pub struct TxMorph { /// Version of the Morph transaction format. /// Used for future extensibility. - #[cfg_attr( - feature = "serde", - serde( - default, - with = "alloy_serde::quantity", - skip_serializing_if = "is_morph_tx_version_0" - ) - )] + #[cfg_attr(feature = "serde", serde(default, with = "alloy_serde::quantity"))] pub version: u8, /// Token ID for alternative fee payment. @@ -160,20 +172,30 @@ pub struct TxMorph { /// Reference key for the transaction (optional, v1 only). /// Used for indexing and looking up transactions by external systems. /// This is a 32-byte value that can be used to group related transactions. - #[cfg_attr( - feature = "serde", - serde(default, skip_serializing_if = "Option::is_none") - )] + #[cfg_attr(feature = "serde", serde(default))] pub reference: Option, - /// Memo field for arbitrary data (optional, v1 only). + /// Memo field for arbitrary data (optional, v1+). /// Can be used to attach additional information to the transaction. /// Maximum length is 64 bytes. + #[cfg_attr(feature = "serde", serde(default))] + pub memo: Option, + + /// EIP-7702 authorization list (v2 only). + /// + /// Always empty for V0 and V1. A V2 transaction may carry an empty list, in + /// which case it behaves exactly like V1; the tuples are standard + /// [`SignedAuthorization`]s and are applied exactly like an EIP-7702 + /// (`0x04`) transaction's list. + /// + /// JSON: every V2 transaction emits the key (an empty list as `[]`) and V0 + /// and V1 never do, matching go-ethereum's `MarshalJSON`. An absent key, + /// `[]` and `null` all decode to an empty list. #[cfg_attr( feature = "serde", - serde(default, skip_serializing_if = "Option::is_none") + serde(default, deserialize_with = "alloy_serde::null_as_default") )] - pub memo: Option, + pub authorization_list: Vec, /// Input has two uses depending if transaction is Create or Call (if `to` /// field is None or Some). @@ -185,6 +207,67 @@ pub struct TxMorph { pub input: Bytes, } +/// Same field layout as the derived `Deserialize`, except that +/// `authorizationList` follows the version instead of the list length: a V2 +/// transaction always emits it, so an empty list serializes as `[]` like +/// go-ethereum does, while V0 and V1 never emit it. +#[cfg(feature = "serde")] +impl serde::Serialize for TxMorph { + fn serialize(&self, serializer: S) -> Result { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct Repr<'a> { + #[serde(with = "alloy_serde::quantity")] + chain_id: ChainId, + #[serde(with = "alloy_serde::quantity")] + nonce: u64, + #[serde(with = "alloy_serde::quantity", rename = "gas")] + gas_limit: u64, + #[serde(with = "alloy_serde::quantity")] + max_fee_per_gas: u128, + #[serde(with = "alloy_serde::quantity")] + max_priority_fee_per_gas: u128, + to: &'a TxKind, + value: &'a U256, + access_list: &'a AccessList, + #[serde( + with = "alloy_serde::quantity", + skip_serializing_if = "is_morph_tx_version_0" + )] + version: u8, + #[serde(with = "alloy_serde::quantity", rename = "feeTokenID")] + fee_token_id: u16, + fee_limit: &'a U256, + #[serde(skip_serializing_if = "Option::is_none")] + reference: Option<&'a B256>, + #[serde(skip_serializing_if = "Option::is_none")] + memo: Option<&'a Bytes>, + #[serde(skip_serializing_if = "Option::is_none")] + authorization_list: Option<&'a Vec>, + input: &'a Bytes, + } + + Repr { + chain_id: self.chain_id, + nonce: self.nonce, + gas_limit: self.gas_limit, + max_fee_per_gas: self.max_fee_per_gas, + max_priority_fee_per_gas: self.max_priority_fee_per_gas, + to: &self.to, + value: &self.value, + access_list: &self.access_list, + version: self.version, + fee_token_id: self.fee_token_id, + fee_limit: &self.fee_limit, + reference: self.reference.as_ref(), + memo: self.memo.as_ref(), + authorization_list: self.is_v2().then_some(&self.authorization_list), + input: &self.input, + } + .serialize(serializer) + } +} + impl TxMorph { /// Get the transaction type. #[doc(alias = "transaction_type")] @@ -231,6 +314,11 @@ impl TxMorph { /// - Version 0 (legacy format): FeeTokenID must be > 0, Reference and Memo must not be set /// - Version 1 (with Reference/Memo): FeeTokenID, Reference, Memo are all optional; /// if FeeTokenID is 0, FeeLimit must not be set + /// - Version 2 (with authorization list): all V1 rules. The authorization + /// list may be empty (the transaction then behaves like V1); a non-empty + /// list requires `to` to be a call (no CREATE), matching the EIP-7702 + /// `0x04` static rule + /// - Versions 0 and 1 must not carry an authorization list /// - Other versions: not supported pub fn validate_version(&self) -> Result<(), &'static str> { match self.version { @@ -251,6 +339,9 @@ impl TxMorph { if self.memo.as_ref().is_some_and(|m| !m.is_empty()) { return Err("version 0 MorphTx does not support Memo field"); } + if self.has_authorizations() { + return Err("version 0 MorphTx does not support authorization list"); + } } MORPH_TX_VERSION_1 => { // Version 1: FeeTokenID, Reference, Memo are all optional @@ -258,6 +349,22 @@ impl TxMorph { if self.fee_token_id == 0 && self.fee_limit > U256::ZERO { return Err("version 1 MorphTx cannot have FeeLimit when FeeTokenID is 0"); } + if self.has_authorizations() { + return Err("version 1 MorphTx does not support authorization list"); + } + } + MORPH_TX_VERSION_2 => { + if self.fee_token_id == 0 && self.fee_limit > U256::ZERO { + return Err("version 2 MorphTx cannot have FeeLimit when FeeTokenID is 0"); + } + // An empty list is allowed (V2 then behaves like V1). With + // authorizations the transaction cannot be a CREATE, the same + // static rule as EIP-7702 SetCode transactions. + if self.has_authorizations() && self.to.is_create() { + return Err( + "version 2 MorphTx with an authorization list cannot create a contract", + ); + } } _ => { return Err("unsupported MorphTx version"); @@ -276,6 +383,42 @@ impl TxMorph { self.version == MORPH_TX_VERSION_1 } + /// Returns true if this is a version 2 MorphTx (with EIP-7702 authorization list). + pub const fn is_v2(&self) -> bool { + self.version == MORPH_TX_VERSION_2 + } + + /// Returns true if the authorization list is non-empty. + /// + /// This looks at the raw field regardless of `version`; use + /// [`Transaction::authorization_list`] for the version-gated view. + pub fn has_authorizations(&self) -> bool { + !self.authorization_list.is_empty() + } + + /// Authorization tuples that are part of the V2 wire and signing encodings. + /// + /// Only V2 encodes the list (an empty V2 list encodes as the empty RLP list + /// `0xc0`); V0/V1 must never carry one (see [`Self::validate_version`]). + /// The debug assertion catches callers that encode such an inconsistent + /// transaction instead of silently dropping the list from the wire bytes. + /// + /// Returns a `Vec` reference because alloy-rlp implements `Encodable` for + /// `Vec` but not for `[T]`. + fn encoded_authorization_list(&self) -> &Vec { + static EMPTY: Vec = Vec::new(); + if self.is_v2() { + &self.authorization_list + } else { + debug_assert!( + !self.has_authorizations(), + "MorphTx version {} must not carry an authorization list", + self.version + ); + &EMPTY + } + } + /// Calculate the in-memory size of this transaction. pub fn size(&self) -> usize { mem::size_of::() + // chain_id @@ -291,16 +434,19 @@ impl TxMorph { mem::size_of::() + // fee_limit mem::size_of::>() + // reference self.memo.as_ref().map_or(0, |m| m.len()) + // memo + mem::size_of::>() + // authorization_list + self.authorization_list.len() * mem::size_of::() + self.input.len() // input } /// Outputs the length of the transaction's RLP fields, without a RLP header. /// - /// Note: For V1, the version byte is NOT included here - it's encoded as a prefix byte + /// Note: For V1+, the version byte is NOT included here - it's encoded as a prefix byte /// before the RLP data, similar to txType. /// /// V0 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit /// V1 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit, Reference, Memo + /// V2 format: V1 fields, AuthorizationList #[doc(hidden)] pub fn fields_len(&self) -> usize { let mut len = 0; @@ -329,16 +475,21 @@ impl TxMorph { // Memo is Option - encoded as RLP bytes or empty len += self.memo.as_ref().map_or(0usize.length(), |m| m.0.length()); } + if self.is_v2() { + // V2 format: adds the EIP-7702 authorization list after Memo + len += self.encoded_authorization_list().length(); + } len } /// Encodes only the transaction's RLP fields into the desired buffer, without a RLP header. /// - /// Note: For V1, the version byte is NOT included here - it's encoded as a prefix byte + /// Note: For V1+, the version byte is NOT included here - it's encoded as a prefix byte /// before the RLP data by the caller (encode_2718). /// /// V0 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit /// V1 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit, Reference, Memo + /// V2 format: V1 fields, AuthorizationList pub fn encode_fields(&self, out: &mut dyn BufMut) { // Common fields self.chain_id.encode(out); @@ -370,36 +521,54 @@ impl TxMorph { Bytes::new().encode(out); // Encode empty bytes for None } } + if self.is_v2() { + // V2 format: EIP-7702 authorization list, encoded exactly like TxEip7702 + self.encoded_authorization_list().encode(out); + } + } + + /// Determines the wire-format version from the first byte after the txType byte. + /// + /// - `0x00` or an RLP list prefix (`>= 0xC0`): V0 (no version byte; matches + /// go-ethereum's `decode()` which routes `firstByte == 0` to V0) + /// - `0x01`: V1 + /// - `0x02`: V2 + /// - anything else: unsupported + /// + /// Returns the version and whether a version byte must be skipped. + fn wire_version(first_byte: u8) -> alloy_rlp::Result<(u8, bool)> { + if first_byte == MORPH_TX_VERSION_0 || first_byte >= 0xC0 { + Ok((MORPH_TX_VERSION_0, false)) + } else if first_byte == MORPH_TX_VERSION_1 || first_byte == MORPH_TX_VERSION_2 { + Ok((first_byte, true)) + } else { + Err(alloy_rlp::Error::Custom("unsupported morph tx version")) + } } /// Decodes the inner fields from RLP bytes (after txType byte is consumed). /// /// Version detection based on first byte: /// - V0 format: first byte is 0 or RLP list prefix (>= 0xC0) → direct RLP decode - /// - V1+ format: first byte is version (0x01, 0x02, ...) → skip version byte, then RLP decode + /// - V1/V2 format: first byte is version (0x01 / 0x02) → skip version byte, then RLP decode /// /// V0 RLP: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit /// V1 RLP: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit, Reference, Memo + /// V2 RLP: V1 fields, AuthorizationList pub fn decode_fields(buf: &mut &[u8]) -> alloy_rlp::Result { if buf.is_empty() { return Err(alloy_rlp::Error::InputTooShort); } - let first_byte = buf[0]; - - // Check first byte to determine version: - // - V0 format (legacy AltFeeTx): first byte is 0 or RLP list prefix (0xC0-0xFF), no version prefix - // - V1+ format: first byte is version (0x01, 0x02, ...) followed by RLP - if first_byte == 0 || first_byte >= 0xC0 { - // V0 format: direct RLP decode (legacy compatible) - Self::decode_fields_v0(buf) - } else if first_byte == MORPH_TX_VERSION_1 { - // V1 format: first byte is version, rest is RLP - // Skip the version byte + let (version, has_version_byte) = Self::wire_version(buf[0])?; + if has_version_byte { *buf = &buf[1..]; - Self::decode_fields_v1(buf) - } else { - Err(alloy_rlp::Error::Custom("unsupported morph tx version")) + } + + match version { + MORPH_TX_VERSION_0 => Self::decode_fields_v0(buf), + MORPH_TX_VERSION_1 => Self::decode_fields_v1(buf), + _ => Self::decode_fields_v2(buf), } } @@ -423,17 +592,46 @@ impl TxMorph { /// /// Note: Version is NOT in the RLP - it was already consumed as a prefix byte. fn decode_fields_v1(buf: &mut &[u8]) -> alloy_rlp::Result { + Self::decode_fields_versioned(buf, MORPH_TX_VERSION_1) + } + + /// Decodes V2 format fields (for decode_fields, includes RLP header handling). + /// + /// V2 format (after version byte is consumed): V1 fields, AuthorizationList + fn decode_fields_v2(buf: &mut &[u8]) -> alloy_rlp::Result { + Self::decode_fields_versioned(buf, MORPH_TX_VERSION_2) + } + + /// Decodes V1/V2 format fields, including the RLP list header. + fn decode_fields_versioned(buf: &mut &[u8], version: u8) -> alloy_rlp::Result { // Need to decode RLP header first let header = Header::decode(buf)?; if !header.list { return Err(alloy_rlp::Error::UnexpectedString); } - Self::decode_fields_v1_inner(buf) + Self::decode_fields_versioned_inner(buf, version) } /// Decodes V1 format fields (inner, assumes RLP header already consumed). fn decode_fields_v1_inner(buf: &mut &[u8]) -> alloy_rlp::Result { + Self::decode_fields_versioned_inner(buf, MORPH_TX_VERSION_1) + } + + /// Decodes V2 format fields (inner, assumes RLP header already consumed). + fn decode_fields_v2_inner(buf: &mut &[u8]) -> alloy_rlp::Result { + Self::decode_fields_versioned_inner(buf, MORPH_TX_VERSION_2) + } + + /// Decodes V1/V2 format fields (inner, assumes RLP header already consumed). + /// + /// V2 reads one extra field, the EIP-7702 authorization list, after Memo. + /// An empty V2 list is valid and decodes as an empty list (behaving like V1). + fn decode_fields_versioned_inner(buf: &mut &[u8], version: u8) -> alloy_rlp::Result { + debug_assert!( + version == MORPH_TX_VERSION_1 || version == MORPH_TX_VERSION_2, + "versioned decoder only handles V1 and V2" + ); let chain_id = Decodable::decode(buf)?; let nonce = Decodable::decode(buf)?; let max_priority_fee_per_gas = Decodable::decode(buf)?; @@ -466,6 +664,13 @@ impl TxMorph { Some(memo_bytes) }; + // V2 only: authorization list, same RLP shape as TxEip7702 (may be empty). + let authorization_list = if version == MORPH_TX_VERSION_2 { + Vec::::decode(buf)? + } else { + Vec::new() + }; + Ok(Self { chain_id, nonce, @@ -476,11 +681,12 @@ impl TxMorph { value, input, access_list, - version: MORPH_TX_VERSION_1, + version, fee_token_id, fee_limit, reference, memo, + authorization_list, }) } @@ -520,16 +726,18 @@ impl TxMorph { fee_limit, reference: None, memo: None, + authorization_list: Vec::new(), }) } /// Computes the hash used for signing the transaction. /// - /// Note: The sigHash encoding differs from transaction encoding for V1: - /// - Transaction encoding: `[version byte] + RLP([..., FeeTokenID, FeeLimit, Reference, Memo])` - /// - SigHash encoding: `TxType + RLP([..., FeeTokenID, FeeLimit, Version, Reference, Memo])` + /// Note: The sigHash encoding differs from transaction encoding for V1+: + /// - Transaction encoding: `[version byte] + RLP([..., FeeTokenID, FeeLimit, Reference, Memo, (AuthorizationList)])` + /// - SigHash encoding: `TxType + RLP([..., FeeTokenID, FeeLimit, Version, Reference, Memo, (AuthorizationList)])` /// - /// For V1, Version is included IN the RLP for signing, not as a prefix. + /// For V1+, Version is included IN the RLP for signing, not as a prefix. + /// V2 appends the authorization list after Memo in both encodings. pub fn signature_hash(&self) -> B256 { let mut buf = Vec::new(); self.encode_for_sig_hash(&mut buf); @@ -540,8 +748,9 @@ impl TxMorph { /// /// V0 format: TxType + RLP([..., FeeTokenID, FeeLimit]) /// V1 format: TxType + RLP([..., FeeTokenID, FeeLimit, Version, Reference, Memo]) + /// V2 format: TxType + RLP([..., FeeTokenID, FeeLimit, Version, Reference, Memo, AuthorizationList]) /// - /// Note: For V1, Version is included in the RLP (after FeeLimit), not as a prefix byte. + /// Note: For V1+, Version is included in the RLP (after FeeLimit), not as a prefix byte. fn encode_for_sig_hash(&self, out: &mut dyn BufMut) { // Write txType out.put_u8(MORPH_TX_TYPE_ID); @@ -573,7 +782,7 @@ impl TxMorph { len += self.fee_limit.length(); if !self.is_v0() { - // V1 sigHash: includes Version, Reference, Memo IN the RLP + // V1+ sigHash: includes Version, Reference, Memo IN the RLP len += self.version.length(); len += self .reference @@ -581,6 +790,10 @@ impl TxMorph { .map_or(0usize.length(), |r| r.0.length()); len += self.memo.as_ref().map_or(0usize.length(), |m| m.0.length()); } + if self.is_v2() { + // V2 sigHash: authorization list is covered by the signature + len += self.encoded_authorization_list().length(); + } len } @@ -588,6 +801,7 @@ impl TxMorph { /// /// V0 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit /// V1 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit, Version, Reference, Memo + /// V2 format: V1 fields, AuthorizationList fn encode_sig_hash_fields(&self, out: &mut dyn BufMut) { self.chain_id.encode(out); self.nonce.encode(out); @@ -602,7 +816,7 @@ impl TxMorph { self.fee_limit.encode(out); if !self.is_v0() { - // V1 sigHash: includes Version, Reference, Memo IN the RLP + // V1+ sigHash: includes Version, Reference, Memo IN the RLP self.version.encode(out); if let Some(ref r) = self.reference { r.0.encode(out); @@ -615,6 +829,10 @@ impl TxMorph { Bytes::new().encode(out); } } + if self.is_v2() { + // V2 sigHash: authorization list is covered by the signature + self.encoded_authorization_list().encode(out); + } } } @@ -689,8 +907,18 @@ impl Transaction for TxMorph { None } + /// Returns the EIP-7702 authorization list of a V2 transaction, if any. + /// + /// `None` for V0/V1 (even if the raw field is populated on an invalid + /// in-memory value) and for a V2 transaction with an empty list, so the + /// txpool authority tracking and the EVM authorization application only + /// ever see lists that will actually be applied. fn authorization_list(&self) -> Option<&[SignedAuthorization]> { - None + if self.is_v2() && !self.authorization_list.is_empty() { + Some(&self.authorization_list) + } else { + None + } } } @@ -703,11 +931,12 @@ impl RlpEcdsaEncodableTx for TxMorph { self.encode_fields(out); } - /// Override: For V1, include the version byte prefix before the RLP list. + /// Override: For V1+, include the version byte prefix before the RLP list. /// /// Wire format: /// - V0: `RLP([fields..., V, R, S])` /// - V1: `version_byte(0x01) + RLP([fields..., V, R, S])` + /// - V2: `version_byte(0x02) + RLP([fields..., authorizationList, V, R, S])` fn rlp_encode_signed(&self, signature: &Signature, out: &mut dyn BufMut) { if !self.is_v0() { out.put_u8(self.version); @@ -739,32 +968,29 @@ impl RlpEcdsaDecodableTx for TxMorph { Self::decode_fields(buf) } - /// Override: Handle the V1 version byte before the RLP list. + /// Override: Handle the V1/V2 version byte before the RLP list. /// /// Wire format (after txType byte is consumed): /// - V0: `RLP([fields_v0..., V, R, S])` /// - V1: `version_byte(0x01) + RLP([fields_v1..., V, R, S])` + /// - V2: `version_byte(0x02) + RLP([fields_v2..., V, R, S])` /// /// The default implementation assumes the buffer starts with an RLP list header, - /// which fails for V1 because the first byte is the version byte (0x01). + /// which fails for V1+ because the first byte is the version byte. + /// + /// Each version has a fixed number of list elements; a payload with extra + /// elements (e.g. a V1 prefix followed by V2 fields) fails the trailing + /// [`alloy_rlp::Error::ListLengthMismatch`] check, matching go-ethereum's + /// `rlp: input list has too many elements`. fn rlp_decode_with_signature(buf: &mut &[u8]) -> alloy_rlp::Result<(Self, Signature)> { if buf.is_empty() { return Err(alloy_rlp::Error::InputTooShort); } - let first_byte = buf[0]; - - // Detect version: - // - V1: first byte is version byte (0x01), skip it - // - V0: first byte is 0 or RLP list prefix (>= 0xC0), no version prefix - let version = if first_byte == MORPH_TX_VERSION_1 { - *buf = &buf[1..]; // skip version byte - MORPH_TX_VERSION_1 - } else if first_byte == MORPH_TX_VERSION_0 || first_byte >= 0xC0 { - MORPH_TX_VERSION_0 - } else { - return Err(alloy_rlp::Error::Custom("unsupported morph tx version")); - }; + let (version, has_version_byte) = Self::wire_version(buf[0])?; + if has_version_byte { + *buf = &buf[1..]; + } // Now decode: RLP([fields..., V, R, S]) let header = Header::decode(buf)?; @@ -775,10 +1001,10 @@ impl RlpEcdsaDecodableTx for TxMorph { let remaining = buf.len(); // Decode fields based on version - let tx = if version == MORPH_TX_VERSION_1 { - Self::decode_fields_v1_inner(buf)? - } else { - Self::decode_fields_v0_inner(buf)? + let tx = match version { + MORPH_TX_VERSION_0 => Self::decode_fields_v0_inner(buf)?, + MORPH_TX_VERSION_1 => Self::decode_fields_v1_inner(buf)?, + _ => Self::decode_fields_v2_inner(buf)?, }; let signature = Signature::decode_rlp_vrs(buf, bool::decode)?; @@ -820,7 +1046,7 @@ impl Encodable for TxMorph { /// Encodes TxMorph to RLP. /// /// For V0: RLP([fields...]) - /// For V1: [version byte] + RLP([fields...]) + /// For V1+: [version byte] + RLP([fields...]) fn encode(&self, out: &mut dyn BufMut) { if !self.is_v0() { // V1+: write version byte before RLP @@ -840,27 +1066,24 @@ impl Encodable for TxMorph { } impl Decodable for TxMorph { - /// Decodes TxMorph from RLP bytes (after txType byte is consumed). + /// Decodes an unsigned TxMorph from RLP bytes (after txType byte is consumed). /// - /// This handles both V0 and V1 formats: + /// This handles all formats: /// - V0: RLP list directly - /// - V1: version byte + RLP list + /// - V1/V2: version byte + RLP list + /// + /// Like the signed path, the list must be consumed exactly: extra trailing + /// elements (e.g. an authorization list on a V1 prefix) are rejected with + /// [`alloy_rlp::Error::ListLengthMismatch`]. fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { if buf.is_empty() { return Err(alloy_rlp::Error::InputTooShort); } - let first_byte = buf[0]; - - // Check if this is a version prefix (V1+) or RLP list header (V0) - if first_byte == MORPH_TX_VERSION_1 { - // V1: skip version byte, then decode RLP + let (version, has_version_byte) = Self::wire_version(buf[0])?; + if has_version_byte { *buf = &buf[1..]; - } else if first_byte != MORPH_TX_VERSION_0 && first_byte < 0xC0 { - // Invalid: not a version we support and not an RLP list - return Err(alloy_rlp::Error::Custom("unsupported morph tx version")); } - // V0: first_byte is 0 or RLP list prefix (>= 0xC0) let header = Header::decode(buf)?; if !header.list { @@ -872,12 +1095,20 @@ impl Decodable for TxMorph { return Err(alloy_rlp::Error::InputTooShort); } - // Determine version based on what we saw - if first_byte == MORPH_TX_VERSION_1 { - Self::decode_fields_v1_inner(buf) - } else { - Self::decode_fields_v0_inner(buf) + let tx = match version { + MORPH_TX_VERSION_0 => Self::decode_fields_v0_inner(buf)?, + MORPH_TX_VERSION_1 => Self::decode_fields_v1_inner(buf)?, + _ => Self::decode_fields_v2_inner(buf)?, + }; + + if buf.len() + header.payload_length != remaining { + return Err(alloy_rlp::Error::ListLengthMismatch { + expected: header.payload_length, + got: remaining - buf.len(), + }); } + + Ok(tx) } } @@ -922,6 +1153,10 @@ mod compact_txmorph { /// - `memo` and `input` are packed into a single `Bytes` field (`data`) because /// the derive macro only allows one `Bytes` field and it must be last. /// Format: `[memo_len: u8][memo_bytes][input_bytes]`. + /// - `authorization_list` (V2) was appended after `reference`. It only adds a + /// single presence bit to the struct flags (44 → 45 bits, still 6 flag + /// bytes), so rows written before V2 decode unchanged (empty list). The + /// layout is locked by `test_compact_decodes_pre_v2_bytes`; do not reorder. #[derive(Debug, Clone, PartialEq, Eq, Hash, Compact)] #[reth_codecs(crate = "reth_codecs")] struct TxMorphCompact { @@ -939,6 +1174,9 @@ mod compact_txmorph { fee_token_id: u64, fee_limit: U256, reference: Option, + /// V2 EIP-7702 authorization list; `None` for V0/V1 rows and for V2 + /// rows whose list is empty. + authorization_list: Option>, /// Packed: `[memo_len: u8][memo_bytes][input_bytes]` (must be last) data: Bytes, } @@ -968,6 +1206,8 @@ mod compact_txmorph { fee_token_id: u64::from(self.fee_token_id), fee_limit: self.fee_limit, reference: self.reference, + authorization_list: (!self.authorization_list.is_empty()) + .then(|| self.authorization_list.clone()), data: data.into(), }; helper.to_compact(buf) @@ -999,6 +1239,7 @@ mod compact_txmorph { fee_limit: helper.fee_limit, reference: helper.reference, memo, + authorization_list: helper.authorization_list.unwrap_or_default(), input, }; (tx, remaining) @@ -1253,6 +1494,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: Some(reference), memo: Some(memo.clone()), + authorization_list: Vec::new(), }; // Test Transaction trait methods @@ -1323,6 +1565,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: Some(reference), memo: Some(memo), + authorization_list: Vec::new(), }; // Encode @@ -1368,6 +1611,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, // V0 has no reference memo: None, // V0 has no memo + authorization_list: Vec::new(), }; // Encode @@ -1413,6 +1657,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; // Encode @@ -1442,6 +1687,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let mut buf = Vec::new(); @@ -1474,6 +1720,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; // Encode the transaction @@ -1552,6 +1799,7 @@ mod tests { fee_limit: U256::ZERO, reference: None, memo: None, + authorization_list: Vec::new(), }; let size = tx.size(); @@ -1575,6 +1823,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let fields_len = tx.fields_len(); @@ -1602,6 +1851,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let mut buf = Vec::new(); @@ -1644,6 +1894,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let hash = tx.signature_hash(); @@ -1670,6 +1921,7 @@ mod tests { fee_limit: U256::ZERO, reference: Some(reference), memo: Some(memo.clone()), + authorization_list: Vec::new(), }; // Test trait methods @@ -1791,6 +2043,7 @@ mod tests { fee_limit: U256::ZERO, reference: Some(B256::from([0xab; 32])), memo: Some(Bytes::from(vec![0xca, 0xfe])), + authorization_list: Vec::new(), }; let mut buf = Vec::new(); @@ -1831,6 +2084,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let mut buf = Vec::new(); @@ -1901,13 +2155,23 @@ mod tests { assert!(!v1_tx.is_v0()); assert!(v1_tx.is_v1()); - // Unsupported version (e.g., 2) - neither is_v0 nor is_v1 + // V2 transaction - neither is_v0 nor is_v1 let v2_tx = TxMorph { - version: 2, + version: MORPH_TX_VERSION_2, ..Default::default() }; assert!(!v2_tx.is_v0()); assert!(!v2_tx.is_v1()); // is_v1 uses == not >=, so version 2 is not v1 + assert!(v2_tx.is_v2()); + + // Unsupported version (e.g., 3) - none of the helpers match + let v3_tx = TxMorph { + version: 3, + ..Default::default() + }; + assert!(!v3_tx.is_v0()); + assert!(!v3_tx.is_v1()); + assert!(!v3_tx.is_v2()); } #[test] @@ -2051,6 +2315,7 @@ mod tests { fee_limit: U256::ZERO, reference: Some(reference), memo: Some(memo.clone()), + authorization_list: Vec::new(), }; // Create a dummy signature for testing @@ -2153,6 +2418,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let signature = Signature::new(U256::from(1u64), U256::from(2u64), false); @@ -2267,6 +2533,7 @@ mod tests { fee_limit: U256::from(999u64), reference: Some(B256::from([0xab; 32])), memo: Some(Bytes::from(vec![0xca, 0xfe, 0xba, 0xbe])), + authorization_list: Vec::new(), input: Bytes::from(vec![0x12, 0x34, 0x56]), }; @@ -2297,6 +2564,7 @@ mod tests { fee_limit: U256::from(500u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Bytes::from(vec![0x60, 0x80, 0x60, 0x40]), }; @@ -2307,4 +2575,547 @@ mod tests { assert!(remaining.is_empty()); assert_eq!(tx, decoded); } + + // ========================================================================= + // V2 (EIP-7702 authorization list) tests + // ========================================================================= + + use alloy_eips::eip7702::Authorization; + + /// A syntactically valid authorization tuple (the signature is not + /// recoverable; recovery only matters at execution time). + fn sample_authorization(nonce: u64) -> SignedAuthorization { + Authorization { + chain_id: U256::from(2818), + address: address!("2222222222222222222222222222222222222222"), + nonce, + } + .into_signed(Signature::new( + U256::from(0x1111u64), + U256::from(0x2222u64), + true, + )) + } + + fn sample_v2_tx(fee_token_id: u16) -> TxMorph { + TxMorph { + chain_id: 2818, + nonce: 26, + gas_limit: 3_000_000, + max_fee_per_gas: 1_000_000_000, + max_priority_fee_per_gas: 0, + to: TxKind::Call(address!("1111111111111111111111111111111111111111")), + value: U256::ZERO, + access_list: AccessList::default(), + input: Bytes::new(), + version: MORPH_TX_VERSION_2, + fee_token_id, + fee_limit: if fee_token_id > 0 { + U256::from(1_000_000_000_000_000_000u128) + } else { + U256::ZERO + }, + reference: Some(B256::from([0x01; 32])), + memo: Some(Bytes::from_static(b"invoice-1")), + authorization_list: vec![sample_authorization(27), sample_authorization(28)], + } + } + + #[test] + fn test_morph_transaction_v2_validate_rules() { + // Valid V2 with token fee and with ETH fee. + assert!(sample_v2_tx(1).validate().is_ok()); + assert!(sample_v2_tx(0).validate().is_ok()); + + // V2 may carry an empty list (it then behaves like V1). + let empty = TxMorph { + authorization_list: Vec::new(), + ..sample_v2_tx(0) + }; + assert!(empty.validate().is_ok()); + assert!(!empty.has_authorizations()); + + // With authorizations V2 cannot create a contract (same rule as EIP-7702 + // SetCode); without them CREATE is allowed exactly like V1. + let create = TxMorph { + to: TxKind::Create, + input: Bytes::from_static(&[0x60, 0x80]), + ..sample_v2_tx(0) + }; + assert_eq!( + create.validate().unwrap_err(), + "version 2 MorphTx with an authorization list cannot create a contract" + ); + let create_without_authorizations = TxMorph { + authorization_list: Vec::new(), + ..create + }; + assert!(create_without_authorizations.validate().is_ok()); + + // V1 fee rule still applies to V2. + let fee_limit_without_token = TxMorph { + fee_token_id: 0, + fee_limit: U256::from(1u64), + ..sample_v2_tx(0) + }; + assert_eq!( + fee_limit_without_token.validate().unwrap_err(), + "version 2 MorphTx cannot have FeeLimit when FeeTokenID is 0" + ); + + // V0 / V1 must not carry a list. + let v1_with_list = TxMorph { + version: MORPH_TX_VERSION_1, + ..sample_v2_tx(0) + }; + assert_eq!( + v1_with_list.validate().unwrap_err(), + "version 1 MorphTx does not support authorization list" + ); + let v0_with_list = TxMorph { + version: MORPH_TX_VERSION_0, + fee_token_id: 1, + reference: None, + memo: None, + ..sample_v2_tx(1) + }; + assert_eq!( + v0_with_list.validate().unwrap_err(), + "version 0 MorphTx does not support authorization list" + ); + + // An empty list on V1 is the normal state. + let v1_empty_list = TxMorph { + version: MORPH_TX_VERSION_1, + authorization_list: Vec::new(), + ..sample_v2_tx(0) + }; + assert!(v1_empty_list.validate().is_ok()); + } + + #[test] + fn test_morph_transaction_authorization_list_accessor_is_version_gated() { + let v2 = sample_v2_tx(0); + assert_eq!( + Transaction::authorization_list(&v2).map(<[SignedAuthorization]>::len), + Some(2) + ); + + // Even if an (invalid) V1 value carries the raw field, the trait view is None, + // so pool authority tracking and the EVM never see it. + let v1 = TxMorph { + version: MORPH_TX_VERSION_1, + ..sample_v2_tx(0) + }; + assert!(Transaction::authorization_list(&v1).is_none()); + assert!(v1.has_authorizations()); + + // A V2 with an empty list has nothing to apply: the trait view is None + // (like a plain V1), so nothing downstream treats it as a 7702 carrier. + let v2_empty = TxMorph { + authorization_list: Vec::new(), + ..sample_v2_tx(0) + }; + assert!(Transaction::authorization_list(&v2_empty).is_none()); + assert!(!v2_empty.has_authorizations()); + } + + #[test] + fn test_morph_transaction_rlp_roundtrip_v2() { + let tx = sample_v2_tx(1); + + let mut buf = Vec::new(); + tx.encode(&mut buf); + assert_eq!(buf[0], MORPH_TX_VERSION_2, "V2 wire prefix byte"); + assert!(buf[1] >= 0xC0, "RLP list header follows the version byte"); + assert_eq!(buf.len(), tx.length()); + + let decoded = TxMorph::decode(&mut buf.as_slice()).expect("Should decode V2"); + assert_eq!(decoded, tx); + assert!(decoded.is_v2()); + + // decode_fields (the RlpEcdsaDecodableTx fallback) takes the same route. + let via_fields = TxMorph::decode_fields(&mut buf.as_slice()).expect("decode_fields V2"); + assert_eq!(via_fields, tx); + } + + #[test] + fn test_morph_signed_v2_decode_2718_roundtrip() { + use alloy_consensus::Signed; + use alloy_consensus::transaction::{RlpEcdsaDecodableTx, RlpEcdsaEncodableTx}; + use alloy_eips::eip2718::Decodable2718; + + let tx = sample_v2_tx(1); + let signature = Signature::new(U256::from(1u64), U256::from(2u64), false); + + let mut signed_buf = Vec::new(); + tx.rlp_encode_signed(&signature, &mut signed_buf); + assert_eq!(signed_buf[0], MORPH_TX_VERSION_2); + assert_eq!( + signed_buf.len(), + tx.rlp_encoded_length_with_signature(&signature) + ); + + let (decoded_tx, decoded_sig) = + TxMorph::rlp_decode_with_signature(&mut signed_buf.as_slice()) + .expect("Should decode V2 signed tx"); + assert_eq!(decoded_tx, tx); + assert_eq!(decoded_sig, signature); + + // Full EIP-2718 roundtrip: 0x7f || 0x02 || rlp([...]) + let signed_tx = Signed::new_unhashed(tx.clone(), signature); + let mut eip2718_buf = Vec::new(); + signed_tx.encode_2718(&mut eip2718_buf); + assert_eq!(eip2718_buf[0], MORPH_TX_TYPE_ID); + assert_eq!(eip2718_buf[1], MORPH_TX_VERSION_2); + assert_eq!(eip2718_buf.len(), signed_tx.encode_2718_len()); + + let decoded_signed = Signed::::decode_2718(&mut eip2718_buf.as_slice()) + .expect("Should decode V2 signed tx via decode_2718"); + assert_eq!(decoded_signed.tx(), &tx); + assert_eq!(decoded_signed.hash(), signed_tx.hash()); + } + + /// Locks the V2 wire layout: the authorization list sits between `memo` + /// and the transaction signature, and the signing payload carries the + /// version inside the RLP list (no `0x02` prefix) followed by the list. + #[test] + fn test_morph_transaction_v2_wire_and_sig_hash_layout() { + use alloy_consensus::transaction::RlpEcdsaEncodableTx; + + let tx = sample_v2_tx(1); + let signature = Signature::new(U256::from(1u64), U256::from(2u64), false); + let auth_list = tx.authorization_list.clone(); + + // Common prefix shared by the wire and signing encodings. + let mut common = Vec::new(); + tx.chain_id.encode(&mut common); + tx.nonce.encode(&mut common); + tx.max_priority_fee_per_gas.encode(&mut common); + tx.max_fee_per_gas.encode(&mut common); + tx.gas_limit.encode(&mut common); + tx.to.encode(&mut common); + tx.value.encode(&mut common); + tx.input.encode(&mut common); + tx.access_list.encode(&mut common); + tx.fee_token_id.encode(&mut common); + tx.fee_limit.encode(&mut common); + + let mut tail = Vec::new(); + tx.reference.unwrap().0.encode(&mut tail); + tx.memo.clone().unwrap().encode(&mut tail); + auth_list.encode(&mut tail); + + // Wire: 0x02 || rlp([common..., reference, memo, authorizationList, yParity, r, s]) + let mut wire_payload = common.clone(); + wire_payload.extend_from_slice(&tail); + signature.write_rlp_vrs(&mut wire_payload, signature.v()); + let mut expected_wire = vec![MORPH_TX_VERSION_2]; + Header { + list: true, + payload_length: wire_payload.len(), + } + .encode(&mut expected_wire); + expected_wire.extend_from_slice(&wire_payload); + + let mut actual_wire = Vec::new(); + tx.rlp_encode_signed(&signature, &mut actual_wire); + assert_eq!(actual_wire, expected_wire, "V2 wire layout"); + + // Signing: 0x7f || rlp([common..., version, reference, memo, authorizationList]) + let mut sig_payload = common; + tx.version.encode(&mut sig_payload); + sig_payload.extend_from_slice(&tail); + let mut expected_sig_preimage = vec![MORPH_TX_TYPE_ID]; + Header { + list: true, + payload_length: sig_payload.len(), + } + .encode(&mut expected_sig_preimage); + expected_sig_preimage.extend_from_slice(&sig_payload); + + let mut actual_sig_preimage = Vec::new(); + tx.encode_for_signing(&mut actual_sig_preimage); + assert_eq!( + actual_sig_preimage, expected_sig_preimage, + "V2 sigHash layout" + ); + assert_eq!(tx.signature_hash(), keccak256(&expected_sig_preimage)); + assert_eq!(tx.payload_len_for_signature(), expected_sig_preimage.len()); + } + + #[test] + fn test_morph_transaction_v2_signature_hash_covers_authorization_list() { + let tx = sample_v2_tx(0); + let other_list = TxMorph { + authorization_list: vec![sample_authorization(99)], + ..tx.clone() + }; + assert_ne!(tx.signature_hash(), other_list.signature_hash()); + + // Same base fields as V1: the version and the list both move the hash. + let v1 = TxMorph { + version: MORPH_TX_VERSION_1, + authorization_list: Vec::new(), + ..tx.clone() + }; + assert_ne!(tx.signature_hash(), v1.signature_hash()); + } + + /// V1 payloads have a fixed element count: an appended authorization list + /// (i.e. V2 fields behind a V1 prefix) must be rejected, not silently + /// ignored, on both the signed and the unsigned decode paths. + #[test] + fn test_v1_wire_with_trailing_authorization_list_rejected() { + use alloy_consensus::transaction::{RlpEcdsaDecodableTx, RlpEcdsaEncodableTx}; + + let tx = sample_v2_tx(0); + + // Unsigned path: rewrite the version byte so a V1 decoder sees 14 fields. + let mut unsigned = Vec::new(); + tx.encode(&mut unsigned); + unsigned[0] = MORPH_TX_VERSION_1; + let err = TxMorph::decode(&mut unsigned.as_slice()).unwrap_err(); + assert!( + matches!(err, alloy_rlp::Error::ListLengthMismatch { .. }), + "unsigned V1 decode must reject trailing elements, got {err:?}" + ); + + // Signed path: the V1 decoder reads the list header where yParity should be. + let signature = Signature::new(U256::from(1u64), U256::from(2u64), false); + let mut signed = Vec::new(); + tx.rlp_encode_signed(&signature, &mut signed); + signed[0] = MORPH_TX_VERSION_1; + let err = TxMorph::rlp_decode_with_signature(&mut signed.as_slice()).unwrap_err(); + assert!( + !err.to_string().contains("unsupported"), + "expected an RLP-level error, got {err}" + ); + } + + /// A V2 with an empty list is valid and encodes as the V1 field list plus + /// one empty RLP list (`0xc0`): same payload as V1, version byte `0x02`. + #[test] + fn test_v2_wire_with_empty_authorization_list_is_v1_layout_plus_empty_list() { + let v2 = TxMorph { + authorization_list: Vec::new(), + ..sample_v2_tx(0) + }; + let v1 = TxMorph { + version: MORPH_TX_VERSION_1, + ..v2.clone() + }; + assert!(v2.validate().is_ok()); + + let mut v2_buf = Vec::new(); + v2.encode(&mut v2_buf); + let mut v1_buf = Vec::new(); + v1.encode(&mut v1_buf); + assert_eq!(v2_buf[0], MORPH_TX_VERSION_2); + assert_eq!(v1_buf[0], MORPH_TX_VERSION_1); + + // Strip the version byte and the list header from both encodings. + let mut v2_payload = &v2_buf[1..]; + let v2_header = Header::decode(&mut v2_payload).unwrap(); + let mut v1_payload = &v1_buf[1..]; + let v1_header = Header::decode(&mut v1_payload).unwrap(); + assert!(v2_header.list && v1_header.list); + assert_eq!(v2_header.payload_length, v1_header.payload_length + 1); + assert_eq!( + v2_payload, + [v1_payload, &[alloy_rlp::EMPTY_LIST_CODE][..]].concat(), + "V2 with an empty list = V1 fields + 0xc0" + ); + + // Round trip: the empty list decodes as empty and the value is unchanged. + let decoded = TxMorph::decode(&mut v2_buf.as_slice()).expect("V2 with empty list decodes"); + assert_eq!(decoded, v2); + assert!(decoded.authorization_list.is_empty()); + assert!(decoded.validate().is_ok()); + + // The version still moves the signature hash even though the list is empty. + assert_ne!(v2.signature_hash(), v1.signature_hash()); + } + + #[test] + fn test_morph_transaction_rejects_unknown_version_byte() { + let mut buf: &[u8] = &[0x03, 0xc0]; + let err = TxMorph::decode(&mut buf).unwrap_err(); + assert!(err.to_string().contains("unsupported morph tx version")); + } + + #[test] + fn test_morph_transaction_size_counts_authorizations() { + let v2 = sample_v2_tx(0); + let v1 = TxMorph { + version: MORPH_TX_VERSION_1, + authorization_list: Vec::new(), + ..v2.clone() + }; + assert!(v2.size() > v1.size()); + } + + #[cfg(feature = "serde")] + #[test] + fn test_tx_morph_serde_v2_outputs_authorization_list() { + let v2 = sample_v2_tx(1); + let json = serde_json::to_value(&v2).unwrap(); + assert_eq!(json["version"], serde_json::json!("0x2")); + let list = json["authorizationList"] + .as_array() + .expect("V2 JSON carries authorizationList"); + assert_eq!(list.len(), 2); + for key in ["chainId", "address", "nonce", "yParity", "r", "s"] { + assert!( + list[0].get(key).is_some(), + "authorization tuple must have `{key}` (same shape as 0x04)" + ); + } + + let roundtrip: TxMorph = serde_json::from_value(json).unwrap(); + assert_eq!(roundtrip, v2); + + // V0 / V1 never emit the key, and the hand-written serializer stays in + // step with the derived deserializer for every version. + for version in [MORPH_TX_VERSION_0, MORPH_TX_VERSION_1] { + let tx = TxMorph { + version, + authorization_list: Vec::new(), + ..sample_v2_tx(1) + }; + let json = serde_json::to_value(&tx).unwrap(); + assert!(json.get("authorizationList").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), tx); + } + + // A V2 with an empty list still emits the key, as `[]` (go-ethereum + // does the same), and `[]`, an absent key and `null` all decode back + // to the same (empty) value. + let v2_empty = TxMorph { + authorization_list: Vec::new(), + ..sample_v2_tx(1) + }; + let mut json = serde_json::to_value(&v2_empty).unwrap(); + assert_eq!(json["version"], serde_json::json!("0x2")); + assert_eq!(json["authorizationList"], serde_json::json!([])); + let empty: TxMorph = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(empty, v2_empty); + json.as_object_mut().unwrap().remove("authorizationList"); + let absent: TxMorph = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(absent, v2_empty); + json["authorizationList"] = serde_json::Value::Null; + let null: TxMorph = serde_json::from_value(json).unwrap(); + assert_eq!(null, v2_empty); + } + + #[cfg(feature = "reth-codec")] + #[test] + fn test_compact_roundtrip_v2_with_authorization_list() { + use reth_codecs::Compact; + + let tx = sample_v2_tx(1); + let mut buf = Vec::new(); + tx.to_compact(&mut buf); + let (decoded, remaining) = TxMorph::from_compact(&buf, buf.len()); + + assert!(remaining.is_empty()); + assert_eq!(tx, decoded); + } + + /// A V2 with an empty list stores the list as absent (same bytes as a V1 + /// row apart from the version) and decodes back to an empty list. + #[cfg(feature = "reth-codec")] + #[test] + fn test_compact_roundtrip_v2_with_empty_authorization_list() { + use reth_codecs::Compact; + + let v2_empty = TxMorph { + authorization_list: Vec::new(), + ..sample_v2_tx(1) + }; + let mut buf = Vec::new(); + v2_empty.to_compact(&mut buf); + let (decoded, remaining) = TxMorph::from_compact(&buf, buf.len()); + assert!(remaining.is_empty()); + assert_eq!(decoded, v2_empty); + assert!(decoded.authorization_list.is_empty()); + + let v1 = TxMorph { + version: MORPH_TX_VERSION_1, + ..v2_empty + }; + let mut v1_buf = Vec::new(); + v1.to_compact(&mut v1_buf); + assert_eq!( + buf.len(), + v1_buf.len(), + "an empty list adds no storage bytes" + ); + } + + /// Storage layout lock: rows written before the V2 field existed must keep + /// decoding byte-for-byte, and pre-V2 transactions must still encode to the + /// exact same bytes (the new presence bit only occupies previously unused + /// flag padding). Vectors were produced by the pre-V2 `Compact` impl. + #[cfg(feature = "reth-codec")] + #[test] + fn test_compact_decodes_pre_v2_bytes() { + use alloy_primitives::hex; + use reth_codecs::Compact; + + let v1 = TxMorph { + chain_id: 2818, + nonce: 42, + gas_limit: 21_000, + max_fee_per_gas: 100_000_000_000, + max_priority_fee_per_gas: 2_000_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::from(1_000_000_000_000_000_000u128), + access_list: AccessList::default(), + version: 1, + fee_token_id: 7, + fee_limit: U256::from(999u64), + reference: Some(B256::from([0xab; 32])), + memo: Some(Bytes::from(vec![0xca, 0xfe, 0xba, 0xbe])), + authorization_list: Vec::new(), + input: Bytes::from(vec![0x12, 0x34, 0x56]), + }; + let v1_bytes = hex::decode( + "1252482442080b022a5208174876e8007735940000000000000000000000000000000000000000020de0b6b3a764000000010703e7abababababababababababababababababababababababababababababababab04cafebabe123456", + ) + .unwrap(); + + let v0 = TxMorph { + chain_id: 2818, + nonce: 0, + gas_limit: 100_000, + max_fee_per_gas: 50_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Create, + value: U256::ZERO, + access_list: AccessList::default(), + version: 0, + fee_token_id: 1, + fee_limit: U256::from(500u64), + reference: None, + memo: None, + authorization_list: Vec::new(), + input: Bytes::from(vec![0x60, 0x80, 0x60, 0x40]), + }; + let v0_bytes = + hex::decode("0253080042000b020186a00ba43b74003b9aca00000101f40060806040").unwrap(); + + for (name, tx, bytes) in [("v1", v1, v1_bytes), ("v0", v0, v0_bytes)] { + let (decoded, remaining) = TxMorph::from_compact(&bytes, bytes.len()); + assert!(remaining.is_empty(), "{name}: pre-V2 bytes fully consumed"); + assert_eq!(decoded, tx, "{name}: pre-V2 bytes decode unchanged"); + + let mut reencoded = Vec::new(); + tx.to_compact(&mut reencoded); + assert_eq!( + reencoded, bytes, + "{name}: pre-V2 rows re-encode identically" + ); + } + } } diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 4515a2ae..3b7c81f6 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -43,6 +43,23 @@ pub enum MorphInvalidTransaction { /// Available token balance. available: U256, }, + + /// A MorphTx below version 2 carries an EIP-7702 authorization list. + /// + /// Only MorphTx V2 (Onyx onwards) may carry authorizations; the RLP decoders + /// never produce this shape, so it only surfaces for malformed simulation + /// requests. + #[error("MorphTx version {version} does not support an authorization list")] + AuthorizationListNotSupported { + /// The transaction's MorphTx version. + version: u8, + }, + + /// A MorphTx carrying an EIP-7702 authorization list is a contract creation. + /// + /// Same rule as `ErrSetCodeTxCreate` for `0x04` transactions. + #[error("MorphTx with an authorization list cannot create a contract")] + AuthorizationListCreate, } impl InvalidTxError for MorphInvalidTransaction { diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index a0d9f2eb..b502ac88 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1,6 +1,7 @@ //! Morph EVM Handler implementation. use alloy_primitives::{Address, Bytes, U256}; +use morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_2; use revm::{ ExecuteEvm, context::{ @@ -14,6 +15,7 @@ use revm::{ handler::{EvmTr, FrameTr, Handler, MainnetHandler, post_execution, pre_execution, validation}, inspector::{Inspector, InspectorHandler}, interpreter::{Gas, GasTracker, InitialAndFloorGas, interpreter::EthInterpreter}, + primitives::hardfork::SpecId, }; use crate::{ @@ -84,13 +86,50 @@ where .map(|result| result.map_haltreason(Into::into)) } + /// Applies the EIP-7702 authorization list. + /// + /// revm's default implementation only applies the list when + /// `tx_type == 0x04`; MorphTx (`0x7F`) maps to `TransactionType::Custom` + /// and would be skipped silently, charging the sender for authorizations + /// that never take effect. MorphTx V2 lists are applied here with the same + /// `pre_execution::apply_auth_list` routine and refund accounting as `0x04`. + /// + /// The EIP-2780 (Amsterdam) runtime-charge variant is not handled for + /// MorphTx: no Morph hardfork enables it (see + /// `test_morph_hardforks_do_not_enable_amsterdam_state_gas`). #[inline] fn apply_eip7702_auth_list( &self, evm: &mut Self::Evm, init_and_floor_gas: &mut GasTracker, ) -> Result, Self::Error> { - pre_execution::apply_eip7702_auth_list(evm.ctx(), init_and_floor_gas) + if !evm.ctx_ref().tx().is_morph_tx() { + return pre_execution::apply_eip7702_auth_list(evm.ctx(), init_and_floor_gas); + } + + // `validate_env` already enforced that only V2 carries a list, so V0/V1 + // (and a V2 with an empty list, which behaves like V1) fall through here + // with nothing to apply. + if evm.ctx_ref().tx().authorization_list_len() == 0 { + return Ok(Some(0)); + } + + let chain_id = evm.ctx_ref().cfg().chain_id(); + let (tx, journal) = evm.ctx().tx_journal_mut(); + let refunded_accounts = pre_execution::apply_auth_list::<_, Self::Error>( + chain_id, + tx.authorization_list(), + journal, + )?; + + let regular_gas_refund = evm + .ctx_ref() + .cfg() + .gas_params() + .tx_eip7702_auth_refund_regular() + .saturating_mul(refunded_accounts); + + Ok(Some(regular_gas_refund)) } #[inline] @@ -251,6 +290,14 @@ where )?; } + // The `Custom` branch also skips the EIP-7702 static rules (Prague gate, + // no CREATE) that revm applies to `0x04`. A MorphTx V2 carrying + // authorizations must obey the same rules; an empty V2 list is allowed + // and needs no extra checks. + if evm.ctx_ref().tx().is_morph_tx() { + self.validate_morph_tx_authorization_list(evm)?; + } + Ok(()) } @@ -344,6 +391,49 @@ impl MorphEvmHandler where DB: alloy_evm::Database, { + /// Static EIP-7702 rules for MorphTx, mirroring the `Eip7702` branch of + /// revm's `validate_env` that `TransactionType::Custom` skips: + /// + /// - V0/V1 must not carry an authorization list + /// - V2 with an empty list needs no extra checks (it behaves like V1) + /// - V2 with authorizations requires Prague (always true past Viridian, + /// asserted anyway) and a call target (no CREATE) + #[inline] + fn validate_morph_tx_authorization_list( + &self, + evm: &mut MorphEvm, + ) -> Result<(), EVMError> { + let tx = evm.ctx_ref().tx(); + let version = tx.version.unwrap_or_default(); + let auth_list_len = tx.authorization_list_len(); + + if version < MORPH_TX_VERSION_2 { + if auth_list_len != 0 { + return Err( + MorphInvalidTransaction::AuthorizationListNotSupported { version }.into(), + ); + } + return Ok(()); + } + + if auth_list_len == 0 { + return Ok(()); + } + + let spec: SpecId = (*evm.ctx_ref().cfg().spec()).into(); + if !spec.is_enabled_in(SpecId::PRAGUE) { + return Err(MorphInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::Eip7702NotSupported, + ) + .into()); + } + if tx.kind().is_create() { + return Err(MorphInvalidTransaction::AuthorizationListCreate.into()); + } + + Ok(()) + } + /// Validate and deduct ETH-based gas fees. #[inline] fn validate_and_deduct_eth_fee( @@ -1215,6 +1305,565 @@ mod tests { )); } + // ========================================================================= + // MorphTx V2 (EIP-7702 authorization list) handler rules + // ========================================================================= + + use alloy_consensus::transaction::Either; + use alloy_eips::eip7702::{Authorization, RecoveredAuthority}; + use morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1; + use revm::context_interface::transaction::{RecoveredAuthorization, SignedAuthorization}; + + fn sample_signed_authorization() -> SignedAuthorization { + Authorization { + chain_id: U256::from(1), + address: Address::with_last_byte(0x42), + nonce: 0, + } + .into_signed(alloy_primitives::Signature::new( + U256::from(1), + U256::from(2), + true, + )) + } + + fn recovered_authorization( + authority: Address, + delegate: Address, + chain_id: u64, + nonce: u64, + ) -> Either { + Either::Right(RecoveredAuthorization::new_unchecked( + Authorization { + chain_id: U256::from(chain_id), + address: delegate, + nonce, + }, + RecoveredAuthority::Valid(authority), + )) + } + + fn morph_tx_env_with_authorizations( + version: Option, + kind: TxKind, + authorization_list: Vec>, + ) -> MorphTxEnv { + MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + gas_limit: 100_000, + kind, + authorization_list, + ..Default::default() + }, + version, + fee_token_id: Some(0), + ..Default::default() + } + } + + fn evm_with_spec(spec: MorphHardfork) -> MorphEvm, NoOpInspector> { + MorphEvm::new( + MorphContext::new(CacheDB::new(EmptyDB::default()), spec), + NoOpInspector, + ) + } + + fn validate_env_of( + evm: &mut MorphEvm, NoOpInspector>, + ) -> Result<(), EVMError> { + as Handler>::validate_env(&MorphEvmHandler::default(), evm) + } + + #[test] + fn validate_env_accepts_v2_morph_tx_with_authorization_list() { + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_2), + TxKind::Call(Address::ZERO), + vec![Either::Left(sample_signed_authorization())], + ); + + assert!(validate_env_of(&mut evm).is_ok()); + } + + #[test] + fn validate_env_rejects_v1_morph_tx_with_authorization_list() { + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_1), + TxKind::Call(Address::ZERO), + vec![Either::Left(sample_signed_authorization())], + ); + + let err = validate_env_of(&mut evm).unwrap_err(); + assert!(matches!( + err, + EVMError::Transaction(MorphInvalidTransaction::AuthorizationListNotSupported { + version: MORPH_TX_VERSION_1 + }) + )); + } + + /// A V2 with an empty list is a V1 in all but the version byte: no 7702 + /// static rule applies (revm's `EmptyAuthorizationList` is `0x04`-only). + #[test] + fn validate_env_accepts_v2_morph_tx_with_empty_authorization_list() { + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_2), + TxKind::Call(Address::ZERO), + vec![], + ); + + assert!(validate_env_of(&mut evm).is_ok()); + } + + /// Without authorizations a V2 may create a contract, exactly like V1. + #[test] + fn validate_env_accepts_v2_morph_tx_create_without_authorizations() { + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.tx = morph_tx_env_with_authorizations(Some(MORPH_TX_VERSION_2), TxKind::Create, vec![]); + + assert!(validate_env_of(&mut evm).is_ok()); + } + + #[test] + fn validate_env_rejects_v2_morph_tx_create() { + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_2), + TxKind::Create, + vec![Either::Left(sample_signed_authorization())], + ); + + let err = validate_env_of(&mut evm).unwrap_err(); + assert!(matches!( + err, + EVMError::Transaction(MorphInvalidTransaction::AuthorizationListCreate) + )); + } + + #[test] + fn validate_env_rejects_v2_morph_tx_before_prague() { + // Structurally unreachable on Morph (Onyx > Viridian = Prague), but the + // guard mirrors revm's `Eip7702NotSupported` for `0x04`. + let mut evm = evm_with_spec(MorphHardfork::Morph203); + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_2), + TxKind::Call(Address::ZERO), + vec![Either::Left(sample_signed_authorization())], + ); + + let err = validate_env_of(&mut evm).unwrap_err(); + assert!(matches!( + err, + EVMError::Transaction(MorphInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::Eip7702NotSupported + )) + )); + } + + #[test] + fn validate_env_keeps_accepting_v1_morph_tx_without_authorizations() { + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_1), + TxKind::Call(Address::ZERO), + vec![], + ); + + assert!(validate_env_of(&mut evm).is_ok()); + } + + fn apply_auth_list_of(evm: &mut MorphEvm, NoOpInspector>) -> Option { + let mut gas = GasTracker::new(100_000, 100_000, 0); + as Handler>::apply_eip7702_auth_list( + &MorphEvmHandler::default(), + evm, + &mut gas, + ) + .expect("authorization application must not fail") + } + + fn account_nonce_and_code( + evm: &mut MorphEvm, NoOpInspector>, + address: Address, + ) -> (u64, Option) { + let account = evm + .ctx() + .journal_mut() + .load_account_with_code_mut(address) + .unwrap() + .data; + let info = &account.account().info; + (info.nonce, info.code.clone()) + } + + /// revm's default hook is a no-op for `TransactionType::Custom`; the Morph + /// override must apply a V2 list exactly like a `0x04` transaction, and + /// report the EIP-7702 refund for authorities that already exist. + #[test] + fn apply_eip7702_auth_list_delegates_v2_morph_tx_authorities() { + let authority = Address::with_last_byte(0xaa); + let delegate = Address::with_last_byte(0x42); + + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info( + authority, + AccountInfo { + balance: U256::from(1), + nonce: 0, + ..Default::default() + }, + ); + let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Onyx), NoOpInspector); + evm.cfg.chain_id = 1; + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_2), + TxKind::Call(Address::ZERO), + vec![recovered_authorization(authority, delegate, 1, 0)], + ); + + let refund = apply_auth_list_of(&mut evm); + assert_eq!( + refund, + Some( + evm.ctx_ref() + .cfg() + .gas_params() + .tx_eip7702_auth_refund_regular() + ), + "an existing authority earns the regular EIP-7702 refund" + ); + assert_eq!(refund, Some(12_500)); + + let (nonce, code) = account_nonce_and_code(&mut evm, authority); + assert_eq!(nonce, 1, "authority nonce is consumed by the delegation"); + let code = code.expect("delegation designator written"); + assert!(code.is_eip7702()); + assert_eq!(code, Bytecode::new_eip7702(delegate)); + } + + #[test] + fn apply_eip7702_auth_list_skips_invalid_v2_tuples() { + let authority = Address::with_last_byte(0xaa); + let delegate = Address::with_last_byte(0x42); + + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.cfg.chain_id = 1; + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_2), + TxKind::Call(Address::ZERO), + vec![ + // wrong chain id + recovered_authorization(authority, delegate, 999, 0), + // wrong nonce (authority is at 0) + recovered_authorization(authority, delegate, 1, 5), + ], + ); + + assert_eq!(apply_auth_list_of(&mut evm), Some(0)); + + let (nonce, code) = account_nonce_and_code(&mut evm, authority); + assert_eq!(nonce, 0); + assert!(code.is_none_or(|code| code.is_empty())); + } + + #[test] + fn apply_eip7702_auth_list_is_noop_for_morph_tx_without_authorizations() { + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_1), + TxKind::Call(Address::ZERO), + vec![], + ); + + assert_eq!(apply_auth_list_of(&mut evm), Some(0)); + } + + fn evm_with_authority( + authority: Address, + info: AccountInfo, + ) -> MorphEvm, NoOpInspector> { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(authority, info); + let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Onyx), NoOpInspector); + evm.cfg.chain_id = 1; + evm + } + + fn v2_env_with( + authorization_list: Vec>, + ) -> MorphTxEnv { + morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_2), + TxKind::Call(Address::ZERO), + authorization_list, + ) + } + + /// A never-seen authority is created by the delegation and earns no refund + /// (the 25 000 intrinsic gas pays for the new account). + #[test] + fn apply_eip7702_auth_list_creates_fresh_authority_without_refund() { + let authority = Address::with_last_byte(0xaa); + let delegate = Address::with_last_byte(0x42); + + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.cfg.chain_id = 1; + evm.tx = v2_env_with(vec![recovered_authorization(authority, delegate, 1, 0)]); + + assert_eq!(apply_auth_list_of(&mut evm), Some(0)); + + let (nonce, code) = account_nonce_and_code(&mut evm, authority); + assert_eq!(nonce, 1); + assert_eq!(code, Some(Bytecode::new_eip7702(delegate))); + } + + /// An authority that is a real contract can never be delegated (EIP-7702 rule 5). + #[test] + fn apply_eip7702_auth_list_skips_authority_with_contract_code() { + let authority = Address::with_last_byte(0xaa); + let delegate = Address::with_last_byte(0x42); + // PUSH1 0 PUSH1 0 REVERT + let contract_code = Bytecode::new_raw(Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd])); + + let mut evm = evm_with_authority( + authority, + AccountInfo { + balance: U256::from(1), + nonce: 3, + code_hash: contract_code.hash_slow(), + code: Some(contract_code.clone()), + ..Default::default() + }, + ); + evm.tx = v2_env_with(vec![recovered_authorization(authority, delegate, 1, 3)]); + + assert_eq!(apply_auth_list_of(&mut evm), Some(0)); + + let (nonce, code) = account_nonce_and_code(&mut evm, authority); + assert_eq!(nonce, 3, "contract authority is left untouched"); + assert_eq!(code, Some(contract_code)); + } + + /// An already-delegated authority can be re-pointed at a new delegate. + #[test] + fn apply_eip7702_auth_list_redelegates_already_delegated_authority() { + let authority = Address::with_last_byte(0xaa); + let first = Address::with_last_byte(0x41); + let second = Address::with_last_byte(0x42); + let existing = Bytecode::new_eip7702(first); + + let mut evm = evm_with_authority( + authority, + AccountInfo { + balance: U256::from(1), + nonce: 5, + code_hash: existing.hash_slow(), + code: Some(existing), + ..Default::default() + }, + ); + evm.tx = v2_env_with(vec![recovered_authorization(authority, second, 1, 5)]); + + assert_eq!(apply_auth_list_of(&mut evm), Some(12_500)); + + let (nonce, code) = account_nonce_and_code(&mut evm, authority); + assert_eq!(nonce, 6); + assert_eq!(code, Some(Bytecode::new_eip7702(second))); + } + + /// Delegating to the zero address clears the designator (EIP-7702 rule 8). + #[test] + fn apply_eip7702_auth_list_zero_address_clears_delegation() { + let authority = Address::with_last_byte(0xaa); + let existing = Bytecode::new_eip7702(Address::with_last_byte(0x41)); + + let mut evm = evm_with_authority( + authority, + AccountInfo { + balance: U256::from(1), + nonce: 5, + code_hash: existing.hash_slow(), + code: Some(existing), + ..Default::default() + }, + ); + evm.tx = v2_env_with(vec![recovered_authorization( + authority, + Address::ZERO, + 1, + 5, + )]); + + assert_eq!(apply_auth_list_of(&mut evm), Some(12_500)); + + let (nonce, code) = account_nonce_and_code(&mut evm, authority); + assert_eq!(nonce, 6); + assert!( + code.is_none_or(|code| code.is_empty()), + "zero-address delegation must clear the code" + ); + } + + /// A tuple whose authority could not be recovered (the shape + /// `MorphTxEnv::from_recovered_tx` produces for a bad signature) is + /// skipped, not fatal. + #[test] + fn apply_eip7702_auth_list_skips_tuple_with_invalid_authority() { + let delegate = Address::with_last_byte(0x42); + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.cfg.chain_id = 1; + evm.tx = v2_env_with(vec![Either::Right(RecoveredAuthorization::new_unchecked( + Authorization { + chain_id: U256::from(1), + address: delegate, + nonce: 0, + }, + RecoveredAuthority::Invalid, + ))]); + + assert_eq!(apply_auth_list_of(&mut evm), Some(0)); + assert!( + evm.ctx() + .journal_mut() + .inner + .state + .values() + .all(|account| account + .info + .code + .as_ref() + .is_none_or(|code| code.is_empty())), + "no account may have been delegated" + ); + } + + /// `chainId = 0` tuples are valid on every chain (EIP-7702 rule 1). + #[test] + fn apply_eip7702_auth_list_accepts_chain_id_zero_tuple() { + let authority = Address::with_last_byte(0xaa); + let delegate = Address::with_last_byte(0x42); + + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.cfg.chain_id = 1; + evm.tx = v2_env_with(vec![recovered_authorization(authority, delegate, 0, 0)]); + + assert_eq!(apply_auth_list_of(&mut evm), Some(0)); + + let (nonce, code) = account_nonce_and_code(&mut evm, authority); + assert_eq!(nonce, 1); + assert_eq!(code, Some(Bytecode::new_eip7702(delegate))); + } + + /// `nonce = 2^64 - 1` tuples are skipped (EIP-7702 rule 2). + #[test] + fn apply_eip7702_auth_list_skips_nonce_max_tuple() { + let authority = Address::with_last_byte(0xaa); + let delegate = Address::with_last_byte(0x42); + + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.cfg.chain_id = 1; + evm.tx = v2_env_with(vec![recovered_authorization( + authority, + delegate, + 1, + u64::MAX, + )]); + + assert_eq!(apply_auth_list_of(&mut evm), Some(0)); + + let (nonce, code) = account_nonce_and_code(&mut evm, authority); + assert_eq!(nonce, 0); + assert!(code.is_none_or(|code| code.is_empty())); + } + + /// Two tuples for the same authority apply in order: the second one sees the + /// nonce bumped by the first, and both earn the refund. + #[test] + fn apply_eip7702_auth_list_applies_consecutive_tuples_for_same_authority() { + let authority = Address::with_last_byte(0xaa); + let first = Address::with_last_byte(0x41); + let second = Address::with_last_byte(0x42); + + let mut evm = evm_with_authority( + authority, + AccountInfo { + balance: U256::from(1), + nonce: 0, + ..Default::default() + }, + ); + evm.tx = v2_env_with(vec![ + recovered_authorization(authority, first, 1, 0), + recovered_authorization(authority, second, 1, 1), + ]); + + assert_eq!(apply_auth_list_of(&mut evm), Some(25_000)); + + let (nonce, code) = account_nonce_and_code(&mut evm, authority); + assert_eq!(nonce, 2); + assert_eq!(code, Some(Bytecode::new_eip7702(second))); + } + + /// Simulation (`eth_call`, fee charge disabled) still enforces the static + /// V2 rules; only the fee-cap check is fee-dependent. + #[test] + fn validate_env_enforces_v2_rules_when_fee_charge_is_disabled() { + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.cfg.disable_fee_charge = true; + evm.tx = morph_tx_env_with_authorizations( + Some(MORPH_TX_VERSION_2), + TxKind::Create, + vec![Either::Left(sample_signed_authorization())], + ); + + let err = validate_env_of(&mut evm).unwrap_err(); + assert!(matches!( + err, + EVMError::Transaction(MorphInvalidTransaction::AuthorizationListCreate) + )); + } + + /// revm's intrinsic gas charges 25 000 per authorization from + /// `authorization_list_len()` regardless of the transaction type, so a + /// MorphTx V2 needs no Morph-specific handling here (design doc 5.6). + #[test] + fn validate_initial_tx_gas_charges_per_authorization_for_morph_tx_v2() { + let mut evm = evm_with_spec(MorphHardfork::Onyx); + evm.tx = v2_env_with(vec![ + Either::Left(sample_signed_authorization()), + Either::Left(sample_signed_authorization()), + ]); + + // 21_000 base + 2 × 25_000 per authorization. + evm.tx.inner.gas_limit = 70_999; + let err = as Handler>::validate_initial_tx_gas( + &MorphEvmHandler::default(), + &mut evm, + ) + .unwrap_err(); + assert!(matches!( + err, + EVMError::Transaction(MorphInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::CallGasCostMoreThanGasLimit { + initial_gas: 71_000, + gas_limit: 70_999, + } + )) + )); + + evm.tx.inner.gas_limit = 71_000; + let initial = as Handler>::validate_initial_tx_gas( + &MorphEvmHandler::default(), + &mut evm, + ) + .expect("exact intrinsic gas is accepted"); + assert_eq!(initial.initial_regular_gas, 71_000); + } + #[test] fn validate_initial_tx_gas_uses_configured_gas_params() { let mut evm = MorphEvm::new( diff --git a/crates/revm/src/precompiles.rs b/crates/revm/src/precompiles.rs index cce783fa..e1ed4220 100644 --- a/crates/revm/src/precompiles.rs +++ b/crates/revm/src/precompiles.rs @@ -116,7 +116,7 @@ impl MorphPrecompiles { // Morph203 and Viridian share the same precompile set MorphHardfork::Morph203 | MorphHardfork::Viridian => morph203(), // Emerald and Jade share the same precompile set. - MorphHardfork::Emerald | MorphHardfork::Jade => emerald(), + MorphHardfork::Emerald | MorphHardfork::Jade | MorphHardfork::Onyx => emerald(), hardfork => unreachable!("unsupported Morph hardfork: {hardfork:?}"), }; diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index bddd2768..bcf4afdb 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -9,7 +9,10 @@ use alloy_eips::eip2718::Encodable2718; use alloy_eips::eip2930::AccessList; use alloy_eips::eip7702::RecoveredAuthority; use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; -use morph_primitives::{L1_TX_TYPE_ID, MORPH_TX_TYPE_ID, MorphTxEnvelope, TxMorph}; +use morph_primitives::{ + L1_TX_TYPE_ID, MORPH_TX_TYPE_ID, MorphTxEnvelope, TxMorph, + transaction::morph_transaction::{MORPH_TX_VERSION_1, MORPH_TX_VERSION_2}, +}; use reth_evm::{FromRecoveredTx, FromTxWithEncoded, ToTxEnv, TransactionEnvMut}; use revm::context::{Transaction, TxEnv}; use revm::context_interface::transaction::{ @@ -115,7 +118,7 @@ impl MorphTxEnv { // 64 bytes of 0xff followed by yParity=1. let placeholder_signature = Signature::new(U256::MAX, U256::MAX, true); - match self.build_morph_tx_for_l1_fee(fallback_chain_id) { + match self.build_morph_tx_for_l1_fee(fallback_chain_id, placeholder_signature) { Some(morph_tx) => { let signed = morph_tx.into_signed(placeholder_signature); MorphTxEnvelope::Morph(signed).rlp() @@ -126,11 +129,43 @@ impl MorphTxEnv { } } - fn build_morph_tx_for_l1_fee(&self, fallback_chain_id: u64) -> Option { + fn build_morph_tx_for_l1_fee( + &self, + fallback_chain_id: u64, + placeholder_signature: Signature, + ) -> Option { if !self.is_morph_tx() { return None; } + let version = self.version.unwrap_or_else(|| { + // If version is missing (e.g. from an older transaction type), fall back to + // the smallest version that can represent the request so the L1 fee is not + // underpriced: V2 when an authorization list is present, otherwise V1. + let fallback = if self.inner.authorization_list.is_empty() { + MORPH_TX_VERSION_1 + } else { + MORPH_TX_VERSION_2 + }; + tracing::debug!( + target: "morph::evm", + fallback, + "MorphTx version not set, falling back for L1 fee calculation to safely overestimate" + ); + fallback + }); + + // V2 carries the authorization list in its RLP payload (an empty list + // still encodes as `0xc0`); leaving it out would under-size the L1 data + // fee by the whole list (geth's `asUnsignedMorphTx` sizes it too). + // Recovered authorizations no longer carry their signature and reuse + // the fee-sizing placeholder. + let authorization_list = if version == MORPH_TX_VERSION_2 { + self.signed_authorizations_for_l1_fee(placeholder_signature) + } else { + Vec::new() + }; + Some(TxMorph { chain_id: self.chain_id().unwrap_or(fallback_chain_id), nonce: self.inner.nonce, @@ -143,17 +178,10 @@ impl MorphTxEnv { input: self.input().clone(), fee_token_id: self.fee_token_id.unwrap_or_default(), fee_limit: self.fee_limit.unwrap_or_default(), - version: self.version.unwrap_or_else(|| { - // If version is missing (e.g. from an older transaction type), we fallback to V1 - // to safely overestimate the L1 fee, ensuring we don't underprice the transaction. - tracing::debug!( - target: "morph::evm", - "MorphTx version not set, falling back to V1 for L1 fee calculation to safely overestimate" - ); - morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 - }), + version, reference: self.reference, memo: self.memo.clone(), + authorization_list, }) } @@ -900,4 +928,161 @@ mod tests { }; assert!(!tx.encode_for_l1_fee(53077).is_empty()); } + + fn sample_signed_authorization() -> SignedAuthorization { + alloy_eips::eip7702::Authorization { + chain_id: U256::from(53077), + address: Address::with_last_byte(0x42), + nonce: 7, + } + .into_signed(Signature::new(U256::from(1), U256::from(2), true)) + } + + fn morph_v2_tx_env( + version: Option, + authorization: Either, + ) -> MorphTxEnv { + MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + chain_id: Some(53077), + gas_limit: 100_000, + gas_price: 20_000_000_000, + gas_priority_fee: Some(1_000_000_000), + nonce: 1, + kind: TxKind::Call(Address::with_last_byte(0xf1)), + authorization_list: vec![authorization], + ..Default::default() + }, + version, + fee_token_id: Some(1), + fee_limit: Some(U256::from(1000)), + ..Default::default() + } + } + + /// The simulated MorphTx V2 envelope must carry the authorization list so + /// `eth_estimateGas` / `eth_call` size the L1 data fee like geth's + /// `asUnsignedMorphTx`; dropping it would under-price the transaction. + #[test] + fn encode_for_l1_fee_morph_tx_v2_includes_authorization_list() { + let signed_authorization = sample_signed_authorization(); + let tx = morph_v2_tx_env( + Some(MORPH_TX_VERSION_2), + Either::Left(signed_authorization.clone()), + ); + + let encoded = tx.encode_for_l1_fee(53077); + assert_eq!(encoded[0], MORPH_TX_TYPE_ID); + assert_eq!(encoded[1], MORPH_TX_VERSION_2); + + let decoded = MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).unwrap(); + let MorphTxEnvelope::Morph(decoded) = decoded else { + panic!("expected MorphTx envelope"); + }; + assert_eq!(decoded.tx().version, MORPH_TX_VERSION_2); + assert_eq!(decoded.tx().fee_token_id, 1); + assert_eq!(decoded.tx().authorization_list, vec![signed_authorization]); + + // Sanity: the list actually contributes bytes versus the V1 shape. + let v1 = MorphTxEnv { + version: Some(MORPH_TX_VERSION_1), + inner: TxEnv { + authorization_list: vec![], + ..tx.inner.clone() + }, + ..tx + }; + assert!(encoded.len() > v1.encode_for_l1_fee(53077).len()); + } + + #[test] + fn encode_for_l1_fee_morph_tx_version_fallback_picks_v2_with_authorizations() { + let tx = morph_v2_tx_env(None, Either::Left(sample_signed_authorization())); + + let encoded = tx.encode_for_l1_fee(53077); + let decoded = MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).unwrap(); + let MorphTxEnvelope::Morph(decoded) = decoded else { + panic!("expected MorphTx envelope"); + }; + assert_eq!(decoded.tx().version, MORPH_TX_VERSION_2); + assert_eq!(decoded.tx().authorization_list.len(), 1); + + // Without a list the fallback stays V1. + let no_list = MorphTxEnv { + inner: TxEnv { + authorization_list: vec![], + ..tx.inner.clone() + }, + ..tx + }; + let encoded = no_list.encode_for_l1_fee(53077); + assert_eq!(encoded[1], MORPH_TX_VERSION_1); + } + + #[test] + fn encode_for_l1_fee_morph_tx_v2_recovered_authorization_uses_placeholder_signature() { + let recovered = RecoveredAuthorization::new_unchecked( + alloy_eips::eip7702::Authorization { + chain_id: U256::from(53077), + address: Address::with_last_byte(0x42), + nonce: 7, + }, + RecoveredAuthority::Valid(Address::with_last_byte(0x99)), + ); + let tx = morph_v2_tx_env(Some(MORPH_TX_VERSION_2), Either::Right(recovered)); + + let encoded = tx.encode_for_l1_fee(53077); + let decoded = MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).unwrap(); + let MorphTxEnvelope::Morph(decoded) = decoded else { + panic!("expected MorphTx envelope"); + }; + let list = decoded.tx().authorization_list.clone(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].nonce, 7); + // go-ethereum's placeholder: r = s = 0xff..ff, yParity = 1 + assert_eq!(list[0].r(), U256::MAX); + assert_eq!(list[0].s(), U256::MAX); + } + + #[test] + fn from_recovered_tx_morph_v2_populates_authorization_list_and_version() { + use alloy_consensus::Signed; + + let signed_authorization = sample_signed_authorization(); + let morph_tx = TxMorph { + chain_id: 53077, + nonce: 1, + gas_limit: 100_000, + max_fee_per_gas: 20_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(Address::with_last_byte(0xf1)), + value: U256::ZERO, + access_list: AccessList::default(), + input: Bytes::new(), + version: MORPH_TX_VERSION_2, + fee_token_id: 0, + fee_limit: U256::ZERO, + reference: None, + memo: None, + authorization_list: vec![signed_authorization.clone()], + }; + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + morph_tx, + Signature::new(U256::from(1), U256::from(2), false), + B256::ZERO, + )); + + let env = MorphTxEnv::from_recovered_tx(&envelope, Address::with_last_byte(0x01)); + assert_eq!(env.version, Some(MORPH_TX_VERSION_2)); + assert_eq!(env.inner.tx_type, MORPH_TX_TYPE_ID); + assert_eq!(env.inner.authorization_list.len(), 1); + match &env.inner.authorization_list[0] { + Either::Right(recovered) => { + let (authorization, _authority) = recovered.clone().into_parts(); + assert_eq!(&authorization, signed_authorization.inner()); + } + other => panic!("expected a recovered authorization, got {other:?}"), + } + } } diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index e0a19ad1..6d7735a5 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -8,7 +8,10 @@ use alloy_rpc_types_eth::AccessList; use reth_rpc_convert::{SignTxRequestError, SignableTxRequest, TryIntoSimTx, TryIntoTxEnv}; use reth_rpc_eth_types::EthApiError; -use morph_primitives::{MorphTxEnvelope, TxMorph}; +use morph_primitives::{ + MorphTxEnvelope, TxMorph, + transaction::morph_transaction::{MORPH_TX_VERSION_0, MORPH_TX_VERSION_1, MORPH_TX_VERSION_2}, +}; use morph_revm::{MorphBlockEnv, MorphTxEnv}; use reth_evm::EvmEnv; @@ -117,6 +120,10 @@ impl TryIntoTxEnv for MorphTransactionReq inner.chain_id = Some(evm_env.cfg_env.chain_id); } let legacy_gas_price = inner.gas_price; + let has_authorizations = inner + .authorization_list + .as_ref() + .is_some_and(|list| !list.is_empty()); // Match geth's `ToMessage`, which keys MorphTx detection off the Morph // fields alone (`isMorphTxArgs`) and ignores `gasPrice`. The rule that a @@ -151,11 +158,29 @@ impl TryIntoTxEnv for MorphTransactionReq if let Some(gas_price) = legacy_gas_price { tx_env.inner.gas_priority_fee = Some(gas_price); } - tx_env.version = Some(morph_tx_version( + let version = morph_tx_version( explicit_version, reference.as_ref(), memo.as_ref(), - )); + has_authorizations, + ); + // Same static rules as `TxMorph::validate`, surfaced as parameter + // errors so simulations fail with a clear message rather than an + // EVM-level rejection. A V2 without authorizations needs none of + // them (it behaves like V1). + if version < MORPH_TX_VERSION_2 && has_authorizations { + return Err(EthApiError::InvalidParams(format!( + "MorphTx version {version} does not support an authorization list" + ))); + } + if version == MORPH_TX_VERSION_2 && has_authorizations && tx_env.inner.kind.is_create() + { + return Err(EthApiError::InvalidParams( + "MorphTx version 2 with an authorization list cannot create a contract" + .to_string(), + )); + } + tx_env.version = Some(version); } // Required by `MorphEthApi::caller_gas_allowance` (eth/call.rs) to @@ -192,6 +217,13 @@ fn morph_envelope_from_ethereum( /// - `feeTokenID > 0` (ERC20 gas payment) /// - `reference` is present /// - `memo` is present and non-empty +/// +/// An `authorizationList` on its own does not select a MorphTx: without any +/// Morph field the request stays a standard EIP-7702 (`0x04`) transaction. +/// Together with a Morph field (or an explicit `version: 2`) it selects +/// MorphTx V2. An explicit `version: 2` without a list builds a V2 with an +/// empty list (V1 semantics); an explicit `version: 0/1` with a list is +/// rejected by [`TxMorph::validate`]. fn try_build_morph_tx_from_request( req: &alloy_rpc_types_eth::TransactionRequest, fee_token_id: U64, @@ -213,13 +245,21 @@ fn try_build_morph_tx_from_request( let has_fee_token = fee_token_id_u16 > 0; let has_reference = is_nonzero_reference(reference.as_ref()); let has_memo = memo.as_ref().is_some_and(|m| !m.is_empty()); + // An empty list does not select V2 on its own (like an empty memo does not + // select V1); the list is kept as-is so `validate` rejects V0/V1 carriers. + let authorization_list = req.authorization_list.clone().unwrap_or_default(); if !has_explicit_version && !has_fee_token && !has_reference && !has_memo { // No Morph-specific fields → standard Ethereum tx return Ok(None); } - let version = morph_tx_version(explicit_version, reference.as_ref(), memo.as_ref()); + let version = morph_tx_version( + explicit_version, + reference.as_ref(), + memo.as_ref(), + !authorization_list.is_empty(), + ); // Now build the MorphTx let chain_id = req @@ -256,10 +296,12 @@ fn try_build_morph_tx_from_request( version, reference, memo, + authorization_list, }; - // Validate all MorphTx constraints: version-specific rules, gas fee ordering, - // and memo length. This catches invalid combinations early at the RPC layer. + // Validate all MorphTx constraints: version-specific rules (including the V2 + // authorization-list rules), gas fee ordering, and memo length. This catches + // invalid combinations early at the RPC layer. morph_tx.validate()?; Ok(Some(morph_tx)) @@ -271,27 +313,34 @@ fn explicit_morph_tx_version(version: Option) -> Result, &'stati }; match u8::try_from(version.to::()) { - Ok( - version @ (morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 - | morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1), - ) => Ok(Some(version)), + Ok(version @ (MORPH_TX_VERSION_0 | MORPH_TX_VERSION_1 | MORPH_TX_VERSION_2)) => { + Ok(Some(version)) + } _ => Err("unsupported MorphTx version"), } } +/// Infers the MorphTx version for a request without an explicit `version`. +/// +/// - an authorization list selects V2 +/// - a reference or memo selects V1 +/// - otherwise V0 (token-fee only) fn morph_tx_version( explicit_version: Option, reference: Option<&B256>, memo: Option<&Bytes>, + has_authorizations: bool, ) -> u8 { if let Some(version) = explicit_version { return version; } - if is_nonzero_reference(reference) || memo.is_some_and(|m| !m.is_empty()) { - morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 + if has_authorizations { + MORPH_TX_VERSION_2 + } else if is_nonzero_reference(reference) || memo.is_some_and(|m| !m.is_empty()) { + MORPH_TX_VERSION_1 } else { - morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 + MORPH_TX_VERSION_0 } } @@ -966,7 +1015,7 @@ mod tests { &req, U64::ZERO, U256::ZERO, - Some(U64::from(2)), + Some(U64::from(3)), None, None, ); @@ -974,6 +1023,418 @@ mod tests { assert_eq!(result.unwrap_err(), "unsupported MorphTx version"); } + // ========================================================================= + // MorphTx V2 (EIP-7702 authorization list) request handling + // ========================================================================= + + fn sample_signed_authorization() -> alloy_eips::eip7702::SignedAuthorization { + alloy_eips::eip7702::Authorization { + chain_id: U256::from(2818), + address: address!("0000000000000000000000000000000000000042"), + nonce: 7, + } + .into_signed(Signature::new(U256::from(1), U256::from(2), true)) + } + + fn create_v2_transaction_request() -> TransactionRequest { + TransactionRequest { + authorization_list: Some(vec![sample_signed_authorization()]), + ..create_morph_transaction_request() + } + } + + #[test] + fn try_build_morph_tx_with_authorization_list_selects_v2() { + let req = create_v2_transaction_request(); + let tx = try_build_morph_tx_from_request( + &req, + U64::from(1), + U256::from(1_000_000), + None, + None, + None, + ) + .unwrap() + .expect("fee token + authorization list builds a MorphTx"); + + assert_eq!(tx.version, MORPH_TX_VERSION_2); + assert_eq!(tx.fee_token_id, 1); + assert_eq!(tx.authorization_list, vec![sample_signed_authorization()]); + assert!(tx.validate().is_ok()); + } + + #[test] + fn try_build_morph_tx_authorization_list_without_morph_fields_is_standard_tx() { + // No Morph field → stays a standard (EIP-7702) transaction. + let req = create_v2_transaction_request(); + let result = try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, None, None); + assert!(result.unwrap().is_none()); + } + + #[test] + fn try_build_morph_tx_explicit_v2_with_memo_only_selects_v2() { + let req = create_v2_transaction_request(); + let tx = try_build_morph_tx_from_request( + &req, + U64::ZERO, + U256::ZERO, + None, + None, + Some(Bytes::from("memo")), + ) + .unwrap() + .expect("memo + authorization list builds a MorphTx"); + + assert_eq!(tx.version, MORPH_TX_VERSION_2); + assert_eq!(tx.fee_token_id, 0); + assert_eq!(tx.memo, Some(Bytes::from("memo"))); + } + + /// An explicit `version: 2` without authorizations is honoured: it builds a + /// V2 with an empty list (V1 semantics), whether the key is absent or `[]`. + #[test] + fn try_build_morph_tx_explicit_v2_without_authorization_list_builds_empty_v2() { + for authorization_list in [None, Some(vec![])] { + let req = TransactionRequest { + authorization_list, + ..create_morph_transaction_request() + }; + let tx = try_build_morph_tx_from_request( + &req, + U64::ZERO, + U256::ZERO, + Some(U64::from(2)), + None, + None, + ) + .unwrap() + .expect("explicit version 2 builds a MorphTx"); + assert_eq!(tx.version, MORPH_TX_VERSION_2); + assert!(tx.authorization_list.is_empty()); + assert!(tx.validate().is_ok()); + } + } + + /// Without authorizations a V2 may create a contract, like V1. + #[test] + fn try_build_morph_tx_explicit_v2_create_without_authorizations_is_allowed() { + let mut req = create_morph_transaction_request(); + req.to = None; + req.input = TransactionInput::new(Bytes::from_static(&[0x60, 0x80])); + + let tx = try_build_morph_tx_from_request( + &req, + U64::from(1), + U256::from(100), + Some(U64::from(2)), + None, + None, + ) + .unwrap() + .expect("explicit version 2 builds a MorphTx"); + assert_eq!(tx.version, MORPH_TX_VERSION_2); + assert!(tx.to.is_create()); + assert!(tx.validate().is_ok()); + } + + #[test] + fn try_build_morph_tx_explicit_v1_rejects_authorization_list() { + let req = create_v2_transaction_request(); + let result = try_build_morph_tx_from_request( + &req, + U64::ZERO, + U256::ZERO, + Some(U64::from(1)), + None, + None, + ); + assert_eq!( + result.unwrap_err(), + "version 1 MorphTx does not support authorization list" + ); + + let result = try_build_morph_tx_from_request( + &req, + U64::from(1), + U256::from(100), + Some(U64::from(0)), + None, + None, + ); + assert_eq!( + result.unwrap_err(), + "version 0 MorphTx does not support authorization list" + ); + } + + #[test] + fn try_build_morph_tx_empty_authorization_list_is_not_v2_trigger() { + let req = TransactionRequest { + authorization_list: Some(vec![]), + ..create_morph_transaction_request() + }; + let tx = + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None) + .unwrap() + .unwrap(); + assert_eq!(tx.version, MORPH_TX_VERSION_0); + assert!(tx.authorization_list.is_empty()); + } + + #[test] + fn try_build_morph_tx_v2_rejects_create() { + let mut req = create_v2_transaction_request(); + req.to = None; + req.input = TransactionInput::new(Bytes::from_static(&[0x60, 0x80])); + + let result = + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None); + assert_eq!( + result.unwrap_err(), + "version 2 MorphTx with an authorization list cannot create a contract" + ); + } + + /// `eth_estimateGas` / `eth_call` for a V2 request must execute with the + /// authorization list and size the L1 data fee with it. + #[test] + fn try_into_tx_env_v2_request_carries_authorization_list_and_sizes_l1_fee() { + let request = MorphTransactionRequest { + inner: create_v2_transaction_request(), + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(1000000)), + version: None, + reference: None, + memo: None, + }; + + let evm_env = create_evm_env(false); + let tx_env = request + .try_into_tx_env(&evm_env) + .expect("conversion should succeed"); + + assert_eq!(tx_env.inner.tx_type, morph_primitives::MORPH_TX_TYPE_ID); + assert_eq!(tx_env.version, Some(MORPH_TX_VERSION_2)); + assert_eq!( + tx_env.inner.authorization_list.len(), + 1, + "EVM env must carry the authorization list" + ); + + let encoded = tx_env.rlp_bytes.expect("rlp_bytes must be populated"); + let envelope = + MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).expect("RLP should decode"); + let MorphTxEnvelope::Morph(signed) = envelope else { + panic!("expected Morph envelope"); + }; + assert_eq!(signed.tx().version, MORPH_TX_VERSION_2); + assert_eq!( + signed.tx().authorization_list.len(), + 1, + "L1 fee sizing must include the authorization list" + ); + } + + #[test] + fn try_into_tx_env_explicit_v1_with_authorization_list_is_invalid_params() { + let request = MorphTransactionRequest { + inner: create_v2_transaction_request(), + fee_token_id: None, + fee_limit: None, + version: Some(U64::from(1)), + reference: None, + memo: None, + }; + + let err = request.try_into_tx_env(&create_evm_env(false)).unwrap_err(); + assert!( + err.to_string() + .contains("MorphTx version 1 does not support an authorization list"), + "unexpected error: {err}" + ); + } + + /// `eth_call` / `eth_estimateGas` with an explicit `version: 2` and no list + /// simulate a V2 with an empty list and size the L1 fee as such. + #[test] + fn try_into_tx_env_explicit_v2_without_authorization_list_builds_empty_v2_env() { + let request = MorphTransactionRequest { + inner: create_morph_transaction_request(), + fee_token_id: None, + fee_limit: None, + version: Some(U64::from(2)), + reference: None, + memo: None, + }; + + let tx_env = request + .try_into_tx_env(&create_evm_env(false)) + .expect("explicit version 2 without a list is valid"); + assert_eq!(tx_env.inner.tx_type, morph_primitives::MORPH_TX_TYPE_ID); + assert_eq!(tx_env.version, Some(MORPH_TX_VERSION_2)); + assert!(tx_env.inner.authorization_list.is_empty()); + + let encoded = tx_env.rlp_bytes.expect("rlp_bytes must be populated"); + let envelope = + MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).expect("RLP should decode"); + let MorphTxEnvelope::Morph(signed) = envelope else { + panic!("expected Morph envelope"); + }; + assert_eq!(signed.tx().version, MORPH_TX_VERSION_2); + assert!(signed.tx().authorization_list.is_empty()); + } + + /// Without authorizations a V2 simulation may create a contract, like V1. + #[test] + fn try_into_tx_env_explicit_v2_create_without_authorizations_is_ok() { + let mut inner = create_morph_transaction_request(); + inner.to = None; + inner.input = TransactionInput::new(Bytes::from_static(&[0x60, 0x80])); + let request = MorphTransactionRequest { + inner, + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(1000)), + version: Some(U64::from(2)), + reference: None, + memo: None, + }; + + let tx_env = request + .try_into_tx_env(&create_evm_env(false)) + .expect("V2 CREATE without authorizations is valid"); + assert_eq!(tx_env.version, Some(MORPH_TX_VERSION_2)); + assert!(tx_env.inner.kind.is_create()); + } + + #[test] + fn try_into_tx_env_v2_create_is_invalid_params() { + let mut inner = create_v2_transaction_request(); + inner.to = None; + inner.input = TransactionInput::new(Bytes::from_static(&[0x60, 0x80])); + let request = MorphTransactionRequest { + inner, + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(1000)), + version: None, + reference: None, + memo: None, + }; + + let err = request.try_into_tx_env(&create_evm_env(false)).unwrap_err(); + assert!( + err.to_string() + .contains("MorphTx version 2 with an authorization list cannot create a contract"), + "unexpected error: {err}" + ); + } + + #[test] + fn try_into_tx_env_authorization_list_without_morph_fields_stays_eip7702() { + let request = MorphTransactionRequest { + inner: create_v2_transaction_request(), + fee_token_id: None, + fee_limit: None, + version: None, + reference: None, + memo: None, + }; + + let tx_env = request + .try_into_tx_env(&create_evm_env(false)) + .expect("conversion should succeed"); + assert_eq!(tx_env.inner.tx_type, 4, "plain SetCode request"); + assert!(tx_env.version.is_none()); + assert_eq!( + tx_env.rlp_bytes.as_ref().and_then(|b| b.first().copied()), + Some(0x04) + ); + } + + #[test] + fn try_into_sim_tx_v2_from_json() { + let request: MorphTransactionRequest = serde_json::from_value(serde_json::json!({ + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "gas": "0x186a0", + "maxFeePerGas": "0x3b9aca00", + "maxPriorityFeePerGas": "0x5f5e100", + "value": "0x0", + "nonce": "0x1", + "chainId": "0xb02", + "feeTokenID": "0x1", + "feeLimit": "0xde0b6b3a7640000", + "authorizationList": [{ + "chainId": "0xb02", + "address": "0x2222222222222222222222222222222222222222", + "nonce": "0x1b", + "yParity": "0x1", + "r": "0x1", + "s": "0x2" + }] + })) + .expect("request should deserialize"); + + let envelope = request + .try_into_sim_tx() + .expect("V2 request should build a MorphTx"); + let MorphTxEnvelope::Morph(signed) = envelope else { + panic!("expected Morph variant"); + }; + assert_eq!(signed.tx().version, MORPH_TX_VERSION_2); + assert_eq!(signed.tx().fee_token_id, 1); + let list = &signed.tx().authorization_list; + assert_eq!(list.len(), 1); + assert_eq!(list[0].nonce, 0x1b); + } + + #[test] + fn from_consensus_tx_morph_tx_v2_outputs_authorization_list() { + use alloy_consensus::Signed; + + let morph_tx = TxMorph { + chain_id: 2818, + nonce: 5, + gas_limit: 50_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + fee_token_id: 3, + fee_limit: U256::from(100_000), + version: MORPH_TX_VERSION_2, + authorization_list: vec![sample_signed_authorization()], + ..Default::default() + }; + let tx = MorphTxEnvelope::Morph(Signed::new_unchecked( + morph_tx, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + )); + let tx_info = TransactionInfo { + hash: Some(B256::ZERO), + block_hash: Some(B256::random()), + block_number: Some(100), + block_timestamp: None, + index: Some(5), + base_fee: Some(1_000_000_000), + }; + + let rpc_tx = MorphRpcTransaction::from_consensus_tx(tx, Address::ZERO, tx_info).unwrap(); + let json = serde_json::to_string(&rpc_tx).unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(value["type"], "0x7f"); + assert_eq!(value["version"], "0x2"); + assert_eq!(json.matches("\"authorizationList\"").count(), 1); + let list = value["authorizationList"].as_array().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!( + list[0]["address"], + "0x0000000000000000000000000000000000000042" + ); + assert_eq!(list[0]["nonce"], "0x7"); + assert_eq!(list[0]["yParity"], "0x1"); + } + #[test] fn try_build_morph_tx_requires_chain_id() { let mut req = create_morph_transaction_request(); @@ -1134,6 +1595,10 @@ mod tests { assert_eq!(json.matches("\"feeLimit\"").count(), 1); assert_eq!(json.matches("\"reference\"").count(), 1); assert_eq!(json.matches("\"memo\"").count(), 1); + assert!( + !json.contains("\"authorizationList\""), + "V1 transactions never emit authorizationList" + ); } #[test] diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index e666c295..022092f2 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -7,7 +7,10 @@ use alloy_evm::Database; use alloy_primitives::{Address, U256}; use morph_chainspec::hardfork::MorphHardfork; -use morph_primitives::{MorphTxEnvelope, transaction::morph_transaction::MORPH_TX_VERSION_1}; +use morph_primitives::{ + MorphTxEnvelope, + transaction::morph_transaction::{MORPH_TX_VERSION_1, MORPH_TX_VERSION_2}, +}; use morph_revm::TokenFeeInfo; use crate::MorphTxError; @@ -67,6 +70,17 @@ pub fn validate_morph_tx( }); } + // V2 (EIP-7702 authorization list) is gated on Onyx. The list itself is + // validated by `TxMorph::validate` below (V0/V1 must not carry one, a + // non-empty V2 list forbids CREATE; an empty V2 list is allowed); authority + // tracking and delegated-sender limits come from the upstream validator, + // which reads the list through `Transaction::authorization_list`. + if !input.hardfork.is_onyx() && morph_tx.version == MORPH_TX_VERSION_2 { + return Err(MorphTxError::InvalidFormat { + reason: "MorphTx version 2 is not yet active (onyx fork not reached)".to_string(), + }); + } + if let Err(reason) = morph_tx.validate() { return Err(MorphTxError::InvalidFormat { reason: reason.to_string(), @@ -226,6 +240,7 @@ mod tests { fee_limit: U256::from(1u64), reference: Some(B256::ZERO), memo: None, + authorization_list: Vec::new(), input: Default::default(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -304,6 +319,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Default::default(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -342,6 +358,7 @@ mod tests { fee_limit: U256::ZERO, reference: None, memo: None, + authorization_list: Vec::new(), input: Default::default(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -386,6 +403,7 @@ mod tests { fee_limit: U256::ZERO, reference: None, memo: None, + authorization_list: Vec::new(), input: Default::default(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -424,6 +442,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Default::default(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( @@ -448,4 +467,103 @@ mod tests { "expected TokenNotFound {{ token_id: 42 }}, got {err:?}" ); } + + fn v2_eth_fee_envelope( + authorization_list: Vec, + ) -> MorphTxEnvelope { + let tx = TxMorph { + chain_id: 2818, + nonce: 0, + gas_limit: 100_000, + max_fee_per_gas: 1_000_000_000, + max_priority_fee_per_gas: 500_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_2, + fee_token_id: 0, + fee_limit: U256::ZERO, + reference: None, + memo: None, + authorization_list, + input: Default::default(), + }; + MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )) + } + + fn sample_authorization() -> alloy_eips::eip7702::SignedAuthorization { + alloy_eips::eip7702::Authorization { + chain_id: U256::from(2818), + address: address!("0000000000000000000000000000000000000042"), + nonce: 0, + } + .into_signed(Signature::test_signature()) + } + + #[test] + fn test_validate_morph_tx_v2_rejected_before_onyx() { + let envelope = v2_eth_fee_envelope(vec![sample_authorization()]); + let input = MorphTxValidationInput { + consensus_tx: &envelope, + sender: address!("1000000000000000000000000000000000000001"), + eth_balance: U256::from(10u128.pow(18)), + l1_data_fee: U256::from(1000u64), + hardfork: MorphHardfork::Jade, + }; + let mut db = EmptyDB::default(); + + let err = validate_morph_tx(&mut db, &input).unwrap_err(); + assert_eq!( + err, + MorphTxError::InvalidFormat { + reason: "MorphTx version 2 is not yet active (onyx fork not reached)".to_string(), + } + ); + } + + #[test] + fn test_validate_morph_tx_v2_eth_fee_path_accepted_after_onyx() { + let envelope = v2_eth_fee_envelope(vec![sample_authorization()]); + let input = MorphTxValidationInput { + consensus_tx: &envelope, + sender: address!("1000000000000000000000000000000000000001"), + eth_balance: U256::from(10u128.pow(18)), + l1_data_fee: U256::from(1000u64), + hardfork: MorphHardfork::Onyx, + }; + let mut db = EmptyDB::default(); + + let result = validate_morph_tx(&mut db, &input).unwrap(); + assert!(!result.uses_token_fee); + } + + /// A V2 without authorizations is admitted like a V1 (still Onyx-gated). + #[test] + fn test_validate_morph_tx_v2_empty_authorization_list_accepted() { + let envelope = v2_eth_fee_envelope(vec![]); + let mut input = MorphTxValidationInput { + consensus_tx: &envelope, + sender: address!("1000000000000000000000000000000000000001"), + eth_balance: U256::from(10u128.pow(18)), + l1_data_fee: U256::ZERO, + hardfork: MorphHardfork::Onyx, + }; + let mut db = EmptyDB::default(); + + let result = validate_morph_tx(&mut db, &input).unwrap(); + assert!(!result.uses_token_fee); + + input.hardfork = MorphHardfork::Jade; + let err = validate_morph_tx(&mut db, &input).unwrap_err(); + assert_eq!( + err, + MorphTxError::InvalidFormat { + reason: "MorphTx version 2 is not yet active (onyx fork not reached)".to_string(), + } + ); + } } diff --git a/crates/txpool/src/transaction.rs b/crates/txpool/src/transaction.rs index 21411db8..08690a73 100644 --- a/crates/txpool/src/transaction.rs +++ b/crates/txpool/src/transaction.rs @@ -273,6 +273,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Bytes::new(), }; let sig = Signature::test_signature(); diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index 1afea643..a6c1f955 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -935,6 +935,7 @@ mod tests { fee_limit: U256::from(300_000u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Default::default(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( From 8f9f37c3e28dae4f8bc9aa9559c7da5d2089ef88 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 17:31:59 +0800 Subject: [PATCH 02/17] chore(deps): bump rustls to 0.23.45 for RUSTSEC-2026-0285 cargo-deny now fails on RUSTSEC-2026-0285: rustls before 0.23.45 accepts TLS 1.3 handshake messages across encryption level boundaries. rustls 0.23.45 requires aws-lc-rs ^1.18 and rustls-webpki ^0.103.14, so those are bumped as well. --- Cargo.lock | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c943baa2..3097665d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1464,9 +1464,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -1475,14 +1475,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -2467,7 +2468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -10065,9 +10066,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -10151,9 +10152,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", From da3d04862fd14c7764b0bef06f2597aa4e9dd4f5 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 17:31:59 +0800 Subject: [PATCH 03/17] fix: address review feedback on MorphTx v2 - primitives: `decode_fields` (behind `rlp_decode_fields`) now requires the RLP list to be consumed exactly for every version, matching `Decodable::decode`, so surplus elements are rejected instead of left unread; add regression tests - node tests: pin the pre-Onyx v2 rejection to the "not yet active" error - statetest: document that the presence of `authorizationList` (even an empty one) selects v2, the same convention used to select 0x04 --- bin/morph-statetest/src/schema.rs | 7 +- crates/node/tests/it/morph_tx.rs | 10 ++- .../src/transaction/morph_transaction.rs | 89 +++++++++++++++++-- 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/bin/morph-statetest/src/schema.rs b/bin/morph-statetest/src/schema.rs index 1670ff83..38fe9b54 100644 --- a/bin/morph-statetest/src/schema.rs +++ b/bin/morph-statetest/src/schema.rs @@ -284,9 +284,10 @@ impl MorphTransactionParts { if let Some(version) = self.version { tx = tx.with_version(version); } else if tx.is_morph_tx() && self.authorization_list.is_some() { - // A MorphTx carrying an authorization list can only be V2; model it - // as such instead of leaving the version unset (which the handler - // treats as V0 and rejects). + // Only V2 may carry an `authorizationList` field, so its presence + // (even `[]`) selects V2 instead of leaving the version unset, which + // the handler would treat as V0. Presence rather than length is the + // same convention `tx_type` uses to select 0x04. tx = tx.with_version(MORPH_TX_VERSION_2); } if let Some(fee_token_id) = self.fee_token_id { diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs index 4bca3e5b..23910773 100644 --- a/crates/node/tests/it/morph_tx.rs +++ b/crates/node/tests/it/morph_tx.rs @@ -1273,10 +1273,14 @@ async fn morph_tx_v2_rejected_before_onyx() -> eyre::Result<()> { .with_authorization_list(vec![authorization]) .build_signed()?; - let result = node.rpc.inject_tx(raw_tx).await; + let err = node + .rpc + .inject_tx(raw_tx) + .await + .expect_err("MorphTx v2 should be rejected by pool before Onyx"); assert!( - result.is_err(), - "MorphTx v2 should be rejected by pool before Onyx" + err.to_string().contains("not yet active"), + "unexpected error: {err}" ); Ok(()) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index bf236ca6..510327e6 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -576,13 +576,7 @@ impl TxMorph { /// /// V0 format: ChainID, Nonce, GasTipCap, GasFeeCap, Gas, To, Value, Data, AccessList, FeeTokenID, FeeLimit fn decode_fields_v0(buf: &mut &[u8]) -> alloy_rlp::Result { - // Need to decode RLP header first - let header = Header::decode(buf)?; - if !header.list { - return Err(alloy_rlp::Error::UnexpectedString); - } - - Self::decode_fields_v0_inner(buf) + Self::decode_exact_list(buf, Self::decode_fields_v0_inner) } /// Decodes V1 format fields (for decode_fields, includes RLP header handling). @@ -604,13 +598,36 @@ impl TxMorph { /// Decodes V1/V2 format fields, including the RLP list header. fn decode_fields_versioned(buf: &mut &[u8], version: u8) -> alloy_rlp::Result { - // Need to decode RLP header first + Self::decode_exact_list(buf, |buf| Self::decode_fields_versioned_inner(buf, version)) + } + + /// Decodes an RLP list header followed by `decode_inner`, requiring the list + /// to be consumed exactly: surplus elements are rejected with + /// [`alloy_rlp::Error::ListLengthMismatch`], as in [`Decodable::decode`]. + fn decode_exact_list( + buf: &mut &[u8], + decode_inner: impl FnOnce(&mut &[u8]) -> alloy_rlp::Result, + ) -> alloy_rlp::Result { let header = Header::decode(buf)?; if !header.list { return Err(alloy_rlp::Error::UnexpectedString); } - Self::decode_fields_versioned_inner(buf, version) + let remaining = buf.len(); + if header.payload_length > remaining { + return Err(alloy_rlp::Error::InputTooShort); + } + + let tx = decode_inner(buf)?; + + if buf.len() + header.payload_length != remaining { + return Err(alloy_rlp::Error::ListLengthMismatch { + expected: header.payload_length, + got: remaining - buf.len(), + }); + } + + Ok(tx) } /// Decodes V1 format fields (inner, assumes RLP header already consumed). @@ -2880,6 +2897,11 @@ mod tests { matches!(err, alloy_rlp::Error::ListLengthMismatch { .. }), "unsigned V1 decode must reject trailing elements, got {err:?}" ); + let err = TxMorph::decode_fields(&mut unsigned.as_slice()).unwrap_err(); + assert!( + matches!(err, alloy_rlp::Error::ListLengthMismatch { .. }), + "V1 decode_fields must reject trailing elements, got {err:?}" + ); // Signed path: the V1 decoder reads the list header where yParity should be. let signature = Signature::new(U256::from(1u64), U256::from(2u64), false); @@ -2893,6 +2915,55 @@ mod tests { ); } + /// `decode_fields` (behind `rlp_decode_fields`) consumes the list exactly + /// for every version, like `Decodable::decode`: a surplus element is + /// rejected rather than left unread. + #[test] + fn test_decode_fields_rejects_surplus_list_elements() { + fn with_extra_element(mut list: &[u8]) -> Vec { + let header = Header::decode(&mut list).unwrap(); + assert!(header.list); + let mut payload = list[..header.payload_length].to_vec(); + payload.push(alloy_rlp::EMPTY_STRING_CODE); + let mut out = Vec::new(); + Header { + list: true, + payload_length: payload.len(), + } + .encode(&mut out); + out.extend_from_slice(&payload); + out + } + + let v2 = sample_v2_tx(1); + let v0 = TxMorph { + version: MORPH_TX_VERSION_0, + reference: None, + memo: None, + authorization_list: Vec::new(), + ..v2.clone() + }; + for tx in [v0, v2] { + let mut encoded = Vec::new(); + tx.encode(&mut encoded); + // V1+ carries the version byte in front of the list. + let prefix_len = usize::from(!tx.is_v0()); + let mut surplus = encoded[..prefix_len].to_vec(); + surplus.extend(with_extra_element(&encoded[prefix_len..])); + + for result in [ + TxMorph::decode_fields(&mut surplus.as_slice()), + TxMorph::decode(&mut surplus.as_slice()), + ] { + assert!( + matches!(result, Err(alloy_rlp::Error::ListLengthMismatch { .. })), + "version {}: surplus element must be rejected, got {result:?}", + tx.version + ); + } + } + } + /// A V2 with an empty list is valid and encodes as the V1 field list plus /// one empty RLP list (`0xc0`): same payload as V1, version byte `0x02`. #[test] From f72e4cd4d4a9ea8635c84431201dd4e736644884 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 18 Sep 2026 15:27:26 +0800 Subject: [PATCH 04/17] feat(rpc): derive the MorphTx version from the request content Drop the `version` selector from `MorphTransactionRequest` and derive the version from the content: V1 is the baseline (the request layer no longer produces V0) and a non-empty `authorizationList` selects V2. An absent, `null` or empty list are the same thing, and a legacy `version` key is ignored like any other unknown key. The rule lives in primitives as `TxMorph::inferred_version` / `with_inferred_version`, so library users derive the version the same way the RPC layer does instead of filling it in by hand. The CREATE rejection now reads "MorphTx with an authorization list cannot create a contract" on every layer. The e2e simulation tests select a MorphTx with a memo instead of the removed key and check that a legacy `version` key leaves the estimate unchanged. Claude-Session: https://claude.ai/code/session_01WYbNZVUBHa4qCoRK46taTS --- crates/consensus/src/validation.rs | 2 +- crates/node/tests/it/rpc.rs | 31 +- .../src/transaction/morph_transaction.rs | 57 +- crates/rpc/src/eth/transaction.rs | 532 +++++++----------- crates/rpc/src/types/request.rs | 38 +- 5 files changed, 289 insertions(+), 371 deletions(-) diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 30412e52..dc26c2f2 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -1984,7 +1984,7 @@ mod tests { .unwrap_err() .to_string(); assert!( - err.contains("version 2 MorphTx with an authorization list cannot create a contract"), + err.contains("MorphTx with an authorization list cannot create a contract"), "unexpected error: {err}" ); } diff --git a/crates/node/tests/it/rpc.rs b/crates/node/tests/it/rpc.rs index f2bae6cf..f1425fc3 100644 --- a/crates/node/tests/it/rpc.rs +++ b/crates/node/tests/it/rpc.rs @@ -573,7 +573,9 @@ async fn transaction_by_hash_exposes_authorization_list_for_morph_tx_v2() -> eyr /// `eth_estimateGas` for a MorphTx v2 request executes with the authorization /// list, so the estimate covers the 25 000 gas per authorization on top of the -/// plain-call cost. +/// plain-call cost. The version is never part of the request: a memo makes the +/// request a MorphTx (V1), the list raises it to V2, and a legacy `version` +/// key changes nothing. #[tokio::test(flavor = "multi_thread")] async fn estimate_gas_for_morph_tx_v2_includes_authorization_gas() -> eyre::Result<()> { reth_tracing::init_test_tracing(); @@ -604,15 +606,22 @@ async fn estimate_gas_for_morph_tx_v2_includes_authorization_gas() -> eyre::Resu "value": "0x0", "maxFeePerGas": "0x4a817c800", "maxPriorityFeePerGas": "0x4a817c800", + "memo": "0x6d", }); - let mut v1_request = base_request.clone(); - v1_request["version"] = serde_json::json!("0x1"); - let v1_estimate: alloy_primitives::U64 = - client.request("eth_estimateGas", (v1_request,)).await?; + let v1_request = base_request.clone(); + let v1_estimate: alloy_primitives::U64 = client + .request("eth_estimateGas", (v1_request.clone(),)) + .await?; + + // A legacy `version` key is ignored: same request, same estimate. + let mut legacy_request = v1_request; + legacy_request["version"] = serde_json::json!("0x1"); + let legacy_estimate: alloy_primitives::U64 = + client.request("eth_estimateGas", (legacy_request,)).await?; + assert_eq!(legacy_estimate, v1_estimate); let mut v2_request = base_request; - v2_request["version"] = serde_json::json!("0x2"); v2_request["authorizationList"] = serde_json::json!([serde_json::to_value(&authorization)?]); let v2_estimate: alloy_primitives::U64 = client .request("eth_estimateGas", (v2_request.clone(),)) @@ -633,8 +642,8 @@ async fn estimate_gas_for_morph_tx_v2_includes_authorization_gas() -> eyre::Resu .await?; assert_eq!(call_result.as_str(), Some("0x")); - // Explicit v2 without authorizations (`[]` or no key at all) is a valid v2 - // with an empty list: it costs exactly what the v1 estimate costs. + // Without authorizations (`[]` or no key at all) the request is a v1 again, + // so it costs exactly what the v1 estimate costs. let mut empty_v2_request = v2_request; empty_v2_request["authorizationList"] = serde_json::json!([]); let empty_v2_estimate: alloy_primitives::U64 = client @@ -642,7 +651,7 @@ async fn estimate_gas_for_morph_tx_v2_includes_authorization_gas() -> eyre::Resu .await?; assert_eq!( empty_v2_estimate, v1_estimate, - "v2 without authorizations must cost the same gas as v1" + "an empty authorization list must cost the same gas as v1" ); empty_v2_request .as_object_mut() @@ -693,7 +702,7 @@ async fn simulation_of_morph_tx_v2_is_not_fork_gated_before_onyx() -> eyre::Resu "value": "0x0", "maxFeePerGas": "0x4a817c800", "maxPriorityFeePerGas": "0x4a817c800", - "version": "0x2", + "memo": "0x6d", "authorizationList": [serde_json::to_value(&authorization)?], }); let estimate: alloy_primitives::U64 = client @@ -762,7 +771,7 @@ async fn eth_call_applies_self_delegation_for_morph_tx_v2() -> eyre::Result<()> "from": sender, "to": sender, "nonce": "0x1", - "version": "0x2", + "memo": "0x6d", "maxFeePerGas": "0x4a817c800", "maxPriorityFeePerGas": "0x4a817c800", "authorizationList": [serde_json::to_value(&authorization)?], diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 510327e6..674b9fbd 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -361,9 +361,7 @@ impl TxMorph { // authorizations the transaction cannot be a CREATE, the same // static rule as EIP-7702 SetCode transactions. if self.has_authorizations() && self.to.is_create() { - return Err( - "version 2 MorphTx with an authorization list cannot create a contract", - ); + return Err("MorphTx with an authorization list cannot create a contract"); } } _ => { @@ -373,6 +371,31 @@ impl TxMorph { Ok(()) } + /// The version a MorphTx built from user intent gets. + /// + /// V1 is the baseline: it is a superset of V0, so the request layer never + /// produces V0 anymore (V0 transactions that already exist stay valid). + /// A non-empty authorization list raises the version to V2; an empty list + /// is the same as no list. + /// + /// Callers that build a [`TxMorph`] by hand must derive `version` through + /// this or [`Self::with_inferred_version`] instead of filling it in: the + /// V0 / V1 encodings cannot carry a list, and [`Self::validate`] rejects a + /// V0 / V1 that does. + pub const fn inferred_version(has_authorizations: bool) -> u8 { + if has_authorizations { + MORPH_TX_VERSION_2 + } else { + MORPH_TX_VERSION_1 + } + } + + /// Sets `version` from the transaction content, see [`Self::inferred_version`]. + pub fn with_inferred_version(mut self) -> Self { + self.version = Self::inferred_version(self.has_authorizations()); + self + } + /// Returns true if this is a version 0 (legacy) MorphTx. pub const fn is_v0(&self) -> bool { self.version == MORPH_TX_VERSION_0 @@ -2661,7 +2684,7 @@ mod tests { }; assert_eq!( create.validate().unwrap_err(), - "version 2 MorphTx with an authorization list cannot create a contract" + "MorphTx with an authorization list cannot create a contract" ); let create_without_authorizations = TxMorph { authorization_list: Vec::new(), @@ -2710,6 +2733,32 @@ mod tests { assert!(v1_empty_list.validate().is_ok()); } + /// V1 is the baseline for anything built from user intent; only a + /// non-empty authorization list raises it to V2, and a hand-filled version + /// is overwritten. + #[test] + fn inferred_version_is_v1_unless_authorizations_are_present() { + assert_eq!(TxMorph::inferred_version(false), MORPH_TX_VERSION_1); + assert_eq!(TxMorph::inferred_version(true), MORPH_TX_VERSION_2); + + let with_list = TxMorph { + version: MORPH_TX_VERSION_0, + ..sample_v2_tx(1) + } + .with_inferred_version(); + assert_eq!(with_list.version, MORPH_TX_VERSION_2); + assert!(with_list.validate().is_ok()); + + let without_list = TxMorph { + version: MORPH_TX_VERSION_2, + authorization_list: Vec::new(), + ..sample_v2_tx(1) + } + .with_inferred_version(); + assert_eq!(without_list.version, MORPH_TX_VERSION_1); + assert!(without_list.validate().is_ok()); + } + #[test] fn test_morph_transaction_authorization_list_accessor_is_version_gated() { let v2 = sample_v2_tx(0); diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 6d7735a5..dfcf238d 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -3,22 +3,20 @@ use crate::MorphTransactionRequest; use alloy_consensus::{EthereumTxEnvelope, SignableTransaction, TxEip4844}; use alloy_network::TxSigner; -use alloy_primitives::{B256, Bytes, Signature, TxKind, U64, U256}; +use alloy_primitives::{B256, Signature, TxKind, U64, U256}; use alloy_rpc_types_eth::AccessList; use reth_rpc_convert::{SignTxRequestError, SignableTxRequest, TryIntoSimTx, TryIntoTxEnv}; use reth_rpc_eth_types::EthApiError; -use morph_primitives::{ - MorphTxEnvelope, TxMorph, - transaction::morph_transaction::{MORPH_TX_VERSION_0, MORPH_TX_VERSION_1, MORPH_TX_VERSION_2}, -}; +use morph_primitives::{MorphTxEnvelope, TxMorph}; use morph_revm::{MorphBlockEnv, MorphTxEnv}; use reth_evm::EvmEnv; /// Converts a [`MorphTransactionRequest`] into a simulated transaction envelope. /// /// Handles both standard Ethereum transactions and Morph-specific fee token transactions. -/// MorphTx version is selected from the Morph-specific fields. +/// The MorphTx version is derived from the content, see +/// [`try_build_morph_tx_from_request`]. impl TryIntoSimTx for MorphTransactionRequest { fn try_into_sim_tx(self) -> Result> { // Try to build a MorphTx; returns None if this should be a standard Ethereum tx @@ -26,7 +24,6 @@ impl TryIntoSimTx for MorphTransactionRequest { &self.inner, self.fee_token_id.unwrap_or_default(), self.fee_limit.unwrap_or_default(), - self.version, self.reference, self.memo.clone(), ); @@ -44,7 +41,6 @@ impl TryIntoSimTx for MorphTransactionRequest { inner, fee_token_id: self.fee_token_id, fee_limit: self.fee_limit, - version: self.version, reference: self.reference, memo: self.memo.clone(), }) @@ -60,7 +56,8 @@ impl TryIntoSimTx for MorphTransactionRequest { /// Builds and signs a transaction from an RPC request. /// /// Supports both standard Ethereum transactions and Morph fee token transactions. -/// MorphTx version is selected from the Morph-specific fields. +/// The MorphTx version is derived from the content, see +/// [`try_build_morph_tx_from_request`]. impl SignableTxRequest for MorphTransactionRequest { async fn try_build_and_sign( self, @@ -71,7 +68,6 @@ impl SignableTxRequest for MorphTransactionRequest { &self.inner, self.fee_token_id.unwrap_or_default(), self.fee_limit.unwrap_or_default(), - self.version, self.reference, self.memo, ); @@ -101,7 +97,8 @@ impl SignableTxRequest for MorphTransactionRequest { /// Converts a transaction request into a transaction environment for EVM execution. /// /// Also encodes the transaction for L1 fee calculation. -/// MorphTx version is selected from the Morph-specific fields. +/// The MorphTx version is derived from the content, see +/// [`try_build_morph_tx_from_request`]. impl TryIntoTxEnv for MorphTransactionRequest { type Err = EthApiError; @@ -111,8 +108,6 @@ impl TryIntoTxEnv for MorphTransactionReq ) -> Result { let fee_token_id = self.fee_token_id; let fee_limit = self.fee_limit; - let explicit_version = explicit_morph_tx_version(self.version) - .map_err(|err| EthApiError::InvalidParams(err.to_string()))?; let reference = normalize_reference(self.reference); let memo = self.memo; let mut inner = self.inner; @@ -132,8 +127,7 @@ impl TryIntoTxEnv for MorphTransactionReq // see `try_build_morph_tx_from_request`. Honouring it here would make // `eth_call` / `eth_estimateGas` silently price a token-fee request as an // ETH transaction. - let is_morph_tx = explicit_version.is_some() - || fee_token_id.is_some_and(|id| id.to::() > 0) + let is_morph_tx = fee_token_id.is_some_and(|id| id.to::() > 0) || is_nonzero_reference(reference.as_ref()) || memo.as_ref().is_some_and(|m| !m.is_empty()); @@ -150,7 +144,7 @@ impl TryIntoTxEnv for MorphTransactionReq }; tx_env.fee_limit = fee_limit; tx_env.reference = reference; - tx_env.memo = memo.clone(); + tx_env.memo = memo; tx_env.inner.tx_type = morph_primitives::MORPH_TX_TYPE_ID; // geth's `ToMessage` maps legacy `gasPrice` to both EIP-1559 caps. // Preserve that shape so fallback MorphTx encoding produces the @@ -158,29 +152,15 @@ impl TryIntoTxEnv for MorphTransactionReq if let Some(gas_price) = legacy_gas_price { tx_env.inner.gas_priority_fee = Some(gas_price); } - let version = morph_tx_version( - explicit_version, - reference.as_ref(), - memo.as_ref(), - has_authorizations, - ); - // Same static rules as `TxMorph::validate`, surfaced as parameter - // errors so simulations fail with a clear message rather than an - // EVM-level rejection. A V2 without authorizations needs none of - // them (it behaves like V1). - if version < MORPH_TX_VERSION_2 && has_authorizations { - return Err(EthApiError::InvalidParams(format!( - "MorphTx version {version} does not support an authorization list" - ))); - } - if version == MORPH_TX_VERSION_2 && has_authorizations && tx_env.inner.kind.is_create() - { + // Same static rule as `TxMorph::validate`, surfaced as a parameter + // error so simulations fail with a clear message rather than an + // EVM-level rejection. + if has_authorizations && tx_env.inner.kind.is_create() { return Err(EthApiError::InvalidParams( - "MorphTx version 2 with an authorization list cannot create a contract" - .to_string(), + "MorphTx with an authorization list cannot create a contract".to_string(), )); } - tx_env.version = Some(version); + tx_env.version = Some(TxMorph::inferred_version(has_authorizations)); } // Required by `MorphEthApi::caller_gas_allowance` (eth/call.rs) to @@ -212,24 +192,22 @@ fn morph_envelope_from_ethereum( /// `Ok(None)` if this should be a standard Ethereum transaction, /// or `Err(...)` if there's a validation error. /// -/// A MorphTx is constructed when any of these conditions are met: -/// - `version` is present +/// A MorphTx is constructed when any of these Morph fields is set: /// - `feeTokenID > 0` (ERC20 gas payment) -/// - `reference` is present -/// - `memo` is present and non-empty +/// - a non-zero `reference` +/// - a non-empty `memo` /// -/// An `authorizationList` on its own does not select a MorphTx: without any -/// Morph field the request stays a standard EIP-7702 (`0x04`) transaction. -/// Together with a Morph field (or an explicit `version: 2`) it selects -/// MorphTx V2. An explicit `version: 2` without a list builds a V2 with an -/// empty list (V1 semantics); an explicit `version: 0/1` with a list is -/// rejected by [`TxMorph::validate`]. +/// The version is not part of the request; it is derived from the content +/// ([`TxMorph::inferred_version`]): V1 is the baseline and a non-empty +/// `authorizationList` selects V2. An absent, `null` or empty list are the +/// same thing, and a legacy `version` key is ignored. A list on its own does +/// not select a MorphTx: without any Morph field the request stays a standard +/// EIP-7702 (`0x04`) transaction. fn try_build_morph_tx_from_request( req: &alloy_rpc_types_eth::TransactionRequest, fee_token_id: U64, fee_limit: U256, - version: Option, - reference: Option, + reference: Option, memo: Option, ) -> Result, &'static str> { let reference = normalize_reference(reference); @@ -238,28 +216,18 @@ fn try_build_morph_tx_from_request( } let fee_token_id_u16 = u16::try_from(fee_token_id.to::()).map_err(|_| "invalid token")?; - let explicit_version = explicit_morph_tx_version(version)?; // Check if this should be a MorphTx - let has_explicit_version = explicit_version.is_some(); let has_fee_token = fee_token_id_u16 > 0; let has_reference = is_nonzero_reference(reference.as_ref()); let has_memo = memo.as_ref().is_some_and(|m| !m.is_empty()); - // An empty list does not select V2 on its own (like an empty memo does not - // select V1); the list is kept as-is so `validate` rejects V0/V1 carriers. - let authorization_list = req.authorization_list.clone().unwrap_or_default(); - - if !has_explicit_version && !has_fee_token && !has_reference && !has_memo { + if !has_fee_token && !has_reference && !has_memo { // No Morph-specific fields → standard Ethereum tx return Ok(None); } - let version = morph_tx_version( - explicit_version, - reference.as_ref(), - memo.as_ref(), - !authorization_list.is_empty(), - ); + let authorization_list = req.authorization_list.clone().unwrap_or_default(); + let version = TxMorph::inferred_version(!authorization_list.is_empty()); // Now build the MorphTx let chain_id = req @@ -307,43 +275,6 @@ fn try_build_morph_tx_from_request( Ok(Some(morph_tx)) } -fn explicit_morph_tx_version(version: Option) -> Result, &'static str> { - let Some(version) = version else { - return Ok(None); - }; - - match u8::try_from(version.to::()) { - Ok(version @ (MORPH_TX_VERSION_0 | MORPH_TX_VERSION_1 | MORPH_TX_VERSION_2)) => { - Ok(Some(version)) - } - _ => Err("unsupported MorphTx version"), - } -} - -/// Infers the MorphTx version for a request without an explicit `version`. -/// -/// - an authorization list selects V2 -/// - a reference or memo selects V1 -/// - otherwise V0 (token-fee only) -fn morph_tx_version( - explicit_version: Option, - reference: Option<&B256>, - memo: Option<&Bytes>, - has_authorizations: bool, -) -> u8 { - if let Some(version) = explicit_version { - return version; - } - - if has_authorizations { - MORPH_TX_VERSION_2 - } else if is_nonzero_reference(reference) || memo.is_some_and(|m| !m.is_empty()) { - MORPH_TX_VERSION_1 - } else { - MORPH_TX_VERSION_0 - } -} - fn is_nonzero_reference(reference: Option<&B256>) -> bool { reference.is_some_and(|reference| *reference != B256::ZERO) } @@ -360,6 +291,9 @@ mod tests { use alloy_primitives::{Address, B256, Bytes, address}; use alloy_rpc_types_eth::{TransactionInfo, TransactionInput, TransactionRequest}; use morph_chainspec::MorphHardfork; + use morph_primitives::transaction::morph_transaction::{ + MORPH_TX_VERSION_1, MORPH_TX_VERSION_2, + }; use reth_rpc_convert::FromConsensusTx; use revm::context::{BlockEnv, CfgEnv}; @@ -421,7 +355,6 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, }; @@ -453,7 +386,6 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, }; @@ -486,7 +418,6 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, }; @@ -522,7 +453,6 @@ mod tests { inner: create_morph_transaction_request(), fee_token_id: Some(U64::from(1)), // Triggers MorphTx (use U64, not U256) fee_limit: Some(U256::from(1000000)), - version: None, reference: Some(reference), memo: Some(memo.clone()), }; @@ -574,13 +504,14 @@ mod tests { ); } + /// Token fee alone is a MorphTx and, like everything without an + /// authorization list, gets the V1 baseline (V0 is never produced). #[test] - fn test_fee_token_only_tx_env_uses_morph_tx_version_0() { + fn test_fee_token_only_tx_env_uses_morph_tx_version_1() { let request = MorphTransactionRequest { inner: create_morph_transaction_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), - version: None, reference: None, memo: None, }; @@ -590,64 +521,90 @@ mod tests { .try_into_tx_env(&evm_env) .expect("conversion should succeed"); - assert_eq!( - tx_env.version, - Some(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0) - ); + assert_eq!(tx_env.version, Some(MORPH_TX_VERSION_1)); } - #[test] - fn test_explicit_version_tx_env_triggers_morph_tx() { - let request = MorphTransactionRequest { - inner: create_morph_transaction_request(), - fee_token_id: None, - fee_limit: None, - version: Some(U64::from(1)), - reference: None, - memo: None, - }; + fn json_request(extra: serde_json::Value) -> MorphTransactionRequest { + let mut value = serde_json::json!({ + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "gas": "0x186a0", + "maxFeePerGas": "0x3b9aca00", + "maxPriorityFeePerGas": "0x5f5e100", + "value": "0x0", + "nonce": "0x1", + "chainId": "0xb02" + }); + value + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + serde_json::from_value(value).expect("request should deserialize") + } - let evm_env = create_evm_env(false); - let tx_env = request - .try_into_tx_env(&evm_env) - .expect("explicit version should trigger MorphTx tx env"); + /// A legacy `version` key is not a Morph field: on its own it does not + /// make the request a MorphTx, and it never overrides the derived version. + #[test] + fn test_legacy_version_key_does_not_trigger_or_select_morph_tx() { + let tx_env = json_request(serde_json::json!({ "version": "0x1" })) + .try_into_tx_env(&create_evm_env(false)) + .expect("a legacy version key alone is a standard request"); + assert_ne!(tx_env.inner.tx_type, morph_primitives::MORPH_TX_TYPE_ID); + assert!(tx_env.version.is_none()); + let tx_env = json_request(serde_json::json!({ "feeTokenID": "0x1", "version": "0x2" })) + .try_into_tx_env(&create_evm_env(false)) + .expect("token fee request"); assert_eq!(tx_env.inner.tx_type, morph_primitives::MORPH_TX_TYPE_ID); assert_eq!( tx_env.version, - Some(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1) + Some(MORPH_TX_VERSION_1), + "version key is ignored" ); } #[test] - fn try_into_sim_tx_explicit_version_triggers_morph_tx() { - let request: MorphTransactionRequest = serde_json::from_value(serde_json::json!({ - "from": "0x0000000000000000000000000000000000000001", - "to": "0x0000000000000000000000000000000000000002", - "gas": "0x186a0", - "maxFeePerGas": "0x3b9aca00", - "maxPriorityFeePerGas": "0x5f5e100", - "value": "0x0", - "nonce": "0x1", - "chainId": "0xb02", - "version": "0x1" - })) - .expect("request should deserialize"); + fn try_into_sim_tx_ignores_legacy_version_key() { + // Alone: standard EIP-1559, and an out-of-range value is not an error. + for version in ["0x1", "0x9"] { + let envelope = json_request(serde_json::json!({ "version": version })) + .try_into_sim_tx() + .expect("a legacy version key alone builds a standard transaction"); + assert!( + matches!(envelope, MorphTxEnvelope::Eip1559(_)), + "got {envelope:?}" + ); + } - let envelope = request + // With a Morph field: the version comes from the content only. + let envelope = json_request(serde_json::json!({ "feeTokenID": "0x1", "version": "0x2" })) .try_into_sim_tx() - .expect("explicit version should build a MorphTx"); + .expect("token fee request builds a MorphTx"); + let MorphTxEnvelope::Morph(signed) = envelope else { + panic!("expected Morph variant"); + }; + assert_eq!(signed.tx().version, MORPH_TX_VERSION_1); + assert!(signed.tx().authorization_list.is_empty()); - match envelope { - MorphTxEnvelope::Morph(signed) => { - assert_eq!( - signed.tx().version, - morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 - ); - assert_eq!(signed.tx().fee_token_id, 0); - } - other => panic!("expected Morph variant, got {other:?}"), - } + let envelope = json_request(serde_json::json!({ + "feeTokenID": "0x1", + "version": "0x1", + "authorizationList": [{ + "chainId": "0xb02", + "address": "0x2222222222222222222222222222222222222222", + "nonce": "0x1b", + "yParity": "0x1", + "r": "0x1", + "s": "0x2" + }] + })) + .try_into_sim_tx() + .expect("a list makes the request V2 whatever the legacy key says"); + let MorphTxEnvelope::Morph(signed) = envelope else { + panic!("expected Morph variant"); + }; + assert_eq!(signed.tx().version, MORPH_TX_VERSION_2); + assert_eq!(signed.tx().authorization_list.len(), 1); } /// Simulation paths keep the Morph fields even when the request carries a @@ -660,7 +617,6 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), - version: None, reference: None, memo: None, }; @@ -698,7 +654,6 @@ mod tests { inner, fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), - version: None, reference: None, memo: None, }; @@ -727,7 +682,6 @@ mod tests { inner: create_morph_transaction_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), - version: None, reference: Some(B256::random()), memo: Some(Bytes::from("test")), }; @@ -767,7 +721,6 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, }; @@ -894,7 +847,7 @@ mod tests { #[test] fn try_build_morph_tx_returns_none_for_standard_tx() { let req = create_basic_transaction_request(); - let result = try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, None, None); + let result = try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, None); assert!(result.is_ok()); assert!(result.unwrap().is_none()); } @@ -902,35 +855,20 @@ mod tests { #[test] fn try_build_morph_tx_with_fee_token_id() { let req = create_morph_transaction_request(); - let result = try_build_morph_tx_from_request( - &req, - U64::from(1), - U256::from(1_000_000), - None, - None, - None, - ); + let result = + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(1_000_000), None, None); assert!(result.is_ok()); let tx = result.unwrap().unwrap(); assert_eq!(tx.fee_token_id, 1); assert_eq!(tx.fee_limit, U256::from(1_000_000)); - assert_eq!( - tx.version, - morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 - ); + assert_eq!(tx.version, MORPH_TX_VERSION_1, "V1 is the baseline"); } #[test] fn try_build_morph_tx_treats_gas_price_with_morph_fields_as_standard_tx() { let req = create_basic_transaction_request(); - let result = try_build_morph_tx_from_request( - &req, - U64::from(1), - U256::from(1_000_000), - None, - None, - None, - ); + let result = + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(1_000_000), None, None); assert!(result.is_ok()); assert!(result.unwrap().is_none()); @@ -940,14 +878,8 @@ mod tests { fn try_build_morph_tx_with_reference_only() { let req = create_morph_transaction_request(); let reference = B256::random(); - let result = try_build_morph_tx_from_request( - &req, - U64::ZERO, - U256::ZERO, - None, - Some(reference), - None, - ); + let result = + try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, Some(reference), None); assert!(result.is_ok()); let tx = result.unwrap().unwrap(); assert_eq!(tx.reference, Some(reference)); @@ -957,21 +889,12 @@ mod tests { #[test] fn try_build_morph_tx_treats_zero_reference_as_absent() { let req = create_morph_transaction_request(); - let result = try_build_morph_tx_from_request( - &req, - U64::from(1), - U256::ZERO, - None, - Some(B256::ZERO), - None, - ); + let result = + try_build_morph_tx_from_request(&req, U64::from(1), U256::ZERO, Some(B256::ZERO), None); assert!(result.is_ok()); let tx = result.unwrap().unwrap(); - assert_eq!( - tx.version, - morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 - ); + assert_eq!(tx.version, MORPH_TX_VERSION_1); assert_eq!(tx.reference, None); } @@ -979,14 +902,8 @@ mod tests { fn try_build_morph_tx_with_memo_only() { let req = create_morph_transaction_request(); let memo = Bytes::from("hello world"); - let result = try_build_morph_tx_from_request( - &req, - U64::ZERO, - U256::ZERO, - None, - None, - Some(memo.clone()), - ); + let result = + try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, Some(memo.clone())); assert!(result.is_ok()); let tx = result.unwrap().unwrap(); assert_eq!(tx.memo, Some(memo)); @@ -995,34 +912,13 @@ mod tests { #[test] fn try_build_morph_tx_empty_memo_is_not_trigger() { let req = create_morph_transaction_request(); - let result = try_build_morph_tx_from_request( - &req, - U64::ZERO, - U256::ZERO, - None, - None, - Some(Bytes::new()), - ); + let result = + try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, Some(Bytes::new())); assert!(result.is_ok()); // Empty memo should NOT trigger MorphTx creation assert!(result.unwrap().is_none()); } - #[test] - fn try_build_morph_tx_rejects_unsupported_explicit_version() { - let req = create_morph_transaction_request(); - let result = try_build_morph_tx_from_request( - &req, - U64::ZERO, - U256::ZERO, - Some(U64::from(3)), - None, - None, - ); - - assert_eq!(result.unwrap_err(), "unsupported MorphTx version"); - } - // ========================================================================= // MorphTx V2 (EIP-7702 authorization list) request handling // ========================================================================= @@ -1046,16 +942,10 @@ mod tests { #[test] fn try_build_morph_tx_with_authorization_list_selects_v2() { let req = create_v2_transaction_request(); - let tx = try_build_morph_tx_from_request( - &req, - U64::from(1), - U256::from(1_000_000), - None, - None, - None, - ) - .unwrap() - .expect("fee token + authorization list builds a MorphTx"); + let tx = + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(1_000_000), None, None) + .unwrap() + .expect("fee token + authorization list builds a MorphTx"); assert_eq!(tx.version, MORPH_TX_VERSION_2); assert_eq!(tx.fee_token_id, 1); @@ -1067,19 +957,18 @@ mod tests { fn try_build_morph_tx_authorization_list_without_morph_fields_is_standard_tx() { // No Morph field → stays a standard (EIP-7702) transaction. let req = create_v2_transaction_request(); - let result = try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, None, None); + let result = try_build_morph_tx_from_request(&req, U64::ZERO, U256::ZERO, None, None); assert!(result.unwrap().is_none()); } #[test] - fn try_build_morph_tx_explicit_v2_with_memo_only_selects_v2() { + fn try_build_morph_tx_memo_with_authorization_list_selects_v2() { let req = create_v2_transaction_request(); let tx = try_build_morph_tx_from_request( &req, U64::ZERO, U256::ZERO, None, - None, Some(Bytes::from("memo")), ) .unwrap() @@ -1090,10 +979,10 @@ mod tests { assert_eq!(tx.memo, Some(Bytes::from("memo"))); } - /// An explicit `version: 2` without authorizations is honoured: it builds a - /// V2 with an empty list (V1 semantics), whether the key is absent or `[]`. + /// Without authorizations the request is a V1, whether the list key is + /// absent or `[]`: an empty-list V2 cannot come out of the request layer. #[test] - fn try_build_morph_tx_explicit_v2_without_authorization_list_builds_empty_v2() { + fn try_build_morph_tx_without_authorizations_is_v1_never_empty_v2() { for authorization_list in [None, Some(vec![])] { let req = TransactionRequest { authorization_list, @@ -1103,81 +992,42 @@ mod tests { &req, U64::ZERO, U256::ZERO, - Some(U64::from(2)), - None, None, + Some(Bytes::from("memo")), ) .unwrap() - .expect("explicit version 2 builds a MorphTx"); - assert_eq!(tx.version, MORPH_TX_VERSION_2); + .expect("memo builds a MorphTx"); + assert_eq!(tx.version, MORPH_TX_VERSION_1); assert!(tx.authorization_list.is_empty()); assert!(tx.validate().is_ok()); } } - /// Without authorizations a V2 may create a contract, like V1. + /// Without authorizations a MorphTx may create a contract. #[test] - fn try_build_morph_tx_explicit_v2_create_without_authorizations_is_allowed() { + fn try_build_morph_tx_create_without_authorizations_is_v1() { let mut req = create_morph_transaction_request(); req.to = None; req.input = TransactionInput::new(Bytes::from_static(&[0x60, 0x80])); - let tx = try_build_morph_tx_from_request( - &req, - U64::from(1), - U256::from(100), - Some(U64::from(2)), - None, - None, - ) - .unwrap() - .expect("explicit version 2 builds a MorphTx"); - assert_eq!(tx.version, MORPH_TX_VERSION_2); + let tx = try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None) + .unwrap() + .expect("token fee builds a MorphTx"); + assert_eq!(tx.version, MORPH_TX_VERSION_1); assert!(tx.to.is_create()); assert!(tx.validate().is_ok()); } - #[test] - fn try_build_morph_tx_explicit_v1_rejects_authorization_list() { - let req = create_v2_transaction_request(); - let result = try_build_morph_tx_from_request( - &req, - U64::ZERO, - U256::ZERO, - Some(U64::from(1)), - None, - None, - ); - assert_eq!( - result.unwrap_err(), - "version 1 MorphTx does not support authorization list" - ); - - let result = try_build_morph_tx_from_request( - &req, - U64::from(1), - U256::from(100), - Some(U64::from(0)), - None, - None, - ); - assert_eq!( - result.unwrap_err(), - "version 0 MorphTx does not support authorization list" - ); - } - #[test] fn try_build_morph_tx_empty_authorization_list_is_not_v2_trigger() { let req = TransactionRequest { authorization_list: Some(vec![]), ..create_morph_transaction_request() }; - let tx = - try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None) - .unwrap() - .unwrap(); - assert_eq!(tx.version, MORPH_TX_VERSION_0); + let tx = try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None) + .unwrap() + .unwrap(); + assert_eq!(tx.version, MORPH_TX_VERSION_1); assert!(tx.authorization_list.is_empty()); } @@ -1188,10 +1038,10 @@ mod tests { req.input = TransactionInput::new(Bytes::from_static(&[0x60, 0x80])); let result = - try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None); + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); assert_eq!( result.unwrap_err(), - "version 2 MorphTx with an authorization list cannot create a contract" + "MorphTx with an authorization list cannot create a contract" ); } @@ -1203,7 +1053,6 @@ mod tests { inner: create_v2_transaction_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000000)), - version: None, reference: None, memo: None, }; @@ -1235,58 +1084,63 @@ mod tests { ); } + /// An ETH-fee request with a memo and a list simulates as a V2 (vector 2). #[test] - fn try_into_tx_env_explicit_v1_with_authorization_list_is_invalid_params() { + fn try_into_tx_env_memo_with_authorization_list_is_v2_env() { let request = MorphTransactionRequest { inner: create_v2_transaction_request(), fee_token_id: None, fee_limit: None, - version: Some(U64::from(1)), - reference: None, - memo: None, - }; - - let err = request.try_into_tx_env(&create_evm_env(false)).unwrap_err(); - assert!( - err.to_string() - .contains("MorphTx version 1 does not support an authorization list"), - "unexpected error: {err}" - ); - } - - /// `eth_call` / `eth_estimateGas` with an explicit `version: 2` and no list - /// simulate a V2 with an empty list and size the L1 fee as such. - #[test] - fn try_into_tx_env_explicit_v2_without_authorization_list_builds_empty_v2_env() { - let request = MorphTransactionRequest { - inner: create_morph_transaction_request(), - fee_token_id: None, - fee_limit: None, - version: Some(U64::from(2)), reference: None, - memo: None, + memo: Some(Bytes::from("memo")), }; let tx_env = request .try_into_tx_env(&create_evm_env(false)) - .expect("explicit version 2 without a list is valid"); + .expect("memo + list is a valid V2 request"); assert_eq!(tx_env.inner.tx_type, morph_primitives::MORPH_TX_TYPE_ID); assert_eq!(tx_env.version, Some(MORPH_TX_VERSION_2)); - assert!(tx_env.inner.authorization_list.is_empty()); + assert_eq!(tx_env.fee_token_id, None); + assert_eq!(tx_env.inner.authorization_list.len(), 1); + } - let encoded = tx_env.rlp_bytes.expect("rlp_bytes must be populated"); - let envelope = - MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).expect("RLP should decode"); - let MorphTxEnvelope::Morph(signed) = envelope else { - panic!("expected Morph envelope"); - }; - assert_eq!(signed.tx().version, MORPH_TX_VERSION_2); - assert!(signed.tx().authorization_list.is_empty()); + /// `eth_call` / `eth_estimateGas` without authorizations (absent key or + /// `[]`) simulate a V1 and size the L1 fee as such: no empty-list V2. + #[test] + fn try_into_tx_env_empty_authorization_list_builds_v1_env() { + for authorization_list in [None, Some(vec![])] { + let request = MorphTransactionRequest { + inner: TransactionRequest { + authorization_list, + ..create_morph_transaction_request() + }, + fee_token_id: None, + fee_limit: None, + reference: None, + memo: Some(Bytes::from("memo")), + }; + + let tx_env = request + .try_into_tx_env(&create_evm_env(false)) + .expect("memo without a list is a valid V1 request"); + assert_eq!(tx_env.inner.tx_type, morph_primitives::MORPH_TX_TYPE_ID); + assert_eq!(tx_env.version, Some(MORPH_TX_VERSION_1)); + assert!(tx_env.inner.authorization_list.is_empty()); + + let encoded = tx_env.rlp_bytes.expect("rlp_bytes must be populated"); + let envelope = + MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).expect("RLP should decode"); + let MorphTxEnvelope::Morph(signed) = envelope else { + panic!("expected Morph envelope"); + }; + assert_eq!(signed.tx().version, MORPH_TX_VERSION_1); + assert!(signed.tx().authorization_list.is_empty()); + } } - /// Without authorizations a V2 simulation may create a contract, like V1. + /// Without authorizations a simulated MorphTx may create a contract. #[test] - fn try_into_tx_env_explicit_v2_create_without_authorizations_is_ok() { + fn try_into_tx_env_create_without_authorizations_is_v1() { let mut inner = create_morph_transaction_request(); inner.to = None; inner.input = TransactionInput::new(Bytes::from_static(&[0x60, 0x80])); @@ -1294,15 +1148,14 @@ mod tests { inner, fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000)), - version: Some(U64::from(2)), reference: None, memo: None, }; let tx_env = request .try_into_tx_env(&create_evm_env(false)) - .expect("V2 CREATE without authorizations is valid"); - assert_eq!(tx_env.version, Some(MORPH_TX_VERSION_2)); + .expect("CREATE without authorizations is valid"); + assert_eq!(tx_env.version, Some(MORPH_TX_VERSION_1)); assert!(tx_env.inner.kind.is_create()); } @@ -1315,7 +1168,6 @@ mod tests { inner, fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(1000)), - version: None, reference: None, memo: None, }; @@ -1323,7 +1175,7 @@ mod tests { let err = request.try_into_tx_env(&create_evm_env(false)).unwrap_err(); assert!( err.to_string() - .contains("MorphTx version 2 with an authorization list cannot create a contract"), + .contains("MorphTx with an authorization list cannot create a contract"), "unexpected error: {err}" ); } @@ -1334,7 +1186,6 @@ mod tests { inner: create_v2_transaction_request(), fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, }; @@ -1440,7 +1291,7 @@ mod tests { let mut req = create_morph_transaction_request(); req.chain_id = None; let result = - try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None); + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); assert!(result.is_err()); assert!(result.unwrap_err().contains("chain_id")); } @@ -1454,7 +1305,7 @@ mod tests { }; let result = - try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None); + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); assert_eq!(result.unwrap_err(), "data and input fields must match"); } @@ -1465,7 +1316,7 @@ mod tests { req.to = None; let result = - try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None, None); + try_build_morph_tx_from_request(&req, U64::from(1), U256::from(100), None, None); assert_eq!(result.unwrap_err(), "contract creation requires initcode"); } @@ -1477,7 +1328,6 @@ mod tests { &req, U64::from(2), U256::from(500_000), - None, Some(B256::random()), Some(Bytes::from("memo")), ); diff --git a/crates/rpc/src/types/request.rs b/crates/rpc/src/types/request.rs index 4b541a0b..99324664 100644 --- a/crates/rpc/src/types/request.rs +++ b/crates/rpc/src/types/request.rs @@ -9,11 +9,12 @@ use serde::{Deserialize, Serialize}; /// Extends standard Ethereum transaction request with: /// - `feeTokenID`: Token ID for ERC20 gas payment /// - `feeLimit`: Maximum token amount willing to pay for fees -/// - `version`: Explicit MorphTx version selector /// - `reference`: 32-byte reference key for transaction indexing /// - `memo`: Arbitrary memo data (up to 64 bytes) /// -/// When omitted, MorphTx version is inferred from Morph-specific fields. +/// The MorphTx version is not part of the request: it is derived from the +/// content (V1 unless a non-empty `authorizationList` makes it V2). A legacy +/// `version` key is ignored like any other unknown key. #[derive( Debug, Clone, @@ -45,10 +46,6 @@ pub struct MorphTransactionRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub fee_limit: Option, - /// Explicit MorphTx version selector (only for MorphTx type 0x7F). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, - /// Reference key for transaction indexing (32 bytes). /// Used for looking up transactions by external systems. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -76,14 +73,13 @@ impl AsMut for MorphTransactionRequest { /// Creates a [`MorphTransactionRequest`] from a standard [`TransactionRequest`]. /// -/// Sets `fee_token_id`, `fee_limit`, `version`, `reference`, and `memo` to `None`. +/// Sets `fee_token_id`, `fee_limit`, `reference`, and `memo` to `None`. impl From for MorphTransactionRequest { fn from(value: TransactionRequest) -> Self { Self { inner: value, fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, } @@ -120,7 +116,6 @@ mod tests { assert_eq!(morph_req.inner, inner); assert!(morph_req.fee_token_id.is_none()); assert!(morph_req.fee_limit.is_none()); - assert!(morph_req.version.is_none()); assert!(morph_req.reference.is_none()); assert!(morph_req.memo.is_none()); } @@ -131,7 +126,6 @@ mod tests { inner: basic_inner_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(500)), - version: Some(U64::from(1)), reference: Some(b256!( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" )), @@ -186,7 +180,6 @@ mod tests { inner: basic_inner_request(), fee_token_id: Some(U64::from(5)), fee_limit: Some(U256::from(999)), - version: Some(U64::from(1)), reference: Some(b256!( "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" )), @@ -203,13 +196,11 @@ mod tests { inner: basic_inner_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(100)), - version: Some(U64::from(1)), ..Default::default() }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("\"feeTokenID\"")); assert!(json.contains("\"feeLimit\"")); - assert!(json.contains("\"version\"")); } #[test] @@ -226,13 +217,32 @@ mod tests { assert!(!json.contains("memo")); } + /// A `version` key from older clients is not an error; the version is + /// derived from the content instead. + #[test] + fn serde_ignores_legacy_version_key() { + let with_key: MorphTransactionRequest = serde_json::from_value(serde_json::json!({ + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "feeTokenID": "0x1", + "version": "0x2" + })) + .expect("a legacy version key must not break deserialization"); + let without_key: MorphTransactionRequest = serde_json::from_value(serde_json::json!({ + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "feeTokenID": "0x1" + })) + .unwrap(); + assert_eq!(with_key, without_key); + } + #[test] fn default_creates_empty_request() { let req = MorphTransactionRequest::default(); assert_eq!(req.inner, TransactionRequest::default()); assert!(req.fee_token_id.is_none()); assert!(req.fee_limit.is_none()); - assert!(req.version.is_none()); assert!(req.reference.is_none()); assert!(req.memo.is_none()); } From 9a86ddebcfd164c0cd7e957038cd951e6c16997b Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 18 Sep 2026 16:34:30 +0800 Subject: [PATCH 05/17] refactor: rename the Onyx hardfork to Celadon The fork that activates MorphTx v2 was carried under the placeholder name Onyx. It is now named Celadon, so rename it everywhere on this branch: - `MorphHardfork::Onyx` -> `MorphHardfork::Celadon`, together with `is_onyx` / `is_onyx_active_at_timestamp` and the test schedule `HardforkSchedule::PreOnyx` - genesis key `onyxTime` -> `celadonTime` (no alias is kept: the old key never shipped in a bundled chainspec, and an unknown key is ignored, so a private devnet genesis has to switch to the new key) - the pre-fork rejection now reads `MorphTx version 2 is not yet active (celadon fork not reached)` - the statetest fork name `Onyx` -> `Celadon` (`osaka` still maps to it) - test names and comments No behaviour change besides those names. Claude-Session: https://claude.ai/code/session_01WYbNZVUBHa4qCoRK46taTS --- bin/morph-statetest/src/schema.rs | 14 ++--- crates/chainspec/src/genesis.rs | 14 ++--- crates/chainspec/src/hardfork.rs | 56 +++++++++---------- crates/chainspec/src/spec.rs | 24 ++++---- crates/consensus/src/validation.rs | 36 ++++++------ crates/node/src/test_utils.rs | 26 ++++----- crates/node/tests/assets/test-genesis.json | 2 +- crates/node/tests/it/hardfork.rs | 8 +-- crates/node/tests/it/morph_tx.rs | 14 ++--- crates/node/tests/it/rpc.rs | 10 ++-- .../src/transaction/morph_transaction.rs | 2 +- crates/revm/src/error.rs | 2 +- crates/revm/src/handler.rs | 34 +++++------ crates/revm/src/precompiles.rs | 2 +- crates/txpool/src/morph_tx_validation.rs | 22 ++++---- 15 files changed, 134 insertions(+), 132 deletions(-) diff --git a/bin/morph-statetest/src/schema.rs b/bin/morph-statetest/src/schema.rs index 38fe9b54..ee9661a5 100644 --- a/bin/morph-statetest/src/schema.rs +++ b/bin/morph-statetest/src/schema.rs @@ -368,7 +368,7 @@ pub fn parse_fork(name: &str) -> Result { "jade" => Ok(MorphHardfork::Jade), // OSAKA is the spec level of the latest Morph fork, so the generic // Ethereum name maps to it (matches `MorphHardfork::from(SpecId::OSAKA)`). - "onyx" | "osaka" => Ok(MorphHardfork::Onyx), + "celadon" | "osaka" => Ok(MorphHardfork::Celadon), "cancun" => Ok(MorphHardfork::Morph203), _ => Err(SchemaError::UnknownFork(name.to_string())), } @@ -535,7 +535,7 @@ mod tests { "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" }, "post": { - "Onyx": [{ + "Celadon": [{ "indexes": { "data": 0, "gas": 0, "value": 0 }, "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", "logs": "0x0000000000000000000000000000000000000000000000000000000000000000", @@ -548,9 +548,9 @@ mod tests { .expect("suite should parse"); let unit = suite.0.values().next().unwrap(); - let post = &unit.post["Onyx"][0]; + let post = &unit.post["Celadon"][0]; let tx = unit - .morph_tx_env(post, MorphHardfork::Onyx) + .morph_tx_env(post, MorphHardfork::Celadon) .expect("tx env should build"); assert!(tx.is_morph_tx()); @@ -571,9 +571,9 @@ mod tests { } #[test] - fn parse_fork_maps_onyx_and_osaka() { - assert_eq!(parse_fork("Onyx").unwrap(), MorphHardfork::Onyx); - assert_eq!(parse_fork("osaka").unwrap(), MorphHardfork::Onyx); + fn parse_fork_maps_celadon_and_osaka() { + assert_eq!(parse_fork("Celadon").unwrap(), MorphHardfork::Celadon); + assert_eq!(parse_fork("osaka").unwrap(), MorphHardfork::Celadon); assert_eq!(parse_fork("jade").unwrap(), MorphHardfork::Jade); } diff --git a/crates/chainspec/src/genesis.rs b/crates/chainspec/src/genesis.rs index c37448f3..4ebc8f81 100644 --- a/crates/chainspec/src/genesis.rs +++ b/crates/chainspec/src/genesis.rs @@ -40,7 +40,7 @@ impl TryFrom<&OtherFields> for MorphGenesisInfo { /// the Morph hardforks were activated. /// /// Note: Bernoulli and Curie use block-based activation, while Morph203, Viridian, -/// Emerald, Jade, and Onyx use timestamp-based activation (matching go-ethereum behavior). +/// Emerald, Jade, and Celadon use timestamp-based activation (matching go-ethereum behavior). #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MorphHardforkInfo { @@ -62,9 +62,9 @@ pub struct MorphHardforkInfo { /// Jade hardfork timestamp. #[serde(skip_serializing_if = "Option::is_none")] pub jade_fork_time: Option, - /// Onyx hardfork timestamp. + /// Celadon hardfork timestamp. #[serde(skip_serializing_if = "Option::is_none")] - pub onyx_time: Option, + pub celadon_time: Option, } impl MorphHardforkInfo { @@ -140,7 +140,7 @@ mod tests { "viridianTime": 4000, "emeraldTime": 5000, "jadeForkTime": 6000, - "onyxTime": 7000 + "celadonTime": 7000 } "#; @@ -156,13 +156,13 @@ mod tests { viridian_time: Some(4000), emerald_time: Some(5000), jade_fork_time: Some(6000), - onyx_time: Some(7000), + celadon_time: Some(7000), } ); } #[test] - fn test_extract_morph_hardfork_info_without_onyx() { + fn test_extract_morph_hardfork_info_without_celadon() { // Genesis files scheduled through Jade (current mainnet/hoodi) must keep parsing. let genesis_info = r#" { @@ -179,7 +179,7 @@ mod tests { let hardfork_info = MorphHardforkInfo::extract_from(&others).unwrap(); assert_eq!(hardfork_info.jade_fork_time, Some(6000)); - assert_eq!(hardfork_info.onyx_time, None); + assert_eq!(hardfork_info.celadon_time, None); } #[test] diff --git a/crates/chainspec/src/hardfork.rs b/crates/chainspec/src/hardfork.rs index b5ddf384..5c3234e0 100644 --- a/crates/chainspec/src/hardfork.rs +++ b/crates/chainspec/src/hardfork.rs @@ -29,7 +29,7 @@ //! ## Current State //! //! Bernoulli and Curie use block-based activation, while Morph203, Viridian, -//! Emerald, Jade, and Onyx use timestamp-based activation. +//! Emerald, Jade, and Celadon use timestamp-based activation. use alloy_evm::revm::primitives::hardfork::SpecId; use alloy_hardforks::hardfork; @@ -39,7 +39,7 @@ hardfork!( /// Morph-specific hardforks for network upgrades. /// /// Note: Bernoulli and Curie use block-based activation, while Morph203, Viridian, - /// Emerald, Jade, and Onyx use timestamp-based activation (matching go-ethereum behavior). + /// Emerald, Jade, and Celadon use timestamp-based activation (matching go-ethereum behavior). #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Default)] MorphHardfork { @@ -56,10 +56,10 @@ hardfork!( /// Jade hardfork (timestamp-based). #[default] Jade, - /// Onyx hardfork (timestamp-based). + /// Celadon hardfork (timestamp-based). /// /// Activates MorphTx version 2, which carries an EIP-7702 authorization list. - Onyx, + Celadon, } ); @@ -100,10 +100,10 @@ impl MorphHardfork { self >= Self::Jade } - /// Returns `true` if this hardfork is Onyx or later. + /// Returns `true` if this hardfork is Celadon or later. #[inline] - pub fn is_onyx(self) -> bool { - self >= Self::Onyx + pub fn is_celadon(self) -> bool { + self >= Self::Celadon } } @@ -151,19 +151,19 @@ pub trait MorphHardforks: EthereumHardforks { .active_at_timestamp(timestamp) } - /// Convenience method to check if Onyx hardfork is active at a given timestamp. - fn is_onyx_active_at_timestamp(&self, timestamp: u64) -> bool { - self.morph_fork_activation(MorphHardfork::Onyx) + /// Convenience method to check if Celadon hardfork is active at a given timestamp. + fn is_celadon_active_at_timestamp(&self, timestamp: u64) -> bool { + self.morph_fork_activation(MorphHardfork::Celadon) .active_at_timestamp(timestamp) } /// Retrieves the latest Morph hardfork active at a given block and timestamp. /// /// Note: This method checks both block-based (Bernoulli, Curie) and - /// timestamp-based (Morph203, Viridian, Emerald, Jade, Onyx) hardforks. + /// timestamp-based (Morph203, Viridian, Emerald, Jade, Celadon) hardforks. fn morph_hardfork_at(&self, block_number: u64, timestamp: u64) -> MorphHardfork { - if self.is_onyx_active_at_timestamp(timestamp) { - MorphHardfork::Onyx + if self.is_celadon_active_at_timestamp(timestamp) { + MorphHardfork::Celadon } else if self.is_jade_active_at_timestamp(timestamp) { MorphHardfork::Jade } else if self.is_emerald_active_at_timestamp(timestamp) { @@ -187,14 +187,14 @@ impl From for SpecId { /// The mapping must match go-ethereum Morph's EVM instruction sets: /// - Bernoulli/Curie/Morph203 = CANCUN gas tables (MCOPY, TSTORE/TLOAD, transient storage) /// - Viridian = PRAGUE (adds EIP-7702 delegation designator) - /// - Emerald/Jade/Onyx = OSAKA (adds EIP-7939 CLZ opcode) + /// - Emerald/Jade/Celadon = OSAKA (adds EIP-7939 CLZ opcode) fn from(value: MorphHardfork) -> Self { match value { MorphHardfork::Bernoulli | MorphHardfork::Curie | MorphHardfork::Morph203 => { Self::CANCUN } MorphHardfork::Viridian => Self::PRAGUE, - MorphHardfork::Emerald | MorphHardfork::Jade | MorphHardfork::Onyx => Self::OSAKA, + MorphHardfork::Emerald | MorphHardfork::Jade | MorphHardfork::Celadon => Self::OSAKA, } } } @@ -207,7 +207,7 @@ impl From for MorphHardfork { /// latest hardfork for the given spec level. fn from(spec: SpecId) -> Self { if spec.is_enabled_in(SpecId::OSAKA) { - Self::Onyx + Self::Celadon } else if spec.is_enabled_in(SpecId::PRAGUE) { Self::Viridian } else { @@ -234,7 +234,7 @@ mod tests { assert_eq!(SpecId::from(MorphHardfork::Viridian), SpecId::PRAGUE); assert_eq!(SpecId::from(MorphHardfork::Emerald), SpecId::OSAKA); assert_eq!(SpecId::from(MorphHardfork::Jade), SpecId::OSAKA); - assert_eq!(SpecId::from(MorphHardfork::Onyx), SpecId::OSAKA); + assert_eq!(SpecId::from(MorphHardfork::Celadon), SpecId::OSAKA); } #[test] @@ -246,7 +246,7 @@ mod tests { MorphHardfork::Viridian, MorphHardfork::Emerald, MorphHardfork::Jade, - MorphHardfork::Onyx, + MorphHardfork::Celadon, ]; for fork in forks { @@ -309,15 +309,15 @@ mod tests { fn test_specid_to_morph_hardfork_mapping() { assert_eq!(MorphHardfork::from(SpecId::CANCUN), MorphHardfork::Morph203); assert_eq!(MorphHardfork::from(SpecId::PRAGUE), MorphHardfork::Viridian); - assert_eq!(MorphHardfork::from(SpecId::OSAKA), MorphHardfork::Onyx); + assert_eq!(MorphHardfork::from(SpecId::OSAKA), MorphHardfork::Celadon); } #[test] - fn test_is_onyx() { - assert!(MorphHardfork::Onyx.is_onyx()); - assert!(MorphHardfork::Onyx.is_jade()); - assert!(!MorphHardfork::Jade.is_onyx()); - assert!(!MorphHardfork::Emerald.is_onyx()); + fn test_is_celadon() { + assert!(MorphHardfork::Celadon.is_celadon()); + assert!(MorphHardfork::Celadon.is_jade()); + assert!(!MorphHardfork::Jade.is_celadon()); + assert!(!MorphHardfork::Emerald.is_celadon()); } /// SpecIds below CANCUN should map to Morph203 (the latest CANCUN-level hardfork). @@ -341,12 +341,12 @@ mod tests { let spec = SpecId::from(MorphHardfork::Bernoulli); assert_eq!(MorphHardfork::from(spec), MorphHardfork::Morph203); - // Emerald -> OSAKA -> Onyx (latest OSAKA hardfork) + // Emerald -> OSAKA -> Celadon (latest OSAKA hardfork) let spec = SpecId::from(MorphHardfork::Emerald); - assert_eq!(MorphHardfork::from(spec), MorphHardfork::Onyx); + assert_eq!(MorphHardfork::from(spec), MorphHardfork::Celadon); - // Jade -> OSAKA -> Onyx (latest OSAKA hardfork) + // Jade -> OSAKA -> Celadon (latest OSAKA hardfork) let spec = SpecId::from(MorphHardfork::Jade); - assert_eq!(MorphHardfork::from(spec), MorphHardfork::Onyx); + assert_eq!(MorphHardfork::from(spec), MorphHardfork::Celadon); } } diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 1776323d..0db76c4b 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -105,13 +105,13 @@ fn build_hardforks(genesis: &Genesis, chain_info: &MorphGenesisInfo) -> ChainHar .into_iter() .filter_map(|(fork, block)| block.map(|b| (fork, ForkCondition::Block(b)))); - // Morph timestamp-based hardforks (Morph203, Viridian, Emerald, Jade, Onyx) + // Morph timestamp-based hardforks (Morph203, Viridian, Emerald, Jade, Celadon) let time_forks = vec![ (MorphHardfork::Morph203, hardfork_info.morph203_time), (MorphHardfork::Viridian, hardfork_info.viridian_time), (MorphHardfork::Emerald, hardfork_info.emerald_time), (MorphHardfork::Jade, hardfork_info.jade_fork_time), - (MorphHardfork::Onyx, hardfork_info.onyx_time), + (MorphHardfork::Celadon, hardfork_info.celadon_time), ] .into_iter() .filter_map(|(fork, time)| time.map(|t| (fork, ForkCondition::Timestamp(t)))); @@ -646,7 +646,7 @@ mod tests { } #[test] - fn test_onyx_activation_from_genesis() { + fn test_celadon_activation_from_genesis() { let genesis_json = json!({ "config": { "chainId": 1337, @@ -671,7 +671,7 @@ mod tests { "viridianTime": 0, "emeraldTime": 0, "jadeForkTime": 6000, - "onyxTime": 7000, + "celadonTime": 7000, "morph": {} }, "alloc": {} @@ -682,24 +682,24 @@ mod tests { let chainspec = MorphChainSpec::from(genesis); assert_eq!( - chainspec.fork(MorphHardfork::Onyx), + chainspec.fork(MorphHardfork::Celadon), ForkCondition::Timestamp(7000) ); - assert!(!chainspec.is_onyx_active_at_timestamp(6999)); - assert!(chainspec.is_onyx_active_at_timestamp(7000)); + assert!(!chainspec.is_celadon_active_at_timestamp(6999)); + assert!(chainspec.is_celadon_active_at_timestamp(7000)); - // Onyx must be reported as the latest fork once active, and must not + // Celadon must be reported as the latest fork once active, and must not // shadow Jade before its own activation. assert_eq!(chainspec.morph_hardfork_at(1, 6000), MorphHardfork::Jade); - assert_eq!(chainspec.morph_hardfork_at(1, 7000), MorphHardfork::Onyx); + assert_eq!(chainspec.morph_hardfork_at(1, 7000), MorphHardfork::Celadon); } #[test] - fn test_onyx_absent_from_genesis_never_activates() { + fn test_celadon_absent_from_genesis_never_activates() { // The bundled mainnet/hoodi chainspecs are scheduled through Jade only. let chainspec = MorphChainSpec::from(create_test_genesis()); - assert!(!chainspec.is_onyx_active_at_timestamp(0)); - assert!(!chainspec.is_onyx_active_at_timestamp(u64::MAX)); + assert!(!chainspec.is_celadon_active_at_timestamp(0)); + assert!(!chainspec.is_celadon_active_at_timestamp(u64::MAX)); } #[test] diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index dc26c2f2..4033b47d 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -320,10 +320,10 @@ impl Consensus for MorphConsensus { let is_jade = self .chain_spec .is_jade_active_at_timestamp(block.header().timestamp()); - let is_onyx = self + let is_celadon = self .chain_spec - .is_onyx_active_at_timestamp(block.header().timestamp()); - validate_morph_txs(&block.body().transactions, is_emerald, is_jade, is_onyx)?; + .is_celadon_active_at_timestamp(block.header().timestamp()); + validate_morph_txs(&block.body().transactions, is_emerald, is_jade, is_celadon)?; // Validate L1 messages ordering and internal consistency with header. // This is the body-level half of L1 validation; it verifies that the L1 @@ -647,7 +647,7 @@ fn validate_l1_messages_in_block( /// Performs three checks per MorphTx: /// 1. **Type hardfork gate**: rejects MorphTx before the Emerald fork is active /// 2. **Version hardfork gate**: rejects V1 transactions before the Jade fork is -/// active and V2 transactions before the Onyx fork is active +/// active and V2 transactions before the Celadon fork is active /// 3. **Field validation**: delegates to [`TxMorph::validate()`] for version-specific /// field constraints (including the V2 authorization-list rules), memo length, /// and gas price ordering @@ -657,7 +657,7 @@ fn validate_morph_txs( txs: &[MorphTxEnvelope], is_emerald: bool, is_jade: bool, - is_onyx: bool, + is_celadon: bool, ) -> Result<(), ConsensusError> { for tx in txs { let morph_tx = match tx { @@ -679,10 +679,10 @@ fn validate_morph_txs( ))); } - // Reject MorphTx V2 (EIP-7702 authorization list) before Onyx fork. - if !is_onyx && morph_tx.version == MORPH_TX_VERSION_2 { + // Reject MorphTx V2 (EIP-7702 authorization list) before Celadon fork. + if !is_celadon && morph_tx.version == MORPH_TX_VERSION_2 { return Err(ConsensusError::other(MorphConsensusError::InvalidBody( - "MorphTx version 2 is not yet active (onyx fork not reached)".into(), + "MorphTx version 2 is not yet active (celadon fork not reached)".into(), ))); } @@ -1939,24 +1939,24 @@ mod tests { } #[test] - fn test_validate_morph_tx_v2_before_onyx_rejected() { + fn test_validate_morph_tx_v2_before_celadon_rejected() { let txs = [create_morph_tx_v2()]; let result = validate_morph_txs(&txs, true, true, false); assert!( result .unwrap_err() .to_string() - .contains("onyx fork not reached") + .contains("celadon fork not reached") ); } #[test] - fn test_validate_morph_tx_v2_after_onyx_valid() { + fn test_validate_morph_tx_v2_after_celadon_valid() { let txs = [create_morph_tx_v2()]; assert!(validate_morph_txs(&txs, true, true, true).is_ok()); } - /// A V2 with an empty list is valid after Onyx (and still Onyx-gated). + /// A V2 with an empty list is valid after Celadon (and still Celadon-gated). #[test] fn test_validate_morph_tx_v2_empty_authorization_list_accepted() { let txs = [create_morph_tx_v2_with( @@ -1969,7 +1969,7 @@ mod tests { validate_morph_txs(&txs, true, true, false) .unwrap_err() .to_string() - .contains("onyx fork not reached") + .contains("celadon fork not reached") ); } @@ -2017,7 +2017,7 @@ mod tests { } #[test] - fn test_validate_block_pre_execution_rejects_v2_without_onyx() { + fn test_validate_block_pre_execution_rejects_v2_without_celadon() { // `create_test_chainspec` schedules forks through Jade only. let consensus = MorphConsensus::new(create_test_chainspec()); let block = create_sealed_block(0, vec![create_morph_tx_v2()]); @@ -2027,13 +2027,13 @@ mod tests { .unwrap_err() .to_string(); assert!( - err.contains("onyx fork not reached"), + err.contains("celadon fork not reached"), "unexpected error: {err}" ); } #[test] - fn test_validate_block_pre_execution_uses_chainspec_onyx_activation() { + fn test_validate_block_pre_execution_uses_chainspec_celadon_activation() { let genesis_json = serde_json::json!({ "config": { "chainId": 1337, @@ -2053,7 +2053,7 @@ mod tests { "viridianTime": 0, "emeraldTime": 0, "jadeForkTime": 0, - "onyxTime": 1000, + "celadonTime": 1000, "morph": {} }, "alloc": {} @@ -2067,7 +2067,7 @@ mod tests { .unwrap_err() .to_string(); assert!( - err.contains("onyx fork not reached"), + err.contains("celadon fork not reached"), "unexpected error: {err}" ); diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs index f4773119..47e12cf9 100644 --- a/crates/node/src/test_utils.rs +++ b/crates/node/src/test_utils.rs @@ -76,18 +76,18 @@ pub enum HardforkSchedule { #[default] AllActive, - /// Onyx is NOT active; all other forks are active at t=0. + /// Celadon is NOT active; all other forks are active at t=0. /// - /// Use this to test pre-Onyx behavior: MorphTx v2 (authorization list) rejected. - PreOnyx, + /// Use this to test pre-Celadon behavior: MorphTx v2 (authorization list) rejected. + PreCeladon, - /// Jade and Onyx are NOT active; all other forks are active at t=0. + /// Jade and Celadon are NOT active; all other forks are active at t=0. /// /// Use this to test pre-Jade behavior: state root validation skipped, /// MorphTx v1 rejected, etc. PreJade, - /// Viridian, Emerald, Jade, and Onyx are NOT active; all earlier forks are at t=0. + /// Viridian, Emerald, Jade, and Celadon are NOT active; all earlier forks are at t=0. /// /// Use this to test pre-Viridian behavior: EIP-7702 rejected, etc. PreViridian, @@ -112,7 +112,7 @@ impl HardforkSchedule { /// used to determine which forks are currently active on those networks. fn reference_genesis_json(&self) -> Option<&'static str> { match self { - Self::AllActive | Self::PreOnyx | Self::PreJade | Self::PreViridian => None, + Self::AllActive | Self::PreCeladon | Self::PreJade | Self::PreViridian => None, Self::Hoodi => Some(include_str!("../../chainspec/res/genesis/hoodi.json")), Self::Mainnet => Some(include_str!("../../chainspec/res/genesis/mainnet.json")), } @@ -121,8 +121,8 @@ impl HardforkSchedule { /// Apply this schedule's fork timestamps to a mutable genesis JSON value. /// /// - `AllActive`: no changes (test genesis already has all forks at 0) - /// - `PreOnyx`: set `onyxTime` to `u64::MAX` - /// - `PreJade`: set `jadeForkTime` and `onyxTime` to `u64::MAX` + /// - `PreCeladon`: set `celadonTime` to `u64::MAX` + /// - `PreJade`: set `jadeForkTime` and `celadonTime` to `u64::MAX` /// - `Hoodi`/`Mainnet`: compare each `*Time` key against the reference network; /// forks active now → 0, forks not yet active → `u64::MAX`. /// Block-based forks (`*Block`) are always kept at 0. @@ -131,23 +131,23 @@ impl HardforkSchedule { Self::AllActive => { // nothing to do — test genesis has all forks at 0 } - Self::PreOnyx => { - // Disable only Onyx; all other forks remain at 0. + Self::PreCeladon => { + // Disable only Celadon; all other forks remain at 0. let config = genesis["config"].as_object_mut().expect("genesis.config"); - config.insert("onyxTime".to_string(), serde_json::json!(u64::MAX)); + config.insert("celadonTime".to_string(), serde_json::json!(u64::MAX)); } Self::PreJade => { // Disable Jade and everything after it; all earlier forks remain at 0. let config = genesis["config"].as_object_mut().expect("genesis.config"); config.insert("jadeForkTime".to_string(), serde_json::json!(u64::MAX)); - config.insert("onyxTime".to_string(), serde_json::json!(u64::MAX)); + config.insert("celadonTime".to_string(), serde_json::json!(u64::MAX)); } Self::PreViridian => { let config = genesis["config"].as_object_mut().expect("genesis.config"); config.insert("viridianTime".to_string(), serde_json::json!(u64::MAX)); config.insert("emeraldTime".to_string(), serde_json::json!(u64::MAX)); config.insert("jadeForkTime".to_string(), serde_json::json!(u64::MAX)); - config.insert("onyxTime".to_string(), serde_json::json!(u64::MAX)); + config.insert("celadonTime".to_string(), serde_json::json!(u64::MAX)); } Self::Hoodi | Self::Mainnet => { let reference_json = self.reference_genesis_json().unwrap(); diff --git a/crates/node/tests/assets/test-genesis.json b/crates/node/tests/assets/test-genesis.json index 9d801601..aa479385 100644 --- a/crates/node/tests/assets/test-genesis.json +++ b/crates/node/tests/assets/test-genesis.json @@ -20,7 +20,7 @@ "viridianTime": 0, "emeraldTime": 0, "jadeForkTime": 0, - "onyxTime": 0, + "celadonTime": 0, "morph": { "feeVaultAddress": "0x530000000000000000000000000000000000000a" } diff --git a/crates/node/tests/it/hardfork.rs b/crates/node/tests/it/hardfork.rs index 121861e0..b1e488a1 100644 --- a/crates/node/tests/it/hardfork.rs +++ b/crates/node/tests/it/hardfork.rs @@ -63,15 +63,15 @@ async fn pre_jade_chain_advances() -> eyre::Result<()> { Ok(()) } -/// With Onyx disabled (pre-Onyx schedule), blocks are still built correctly. +/// With Celadon disabled (pre-Celadon schedule), blocks are still built correctly. /// -/// Only MorphTx v2 is gated on Onyx; everything else behaves as under Jade. +/// Only MorphTx v2 is gated on Celadon; everything else behaves as under Jade. #[tokio::test(flavor = "multi_thread")] -async fn pre_onyx_chain_advances() -> eyre::Result<()> { +async fn pre_celadon_chain_advances() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (mut nodes, wallet) = TestNodeBuilder::new() - .with_schedule(HardforkSchedule::PreOnyx) + .with_schedule(HardforkSchedule::PreCeladon) .build() .await?; let mut node = nodes.pop().unwrap(); diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs index 23910773..d522a44a 100644 --- a/crates/node/tests/it/morph_tx.rs +++ b/crates/node/tests/it/morph_tx.rs @@ -750,7 +750,7 @@ async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { } // ============================================================================= -// MorphTx v2 (EIP-7702 authorization list) — Onyx gating and delegation +// MorphTx v2 (EIP-7702 authorization list) — Celadon gating and delegation // ============================================================================= /// Asserts that `authority` is delegated to `delegate` (`0xef0100 || delegate`) @@ -1249,13 +1249,13 @@ async fn morph_tx_v2_pending_authorization_limits_authority_inflight_txs() -> ey Ok(()) } -/// MorphTx v2 is rejected by the pool while Onyx is not active. +/// MorphTx v2 is rejected by the pool while Celadon is not active. #[tokio::test(flavor = "multi_thread")] -async fn morph_tx_v2_rejected_before_onyx() -> eyre::Result<()> { +async fn morph_tx_v2_rejected_before_celadon() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (mut nodes, wallet) = TestNodeBuilder::new() - .with_schedule(HardforkSchedule::PreOnyx) + .with_schedule(HardforkSchedule::PreCeladon) .build() .await?; let node = nodes.pop().unwrap(); @@ -1277,7 +1277,7 @@ async fn morph_tx_v2_rejected_before_onyx() -> eyre::Result<()> { .rpc .inject_tx(raw_tx) .await - .expect_err("MorphTx v2 should be rejected by pool before Onyx"); + .expect_err("MorphTx v2 should be rejected by pool before Celadon"); assert!( err.to_string().contains("not yet active"), "unexpected error: {err}" @@ -1286,9 +1286,9 @@ async fn morph_tx_v2_rejected_before_onyx() -> eyre::Result<()> { Ok(()) } -/// MorphTx v1 keeps working after Onyx (only v2 is new). +/// MorphTx v1 keeps working after Celadon (only v2 is new). #[tokio::test(flavor = "multi_thread")] -async fn morph_tx_v1_still_accepted_after_onyx() -> eyre::Result<()> { +async fn morph_tx_v1_still_accepted_after_celadon() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; diff --git a/crates/node/tests/it/rpc.rs b/crates/node/tests/it/rpc.rs index f1425fc3..6613e466 100644 --- a/crates/node/tests/it/rpc.rs +++ b/crates/node/tests/it/rpc.rs @@ -670,15 +670,15 @@ async fn estimate_gas_for_morph_tx_v2_includes_authorization_gas() -> eyre::Resu } /// Simulation is not fork-gated, exactly like V1 (geth only gates -/// `setDefaults`, i.e. the send paths): before Onyx `eth_estimateGas` and +/// `setDefaults`, i.e. the send paths): before Celadon `eth_estimateGas` and /// `eth_call` still simulate a V2 request, while sending the same transaction /// is rejected by the pool. #[tokio::test(flavor = "multi_thread")] -async fn simulation_of_morph_tx_v2_is_not_fork_gated_before_onyx() -> eyre::Result<()> { +async fn simulation_of_morph_tx_v2_is_not_fork_gated_before_celadon() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (mut nodes, wallet) = TestNodeBuilder::new() - .with_schedule(HardforkSchedule::PreOnyx) + .with_schedule(HardforkSchedule::PreCeladon) .build() .await?; let node = nodes.pop().unwrap(); @@ -710,7 +710,7 @@ async fn simulation_of_morph_tx_v2_is_not_fork_gated_before_onyx() -> eyre::Resu .await?; assert!( estimate.to::() >= 21_000 + 25_000, - "pre-Onyx estimate must still price the authorization: {estimate}" + "pre-Celadon estimate must still price the authorization: {estimate}" ); let call_result: Value = client.request("eth_call", (request, "latest")).await?; assert_eq!(call_result.as_str(), Some("0x")); @@ -725,7 +725,7 @@ async fn simulation_of_morph_tx_v2_is_not_fork_gated_before_onyx() -> eyre::Resu .rpc .inject_tx(raw_tx) .await - .expect_err("MorphTx v2 must be rejected by the pool before Onyx"); + .expect_err("MorphTx v2 must be rejected by the pool before Celadon"); assert!( err.to_string().contains("not yet active"), "unexpected error: {err}" diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 674b9fbd..116300be 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -5,7 +5,7 @@ //! - ERC20 tokens for gas payment instead of native ETH //! - Transaction reference for indexing/lookup //! - Memo field for arbitrary data -//! - EIP-7702 authorization list (version 2, Onyx onwards) +//! - EIP-7702 authorization list (version 2, Celadon onwards) //! //! Wire formats (after the `0x7F` type byte): //! - V0: `RLP([chainId, nonce, gasTipCap, gasFeeCap, gas, to, value, data, accessList, feeTokenID, feeLimit, V, R, S])` diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 3b7c81f6..4237d687 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -46,7 +46,7 @@ pub enum MorphInvalidTransaction { /// A MorphTx below version 2 carries an EIP-7702 authorization list. /// - /// Only MorphTx V2 (Onyx onwards) may carry authorizations; the RLP decoders + /// Only MorphTx V2 (Celadon onwards) may carry authorizations; the RLP decoders /// never produce this shape, so it only surfaces for malformed simulation /// requests. #[error("MorphTx version {version} does not support an authorization list")] diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index b502ac88..cae31389 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1377,7 +1377,7 @@ mod tests { #[test] fn validate_env_accepts_v2_morph_tx_with_authorization_list() { - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.tx = morph_tx_env_with_authorizations( Some(MORPH_TX_VERSION_2), TxKind::Call(Address::ZERO), @@ -1389,7 +1389,7 @@ mod tests { #[test] fn validate_env_rejects_v1_morph_tx_with_authorization_list() { - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.tx = morph_tx_env_with_authorizations( Some(MORPH_TX_VERSION_1), TxKind::Call(Address::ZERO), @@ -1409,7 +1409,7 @@ mod tests { /// static rule applies (revm's `EmptyAuthorizationList` is `0x04`-only). #[test] fn validate_env_accepts_v2_morph_tx_with_empty_authorization_list() { - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.tx = morph_tx_env_with_authorizations( Some(MORPH_TX_VERSION_2), TxKind::Call(Address::ZERO), @@ -1422,7 +1422,7 @@ mod tests { /// Without authorizations a V2 may create a contract, exactly like V1. #[test] fn validate_env_accepts_v2_morph_tx_create_without_authorizations() { - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.tx = morph_tx_env_with_authorizations(Some(MORPH_TX_VERSION_2), TxKind::Create, vec![]); assert!(validate_env_of(&mut evm).is_ok()); @@ -1430,7 +1430,7 @@ mod tests { #[test] fn validate_env_rejects_v2_morph_tx_create() { - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.tx = morph_tx_env_with_authorizations( Some(MORPH_TX_VERSION_2), TxKind::Create, @@ -1446,7 +1446,7 @@ mod tests { #[test] fn validate_env_rejects_v2_morph_tx_before_prague() { - // Structurally unreachable on Morph (Onyx > Viridian = Prague), but the + // Structurally unreachable on Morph (Celadon > Viridian = Prague), but the // guard mirrors revm's `Eip7702NotSupported` for `0x04`. let mut evm = evm_with_spec(MorphHardfork::Morph203); evm.tx = morph_tx_env_with_authorizations( @@ -1466,7 +1466,7 @@ mod tests { #[test] fn validate_env_keeps_accepting_v1_morph_tx_without_authorizations() { - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.tx = morph_tx_env_with_authorizations( Some(MORPH_TX_VERSION_1), TxKind::Call(Address::ZERO), @@ -1517,7 +1517,7 @@ mod tests { ..Default::default() }, ); - let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Onyx), NoOpInspector); + let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Celadon), NoOpInspector); evm.cfg.chain_id = 1; evm.tx = morph_tx_env_with_authorizations( Some(MORPH_TX_VERSION_2), @@ -1550,7 +1550,7 @@ mod tests { let authority = Address::with_last_byte(0xaa); let delegate = Address::with_last_byte(0x42); - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.cfg.chain_id = 1; evm.tx = morph_tx_env_with_authorizations( Some(MORPH_TX_VERSION_2), @@ -1572,7 +1572,7 @@ mod tests { #[test] fn apply_eip7702_auth_list_is_noop_for_morph_tx_without_authorizations() { - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.tx = morph_tx_env_with_authorizations( Some(MORPH_TX_VERSION_1), TxKind::Call(Address::ZERO), @@ -1588,7 +1588,7 @@ mod tests { ) -> MorphEvm, NoOpInspector> { let mut db = CacheDB::new(EmptyDB::default()); db.insert_account_info(authority, info); - let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Onyx), NoOpInspector); + let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Celadon), NoOpInspector); evm.cfg.chain_id = 1; evm } @@ -1610,7 +1610,7 @@ mod tests { let authority = Address::with_last_byte(0xaa); let delegate = Address::with_last_byte(0x42); - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.cfg.chain_id = 1; evm.tx = v2_env_with(vec![recovered_authorization(authority, delegate, 1, 0)]); @@ -1714,7 +1714,7 @@ mod tests { #[test] fn apply_eip7702_auth_list_skips_tuple_with_invalid_authority() { let delegate = Address::with_last_byte(0x42); - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.cfg.chain_id = 1; evm.tx = v2_env_with(vec![Either::Right(RecoveredAuthorization::new_unchecked( Authorization { @@ -1747,7 +1747,7 @@ mod tests { let authority = Address::with_last_byte(0xaa); let delegate = Address::with_last_byte(0x42); - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.cfg.chain_id = 1; evm.tx = v2_env_with(vec![recovered_authorization(authority, delegate, 0, 0)]); @@ -1764,7 +1764,7 @@ mod tests { let authority = Address::with_last_byte(0xaa); let delegate = Address::with_last_byte(0x42); - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.cfg.chain_id = 1; evm.tx = v2_env_with(vec![recovered_authorization( authority, @@ -1812,7 +1812,7 @@ mod tests { /// V2 rules; only the fee-cap check is fee-dependent. #[test] fn validate_env_enforces_v2_rules_when_fee_charge_is_disabled() { - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.cfg.disable_fee_charge = true; evm.tx = morph_tx_env_with_authorizations( Some(MORPH_TX_VERSION_2), @@ -1832,7 +1832,7 @@ mod tests { /// MorphTx V2 needs no Morph-specific handling here (design doc 5.6). #[test] fn validate_initial_tx_gas_charges_per_authorization_for_morph_tx_v2() { - let mut evm = evm_with_spec(MorphHardfork::Onyx); + let mut evm = evm_with_spec(MorphHardfork::Celadon); evm.tx = v2_env_with(vec![ Either::Left(sample_signed_authorization()), Either::Left(sample_signed_authorization()), diff --git a/crates/revm/src/precompiles.rs b/crates/revm/src/precompiles.rs index e1ed4220..d6a578a1 100644 --- a/crates/revm/src/precompiles.rs +++ b/crates/revm/src/precompiles.rs @@ -116,7 +116,7 @@ impl MorphPrecompiles { // Morph203 and Viridian share the same precompile set MorphHardfork::Morph203 | MorphHardfork::Viridian => morph203(), // Emerald and Jade share the same precompile set. - MorphHardfork::Emerald | MorphHardfork::Jade | MorphHardfork::Onyx => emerald(), + MorphHardfork::Emerald | MorphHardfork::Jade | MorphHardfork::Celadon => emerald(), hardfork => unreachable!("unsupported Morph hardfork: {hardfork:?}"), }; diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index 022092f2..64d5e01a 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -70,14 +70,14 @@ pub fn validate_morph_tx( }); } - // V2 (EIP-7702 authorization list) is gated on Onyx. The list itself is + // V2 (EIP-7702 authorization list) is gated on Celadon. The list itself is // validated by `TxMorph::validate` below (V0/V1 must not carry one, a // non-empty V2 list forbids CREATE; an empty V2 list is allowed); authority // tracking and delegated-sender limits come from the upstream validator, // which reads the list through `Transaction::authorization_list`. - if !input.hardfork.is_onyx() && morph_tx.version == MORPH_TX_VERSION_2 { + if !input.hardfork.is_celadon() && morph_tx.version == MORPH_TX_VERSION_2 { return Err(MorphTxError::InvalidFormat { - reason: "MorphTx version 2 is not yet active (onyx fork not reached)".to_string(), + reason: "MorphTx version 2 is not yet active (celadon fork not reached)".to_string(), }); } @@ -505,7 +505,7 @@ mod tests { } #[test] - fn test_validate_morph_tx_v2_rejected_before_onyx() { + fn test_validate_morph_tx_v2_rejected_before_celadon() { let envelope = v2_eth_fee_envelope(vec![sample_authorization()]); let input = MorphTxValidationInput { consensus_tx: &envelope, @@ -520,20 +520,21 @@ mod tests { assert_eq!( err, MorphTxError::InvalidFormat { - reason: "MorphTx version 2 is not yet active (onyx fork not reached)".to_string(), + reason: "MorphTx version 2 is not yet active (celadon fork not reached)" + .to_string(), } ); } #[test] - fn test_validate_morph_tx_v2_eth_fee_path_accepted_after_onyx() { + fn test_validate_morph_tx_v2_eth_fee_path_accepted_after_celadon() { let envelope = v2_eth_fee_envelope(vec![sample_authorization()]); let input = MorphTxValidationInput { consensus_tx: &envelope, sender: address!("1000000000000000000000000000000000000001"), eth_balance: U256::from(10u128.pow(18)), l1_data_fee: U256::from(1000u64), - hardfork: MorphHardfork::Onyx, + hardfork: MorphHardfork::Celadon, }; let mut db = EmptyDB::default(); @@ -541,7 +542,7 @@ mod tests { assert!(!result.uses_token_fee); } - /// A V2 without authorizations is admitted like a V1 (still Onyx-gated). + /// A V2 without authorizations is admitted like a V1 (still Celadon-gated). #[test] fn test_validate_morph_tx_v2_empty_authorization_list_accepted() { let envelope = v2_eth_fee_envelope(vec![]); @@ -550,7 +551,7 @@ mod tests { sender: address!("1000000000000000000000000000000000000001"), eth_balance: U256::from(10u128.pow(18)), l1_data_fee: U256::ZERO, - hardfork: MorphHardfork::Onyx, + hardfork: MorphHardfork::Celadon, }; let mut db = EmptyDB::default(); @@ -562,7 +563,8 @@ mod tests { assert_eq!( err, MorphTxError::InvalidFormat { - reason: "MorphTx version 2 is not yet active (onyx fork not reached)".to_string(), + reason: "MorphTx version 2 is not yet active (celadon fork not reached)" + .to_string(), } ); } From c391f5c21bd5580c8697de1f64384305d672acb4 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 18 Sep 2026 17:30:41 +0800 Subject: [PATCH 06/17] fix(revm): carry the prepaid rounding credit into alt-token refunds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounding the prepaid alt-token fee up and the unused-gas refund up again under-collects: the chain gives back part of a token unit the caller never spent. From Celadon on, the deduction records the numerator its ceiling overcharged and the refund adds it back before rounding down, so the caller is charged the ceiling of the *net* fee. Before Celadon both halves keep rounding up, because that is what mainnet state was built from. Rounding down can reach zero, which the ceiling never did for a non-zero refund. go-ethereum's `TransferAltTokenHybrid` returns early on a zero amount, so the refund now does too: no `Transfer(.., 0)` log on the call path, no slot writes on the direct-slot path. This is not gated on the transaction being MorphTx v2 — it applies to every alt-fee transaction at the fork, so a client without it diverges on the first token-fee transaction after activation, not on the first v2 one. Ports go-ethereum#371 `886d7f40b` and `4d71e2b72`. --- crates/revm/src/evm.rs | 8 + crates/revm/src/handler.rs | 382 ++++++++++++++++++++++++++++++++++- crates/revm/src/token_fee.rs | 169 +++++++++++++++- 3 files changed, 550 insertions(+), 9 deletions(-) diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index eda44e68..e8034f29 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -95,6 +95,13 @@ pub struct MorphEvm { /// Ensures consistent price_ratio/scale between deduct and reimburse, /// matching go-ethereum's `st.feeRate`/`st.tokenScale` caching pattern. pub(crate) cached_token_fee_info: Option, + /// Token-unit numerator the fee deduction overcharged by rounding up. + /// + /// From Celadon on, the unused-gas refund adds this back before rounding + /// down, so the caller pays `ceil` of the net fee instead of + /// `ceil(prepaid) - ceil(refund)`, which under-collects. Mirrors + /// go-ethereum's `st.altFeeRoundingCredit`. + pub(crate) cached_alt_fee_rounding_credit: U256, /// Cached L1 data fee calculated during handler validation. /// Avoids re-encoding the full transaction RLP in the block executor's /// receipt-building path (the handler already has the encoded bytes via @@ -183,6 +190,7 @@ impl MorphEvm { Self { inner, cached_token_fee_info: None, + cached_alt_fee_rounding_credit: U256::ZERO, cached_l1_data_fee: U256::ZERO, pre_fee_refund: 0, pre_fee_logs: Vec::new(), diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index a1c5ccf8..c1eeaa67 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -503,6 +503,7 @@ where 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 hardfork = *evm.ctx_ref().cfg().spec(); let refunded = gas.refunded().max(0) as u64; let reimburse_eth = U256::from( @@ -523,8 +524,25 @@ where } })?; - // Calculate token amount required for total fee - let token_amount_required = token_fee_info.eth_to_token_amount(reimburse_eth); + // From Celadon on, add back the numerator the deduction's rounding-up + // overcharged and round down, so the caller ends up paying `ceil` of the + // net fee. Before Celadon both halves round up independently, which + // under-collects; that is mainnet's history and must stay bit-identical. + // Matches go-ethereum's `refundGas` gate on `IsCeladon`. + let token_amount_required = if hardfork.is_celadon() { + token_fee_info + .eth_to_token_amount_floor(reimburse_eth, evm.cached_alt_fee_rounding_credit) + } else { + token_fee_info.eth_to_token_amount(reimburse_eth) + }; + + // Rounding down can land on zero, which the ceiling never did for a + // non-zero `reimburse_eth`. go-ethereum's `TransferAltTokenHybrid` + // returns early on a zero amount: no `Transfer(.., 0)` log on the + // call path, and no slot writes on the direct-slot path. + if token_amount_required.is_zero() { + return Ok(()); + } // Attempt token refund. Matches go-ethereum's refundGas() which silently logs // and continues on failure: "Continue execution even if refund fails - refund @@ -678,8 +696,11 @@ where // Total fee in ETH let total_eth_fee = l2_gas_fee.saturating_add(l1_data_fee); - // Calculate token amount required for total fee - let token_amount_required = token_fee_info.eth_to_token_amount(total_eth_fee); + // Calculate token amount required for total fee. The credit is the part of + // one token unit the rounding-up overcharged; from Celadon on the refund + // hands it back (see `reimburse_caller_token_fee`). + let (token_amount_required, alt_fee_rounding_credit) = + token_fee_info.eth_to_token_amount_with_credit(total_eth_fee); let fee_limit = token_fee_info.effective_fee_limit(fee_limit_from_tx); @@ -804,6 +825,7 @@ where // Cache token fee info for the reimburse phase, ensuring consistent // price_ratio/scale between deduction and reimbursement. evm.cached_token_fee_info = Some(token_fee_info); + evm.cached_alt_fee_rounding_credit = alt_fee_rounding_credit; evm.cached_l1_data_fee = l1_data_fee; Ok(()) @@ -3034,3 +3056,355 @@ mod tests { } } } + +/// Alt-token refund rounding, gated on Celadon. +/// +/// Uses the registry's direct-slot path so the arithmetic is the only variable; +/// the call path routes the same `token_amount_required` through an ERC20 +/// `transfer`, which [`tests`] already covers. +#[cfg(test)] +mod refund_rounding_tests { + use super::*; + use crate::{L2_TOKEN_REGISTRY_ADDRESS, MorphTxEnv, TokenFeeInfo, compute_mapping_slot}; + use alloy_primitives::{Address, TxKind, U256, address}; + use morph_chainspec::hardfork::MorphHardfork; + use morph_primitives::MORPH_TX_TYPE_ID; + use revm::{ + context::TxEnv, + database::{CacheDB, EmptyDB}, + inspector::NoOpInspector, + state::AccountInfo, + }; + + const CALLER: Address = address!("1000000000000000000000000000000000000001"); + const BENEFICIARY: Address = address!("2000000000000000000000000000000000000002"); + const TOKEN: Address = address!("3000000000000000000000000000000000000003"); + const BALANCE_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); + /// A `price_ratio` of 3 against a `scale` of 1 makes two of every three wei + /// an inexact conversion, which is what the two roundings disagree about. + const PRICE_RATIO: u64 = 3; + const FEE_TOKEN_ID: u16 = 1; + const VAULT_BALANCE: u64 = 1_000_000; + const STARTING_BALANCE: u64 = 1_000_000; + + fn slot_of(account: Address) -> U256 { + compute_mapping_slot_for_address(BALANCE_SLOT, account) + } + + fn token_balance( + evm: &mut MorphEvm, NoOpInspector>, + account: Address, + ) -> U256 { + let journal = evm.ctx.journal_mut(); + journal.load_account_mut(TOKEN).expect("load token"); + *journal.sload(TOKEN, slot_of(account)).expect("sload") + } + + fn ceil_to_token(eth_amount: u64) -> U256 { + TokenFeeInfo { + price_ratio: U256::from(PRICE_RATIO), + scale: U256::from(1u64), + ..Default::default() + } + .eth_to_token_amount(U256::from(eth_amount)) + } + + // ------------------------------------------------------------------------- + // Whole-transaction coverage: deduction, call, refund + // ------------------------------------------------------------------------- + + /// Registers the fee token in the L2TokenRegistry on the direct-slot path. + /// + /// `balanceSlot` is stored one-based, so zero there means "call mode". + fn register_slot_mode_token(db: &mut CacheDB) { + let mut token_id_bytes = [0u8; 32]; + token_id_bytes[30..32].copy_from_slice(&FEE_TOKEN_ID.to_be_bytes()); + let base = compute_mapping_slot(U256::from(151), &token_id_bytes); + + let mut put = |slot: U256, value: U256| { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + }; + put(base, U256::from_be_bytes(TOKEN.into_word().0)); + put(base + U256::from(1), BALANCE_SLOT + U256::from(1)); + let mut status = [0u8; 32]; + status[30] = 18; // decimals + status[31] = 1; // isActive + put(base + U256::from(2), U256::from_be_bytes(status)); + put(base + U256::from(3), U256::from(1u64)); // scale + put( + compute_mapping_slot(U256::from(153), &token_id_bytes), + U256::from(PRICE_RATIO), + ); + } + + /// A funded caller with the fee token registered on the direct-slot path. + fn slot_mode_evm(spec: MorphHardfork) -> MorphEvm, NoOpInspector> { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(TOKEN, AccountInfo::default()); + db.insert_account_info(CALLER, AccountInfo::default()); + register_slot_mode_token(&mut db); + db.insert_account_storage(TOKEN, slot_of(CALLER), U256::from(STARTING_BALANCE)) + .unwrap(); + db.insert_account_storage(TOKEN, slot_of(BENEFICIARY), U256::ZERO) + .unwrap(); + + let mut evm = MorphEvm::new(MorphContext::new(db, spec), NoOpInspector); + evm.block.inner.beneficiary = BENEFICIARY; + evm.block.inner.basefee = 0; + evm.block.inner.gas_limit = 30_000_000; + evm + } + + /// `calldata_len` bytes of non-zero calldata shift the transaction's gas + /// cost, which is what moves `gas_used` through its remainders modulo + /// `PRICE_RATIO`. + fn fee_tx(gas_limit: u64, calldata_len: usize) -> MorphTxEnv { + MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: CALLER, + kind: TxKind::Call(Address::repeat_byte(0x0e)), + gas_limit, + gas_price: 1, + data: alloy_primitives::Bytes::from(vec![0x01; calldata_len]), + ..Default::default() + }, + fee_token_id: Some(FEE_TOKEN_ID), + ..Default::default() + } + } + + /// Runs a whole token-fee transaction and reports + /// `(net token spend, gas used)`. + fn net_token_spend(spec: MorphHardfork, gas_limit: u64, calldata_len: usize) -> (U256, u64) { + let mut evm = slot_mode_evm(spec); + let result = evm + .transact_one(fee_tx(gas_limit, calldata_len)) + .expect("token-fee MorphTx must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + let gas_used = result.tx_gas_used(); + + ( + U256::from(STARTING_BALANCE) - token_balance(&mut evm, CALLER), + gas_used, + ) + } + + /// The deduction must hand the refund the numerator its rounding-up + /// overcharged. + /// + /// Asserted directly, because whether the credit changes the refunded amount + /// depends on how prepaid and remaining gas land modulo `PRICE_RATIO` — an + /// end-to-end assertion alone passes with the credit stuck at zero. + #[test] + fn deduction_records_the_rounding_credit_for_the_refund() { + for (gas_limit, want_credit) in [(99_999u64, 0u64), (100_000, 2), (100_001, 1)] { + let mut evm = slot_mode_evm(MorphHardfork::Celadon); + evm.tx = fee_tx(gas_limit, 0); + MorphEvmHandler::default() + .validate_against_state_and_deduct_caller( + &mut evm, + &mut InitialAndFloorGas::default(), + ) + .expect("deduction must succeed"); + + // The prepaid fee is `gas_limit` at price 1, so the credit is what + // rounding `gas_limit / PRICE_RATIO` up left unused. + assert_eq!( + evm.cached_alt_fee_rounding_credit, + U256::from(want_credit), + "gas_limit {gas_limit}" + ); + } + } + + /// From Celadon on, deduction and refund together charge exactly + /// `ceil(net ETH fee)` in token units, whatever the prepaid amount rounded to. + /// + /// The sweep covers every remainder `gas_used` and the prepaid amount can + /// have modulo `PRICE_RATIO`; the credit only changes the refund for some of + /// those combinations, and the final assertion holds the sweep to covering + /// them. + #[test] + fn celadon_charges_the_ceiling_of_the_net_fee_end_to_end() { + let mut seen_remainders = [false; PRICE_RATIO as usize]; + for calldata_len in 0..PRICE_RATIO as usize { + for gas_limit in 100_000u64..100_000 + PRICE_RATIO { + let (spent, gas_used) = + net_token_spend(MorphHardfork::Celadon, gas_limit, calldata_len); + assert_eq!( + spent, + ceil_to_token(gas_used), + "gas_limit {gas_limit}, calldata_len {calldata_len}, gas_used {gas_used}" + ); + seen_remainders[(gas_used % PRICE_RATIO) as usize] = true; + } + } + assert!( + seen_remainders.iter().all(|seen| *seen), + "the sweep must cover every gas_used remainder modulo {PRICE_RATIO}, \ + otherwise it misses the cases where the credit changes the refund" + ); + } + + /// The pre-Celadon rule under-collects, so the fork gate is load-bearing. + /// + /// Rounding both halves up independently cancels out whenever the prepaid + /// amount and the refund leave the same remainder, so the shortfall shows on + /// only some transactions: the assertion is that at least one swept + /// transaction is short, and that none over-collects. + #[test] + fn pre_celadon_under_collects_end_to_end() { + let mut short = 0; + for calldata_len in 0..PRICE_RATIO as usize { + for gas_limit in 100_000u64..100_000 + PRICE_RATIO { + let (spent, gas_used) = + net_token_spend(MorphHardfork::Jade, gas_limit, calldata_len); + let ceiling = ceil_to_token(gas_used); + assert!( + spent <= ceiling, + "pre-Celadon must never collect more than the ceiling of the net fee, \ + gas_limit {gas_limit}, calldata_len {calldata_len}: spent {spent} > {ceiling}" + ); + if spent < ceiling { + short += 1; + } + } + } + assert!( + short > 0, + "pre-Celadon must under-collect on at least one swept transaction, \ + otherwise this test cannot tell the two rules apart" + ); + } + + // ------------------------------------------------------------------------- + // The refund step in isolation + // ------------------------------------------------------------------------- + + /// An EVM parked right before `reimburse_caller_token_fee`, holding the state + /// the deduction phase would have left behind: the fee already in the vault, + /// and `rounding_credit` recorded from the deduction's rounding-up. + fn evm_after_deduction( + spec: MorphHardfork, + rounding_credit: U256, + ) -> MorphEvm, NoOpInspector> { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(TOKEN, AccountInfo::default()); + db.insert_account_storage(TOKEN, slot_of(BENEFICIARY), U256::from(VAULT_BALANCE)) + .unwrap(); + db.insert_account_storage(TOKEN, slot_of(CALLER), U256::ZERO) + .unwrap(); + + let mut evm = MorphEvm::new(MorphContext::new(db, spec), NoOpInspector); + evm.block.inner.beneficiary = BENEFICIARY; + evm.block.inner.basefee = 0; + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: CALLER, + kind: TxKind::Call(Address::ZERO), + gas_limit: 100_000, + // With a zero basefee the effective gas price is 1, so the ETH to + // refund equals the gas left and the arithmetic reads directly. + gas_price: 1, + ..Default::default() + }, + fee_token_id: Some(FEE_TOKEN_ID), + ..Default::default() + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: TOKEN, + is_active: true, + price_ratio: U256::from(PRICE_RATIO), + scale: U256::from(1u64), + caller: CALLER, + balance: U256::ZERO, + balance_slot: Some(BALANCE_SLOT), + ..Default::default() + }); + evm.cached_alt_fee_rounding_credit = rounding_credit; + evm + } + + /// Refunds `gas_left` worth of gas and reports what reached the caller. + fn refund_with(spec: MorphHardfork, gas_left: u64, rounding_credit: U256) -> U256 { + let mut evm = evm_after_deduction(spec, rounding_credit); + MorphEvmHandler::default() + .reimburse_caller_token_fee(&mut evm, &Gas::new(gas_left)) + .expect("refund must not fail"); + token_balance(&mut evm, CALLER) + } + + /// Before Celadon both halves of the fee round up independently. That + /// under-collects, but it is mainnet's history and must not move. + #[test] + fn pre_celadon_refund_rounds_up() { + // ceil(4 / 3) = 2, whatever credit the deduction recorded. + assert_eq!( + refund_with(MorphHardfork::Jade, 4, U256::ZERO), + U256::from(2u64) + ); + assert_eq!( + refund_with(MorphHardfork::Jade, 4, U256::from(2u64)), + U256::from(2u64) + ); + } + + /// From Celadon on the refund adds the prepaid rounding credit and rounds + /// down. + #[test] + fn celadon_refund_rounds_down_with_the_credit() { + // floor((4 + 0) / 3) = 1 — a whole token unit less than the old rule. + assert_eq!( + refund_with(MorphHardfork::Celadon, 4, U256::ZERO), + U256::from(1u64) + ); + // floor((4 + 2) / 3) = 2: the credit can bring the refund back up. + assert_eq!( + refund_with(MorphHardfork::Celadon, 4, U256::from(2u64)), + U256::from(2u64) + ); + } + + /// Flooring can reach zero, where the ceiling always refunded at least one + /// unit. A zero refund must move no tokens and write no slots, matching + /// go-ethereum's `TransferAltTokenHybrid` early return. + #[test] + fn celadon_zero_refund_touches_nothing() { + // floor(2 / 3) = 0 while ceil(2 / 3) = 1. + assert_eq!( + refund_with(MorphHardfork::Celadon, 2, U256::ZERO), + U256::ZERO + ); + assert_eq!( + refund_with(MorphHardfork::Jade, 2, U256::ZERO), + U256::from(1u64) + ); + + let mut evm = evm_after_deduction(MorphHardfork::Celadon, U256::ZERO); + MorphEvmHandler::default() + .reimburse_caller_token_fee(&mut evm, &Gas::new(2)) + .unwrap(); + assert!( + evm.post_fee_logs.is_empty(), + "a zero refund must emit no Transfer log" + ); + // Asserted before any read of our own: `token_balance` would itself pull + // the account and slot into the journal. + assert!( + evm.ctx + .journal_mut() + .state + .get(&TOKEN) + .is_none_or(|token| token.storage.is_empty()), + "a zero refund must not load or write the token's balance slots" + ); + assert_eq!( + token_balance(&mut evm, BENEFICIARY), + U256::from(VAULT_BALANCE), + "a zero refund must leave the vault balance alone" + ); + } +} diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index 7b1b6574..445035c2 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -199,15 +199,30 @@ impl TokenFeeInfo { entry.load_storage_only(db, caller).map(Some) } - /// Calculate the token amount required for a given ETH amount. + /// Calculate the token amount required for a given ETH amount, rounding up. /// /// Uses the price ratio and scale to convert ETH value to token amount. #[inline] pub fn eth_to_token_amount(&self, eth_amount: U256) -> U256 { + self.eth_to_token_amount_with_credit(eth_amount).0 + } + + /// Same as [`Self::eth_to_token_amount`], and also returns the numerator the + /// rounding-up overcharged. + /// + /// The credit is `price_ratio - remainder` (zero when the division is exact): + /// the part of one whole token unit the caller paid for but did not use. From + /// Celadon on, [`Self::eth_to_token_amount_floor`] hands it back on the refund + /// so that the caller is charged `ceil` of the *net* fee rather than + /// `ceil(prepaid) - ceil(refund)`, which under-collects. + /// + /// Mirrors go-ethereum's `types.EthToAlt`. + #[inline] + pub fn eth_to_token_amount_with_credit(&self, eth_amount: U256) -> (U256, U256) { // If price_ratio or scale is zero (misconfigured token), return MAX to prevent // free-ride transactions. The caller's balance check will reject the tx. if self.price_ratio.is_zero() || self.scale.is_zero() { - return U256::MAX; + return (U256::MAX, U256::ZERO); } // token_amount = eth_amount * scale / price_ratio @@ -215,11 +230,37 @@ impl TokenFeeInfo { .saturating_mul(self.scale) .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)) + if remainder.is_zero() { + (token_amount, U256::ZERO) } else { - token_amount + ( + token_amount.saturating_add(U256::from(1)), + self.price_ratio - remainder, + ) + } + } + + /// Convert an ETH amount plus the prepaid rounding credit into token units, + /// rounding down. + /// + /// `rounding_credit` comes from the matching + /// [`Self::eth_to_token_amount_with_credit`] call made when the fee was + /// deducted. Mirrors go-ethereum's `types.EthToAltFloor`. + /// + /// A misconfigured token refunds nothing. That is the conservative direction + /// (the ceiling path returns `U256::MAX` for the same input to make the + /// deduction fail), and it is unreachable in practice: such a transaction + /// never gets past the balance check at deduction time. + #[inline] + pub fn eth_to_token_amount_floor(&self, eth_amount: U256, rounding_credit: U256) -> U256 { + if self.price_ratio.is_zero() || self.scale.is_zero() { + return U256::ZERO; } + + eth_amount + .saturating_mul(self.scale) + .saturating_add(rounding_credit) + / self.price_ratio } } @@ -589,6 +630,124 @@ pub(crate) mod tests { assert_eq!(token_amount, U256::MAX); } + /// Rounding up the prepaid fee leaves `price_ratio - remainder` of a token + /// unit paid for but unused, which is exactly what the refund gets back. + #[test] + fn eth_to_token_amount_reports_the_rounding_credit() { + let info = TokenFeeInfo { + price_ratio: U256::from(3u64), + scale: U256::from(1u64), + ..Default::default() + }; + + // 10 / 3 = 3 remainder 1 → charge 4, one third of a unit unused → 3 - 1 = 2. + let (amount, credit) = info.eth_to_token_amount_with_credit(U256::from(10u64)); + assert_eq!(amount, U256::from(4u64)); + assert_eq!(credit, U256::from(2u64)); + + // An exact division overcharges nothing. + let (amount, credit) = info.eth_to_token_amount_with_credit(U256::from(9u64)); + assert_eq!(amount, U256::from(3u64)); + assert_eq!(credit, U256::ZERO); + + // The plain ceiling accessor stays the first half of the pair. + assert_eq!( + info.eth_to_token_amount(U256::from(10u64)), + U256::from(4u64) + ); + } + + /// The credit is what makes deduct-then-refund add up to `ceil(net fee)`. + /// Without it, both halves round up independently and the chain + /// under-collects — for `remaining = 4` below, by a whole token unit. + #[test] + fn floor_refund_charges_the_ceiling_of_the_net_fee() { + let info = TokenFeeInfo { + price_ratio: U256::from(3u64), + scale: U256::from(1u64), + ..Default::default() + }; + + for prepaid_eth in 0u64..40 { + let (charged, credit) = info.eth_to_token_amount_with_credit(U256::from(prepaid_eth)); + for remaining_eth in 0..=prepaid_eth { + let refunded = info.eth_to_token_amount_floor(U256::from(remaining_eth), credit); + let net_eth = U256::from(prepaid_eth - remaining_eth); + assert_eq!( + charged - refunded, + info.eth_to_token_amount(net_eth), + "prepaid {prepaid_eth}, remaining {remaining_eth}" + ); + } + } + } + + /// The Celadon change is observable, so the fork gate in the handler is not + /// cosmetic: on an exact deduction the credit is zero and the two roundings + /// disagree for every inexact refund. + #[test] + fn floor_and_ceiling_refunds_differ() { + let info = TokenFeeInfo { + price_ratio: U256::from(3u64), + scale: U256::from(1u64), + ..Default::default() + }; + + // Exact deduction (9 / 3) → no credit; refunding 4 ceils to 2, floors to 1. + let (_, credit) = info.eth_to_token_amount_with_credit(U256::from(9u64)); + assert_eq!(credit, U256::ZERO); + assert_eq!(info.eth_to_token_amount(U256::from(4u64)), U256::from(2u64)); + assert_eq!( + info.eth_to_token_amount_floor(U256::from(4u64), credit), + U256::from(1u64) + ); + } + + /// Flooring can reach zero where the ceiling never does. The handler skips + /// the transfer in that case, matching go-ethereum's `TransferAltTokenHybrid`. + #[test] + fn floor_refund_can_be_zero() { + let info = TokenFeeInfo { + price_ratio: U256::from(5u64), + scale: U256::from(1u64), + ..Default::default() + }; + + let (_, credit) = info.eth_to_token_amount_with_credit(U256::from(5u64)); + assert_eq!(credit, U256::ZERO); + assert_eq!(info.eth_to_token_amount(U256::from(1u64)), U256::from(1u64)); + assert_eq!( + info.eth_to_token_amount_floor(U256::from(1u64), credit), + U256::ZERO + ); + } + + /// A misconfigured token refunds nothing rather than the `U256::MAX` the + /// ceiling path returns to make the deduction fail. + #[test] + fn floor_refund_of_a_misconfigured_token_is_zero() { + for info in [ + TokenFeeInfo { + price_ratio: U256::ZERO, + scale: U256::from(1u64), + ..Default::default() + }, + TokenFeeInfo { + price_ratio: U256::from(1u64), + scale: U256::ZERO, + ..Default::default() + }, + ] { + assert_eq!( + info.eth_to_token_amount_floor(U256::from(10u64), U256::from(3u64)), + U256::ZERO + ); + let (amount, credit) = info.eth_to_token_amount_with_credit(U256::from(10u64)); + assert_eq!(amount, U256::MAX); + assert_eq!(credit, U256::ZERO); + } + } + #[test] fn test_encode_balance_of() { let account = address!("1234567890123456789012345678901234567890"); From 97785095ab94a40640a069ff28f0ca3621ef2912 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 18 Sep 2026 17:47:31 +0800 Subject: [PATCH 07/17] test(statetest): pin the Celadon alt-token refund against morph-geth Nine golden roots from morph-geth 5a0d0d771: three consecutive gas limits on each of Emerald, Jade and Celadon. The fee token is registered with `priceRatio = 3` against `scale = 1` and the transaction carries one non-zero calldata byte, so neither the prepaid fee nor the transaction's gas cost is a multiple of the ratio. That is the only shape where rounding both halves up independently disagrees with charging the ceiling of the net fee: Celadon collects ceil(21_016 / 3) = 7_006 on all three limits, while Emerald and Jade collect 7_005 on two of them and land on a second state root. The 21_016 also pins the gas: morph does not apply the EIP-7623 calldata floor, which would bill 21_040 and miss every root in the fixture. --- .../tests/celadon_alt_token_refund.rs | 33 ++++ .../fixtures/celadon_alt_token_refund.json | 157 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 bin/morph-statetest/tests/celadon_alt_token_refund.rs create mode 100644 bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json diff --git a/bin/morph-statetest/tests/celadon_alt_token_refund.rs b/bin/morph-statetest/tests/celadon_alt_token_refund.rs new file mode 100644 index 00000000..4d57f661 --- /dev/null +++ b/bin/morph-statetest/tests/celadon_alt_token_refund.rs @@ -0,0 +1,33 @@ +//! Golden roots from morph-geth 5a0d0d771 (go-ethereum#371), which reads them back +//! from this same fixture. +//! +//! The fee token is registered with `priceRatio = 3` against `scale = 1`, so +//! converting ETH to token units is inexact, and the transaction carries one +//! non-zero calldata byte so its gas cost is not a multiple of that ratio. Those +//! two together are the only shape where rounding the prepaid fee and the refund +//! up independently disagrees with charging the ceiling of the net fee. +//! +//! Each fork runs three consecutive gas limits, whose prepaid conversions cover +//! every remainder modulo the price ratio: +//! +//! - Celadon collects `ceil(21_016 / 3) = 7_006` on all three. +//! - Emerald and Jade collect `7_005` on the first two — one token unit short — +//! and `7_006` on the third, so they end on two distinct state roots where +//! Celadon has one. +//! +//! The 21_016 also pins the transaction's gas: morph does not apply the EIP-7623 +//! calldata floor, which would bill 21_040 and miss every root here. +use morph_statetest::runner::run_suite_str; + +#[test] +fn celadon_alt_token_refund_matches_geth() { + let outcomes = run_suite_str(include_str!("fixtures/celadon_alt_token_refund.json")).unwrap(); + assert_eq!(outcomes.len(), 9, "3 forks × 3 gas limits"); + for outcome in outcomes { + assert!( + outcome.pass, + "{} / {}: {}", + outcome.test, outcome.fork, outcome.error_msg + ); + } +} diff --git a/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json b/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json new file mode 100644 index 00000000..9f70bea0 --- /dev/null +++ b/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json @@ -0,0 +1,157 @@ +{ + "celadon_alt_token_refund_rounding": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x9734b052146069605dcf2a05300c1dd5cd5852a2844e5491b2eb25d6daa909bc": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000003" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0x1", + "gasLimit": [ + "0x186a1", + "0x186a2", + "0x186a3" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x01" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdfaedbf51f7737495c2597c811b6127d75727bc8e359c7b6261dd5ff6519a14d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 1, + "value": 0 + }, + "hash": "0xdfaedbf51f7737495c2597c811b6127d75727bc8e359c7b6261dd5ff6519a14d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 2, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdfaedbf51f7737495c2597c811b6127d75727bc8e359c7b6261dd5ff6519a14d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 1, + "value": 0 + }, + "hash": "0xdfaedbf51f7737495c2597c811b6127d75727bc8e359c7b6261dd5ff6519a14d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 2, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Celadon": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 1, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 2, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + } +} From 2524997f1073868fdbdd93b645ed20ff7897187d Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 18 Sep 2026 20:13:56 +0800 Subject: [PATCH 08/17] refactor(revm): clear the rounding credit with the other per-tx caches `cached_alt_fee_rounding_credit` was the one per-transaction cache the reset at the top of `validate_against_state_and_deduct_caller` left alone. That is safe today: the credit is only read next to `cached_token_fee_info`, and the same deduction writes both. Clearing it with the rest keeps that true without depending on where the reads happen. No behaviour change. The new test fails if the reset is removed. Claude-Session: https://claude.ai/code/session_01WYbNZVUBHa4qCoRK46taTS --- crates/revm/src/handler.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index c1eeaa67..be10eea8 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -145,6 +145,7 @@ where evm.cached_l1_data_fee = U256::ZERO; evm.pre_fee_refund = 0; evm.cached_token_fee_info = None; + evm.cached_alt_fee_rounding_credit = U256::ZERO; evm.pre_fee_logs.clear(); evm.post_fee_logs.clear(); @@ -3226,6 +3227,41 @@ mod refund_rounding_tests { /// have modulo `PRICE_RATIO`; the credit only changes the refund for some of /// those combinations, and the final assertion holds the sweep to covering /// them. + /// The credit belongs to one transaction. It is only read next to + /// `cached_token_fee_info`, which the same deduction writes, so a stale value + /// cannot reach a refund today. Clearing it with the other per-transaction + /// caches keeps that true without depending on where the reads happen. + #[test] + fn the_rounding_credit_does_not_outlive_its_transaction() { + let handler = MorphEvmHandler::default(); + let mut evm = slot_mode_evm(MorphHardfork::Celadon); + + evm.tx = fee_tx(100_000, 0); + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut InitialAndFloorGas::default()) + .expect("deduction must succeed"); + assert_eq!(evm.cached_alt_fee_rounding_credit, U256::from(2u64)); + + // The next transaction on the same EVM pays no token fee. An L1 message is + // the shortest such path: it clears the caches and returns. + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: morph_primitives::L1_TX_TYPE_ID, + caller: CALLER, + kind: TxKind::Call(Address::repeat_byte(0x0e)), + gas_limit: 100_000, + ..Default::default() + }, + ..Default::default() + }; + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut InitialAndFloorGas::default()) + .expect("an L1 message needs no fee"); + + assert!(evm.cached_token_fee_info.is_none()); + assert_eq!(evm.cached_alt_fee_rounding_credit, U256::ZERO); + } + #[test] fn celadon_charges_the_ceiling_of_the_net_fee_end_to_end() { let mut seen_remainders = [false; PRICE_RATIO as usize]; From 2b26c0fb6bc3ba10469a6387dd279ee8f36137ff Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 18 Sep 2026 20:13:56 +0800 Subject: [PATCH 09/17] test(statetest): cover every net-fee remainder in the Celadon refund fixture The fixture ran one calldata length, whose net fee of 21_016 gas leaves a remainder of 1 modulo the price ratio of 3. For that remainder the prepaid rounding credit never carries into the refund, so a client that rounds the refund down but drops the credit lands on all nine roots: the fixture pinned the fork gate and the rounding direction, not the credit. Run one, two and three non-zero calldata bytes (21_016, 21_032 and 21_048 gas, remainders 1, 2 and 0) against the same three gas limits. With the credit dropped, Celadon over-collects a token unit on three of the six new Celadon cases and misses their roots. The 27 state and logs roots come from morph-geth 5a0d0d771 (go-ethereum#371) and `evm statetest` reads them back from this file. The nine roots that were already here are unchanged. Claude-Session: https://claude.ai/code/session_01WYbNZVUBHa4qCoRK46taTS --- .../tests/celadon_alt_token_refund.rs | 43 +++-- .../fixtures/celadon_alt_token_refund.json | 166 +++++++++++++++++- 2 files changed, 195 insertions(+), 14 deletions(-) diff --git a/bin/morph-statetest/tests/celadon_alt_token_refund.rs b/bin/morph-statetest/tests/celadon_alt_token_refund.rs index 4d57f661..47627950 100644 --- a/bin/morph-statetest/tests/celadon_alt_token_refund.rs +++ b/bin/morph-statetest/tests/celadon_alt_token_refund.rs @@ -2,27 +2,44 @@ //! from this same fixture. //! //! The fee token is registered with `priceRatio = 3` against `scale = 1`, so -//! converting ETH to token units is inexact, and the transaction carries one -//! non-zero calldata byte so its gas cost is not a multiple of that ratio. Those -//! two together are the only shape where rounding the prepaid fee and the refund -//! up independently disagrees with charging the ceiling of the net fee. +//! converting ETH to token units is inexact. Each fork runs three calldata +//! lengths against three consecutive gas limits: //! -//! Each fork runs three consecutive gas limits, whose prepaid conversions cover -//! every remainder modulo the price ratio: +//! - one, two and three non-zero calldata bytes cost 21_016, 21_032 and 21_048 +//! gas, which covers every remainder of the *net* fee modulo the price ratio; +//! - the gas limits 100_001..=100_003 cover every remainder of the *prepaid* fee. //! -//! - Celadon collects `ceil(21_016 / 3) = 7_006` on all three. -//! - Emerald and Jade collect `7_005` on the first two — one token unit short — -//! and `7_006` on the third, so they end on two distinct state roots where -//! Celadon has one. +//! Tokens collected, per gas limit: //! -//! The 21_016 also pins the transaction's gas: morph does not apply the EIP-7623 -//! calldata floor, which would bill 21_040 and miss every root here. +//! | net gas | Emerald, Jade | Celadon | floor without the credit | +//! |---------|---------------------|---------------------|--------------------------| +//! | 21_016 | 7_005, 7_005, 7_006 | 7_006, 7_006, 7_006 | 7_006, 7_006, 7_006 | +//! | 21_032 | 7_011, 7_010, 7_011 | 7_011, 7_011, 7_011 | 7_011, 7_011, 7_012 | +//! | 21_048 | 7_016, 7_016, 7_016 | 7_016, 7_016, 7_016 | 7_017, 7_016, 7_017 | +//! +//! Celadon collects `ceil(net / 3)` on every gas limit, so it ends on one state +//! root per calldata length. Emerald and Jade round the prepaid fee and the refund +//! up independently and come out a token unit short on three of the nine, so the +//! first two rows end on two roots each. +//! +//! The last column is why one calldata length is not enough. With a net fee of +//! 21_016 the prepaid rounding credit never carries into the refund, so a client +//! that rounds the refund down but drops the credit still lands on every root of +//! that row. The other two rows are the ones that pin the credit itself. +//! +//! The gas figures also pin the transaction's gas: morph does not apply the +//! EIP-7623 calldata floor, which would bill 21_040 for the first row and miss +//! every root here. use morph_statetest::runner::run_suite_str; #[test] fn celadon_alt_token_refund_matches_geth() { let outcomes = run_suite_str(include_str!("fixtures/celadon_alt_token_refund.json")).unwrap(); - assert_eq!(outcomes.len(), 9, "3 forks × 3 gas limits"); + assert_eq!( + outcomes.len(), + 27, + "3 forks × 3 calldata lengths × 3 gas limits" + ); for outcome in outcomes { assert!( outcome.pass, diff --git a/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json b/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json index 9f70bea0..b4b85e92 100644 --- a/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json +++ b/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json @@ -60,7 +60,9 @@ "0x0" ], "data": [ - "0x01" + "0x01", + "0x0101", + "0x010101" ], "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" }, @@ -92,6 +94,60 @@ }, "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 0, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 1, + "value": 0 + }, + "hash": "0x8b7ea1313b3109e09cae63d493df2f9a405cad607147f72abcde55d84dee1b5b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 2, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 0, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 1, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 2, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" } ], "Jade": [ @@ -121,6 +177,60 @@ }, "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 0, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 1, + "value": 0 + }, + "hash": "0x8b7ea1313b3109e09cae63d493df2f9a405cad607147f72abcde55d84dee1b5b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 2, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 0, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 1, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 2, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" } ], "Celadon": [ @@ -150,6 +260,60 @@ }, "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 0, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 1, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 2, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 0, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 1, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 2, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" } ] } From 6acdca0a717c6ef6c4621f09375a096403f2dfb9 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 23 Sep 2026 17:06:39 +0800 Subject: [PATCH 10/17] chore(deps): bump imbl to 7.0.2 for RUSTSEC-2026-0292 imbl 7.0.0 depends on imbl-sized-chunks 0.1.3, whose Chunk and InlineArray removal methods can double-free or use-after-free when an element's Drop panics (RUSTSEC-2026-0292). imbl 7.0.2 moves to the fixed imbl-sized-chunks 0.2.0 and drops bitmaps 3.2.1. The dependency comes in through reth-transaction-pool; only Cargo.lock changes. --- Cargo.lock | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3097665d..0e96a416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1641,12 +1641,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitmaps" -version = "3.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" - [[package]] name = "bitvec" version = "1.0.1" @@ -3899,12 +3893,12 @@ dependencies = [ [[package]] name = "imbl" -version = "7.0.0" +version = "7.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e525189e5f603908d0c6e0d402cb5de9c4b2c8866151fabc4ebd771ed2630a2e" +checksum = "46bad832b9b463ed9398b8506488cc2e3b897a9d44f13af115f382aac71f4fec" dependencies = [ "archery", - "bitmaps", + "equivalent", "imbl-sized-chunks", "rand_core 0.9.5", "rand_xoshiro", @@ -3915,12 +3909,9 @@ dependencies = [ [[package]] name = "imbl-sized-chunks" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" -dependencies = [ - "bitmaps", -] +checksum = "2a0813be332553f857953298749fa19549e8b61b80589757c29b4e2a804fa9c6" [[package]] name = "impl-codec" From 201249e021fd589d4b1b4121fe2bf166ffdb625d Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 23 Sep 2026 17:07:47 +0800 Subject: [PATCH 11/17] test: stop naming test-helper parameters `nonce` CodeQL's rust/hard-coded-cryptographic-value query treats an argument bound to a parameter named `nonce` as a cryptographic nonce. Every test that passed a literal account or authorization nonce to these helpers therefore raised a critical alert, each a false positive that had to be dismissed by hand, and every new test added more. Rename the parameters to `tx_nonce` and `auth_nonce`, as the txpool test helpers already do; the query stopped reporting those after the same rename. The nonces the helpers put into transactions and authorizations are unchanged. --- crates/node/src/test_utils.rs | 54 +++++++++++-------- .../src/transaction/morph_transaction.rs | 4 +- crates/revm/src/handler.rs | 4 +- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs index fc3bd3c3..44c19b4e 100644 --- a/crates/node/src/test_utils.rs +++ b/crates/node/src/test_utils.rs @@ -549,18 +549,22 @@ pub fn wallet_at_index(idx: u32, chain_id: u64) -> PrivateKeySigner { /// Creates a signed EIP-1559 transfer transaction with an explicit nonce. /// /// Public version for use in test helpers outside this module. -pub async fn make_transfer_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> Bytes { - transfer_tx_with_nonce(chain_id, signer, nonce).await +pub async fn make_transfer_tx(chain_id: u64, signer: PrivateKeySigner, tx_nonce: u64) -> Bytes { + transfer_tx_with_nonce(chain_id, signer, tx_nonce).await } /// Creates a signed EIP-2930 (type 0x01) transaction. -pub fn make_eip2930_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> eyre::Result { +pub fn make_eip2930_tx( + chain_id: u64, + signer: PrivateKeySigner, + tx_nonce: u64, +) -> eyre::Result { use alloy_consensus::{SignableTransaction, TxEip2930}; use alloy_signer::SignerSync; let tx = TxEip2930 { chain_id, - nonce, + nonce: tx_nonce, gas_price: 20_000_000_000u128, gas_limit: 21_000, to: TxKind::Call(Address::with_last_byte(0x42)), @@ -576,13 +580,17 @@ pub fn make_eip2930_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> e } /// Creates a signed EIP-4844 (type 0x03) transaction. -pub fn make_eip4844_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> eyre::Result { +pub fn make_eip4844_tx( + chain_id: u64, + signer: PrivateKeySigner, + tx_nonce: u64, +) -> eyre::Result { use alloy_consensus::{EthereumTxEnvelope, SignableTransaction, TxEip4844}; use alloy_signer::SignerSync; let tx = TxEip4844 { chain_id, - nonce, + nonce: tx_nonce, gas_limit: 100_000, max_fee_per_gas: 20_000_000_000u128, max_priority_fee_per_gas: 20_000_000_000u128, @@ -601,7 +609,11 @@ pub fn make_eip4844_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> e } /// Creates a signed EIP-7702 (type 0x04) transaction. -pub fn make_eip7702_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> eyre::Result { +pub fn make_eip7702_tx( + chain_id: u64, + signer: PrivateKeySigner, + tx_nonce: u64, +) -> eyre::Result { use alloy_consensus::{SignableTransaction, TxEip7702}; use alloy_eips::eip7702::Authorization; use alloy_signer::SignerSync; @@ -610,7 +622,7 @@ pub fn make_eip7702_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> e let authorization = Authorization { chain_id: U256::from(chain_id), address: delegate_to, - nonce, + nonce: tx_nonce, }; let auth_sig = signer .sign_hash_sync(&authorization.signature_hash()) @@ -619,7 +631,7 @@ pub fn make_eip7702_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> e let tx = TxEip7702 { chain_id, - nonce, + nonce: tx_nonce, gas_limit: 100_000, max_fee_per_gas: 20_000_000_000u128, max_priority_fee_per_gas: 20_000_000_000u128, @@ -639,11 +651,11 @@ pub fn make_eip7702_tx(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> e /// Creates a signed EIP-1559 contract deployment transaction (CREATE). /// /// The returned bytes can be injected into the pool via `node.rpc.inject_tx()`. -/// The deployed contract address is computed by `Address::create(sender, nonce)`. +/// The deployed contract address is computed by `Address::create(sender, tx_nonce)`. pub fn make_deploy_tx( chain_id: u64, signer: PrivateKeySigner, - nonce: u64, + tx_nonce: u64, init_code: impl Into, ) -> eyre::Result { use alloy_consensus::{SignableTransaction, TxEip1559}; @@ -651,7 +663,7 @@ pub fn make_deploy_tx( let tx = TxEip1559 { chain_id, - nonce, + nonce: tx_nonce, gas_limit: 500_000, max_fee_per_gas: 20_000_000_000u128, max_priority_fee_per_gas: 20_000_000_000u128, @@ -668,9 +680,9 @@ pub fn make_deploy_tx( } /// Creates a signed EIP-1559 transfer transaction with an explicit nonce. -async fn transfer_tx_with_nonce(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> Bytes { +async fn transfer_tx_with_nonce(chain_id: u64, signer: PrivateKeySigner, tx_nonce: u64) -> Bytes { let tx = TransactionRequest { - nonce: Some(nonce), + nonce: Some(tx_nonce), value: Some(U256::from(100)), to: Some(TxKind::Call(Address::random())), gas: Some(21_000), @@ -915,7 +927,7 @@ pub fn test_token_balance_slot(account: Address) -> B256 { /// ```ignore /// use morph_node::test_utils::{MorphTxBuilder, TEST_TOKEN_ID}; /// -/// let raw = MorphTxBuilder::new(chain_id, signer, nonce) +/// let raw = MorphTxBuilder::new(chain_id, signer, tx_nonce) /// .with_v0_token_fee(TEST_TOKEN_ID) /// .build_signed()?; /// ``` @@ -923,7 +935,7 @@ pub fn test_token_balance_slot(account: Address) -> B256 { /// # Example — v1 ETH fee /// /// ```ignore -/// let raw = MorphTxBuilder::new(chain_id, signer, nonce) +/// let raw = MorphTxBuilder::new(chain_id, signer, tx_nonce) /// .with_v1_eth_fee() /// .build_signed()?; /// ``` @@ -951,11 +963,11 @@ impl MorphTxBuilder { /// /// Defaults to v0, fee_token_id=0 (must call `with_v0_token_fee` or /// `with_v1_eth_fee` before building). - pub fn new(chain_id: u64, signer: PrivateKeySigner, nonce: u64) -> Self { + pub fn new(chain_id: u64, signer: PrivateKeySigner, tx_nonce: u64) -> Self { Self { chain_id, signer, - nonce, + nonce: tx_nonce, gas_limit: 100_000, max_fee_per_gas: 20_000_000_000u128, max_priority_fee_per_gas: 20_000_000_000u128, @@ -1143,20 +1155,20 @@ impl MorphTxBuilder { /// Signs an EIP-7702 authorization tuple delegating `authority` (the signer) /// to `delegate`, for use in `0x04` or MorphTx v2 authorization lists. /// -/// `nonce` must be the authority's nonce at the time the tuple is applied: +/// `auth_nonce` must be the authority's nonce at the time the tuple is applied: /// for a self-delegating sender that is `tx.nonce + 1`. pub fn sign_authorization( signer: &PrivateKeySigner, chain_id: u64, delegate: Address, - nonce: u64, + auth_nonce: u64, ) -> eyre::Result { use alloy_signer::SignerSync; let authorization = alloy_eips::eip7702::Authorization { chain_id: U256::from(chain_id), address: delegate, - nonce, + nonce: auth_nonce, }; let auth_sig = signer .sign_hash_sync(&authorization.signature_hash()) diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 116300be..8789362e 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -2624,11 +2624,11 @@ mod tests { /// A syntactically valid authorization tuple (the signature is not /// recoverable; recovery only matters at execution time). - fn sample_authorization(nonce: u64) -> SignedAuthorization { + fn sample_authorization(auth_nonce: u64) -> SignedAuthorization { Authorization { chain_id: U256::from(2818), address: address!("2222222222222222222222222222222222222222"), - nonce, + nonce: auth_nonce, } .into_signed(Signature::new( U256::from(0x1111u64), diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index be10eea8..27fc6118 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1736,13 +1736,13 @@ mod tests { authority: Address, delegate: Address, chain_id: u64, - nonce: u64, + auth_nonce: u64, ) -> Either { Either::Right(RecoveredAuthorization::new_unchecked( Authorization { chain_id: U256::from(chain_id), address: delegate, - nonce, + nonce: auth_nonce, }, RecoveredAuthority::Valid(authority), )) From 40fd5bde71831f73dcf643e3a7c79fbc0052d74f Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 23 Sep 2026 14:38:40 +0800 Subject: [PATCH 12/17] fix(txpool): keep token-fee MorphTx pending and revalidate fees per block Rebased onto the MorphTx v2 branch (#211). The execution-side half of the original series landed in main through #210, so this carries the pool side only: - A token-fee MorphTx reports only its ETH value through `cost()`, so a sender holding tokens but no ETH lands in pending instead of queued and is propagated. The local fee cap still applies to `gas_limit * max_fee_per_gas`. - Maintenance revalidates L1 fees for every sender and fee-token balances for MorphTx against each new head, per transaction like admission and go-ethereum. It skips mined nonces, stops at a nonce gap, leaves pure ETH shortfalls to reth, and removes only the first offending transaction so the pool parks its descendants. - The canonical-head callback publishes L1 fee parameters and the block's EVM environment together. Admission opens state at that head, so a call-mode `balanceOf` runs in the environment of the block whose state it reads. - `MorphTxValidationError` separates state-read failures from invalid transactions: unreadable state neither evicts nor blames a transaction. - `TokenRegistryEntry` is public again so maintenance can cache registry entries per token and balances per (sender, token) for one round. --- crates/node/src/components/pool.rs | 17 +- crates/revm/src/lib.rs | 2 +- crates/revm/src/token_fee.rs | 9 +- crates/txpool/src/error.rs | 43 +- crates/txpool/src/lib.rs | 4 +- crates/txpool/src/maintain.rs | 1619 +++++++++++++++++----- crates/txpool/src/morph_tx_validation.rs | 133 +- crates/txpool/src/transaction.rs | 99 +- crates/txpool/src/validator.rs | 766 +++++++--- 9 files changed, 2093 insertions(+), 599 deletions(-) diff --git a/crates/node/src/components/pool.rs b/crates/node/src/components/pool.rs index d3ec9799..ae4d9199 100644 --- a/crates/node/src/components/pool.rs +++ b/crates/node/src/components/pool.rs @@ -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()) @@ -83,12 +83,15 @@ where // Spawn standard pool maintenance tasks (from reth) spawn_maintenance_tasks(ctx, pool.clone(), &pool_config)?; - // Spawn Morph-specific maintenance task for MorphTx (0x7F) revalidation - // This handles ERC20 token balance changes that reth's standard maintenance - // cannot track (reth only tracks ETH balance via SenderInfo) - ctx.task_executor().spawn_critical_task( + // Revalidate L1 fees for all senders and ERC20 balances for MorphTx (0x7F). + // Reth's standard maintenance only tracks ETH costs without L1 data fees. + ctx.task_executor().spawn_critical_blocking_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"); diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index a887c914..5e420dda 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -73,7 +73,7 @@ pub use l1block::{ }; pub use precompiles::MorphPrecompiles; pub use token_fee::{ - L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, compute_mapping_slot, + L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, TokenRegistryEntry, compute_mapping_slot, compute_mapping_slot_for_address, encode_balance_of_calldata, }; pub use tx::{MorphTxEnv, MorphTxExt}; diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index 445035c2..43b17022 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -56,7 +56,7 @@ pub struct TokenFeeInfo { /// Fee-token registry metadata without any caller-specific balance state. #[derive(Clone, Copy, Debug)] -pub(crate) struct TokenRegistryEntry { +pub struct TokenRegistryEntry { token_address: Address, is_active: bool, decimals: u8, @@ -79,10 +79,7 @@ impl TokenRegistryEntry { } /// Load fee-token metadata without reading a caller's token balance. - pub(crate) fn load( - db: &mut DB, - token_id: u16, - ) -> Result, DB::Error> { + pub fn load(db: &mut DB, token_id: u16) -> Result, DB::Error> { read_registry_entry(db, token_id) } @@ -98,7 +95,7 @@ impl TokenRegistryEntry { } /// Resolve the caller's balance to produce complete fee information. - pub(crate) fn load_for_caller( + pub fn load_for_caller( self, db: &mut DB, caller: Address, diff --git a/crates/txpool/src/error.rs b/crates/txpool/src/error.rs index d676b0bb..f3d09afa 100644 --- a/crates/txpool/src/error.rs +++ b/crates/txpool/src/error.rs @@ -54,12 +54,10 @@ pub enum MorphTxError { value: U256, }, - /// Failed to fetch token information from state. - TokenInfoFetchFailed { - /// The token ID. + /// The token's balanceOf call reverted or returned malformed data. + TokenBalanceQueryFailed { + /// Token whose balance could not be evaluated. token_id: u16, - /// Error message. - message: String, }, /// MorphTx format validation failed (version, memo length, gas fee ordering). @@ -105,8 +103,8 @@ impl fmt::Display for MorphTxError { "insufficient ETH balance for transaction value: balance {balance}, value {value}" ) } - Self::TokenInfoFetchFailed { token_id, message } => { - write!(f, "failed to fetch token info for ID {token_id}: {message}") + Self::TokenBalanceQueryFailed { token_id } => { + write!(f, "balanceOf failed for token ID {token_id}") } Self::InvalidFormat { reason } => { write!(f, "invalid MorphTx format: {reason}") @@ -129,11 +127,9 @@ impl PoolTransactionError for MorphTxError { // Token not found or not active - could be due to temporary state, not penalizable Self::TokenNotFound { .. } | Self::TokenNotActive { .. } => false, // Invalid price ratio - configuration issue, not penalizable - Self::InvalidPriceRatio { .. } => false, + Self::InvalidPriceRatio { .. } | Self::TokenBalanceQueryFailed { .. } => false, // Insufficient balance or fee limit - normal validation failure Self::InsufficientTokenBalance { .. } | Self::InsufficientEthForValue { .. } => false, - // Fetch failures - infrastructure issue, not penalizable - Self::TokenInfoFetchFailed { .. } => false, } } @@ -160,6 +156,22 @@ impl From for InvalidPoolTransactionError { } } +/// Separates a transaction verdict from an unavailable validation state. +/// Only `Invalid` can become an `InvalidPoolTransactionError`. +#[derive(Debug, PartialEq, Eq)] +pub enum MorphTxValidationError { + /// The state was read successfully and the transaction failed validation. + Invalid(MorphTxError), + /// Validation could not read the required state; retry without blaming the transaction. + State(E), +} + +impl From for MorphTxValidationError { + fn from(error: MorphTxError) -> Self { + Self::Invalid(error) + } +} + #[cfg(test)] mod tests { use super::*; @@ -258,13 +270,6 @@ mod tests { assert!(!MorphTxError::TokenNotFound { token_id: 1 }.is_bad_transaction()); assert!(!MorphTxError::TokenNotActive { token_id: 1 }.is_bad_transaction()); assert!(!MorphTxError::InvalidPriceRatio { token_id: 1 }.is_bad_transaction()); - assert!( - !MorphTxError::TokenInfoFetchFailed { - token_id: 1, - message: "error".into() - } - .is_bad_transaction() - ); } #[test] @@ -285,10 +290,6 @@ mod tests { balance: U256::from(5u64), value: U256::from(10u64), }, - MorphTxError::TokenInfoFetchFailed { - token_id: 5, - message: "db error".into(), - }, MorphTxError::InvalidFormat { reason: "bad version".into(), }, diff --git a/crates/txpool/src/lib.rs b/crates/txpool/src/lib.rs index 52ca426b..a0f29dfb 100644 --- a/crates/txpool/src/lib.rs +++ b/crates/txpool/src/lib.rs @@ -33,13 +33,13 @@ #![cfg_attr(docsrs, feature(doc_cfg), allow(unexpected_cfgs))] mod error; -pub use error::MorphTxError; +pub use error::{MorphTxError, MorphTxValidationError}; mod transaction; pub use transaction::MorphPooledTransaction; mod validator; -pub use validator::{MorphL1BlockInfo, MorphTransactionValidator}; +pub use validator::{MorphL1BlockInfo, MorphTransactionValidator, MorphValidationState}; mod maintain; pub use maintain::maintain_morph_pool; diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 7f54f551..df0147fa 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -1,19 +1,37 @@ //! Transaction pool maintenance tasks for Morph L2. //! //! This module provides maintenance tasks for the Morph transaction pool, -//! specifically for revalidating MorphTx (0x7F) transactions when the chain -//! state changes. +//! revalidating L1 fee affordability and MorphTx (0x7F) token balances when the +//! chain state changes. //! //! # Background //! //! MorphTx allows users to pay gas fees using ERC20 tokens. Since reth's txpool //! only tracks ETH balance changes (via `SenderInfo`), it cannot automatically -//! demote MorphTx transactions when the token balance decreases. +//! demote MorphTx transactions when the token balance decreases. Its transaction +//! cost also excludes L1 data fees, so every sender needs L1 fee revalidation. //! //! This maintenance task solves this by: //! 1. Listening to canonical state changes (new blocks) -//! 2. Re-validating all MorphTx transactions in the pool -//! 3. Removing transactions that no longer have sufficient token balance +//! 2. Re-validating each sender's contiguous nonce sequence against current account balances +//! 3. Removing the first transaction with an L1 fee or token shortfall that reth cannot +//! handle, letting the pool park its descendants +//! +//! # Relationship with reth's own maintenance task +//! +//! This task runs *alongside* [`reth_transaction_pool::maintain::maintain_transaction_pool`], +//! and both subscribe to the canonical state stream independently — there is no ordering +//! guarantee between them. Everything reth's task already understands (ETH balance, nonces, +//! base fee, mined transactions) stays its responsibility. This task also checks the +//! sender's **ERC20 token** balance and the **L1 data fees** missing from reth's cost. +//! An ordinary transaction that cannot cover L1 fees is +//! removed so its descendants are parked instead of remaining unchecked in pending. +//! +//! Because the ordering is not guaranteed, this task must tolerate seeing a pool snapshot +//! that still contains transactions the new block already executed. It does so by reading +//! the sender's on-chain nonce and skipping everything below it, mirroring +//! `AllTransactions::update`, which discards those transactions before any affordability +//! check, and go-ethereum's `demoteUnexecutables`, which calls `list.Forward(nonce)` first. //! //! # Reference //! @@ -21,101 +39,192 @@ //! and `demoteUnexecutables` (tx_pool.go), but implemented as a separate //! maintenance task since we cannot modify reth's internal pool logic. -use crate::MorphPooledTransaction; +use crate::{MorphPooledTransaction, MorphTxValidationError}; use alloy_consensus::Transaction; use alloy_consensus::Typed2718; -use alloy_primitives::{Address, TxHash, U256}; -use futures::StreamExt; -use morph_chainspec::hardfork::MorphHardforks; -use morph_revm::L1BlockInfo; +use alloy_primitives::{Address, TxHash}; +use futures::{FutureExt, StreamExt}; +use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; +use morph_revm::{L1BlockInfo, MorphBlockEnv, MorphEvmEnv}; use reth_chainspec::ChainSpecProvider; +use reth_evm::{ConfigureEvm, EvmFactory, EvmFactoryFor}; use reth_primitives_traits::AlloyBlockHeader; use reth_provider::CanonStateSubscriptions; -use reth_revm::Database; use reth_revm::database::StateProviderDatabase; use reth_storage_api::StateProviderFactory; use reth_transaction_pool::{PoolTransaction, TransactionPool}; use std::collections::HashMap; -/// Sender-level rolling affordability budget used during maintenance revalidation. -#[derive(Debug, Clone, Default)] -struct SenderBudget { - /// Remaining ETH budget for this sender. - eth_balance: U256, - /// Remaining token budget per `fee_token_id`. - token_balances: HashMap, +fn exceeds_block_gas_limit(tx_gas_limit: u64, block_gas_limit: u64) -> bool { + tx_gas_limit > block_gas_limit } -/// Applies cumulative sender-budget check for the ETH-fee path and consumes budget on success. +/// Determines which transactions to remove while revalidating all senders' fee affordability. /// -/// Returns `true` if the transaction can be afforded under the current rolling ETH budget. -fn consume_eth_budget( - budget: &mut SenderBudget, - tx_value: U256, - gas_limit: u64, - max_fee_per_gas: u128, - l1_data_fee: U256, -) -> bool { - let gas_fee = U256::from(gas_limit).saturating_mul(U256::from(max_fee_per_gas)); - let total_eth_cost = gas_fee.saturating_add(l1_data_fee).saturating_add(tx_value); - if total_eth_cost > budget.eth_balance { - return false; - } - budget.eth_balance = budget.eth_balance.saturating_sub(total_eth_cost); - true -} +/// Returns the hashes to remove from the pool. Only the first offending transaction of a +/// sender is returned: the pool parks the rest of that sender's transactions on its own when +/// the returned hash is removed (see [`maintain_morph_pool`]). +fn collect_removable_transactions( + db: &mut DB, + l1_block_info: &L1BlockInfo, + evm_env: &MorphEvmEnv, + block_gas_limit: u64, + pool_txs: Vec<&MorphPooledTransaction>, +) -> Vec { + let hardfork = *evm_env.cfg_env.spec(); + // These caches live for exactly this provider/environment snapshot. Cache successful + // reads only; a later block must re-read changed registry parameters and balances. + let mut token_entries = HashMap::new(); + let mut token_balances = HashMap::new(); + // Group by sender and process in nonce order so removing a transaction parks its descendants. + let mut txs_by_sender: HashMap> = HashMap::new(); + for tx in pool_txs { + txs_by_sender.entry(tx.sender()).or_default().push(tx); + } -/// Applies cumulative sender-budget check for the token-fee path and consumes budget on success. -/// -/// Returns `true` if the transaction can be afforded under the current rolling token/ETH budget. -fn consume_token_budget( - budget: &mut SenderBudget, - tx_value: U256, - token_id: Option, - fee_limit: Option, - required_token_amount: U256, - state_token_balance: Option, -) -> bool { - let (token_id, fee_limit) = match (token_id, fee_limit) { - (Some(token_id), Some(fee_limit)) => (token_id, fee_limit), - _ => return false, - }; + let mut to_remove: Vec = Vec::new(); - let token_budget = budget - .token_balances - .entry(token_id) - .or_insert(state_token_balance.unwrap_or(U256::ZERO)); - - // Match REVM semantics with rolling sender budget: - // - fee_limit == 0 => use remaining token budget - // - fee_limit > remaining => cap by remaining token budget - let effective_limit = if fee_limit.is_zero() || fee_limit > *token_budget { - *token_budget - } else { - fee_limit - }; + for (sender, mut sender_txs) in txs_by_sender { + sender_txs.sort_by_key(|tx| tx.transaction().nonce()); - if effective_limit < required_token_amount || tx_value > budget.eth_balance { - return false; - } + // Read one account per sender. Affordability is per transaction, matching + // admission and geth; cumulative ETH parking remains owned by reth. + let account = match db.basic(sender) { + Ok(account) => account.unwrap_or_default(), + Err(err) => { + tracing::warn!( + target: "morph::txpool::maintain", + ?sender, + ?err, + "Failed to get account info; skipping sender" + ); + continue; + } + }; - *token_budget = (*token_budget).saturating_sub(required_token_amount); - budget.eth_balance = budget.eth_balance.saturating_sub(tx_value); - true -} + // The nonce the next executable transaction of this sender must carry. + let mut next_nonce_in_line = account.nonce; -fn exceeds_block_gas_limit(tx_gas_limit: u64, block_gas_limit: u64) -> bool { - tx_gas_limit > block_gas_limit + for tx in sender_txs { + // Access the consensus tx by reference (via Deref chain) instead of + // cloning. Use the pool tx's cached EIP-2718 encoding for L1 fee. + let consensus_tx = tx.transaction(); + + // Already executed by the new block. reth's own maintenance task removes these + // when it applies the same canonical update; both tasks subscribe to the + // canonical stream independently, so this one can still observe them here. + // Charging them would consume a budget the sender no longer owes and strand the + // sender's next, genuinely affordable transaction. + if consensus_tx.nonce() < account.nonce { + continue; + } + + // Nonce gap: the transactions filling it are not in the pool, so how much of + // this sender's balance is still owed by the time this one executes is unknown, + // and nothing from here on is executable anyway. Upstream's + // `AllTransactions::update` short-circuits the sender on a gap for the same + // reason, and go-ethereum only ever applies a per-transaction cost check to its + // queue, never a cumulative one. Anything left behind the gap sits in the queued + // sub-pool, where reth's own stale eviction reaps it. + if consensus_tx.nonce() != next_nonce_in_line { + break; + } + next_nonce_in_line = next_nonce_in_line.saturating_add(1); + + // Reth only sets its block-gas-limit flag at insertion, so a later + // limit reduction needs the same explicit removal for both tx types. + if exceeds_block_gas_limit(consensus_tx.gas_limit(), block_gas_limit) { + to_remove.push(*tx.hash()); + break; + } + + // Reth knows this ETH cost (only value for token-fee MorphTx) and can + // park the transaction until a balance update makes it affordable again. + if *tx.cost() > account.balance { + break; + } + + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(tx.encoded_2718(), hardfork); + if consensus_tx.ty() != morph_primitives::MORPH_TX_TYPE_ID { + if tx.cost().saturating_add(l1_data_fee) > account.balance { + to_remove.push(*tx.hash()); + break; + } + continue; + } + + // Validate each transaction against the same chain balance used at admission. + let input = crate::MorphTxValidationInput { + consensus_tx, + sender, + eth_balance: account.balance, + l1_data_fee, + hardfork, + evm_env, + }; + + match crate::morph_tx_validation::validate_morph_tx_with_token_info( + &input, + |token_id| { + use morph_revm::TokenRegistryEntry; + let entry = match token_entries.entry(token_id) { + std::collections::hash_map::Entry::Occupied(entry) => *entry.get(), + std::collections::hash_map::Entry::Vacant(entry) => { + *entry.insert(TokenRegistryEntry::load(db, token_id)?) + } + }; + let Some(entry) = entry else { + return Ok(None); + }; + let info = match token_balances.entry((sender, token_id)) { + std::collections::hash_map::Entry::Occupied(info) => *info.get(), + std::collections::hash_map::Entry::Vacant(info) => { + *info.insert(entry.load_for_caller(db, sender, evm_env)?) + } + }; + Ok(Some(info)) + }, + ) { + Ok(_) => {} + Err(MorphTxValidationError::State(err)) => { + tracing::warn!( + target: "morph::txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + ?err, + "Could not read token state; leaving sender's MorphTx in the pool" + ); + break; + } + Err(MorphTxValidationError::Invalid(err)) => { + tracing::debug!( + target: "morph::txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + ?err, + "Removing MorphTx: validation failed" + ); + to_remove.push(*tx.hash()); + break; + } + }; + } + } + + to_remove } -/// Maintains the Morph transaction pool by revalidating MorphTx transactions. +/// Maintains the Morph transaction pool by revalidating L1 fees and token balances. /// /// This task runs continuously and: /// - Listens for new canonical blocks /// - Re-validates MorphTx (0x7F) transactions in the pool /// - Removes transactions that no longer have sufficient token balance +/// - Re-validates L1 fee affordability for every sender, including ordinary-only senders +/// - Removes ordinary transactions whose L1 fees make them individually unaffordable, +/// parking their descendants /// -pub async fn maintain_morph_pool(pool: Pool, client: Client) +pub async fn maintain_morph_pool(pool: Pool, client: Client, evm_config: Evm) where Pool: TransactionPool + Clone, Client: ChainSpecProvider @@ -123,209 +232,130 @@ where + CanonStateSubscriptions + Clone + 'static, + Evm: ConfigureEvm::Primitives>, + EvmFactoryFor: EvmFactory, { - let mut chain_events = client.canonical_state_stream(); + let chain_events = client.canonical_state_stream(); + + tracing::info!(target: "morph::txpool::maintain", "Starting Morph fee maintenance task"); - tracing::info!(target: "morph::txpool::maintain", "Starting MorphTx maintenance task"); + maintain_morph_pool_with(pool, client, evm_config, chain_events).await; +} +/// [`maintain_morph_pool`] with an explicit canonical event stream. +async fn maintain_morph_pool_with( + pool: Pool, + client: Client, + evm_config: Evm, + mut chain_events: Events, +) where + Pool: TransactionPool + Clone, + Client: ChainSpecProvider + + StateProviderFactory + + CanonStateSubscriptions + + Clone + + 'static, + Evm: ConfigureEvm::Primitives>, + EvmFactoryFor: EvmFactory, + Events: + futures::Stream> + Unpin, +{ + let mut pending_event = None; loop { - // Wait for the next canonical state change - let Some(event) = chain_events.next().await else { + // Reuse a newer notification that superseded the previous scan. + let event = match pending_event.take() { + Some(event) => Some(event), + None => chain_events.next().await, + }; + let Some(mut event) = event else { tracing::debug!(target: "morph::txpool::maintain", "Chain event stream ended"); break; }; + // Skip ahead to the newest queued notification. A round reads each sender's account + // and any fee-token state, so the chain can advance while we are working; the verdicts + // this task produces are a pure function of the latest state, which makes every + // intermediate block wasted work against a stale view of the pool. + while let Some(next) = chain_events.next().now_or_never().flatten() { + event = next; + } + let new_tip = event.tip(); let block_number = new_tip.number(); - let block_timestamp = new_tip.timestamp(); let block_gas_limit = new_tip.gas_limit(); tracing::trace!( target: "morph::txpool::maintain", block_number, - "Processing new block for MorphTx validation" + "Processing new block for pool fee validation" ); - // Get the hardfork at this block - let hardfork = client - .chain_spec() - .morph_hardfork_at(block_number, block_timestamp); - - // Collect all MorphTx transactions from the pool + // Preserve each sender's complete nonce sequence, including ordinary ETH-fee + // transactions between MorphTx. Filtering first would create false nonce gaps. let all_txs = pool.all_transactions(); - let morph_txs: Vec<_> = all_txs + let pool_txs: Vec<&MorphPooledTransaction> = all_txs .pending .iter() .chain(all_txs.queued.iter()) - .filter(|tx| tx.transaction.ty() == morph_primitives::MORPH_TX_TYPE_ID) + .map(|tx| &tx.transaction) .collect(); - if morph_txs.is_empty() { + if pool_txs.is_empty() { continue; } - // Get state provider for the new tip - let state_provider = match client.state_by_block_hash(new_tip.hash()) { - Ok(provider) => provider, + let state = match crate::validator::validation_state_for_header( + &client, + &evm_config, + new_tip.header(), + ) { + Ok(state) => state, Err(err) => { - tracing::warn!( - target: "morph::txpool::maintain", - %err, - "Failed to get state provider for MorphTx revalidation" - ); - continue; - } - }; - - let mut db = StateProviderDatabase::new(state_provider); - - // Fetch L1 block info for fee calculation - let l1_block_info = match L1BlockInfo::try_fetch(&mut db, hardfork) { - Ok(info) => info, - Err(err) => { - tracing::warn!( - target: "morph::txpool::maintain", - ?err, - "Failed to fetch L1 block info for MorphTx revalidation" - ); + tracing::warn!(target: "morph::txpool::maintain", %err, "Failed to prepare fee revalidation state"); continue; } }; + let mut db = StateProviderDatabase::new(state.provider); + let l1_block_info = state.head.l1_block_info; + let evm_env = &state.head.evm_env; tracing::trace!( target: "morph::txpool::maintain", - count = morph_txs.len(), - "Revalidating MorphTx transactions" + count = pool_txs.len(), + "Revalidating pooled transaction fees" ); - // Group by sender and process in nonce order so affordability is validated cumulatively. - let mut txs_by_sender: HashMap> = HashMap::new(); - for pooled_tx in morph_txs { - let sender = pooled_tx.transaction.sender(); - txs_by_sender.entry(sender).or_default().push(pooled_tx); - } - - // Revalidate each sender's MorphTx set and collect invalid ones - let mut to_remove: Vec = Vec::new(); - - for (sender, mut sender_txs) in txs_by_sender { - sender_txs.sort_by_key(|pooled_tx| pooled_tx.transaction.nonce()); - - // Initialize sender ETH budget once. - let eth_balance = match db.basic(sender) { - Ok(Some(account)) => account.balance, - Ok(None) => U256::ZERO, - Err(err) => { - tracing::warn!( - target: "morph::txpool::maintain", - ?sender, - ?err, - "Failed to get account balance" - ); - continue; - } - }; - - let mut budget = SenderBudget { - eth_balance, - token_balances: HashMap::new(), - }; - - for pooled_tx in sender_txs { - let tx = &pooled_tx.transaction; - // Access the consensus tx by reference (via Deref chain) instead of - // cloning. Use the pool tx's cached EIP-2718 encoding for L1 fee. - let consensus_tx = tx.transaction(); - - if exceeds_block_gas_limit(consensus_tx.gas_limit(), block_gas_limit) { - tracing::debug!( - target: "morph::txpool::maintain", - tx_hash = ?tx.hash(), - ?sender, - tx_gas_limit = consensus_tx.gas_limit(), - block_gas_limit, - "Removing MorphTx: gas limit exceeds current block gas limit" - ); - to_remove.push(*tx.hash()); - break; - } + let to_remove = collect_removable_transactions( + &mut db, + &l1_block_info, + evm_env, + block_gas_limit, + pool_txs, + ); - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(tx.encoded_2718(), hardfork); - - // Use shared validation logic first with current sender ETH budget. - let input = crate::MorphTxValidationInput { - consensus_tx, - sender, - eth_balance: budget.eth_balance, - l1_data_fee, - hardfork, - }; - - let validation = match crate::validate_morph_tx(&mut db, &input) { - Ok(v) => v, - Err(err) => { - tracing::debug!( - target: "morph::txpool::maintain", - tx_hash = ?tx.hash(), - ?sender, - ?err, - "Removing MorphTx: validation failed" - ); - to_remove.push(*tx.hash()); - break; - } - }; - - let fields = consensus_tx.morph_fields(); - let state_token_balance = validation.token_info.as_ref().map(|info| info.balance); - let token_id = fields.as_ref().map(|f| f.fee_token_id); - let fee_limit = fields.as_ref().map(|f| f.fee_limit); - - let affordable = if validation.uses_token_fee { - consume_token_budget( - &mut budget, - consensus_tx.value(), - token_id, - fee_limit, - validation.required_token_amount, - state_token_balance, - ) - } else { - consume_eth_budget( - &mut budget, - consensus_tx.value(), - consensus_tx.gas_limit(), - consensus_tx.max_fee_per_gas(), - l1_data_fee, - ) - }; - if !affordable { - tracing::debug!( - target: "morph::txpool::maintain", - tx_hash = ?tx.hash(), - ?sender, - uses_token_fee = validation.uses_token_fee, - token_id = ?token_id, - required_token_amount = ?validation.required_token_amount, - "Removing MorphTx: insufficient cumulative sender budget" - ); - to_remove.push(*tx.hash()); - break; - } - } + // A new block may arrive during this synchronous scan. Its balance changes + // supersede the verdicts we just calculated; re-scan before deleting anything. + if let Some(event) = chain_events.next().now_or_never().flatten() { + pending_event = Some(event); + continue; } - // Remove invalid transactions and all higher-nonce descendants from the same sender. - // Using remove_transactions_and_descendants ensures that nonce-dependent txs are cleaned - // up immediately rather than becoming orphans that are re-validated every block. + // Remove the offending transactions. `remove_transactions` *parks* each removed + // transaction's descendants instead of deleting them (upstream + // `remove_transaction_by_hash` calls `park_descendant_transactions`), so a + // higher-nonce transaction that is still affordable on its own — a plain ETH-fee + // transaction, say — survives in the queued sub-pool and becomes executable again + // once a replacement for the removed nonce arrives. go-ethereum's + // `demoteUnexecutables` does the same thing by re-enqueueing its `invalids` + // (core/tx_pool.go:1888) rather than dropping them. if !to_remove.is_empty() { let count = to_remove.len(); - pool.remove_transactions_and_descendants(to_remove); + pool.remove_transactions(to_remove); tracing::info!( target: "morph::txpool::maintain", count, block_number, - "Removed invalid MorphTx transactions" + "Removed transactions during pool fee revalidation" ); } } @@ -334,146 +364,1053 @@ where #[cfg(test)] mod tests { use super::*; + use alloy_primitives::U256; #[test] - fn consume_eth_fee_path_updates_budget_and_rejects_when_exhausted() { - let mut budget = SenderBudget { - eth_balance: U256::from(100u64), - token_balances: HashMap::new(), + fn gas_limit_check_rejects_transactions_above_block_limit() { + assert!(exceeds_block_gas_limit(30_000_001, 30_000_000)); + assert!(!exceeds_block_gas_limit(30_000_000, 30_000_000)); + } + + // --------------------------------------------------------------------------------- + // Revalidation round tests + // + // These drive `collect_removable_transactions` against a hand-built state so the + // removal verdict can be asserted without a pool, and one pool-level test covers the + // descendant handling that only the pool can show. + // --------------------------------------------------------------------------------- + + use alloy_consensus::{Signed, transaction::Recovered}; + use alloy_eips::eip2718::Encodable2718; + use alloy_primitives::{Signature, TxKind, address}; + use morph_primitives::{MorphTxEnvelope, TxMorph}; + use morph_revm::{ + L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot, compute_mapping_slot_for_address, + }; + use reth_revm::revm; + use reth_revm::revm::database::{CacheDB, EmptyDB}; + use reth_revm::revm::state::AccountInfo; + + const SIGNER: Address = address!("0000000000000000000000000000000000000001"); + const FEE_TOKEN: Address = address!("5300000000000000000000000000000000000042"); + const TOKEN_ID: u16 = 1; + const BALANCE_SLOT: u64 = 7; + /// `gas_limit * max_fee_per_gas` of [`token_fee_tx`]; at a 1:1 price ratio this is also + /// the per-transaction token requirement at admission and revalidation. + const TX_TOKEN_BUDGET: u64 = 21_000 * 100; + + fn token_id_key(token_id: u16) -> [u8; 32] { + let mut key = [0u8; 32]; + key[30..32].copy_from_slice(&token_id.to_be_bytes()); + key + } + + /// State with [`TOKEN_ID`] registered as an active slot-mode token at a 1:1 price ratio. + fn test_state(account_nonce: u64, eth_balance: u64, token_balance: u64) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info( + SIGNER, + AccountInfo { + nonce: account_nonce, + balance: U256::from(eth_balance), + ..Default::default() + }, + ); + + let token_key = token_id_key(TOKEN_ID); + let base = compute_mapping_slot(U256::from(151), &token_key); + let mut packed = [0u8; 32]; + packed[30] = 18; // decimals + packed[31] = 1; // isActive + for (slot, value) in [ + (base, U256::from_be_bytes(FEE_TOKEN.into_word().0)), + // `balanceSlot` is stored as the actual slot plus one. + (base + U256::from(1), U256::from(BALANCE_SLOT + 1)), + (base + U256::from(2), U256::from_be_bytes(packed)), + (base + U256::from(3), U256::from(1)), // scale + ( + compute_mapping_slot(U256::from(153), &token_key), + U256::from(1), // priceRatio + ), + ] { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + } + + db.insert_account_storage( + FEE_TOKEN, + compute_mapping_slot_for_address(U256::from(BALANCE_SLOT), SIGNER), + U256::from(token_balance), + ) + .unwrap(); + + db + } + + /// A token-fee MorphTx requiring [`TX_TOKEN_BUDGET`] tokens and no ETH. + fn token_fee_tx(tx_nonce: u64) -> MorphPooledTransaction { + token_fee_tx_with_value(tx_nonce, U256::ZERO) + } + + fn token_fee_tx_with_value(tx_nonce: u64, value: U256) -> MorphPooledTransaction { + let tx = TxMorph { + chain_id: 2818, + nonce: tx_nonce, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value, + fee_token_id: TOKEN_ID, + fee_limit: U256::ZERO, + ..Default::default() }; + let recovered = Recovered::new_unchecked( + MorphTxEnvelope::Morph(Signed::new_unhashed(tx, Signature::test_signature())), + SIGNER, + ); + let encoded_len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, encoded_len) + } - let first = consume_eth_budget(&mut budget, U256::from(20u64), 10, 3, U256::from(5u64)); - assert!(first); - // total cost = value(20) + gas(30) + l1(5) = 55 - assert_eq!(budget.eth_balance, U256::from(45u64)); + /// The environment the revalidation round is evaluated in. + fn test_evm_env() -> MorphEvmEnv { + MorphEvmEnv::new( + reth_revm::revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + MorphBlockEnv::default(), + ) + } - let second = consume_eth_budget(&mut budget, U256::from(20u64), 10, 3, U256::from(5u64)); - assert!(!second); - assert_eq!(budget.eth_balance, U256::from(45u64)); + fn removable(db: &mut CacheDB, txs: Vec<&MorphPooledTransaction>) -> Vec { + collect_removable_transactions( + db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + txs, + ) } #[test] - fn consume_token_fee_path_tracks_cumulative_token_budget() { - let mut budget = SenderBudget { - eth_balance: U256::from(10u64), - token_balances: HashMap::new(), - }; + fn transactions_already_executed_by_the_block_do_not_consume_the_budget_again() { + // The block executed nonce 0, which cost far less than the `TX_TOKEN_BUDGET` it + // reserved, so the post-state still affords nonce 1 — but not both at max fee. + let mut db = test_state(1, 0, TX_TOKEN_BUDGET + TX_TOKEN_BUDGET / 2); + let (tx0, tx1) = (token_fee_tx(0), token_fee_tx(1)); - let first = consume_token_budget( - &mut budget, - U256::ZERO, - Some(7), - Some(U256::ZERO), // fee_limit=0 => use full remaining budget - U256::from(60u64), - Some(U256::from(100u64)), + assert!( + removable(&mut db, vec![&tx0, &tx1]).is_empty(), + "nonce 1 is affordable against the post-state and nonce 0 is already mined" ); - assert!(first); + } + + #[test] + fn individually_affordable_token_transactions_are_retained() { + // Each transaction passes admission against the same account balance. + // Maintenance must not evict one merely because their maximum costs add up. + let mut db = test_state(0, 0, TX_TOKEN_BUDGET + TX_TOKEN_BUDGET / 2); + let (tx0, tx1) = (token_fee_tx(0), token_fee_tx(1)); + + assert!(removable(&mut db, vec![&tx0, &tx1]).is_empty()); + } + + #[test] + fn an_ordinary_transaction_between_morph_txs_does_not_hide_the_successor() { + let mut db = test_state(0, 10_000_000, TX_TOKEN_BUDGET - 1); + let (first, middle, last) = (legacy_tx(0), legacy_tx(1), token_fee_tx(2)); assert_eq!( - budget.token_balances.get(&7).copied(), - Some(U256::from(40u64)) + removable(&mut db, vec![&first, &middle, &last]), + vec![*last.hash()] ); + } - let second = consume_token_budget( - &mut budget, - U256::ZERO, - Some(7), - Some(U256::ZERO), - U256::from(50u64), - None, - ); - assert!(!second); - assert_eq!( - budget.token_balances.get(&7).copied(), - Some(U256::from(40u64)) + #[test] + fn ordinary_predecessors_do_not_cause_a_morph_tx_value_to_be_evicted() { + let mut db = test_state(0, TX_TOKEN_BUDGET + 6, 10 * TX_TOKEN_BUDGET); + let (first, last) = (legacy_tx(0), token_fee_tx_with_value(1, U256::from(7))); + assert!(removable(&mut db, vec![&first, &last]).is_empty()); + } + + #[test] + fn morph_eth_shortfalls_remain_owned_by_standard_maintenance() { + let mut db = test_state(0, 6, 10 * TX_TOKEN_BUDGET); + let tx = token_fee_tx_with_value(0, U256::from(7)); + assert!(removable(&mut db, vec![&tx]).is_empty()); + } + + #[test] + fn block_gas_limit_decreases_remove_both_transaction_types() { + for tx in [legacy_tx(0), token_fee_tx(0)] { + let mut db = test_state(0, 10_000_000, 10 * TX_TOKEN_BUDGET); + assert_eq!( + collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 20_000, + vec![&tx] + ), + vec![*tx.hash()] + ); + } + } + + #[test] + fn unaffordable_ordinary_predecessors_remain_owned_by_standard_maintenance() { + let mut db = test_state(0, 0, 10 * TX_TOKEN_BUDGET); + let (first, last) = (legacy_tx(0), token_fee_tx(1)); + assert!(removable(&mut db, vec![&first, &last]).is_empty()); + } + + #[test] + fn transactions_behind_nonce_gaps_are_left_queued() { + // Future nonces stay queued; missing predecessors may alter fee balances. + let mut db = test_state(0, 0, TX_TOKEN_BUDGET); + let (tx0, gapped) = (token_fee_tx(0), token_fee_tx(10)); + + assert!( + removable(&mut db, vec![&tx0, &gapped]).is_empty(), + "transactions behind a gap are left to queued-pool maintenance" ); } #[test] - fn consume_token_fee_path_honors_fee_limit_and_eth_value() { - let mut budget = SenderBudget { - eth_balance: U256::from(5u64), - token_balances: HashMap::new(), - }; + fn a_sender_holding_only_future_nonces_is_left_alone() { + // Nothing this sender holds is executable at the current state nonce, so there is no + // executable front to evaluate — not even for a sender that now holds no tokens. + let mut db = test_state(0, 0, 0); + let gapped = token_fee_tx(5); + + assert!(removable(&mut db, vec![&gapped]).is_empty()); + } + + /// Fails every storage read of the fee token, leaving the rest of the state readable. + #[derive(Debug)] + struct UnreadableToken(CacheDB); + + impl reth_revm::Database for UnreadableToken { + type Error = reth_provider::ProviderError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + Ok(self.0.basic(address).unwrap()) + } + + fn code_by_hash( + &mut self, + code_hash: alloy_primitives::B256, + ) -> Result { + Ok(self.0.code_by_hash(code_hash).unwrap()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + if address == FEE_TOKEN { + return Err(reth_provider::ProviderError::BestBlockNotFound); + } + Ok(self.0.storage(address, index).unwrap()) + } + + fn block_hash(&mut self, number: u64) -> Result { + Ok(self.0.block_hash(number).unwrap()) + } + } + + #[derive(Debug)] + struct CountingDb { + inner: CacheDB, + reads: HashMap, + } + + impl revm::Database for CountingDb { + type Error = core::convert::Infallible; + fn basic(&mut self, address: Address) -> Result, Self::Error> { + self.inner.basic(address) + } + fn code_by_hash( + &mut self, + hash: alloy_primitives::B256, + ) -> Result { + self.inner.code_by_hash(hash) + } + fn storage(&mut self, address: Address, slot: U256) -> Result { + *self.reads.entry(address).or_default() += 1; + self.inner.storage(address, slot) + } + fn block_hash(&mut self, number: u64) -> Result { + self.inner.block_hash(number) + } + } + + #[test] + fn token_cache_is_shared_within_a_round_and_refreshed_next_round() { + for call_mode in [false, true] { + let mut inner = test_state(0, 0, TX_TOKEN_BUDGET); + if call_mode { + let base = compute_mapping_slot(U256::from(151), &token_id_key(TOKEN_ID)); + inner + .insert_account_storage( + L2_TOKEN_REGISTRY_ADDRESS, + base + U256::from(1), + U256::ZERO, + ) + .unwrap(); + // balanceOf reads slot zero; count real EVM SLOADs as well as registry reads. + let code = revm::state::Bytecode::new_raw(alloy_primitives::Bytes::from_static(&[ + 0x5f, 0x54, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3, + ])); + inner.insert_account_info( + FEE_TOKEN, + AccountInfo { + code_hash: code.hash_slow(), + code: Some(code), + ..Default::default() + }, + ); + inner + .insert_account_storage(FEE_TOKEN, U256::ZERO, U256::from(TX_TOKEN_BUDGET)) + .unwrap(); + } + let mut db = CountingDb { + inner, + reads: HashMap::new(), + }; + let txs: Vec<_> = (0..3).map(token_fee_tx).collect(); + assert!( + collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + txs.iter().collect() + ) + .is_empty() + ); + assert_eq!(db.reads[&L2_TOKEN_REGISTRY_ADDRESS], 5); + assert_eq!(db.reads[&FEE_TOKEN], 1); + let balance_key = if call_mode { + U256::ZERO + } else { + compute_mapping_slot_for_address(U256::from(BALANCE_SLOT), SIGNER) + }; + db.inner + .insert_account_storage(FEE_TOKEN, balance_key, U256::ZERO) + .unwrap(); + assert_eq!( + collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + txs.iter().collect() + ), + vec![*txs[0].hash()] + ); + assert_eq!(db.reads[&L2_TOKEN_REGISTRY_ADDRESS], 10); + assert_eq!(db.reads[&FEE_TOKEN], 2); + } + } + + #[test] + fn unreadable_token_state_does_not_remove_transactions() { + let tx = token_fee_tx(0); + let mut db = UnreadableToken(test_state(0, 0, 10 * TX_TOKEN_BUDGET)); + + // Sanity check: the same transaction against readable state is kept as well, so the + // assertion below is about the read failure and not about affordability. + assert!( + removable(&mut db.0.clone(), vec![&tx]).is_empty(), + "transaction is affordable when the token balance can be read" + ); - // fee_limit caps the payment below required amount => reject - let limited = consume_token_budget( - &mut budget, - U256::ZERO, - Some(9), - Some(U256::from(30u64)), - U256::from(40u64), - Some(U256::from(100u64)), + let to_remove = collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + vec![&tx], ); - assert!(!limited); - - // Enough token, but ETH value exceeds remaining ETH budget => reject - let eth_value_fail = consume_token_budget( - &mut budget, - U256::from(6u64), - Some(9), - Some(U256::from(100u64)), - U256::from(10u64), - Some(U256::from(100u64)), + assert!( + to_remove.is_empty(), + "a transient state-read failure must not be treated as an invalid transaction" ); - assert!(!eth_value_fail); } - #[test] - fn consume_mixed_path_sequence_tracks_eth_and_token_together() { - let mut budget = SenderBudget { - eth_balance: U256::from(100u64), - token_balances: HashMap::new(), + // --------------------------------------------------------------------------------- + // Pool-level test: only the pool can show what happens to a removed transaction's + // descendants, so this one drives the maintenance loop against a real pool. + // --------------------------------------------------------------------------------- + + use alloy_consensus::TxLegacy; + use alloy_primitives::Sealable; + use morph_chainspec::{MORPH_MAINNET, MorphChainSpec}; + use morph_evm::MorphEvmConfig; + use morph_primitives::MorphPrimitives; + use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; + use reth_transaction_pool::{ + CoinbaseTipOrdering, Pool, blobstore::InMemoryBlobStore, + validate::EthTransactionValidatorBuilder, + }; + + type TestProvider = MockEthProvider; + + fn storage_key(slot: U256) -> alloy_primitives::B256 { + alloy_primitives::B256::from(slot.to_be_bytes::<32>()) + } + + /// The chain head the pool validates against: an empty Emerald-active block. + fn head_block() -> morph_primitives::Block { + morph_primitives::Block { + header: morph_primitives::MorphHeader::from(alloy_consensus::Header { + number: 1, + timestamp: 1_767_765_600, + gas_limit: 30_000_000, + base_fee_per_gas: Some(10), + ..Default::default() + }), + body: Default::default(), + } + } + + /// Mirrors [`test_state`] for [`MockEthProvider`], which the pool's validator needs. + fn mock_provider(eth_balance: u64, token_balance: u64) -> TestProvider { + let client = MockEthProvider::::new() + .with_chain_spec((**MORPH_MAINNET).clone()) + .with_genesis_block(); + + // MorphTx is only accepted from Emerald onwards, so the head must be past it. + let head = head_block(); + client.add_block(head.header.hash_slow(), head); + + client.add_account(SIGNER, ExtendedAccount::new(0, U256::from(eth_balance))); + + let token_key = token_id_key(TOKEN_ID); + let base = compute_mapping_slot(U256::from(151), &token_key); + let mut packed = [0u8; 32]; + packed[30] = 18; + packed[31] = 1; + client.add_account( + L2_TOKEN_REGISTRY_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + ( + storage_key(base), + U256::from_be_bytes(FEE_TOKEN.into_word().0), + ), + ( + storage_key(base + U256::from(1)), + U256::from(BALANCE_SLOT + 1), + ), + ( + storage_key(base + U256::from(2)), + U256::from_be_bytes(packed), + ), + (storage_key(base + U256::from(3)), U256::from(1)), + ( + storage_key(compute_mapping_slot(U256::from(153), &token_key)), + U256::from(1), + ), + ]), + ); + set_token_balance(&client, token_balance); + client + } + + fn set_token_balance(client: &TestProvider, token_balance: u64) { + client.add_account( + FEE_TOKEN, + ExtendedAccount::new(0, U256::ZERO).extend_storage([( + storage_key(compute_mapping_slot_for_address( + U256::from(BALANCE_SLOT), + SIGNER, + )), + U256::from(token_balance), + )]), + ); + } + + /// A canonical commit of [`head_block`]. + fn commit_event() -> reth_provider::CanonStateNotification { + let block = head_block(); + reth_provider::CanonStateNotification::Commit { + new: std::sync::Arc::new(reth_provider::Chain::new( + [reth_primitives_traits::RecoveredBlock::new_unhashed( + block, + Vec::new(), + )], + Default::default(), + Default::default(), + )), + } + } + + /// A plain ETH-fee transaction, affordable on its own. + fn legacy_tx(tx_nonce: u64) -> MorphPooledTransaction { + legacy_tx_for_sender(tx_nonce, SIGNER) + } + + fn legacy_tx_for_sender(tx_nonce: u64, sender: Address) -> MorphPooledTransaction { + let tx = TxLegacy { + chain_id: Some(2818), + nonce: tx_nonce, + gas_limit: 21_000, + gas_price: 100, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + ..Default::default() }; + let recovered = Recovered::new_unchecked( + MorphTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())), + sender, + ); + let encoded_len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, encoded_len) + } - // Tx1: token-fee path, consumes token only for fee and ETH for value. - let tx1 = consume_token_budget( - &mut budget, - U256::from(10u64), // value in ETH - Some(3), - Some(U256::ZERO), // unlimited by tx field => bounded by remaining token budget - U256::from(70u64), - Some(U256::from(100u64)), + #[test] + fn a_new_head_arriving_during_a_scan_supersedes_its_removals() { + let client = mock_provider(0, TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), ); - assert!(tx1); - assert_eq!(budget.eth_balance, U256::from(90u64)); - assert_eq!( - budget.token_balances.get(&3).copied(), - Some(U256::from(30u64)) + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), ); + let hash = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(0), + )) + .unwrap() + .hash; + set_token_balance(&client, 0); + let mut polls = 0; + let events = futures::stream::poll_fn(|_| { + let poll = polls; + polls += 1; + match poll { + 0 => std::task::Poll::Ready(Some(commit_event())), + // The queue is empty immediately before the synchronous scan. + 1 => std::task::Poll::Pending, + // The next block restores funds while the scan runs. + 2 => { + set_token_balance(&client, TX_TOKEN_BUDGET); + let mut block = head_block(); + block.header.inner.number = 2; + block.header.inner.timestamp += 1; + client.add_block(block.header.hash_slow(), block.clone()); + std::task::Poll::Ready(Some(reth_provider::CanonStateNotification::Commit { + new: std::sync::Arc::new(reth_provider::Chain::new( + [reth_primitives_traits::RecoveredBlock::new_unhashed( + block, + Vec::new(), + )], + Default::default(), + Default::default(), + )), + })) + } + _ => std::task::Poll::Ready(None), + } + }); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + events, + )); + assert!( + pool.get(&hash).is_some(), + "do not apply a verdict superseded by a queued head" + ); + } - // Tx2: ETH-fee path, consumes full ETH cost. - let tx2 = consume_eth_budget( - &mut budget, - U256::from(20u64), // value - 5, // gas_limit - 4, // max_fee_per_gas => gas fee = 20 - U256::from(10u64), // l1 fee + #[test] + fn unchanged_head_does_not_evict_newly_admitted_transactions() { + let client = mock_provider(6_300_000, 10_000_000); + client.add_account( + morph_revm::L1_GAS_PRICE_ORACLE_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + (storage_key(U256::from(1)), U256::from(1)), + ( + storage_key(U256::from(7)), + U256::from(2_000_000_000_000_000u64), + ), + ]), ); - assert!(tx2); - // total eth cost = 20(value) + 20(gas) + 10(l1) = 50 - assert_eq!(budget.eth_balance, U256::from(40u64)); - - // Tx3: token-fee path should now fail because remaining token budget is only 30. - let tx3 = consume_token_budget( - &mut budget, - U256::ZERO, - Some(3), - Some(U256::ZERO), - U256::from(35u64), - None, + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), ); - assert!(!tx3); - // Budgets stay unchanged on failed consumption. - assert_eq!(budget.eth_balance, U256::from(40u64)); - assert_eq!( - budget.token_balances.get(&3).copied(), - Some(U256::from(30u64)) + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), ); + let hashes: Vec<_> = (0..3) + .map(|nonce| { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(nonce), + )) + .unwrap() + .hash + }) + .collect(); + assert_eq!(pool.all_transactions().pending.len(), 3); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + client, + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + futures::stream::iter([commit_event()]), + )); + assert!(hashes.iter().all(|hash| pool.get(hash).is_some())); + assert_eq!(pool.all_transactions().pending.len(), 3); } #[test] - fn gas_limit_check_rejects_transactions_above_block_limit() { - assert!(exceeds_block_gas_limit(30_000_001, 30_000_000)); - assert!(!exceeds_block_gas_limit(30_000_000, 30_000_000)); + fn ordinary_only_senders_are_revalidated_when_l1_fees_rise() { + use reth_transaction_pool::TransactionPoolExt; + + let sender = address!("0000000000000000000000000000000000000009"); + // Each ordinary transaction costs 2,100,000 wei before L1 fees. + // A 2,000,000 L1 fee still fits individually at 6,300,000; + // a 5,000,000 L1 fee does not. + for unrelated_morph in [false, true] { + for (l1_fee, eth_balance, first_unaffordable) in [ + (0u64, 6_300_000u64, None), + (2_000_000, 12_300_000, None), + (2_000_000, 6_300_000, None), + (5_000_000, 6_300_000, Some(0usize)), + ] { + let client = mock_provider(10_000_000, 100_000_000); + client.add_account(sender, ExtendedAccount::new(0, U256::from(20_000_000))); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + let ordinary: Vec<_> = (0..3) + .map(|nonce| { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx_for_sender(nonce, sender), + )) + .unwrap() + .hash + }) + .collect(); + if unrelated_morph { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(0), + )) + .unwrap(); + } + assert_eq!( + pool.all_transactions().pending.len(), + 3 + usize::from(unrelated_morph) + ); + + client.add_account(sender, ExtendedAccount::new(0, U256::from(eth_balance))); + client.add_account( + morph_revm::L1_GAS_PRICE_ORACLE_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + (storage_key(U256::from(1)), U256::from(1)), + ( + storage_key(U256::from(7)), + U256::from(l1_fee) * U256::from(1_000_000_000), + ), + ]), + ); + let event = commit_event(); + pool.on_canonical_state_change(reth_transaction_pool::CanonicalStateUpdate { + new_tip: event.tip(), + pending_block_base_fee: 10, + pending_block_blob_fee: None, + changed_accounts: vec![reth_provider::ChangedAccount { + address: sender, + nonce: 0, + balance: U256::from(eth_balance), + }], + mined_transactions: Vec::new(), + update_kind: reth_transaction_pool::PoolUpdateKind::Commit, + }); + assert_eq!( + pool.all_transactions().pending.len(), + 3 + usize::from(unrelated_morph), + "standard maintenance cannot see the L1 fee shortfall" + ); + + // Later rounds must retain parked descendants behind the removed nonce. + for _ in 0..3 { + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + futures::stream::iter([event.clone()]), + )); + } + let all = pool.all_transactions(); + let pending: Vec<_> = all + .pending + .iter() + .filter(|tx| tx.sender() == sender) + .map(|tx| *tx.hash()) + .collect(); + let queued: Vec<_> = all + .queued + .iter() + .filter(|tx| tx.sender() == sender) + .map(|tx| *tx.hash()) + .collect(); + if let Some(index) = first_unaffordable { + assert!( + pool.get(&ordinary[index]).is_none(), + "remove the first L1-unaffordable ordinary transaction; unrelated MorphTx={unrelated_morph}" + ); + assert_eq!(pending, ordinary[..index]); + assert_eq!(queued, ordinary[index + 1..]); + } else { + assert_eq!(pending, ordinary); + assert!(queued.is_empty()); + } + } + } + } + + #[test] + fn ordinary_only_senders_keep_nonce_gaps_and_eth_parked_transactions() { + use reth_transaction_pool::TransactionPoolExt; + + for (nonces, state_nonce, eth_balance, pending_nonces, queued_nonces) in [ + (vec![0, 2], 0, 3_100_000u64, vec![0], vec![2]), + (vec![5], 0, 2_100_000, vec![], vec![5]), + (vec![0, 1], 0, 2_000_000, vec![], vec![0, 1]), + (vec![0, 1], 1, 3_100_000, vec![1], vec![]), + ] { + let client = mock_provider(10_000_000, 0); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + for nonce in nonces { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(nonce), + )) + .unwrap(); + } + client.add_account( + SIGNER, + ExtendedAccount::new(state_nonce, U256::from(eth_balance)), + ); + // Every ordinary transaction now owes 1,000,000 wei in L1 fees. + client.add_account( + morph_revm::L1_GAS_PRICE_ORACLE_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + (storage_key(U256::from(1)), U256::from(1)), + ( + storage_key(U256::from(7)), + U256::from(1_000_000_000_000_000u64), + ), + ]), + ); + let event = commit_event(); + // Run Morph first to cover a pool snapshot that still contains a mined nonce. + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + futures::stream::iter([event.clone()]), + )); + pool.on_canonical_state_change(reth_transaction_pool::CanonicalStateUpdate { + new_tip: event.tip(), + pending_block_base_fee: 10, + pending_block_blob_fee: None, + changed_accounts: vec![reth_provider::ChangedAccount { + address: SIGNER, + nonce: state_nonce, + balance: U256::from(eth_balance), + }], + mined_transactions: Vec::new(), + update_kind: reth_transaction_pool::PoolUpdateKind::Commit, + }); + // And run after reth parks/removes transactions, covering either task order. + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + client, + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + futures::stream::iter([event]), + )); + let all = pool.all_transactions(); + assert_eq!( + all.pending.iter().map(|tx| tx.nonce()).collect::>(), + pending_nonces + ); + assert_eq!( + all.queued.iter().map(|tx| tx.nonce()).collect::>(), + queued_nonces + ); + } + } + + #[test] + fn morph_after_legacy_nonce_is_revalidated_after_token_balance_drops() { + let client = mock_provider(10_000_000, 10 * TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(0), + )) + .unwrap(); + let token_tx = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(1), + )) + .unwrap() + .hash; + assert_eq!(pool.all_transactions().pending.len(), 2); + assert!(pool.all_transactions().queued.is_empty()); + + set_token_balance(&client, 0); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + client, + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + futures::stream::iter([commit_event()]), + )); + + assert!( + pool.get(&token_tx).is_none(), + "a pending MorphTx following a legacy nonce must still be checked after its token balance becomes zero" + ); + } + + #[test] + fn ordinary_l1_fee_shortfall_parks_the_morph_successor() { + use reth_transaction_pool::TransactionPoolExt; + + // Two individually affordable predecessors must both survive, even if + // their combined maximum gas and L1 costs exceed the account balance. + for (ordinary_count, l1_fee, eth_balance, token_balance) in [ + (1, 0u64, 2_100_000u64, 0u64), + (1, 1_000_000, 2_100_000, 0), + (1, 1_000_000, 2_100_000, 21_000_000), + (2, 1_000_000, 4_200_000, 21_000_000), + ] { + let client = mock_provider(10_000_000, 10 * TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + let ordinary: Vec<_> = (0..ordinary_count) + .map(|nonce| { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(nonce), + )) + .unwrap() + .hash + }) + .collect(); + let token_tx = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(ordinary_count), + )) + .unwrap() + .hash; + assert_eq!(pool.all_transactions().pending.len(), ordinary.len() + 1); + + // All were affordable at admission. The new state still covers reth's + // ordinary transaction cost, but cannot cover the new L1 fee as well. + client.add_account(SIGNER, ExtendedAccount::new(0, U256::from(eth_balance))); + set_token_balance(&client, token_balance); + client.add_account( + morph_revm::L1_GAS_PRICE_ORACLE_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + (storage_key(U256::from(1)), U256::from(1)), + ( + storage_key(U256::from(7)), + U256::from(l1_fee) * U256::from(1_000_000_000), + ), + ]), + ); + let mut block = head_block(); + block.header.inner.number = 2; + block.header.inner.timestamp += 1; + client.add_block(block.header.hash_slow(), block.clone()); + let event = reth_provider::CanonStateNotification::Commit { + new: std::sync::Arc::new(reth_provider::Chain::new( + [reth_primitives_traits::RecoveredBlock::new_unhashed( + block, + Vec::new(), + )], + Default::default(), + Default::default(), + )), + }; + // Exercise the same public canonical update used by standard maintenance. + pool.on_canonical_state_change(reth_transaction_pool::CanonicalStateUpdate { + new_tip: event.tip(), + pending_block_base_fee: 10, + pending_block_blob_fee: None, + changed_accounts: vec![reth_provider::ChangedAccount { + address: SIGNER, + nonce: 0, + balance: U256::from(eth_balance), + }], + mined_transactions: Vec::new(), + update_kind: reth_transaction_pool::PoolUpdateKind::Commit, + }); + assert_eq!( + pool.all_transactions().pending.len(), + ordinary.len() + 1, + "standard maintenance does not see L1 costs" + ); + + for _ in 0..3 { + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + futures::stream::iter([event.clone()]), + )); + } + let all = pool.all_transactions(); + if l1_fee == 0 { + assert!(ordinary.iter().all(|hash| pool.get(hash).is_some())); + assert!( + pool.get(&token_tx).is_none(), + "the unfunded MorphTx is removed" + ); + assert!(all.queued.is_empty()); + } else if ordinary_count == 2 { + assert!(ordinary.iter().all(|hash| pool.get(hash).is_some())); + assert!(pool.get(&token_tx).is_some()); + assert_eq!(all.pending.len(), 3); + assert!(all.queued.is_empty()); + } else { + let (unaffordable, affordable) = ordinary.split_last().unwrap(); + assert!( + pool.get(unaffordable).is_none(), + "the first L1-unaffordable predecessor must be removed" + ); + assert!(affordable.iter().all(|hash| pool.get(hash).is_some())); + assert_eq!(all.pending.len(), affordable.len()); + assert_eq!( + all.queued.iter().map(|tx| *tx.hash()).collect::>(), + [token_tx], + "preserve the successor in queued, including when it still has tokens" + ); + } + } + } + + #[test] + fn removing_a_morph_tx_parks_its_descendants_instead_of_deleting_them() { + let client = mock_provider(10_000_000, 10 * TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + + // nonce 0 pays in tokens, nonce 1 is a plain ETH transaction that only depends on + // nonce 0 through the nonce sequence. + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(0), + )) + .unwrap(); + let descendant = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(1), + )) + .unwrap() + .hash; + + // The sender spends its whole token balance elsewhere, so nonce 0 is no longer payable. + set_token_balance(&client, 0); + let event = commit_event(); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + client, + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + futures::stream::iter([event]), + )); + + assert!( + pool.get(&descendant).is_some(), + "an independently affordable ETH-fee successor must be parked, not deleted" + ); } } diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index dbcacf6d..a7edd8bc 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -11,9 +11,10 @@ use morph_primitives::{ MorphTxEnvelope, transaction::morph_transaction::{MORPH_TX_VERSION_1, MORPH_TX_VERSION_2}, }; -use morph_revm::{MorphEvmEnv, TokenFeeInfo}; +use morph_revm::{MorphEvmEnv, MorphInvalidTransaction, TokenFeeInfo}; +use reth_revm::revm::context::result::EVMError; -use crate::MorphTxError; +use crate::{MorphTxError, MorphTxValidationError}; /// High-level input for MorphTx validation. /// @@ -30,6 +31,11 @@ pub struct MorphTxValidationInput<'a> { pub l1_data_fee: U256, /// Current hardfork pub hardfork: MorphHardfork, + /// The environment a call-mode fee token's `balanceOf` is evaluated in. + /// + /// Must be the environment of the block whose state `db` exposes, so admission and + /// maintenance resolve the same balance the execution layer would. + pub evm_env: &'a MorphEvmEnv, } /// Result of MorphTx validation. @@ -41,8 +47,6 @@ pub struct MorphTxValidationResult { pub token_info: Option, /// The required token amount pub required_token_amount: U256, - /// The amount that will be paid (min of fee_limit and required) - pub amount_to_pay: U256, } /// Validates a MorphTx transaction's token-related fields. @@ -56,18 +60,30 @@ pub struct MorphTxValidationResult { pub fn validate_morph_tx( db: &mut DB, input: &MorphTxValidationInput<'_>, -) -> Result { +) -> Result> { + validate_morph_tx_with_token_info(input, |token_id| { + TokenFeeInfo::load_for_caller(db, token_id, input.sender, input.evm_env) + }) +} + +/// Shared checks with a caller-provided, fixed-state token lookup. +/// Maintenance supplies a per-round cache; admission performs a fresh lookup. +pub(crate) fn validate_morph_tx_with_token_info( + input: &MorphTxValidationInput<'_>, + load_token: impl FnOnce(u16) -> Result, EVMError>, +) -> Result> { // Keep MorphTx structural validation in the shared path so both initial // admission and background revalidation enforce the same invariants. let morph_tx = match input.consensus_tx { MorphTxEnvelope::Morph(signed) => signed.tx(), - _ => return Err(MorphTxError::InvalidTokenId), + _ => return Err(MorphTxError::InvalidTokenId.into()), }; if !input.hardfork.is_jade() && morph_tx.version == MORPH_TX_VERSION_1 { return Err(MorphTxError::InvalidFormat { reason: "MorphTx version 1 is not yet active (jade fork not reached)".to_string(), - }); + } + .into()); } // V2 (EIP-7702 authorization list) is gated on Celadon. The list itself is @@ -78,13 +94,15 @@ pub fn validate_morph_tx( if !input.hardfork.is_celadon() && morph_tx.version == MORPH_TX_VERSION_2 { return Err(MorphTxError::InvalidFormat { reason: "MorphTx version 2 is not yet active (celadon fork not reached)".to_string(), - }); + } + .into()); } if let Err(reason) = morph_tx.validate() { return Err(MorphTxError::InvalidFormat { reason: reason.to_string(), - }); + } + .into()); } let tx_value = morph_tx.value; @@ -92,7 +110,8 @@ pub fn validate_morph_tx( return Err(MorphTxError::InsufficientEthForValue { balance: input.eth_balance, value: tx_value, - }); + } + .into()); } let fee_token_id = morph_tx.fee_token_id; @@ -111,28 +130,22 @@ pub fn validate_morph_tx( return Err(MorphTxError::InsufficientEthForValue { balance: input.eth_balance, value: total_eth_cost, - }); + } + .into()); } return Ok(MorphTxValidationResult { uses_token_fee: false, token_info: None, required_token_amount: U256::ZERO, - amount_to_pay: U256::ZERO, }); } - // Pool admission has no block environment, so a call-mode token's `balanceOf` - // is evaluated under the hardfork's defaults. That matches the pool's previous - // behaviour; threading the real head environment through admission is txpool - // work and does not belong in this change. - let env = MorphEvmEnv::new( - reth_revm::revm::context::CfgEnv::new_with_spec(input.hardfork), - morph_revm::MorphBlockEnv::default(), - ); - let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, &env) - .map_err(|err| MorphTxError::TokenInfoFetchFailed { - token_id: fee_token_id, - message: format!("{err:?}"), + let token_info = load_token(fee_token_id) + .map_err(|err| match err { + EVMError::Database(err) => MorphTxValidationError::State(err), + _ => MorphTxValidationError::Invalid(MorphTxError::TokenBalanceQueryFailed { + token_id: fee_token_id, + }), })? .ok_or(MorphTxError::TokenNotFound { token_id: fee_token_id, @@ -142,14 +155,16 @@ pub fn validate_morph_tx( if !token_info.is_active { return Err(MorphTxError::TokenNotActive { token_id: fee_token_id, - }); + } + .into()); } // Check price ratio is valid if token_info.price_ratio.is_zero() { return Err(MorphTxError::InvalidPriceRatio { token_id: fee_token_id, - }); + } + .into()); } // Txpool admission follows geth's conservative budget check and requires @@ -169,20 +184,28 @@ pub fn validate_morph_tx( token_address: token_info.token_address, balance: effective_limit, required: required_token_amount, - }); + } + .into()); } Ok(MorphTxValidationResult { uses_token_fee: true, token_info: Some(token_info), required_token_amount, - amount_to_pay: required_token_amount, }) } #[cfg(test)] mod tests { use super::*; + + /// The environment the fee-token balance query is evaluated in. + fn test_evm_env(hardfork: MorphHardfork) -> MorphEvmEnv { + MorphEvmEnv::new( + reth_revm::revm::context::CfgEnv::new_with_spec(hardfork), + morph_revm::MorphBlockEnv::default(), + ) + } use alloy_consensus::Signed; use alloy_primitives::{B256, Signature, TxKind, address}; use morph_primitives::{TxMorph, transaction::morph_transaction::MORPH_TX_VERSION_1}; @@ -218,6 +241,7 @@ mod tests { eth_balance: U256::from(1_000_000_000_000_000_000u128), // 1 ETH l1_data_fee: U256::from(100_000), hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; assert_eq!(input.sender, sender); @@ -257,6 +281,7 @@ mod tests { eth_balance: U256::from(1_000_000_000_000_000_000u128), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); @@ -264,9 +289,9 @@ mod tests { assert_eq!( err, - MorphTxError::InvalidFormat { + MorphTxValidationError::Invalid(MorphTxError::InvalidFormat { reason: "version 1 MorphTx cannot have FeeLimit when FeeTokenID is 0".to_string(), - } + }) ); } @@ -298,11 +323,15 @@ mod tests { eth_balance: U256::from(1_000_000_000_000_000_000u128), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; let mut db = EmptyDB::default(); let err = validate_morph_tx(&mut db, &input).unwrap_err(); - assert_eq!(err, MorphTxError::InvalidTokenId); + assert_eq!( + err, + MorphTxValidationError::Invalid(MorphTxError::InvalidTokenId) + ); } #[test] @@ -336,11 +365,15 @@ mod tests { eth_balance: U256::from(100u64), // Insufficient ETH l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; let mut db = EmptyDB::default(); let err = validate_morph_tx(&mut db, &input).unwrap_err(); - assert!(matches!(err, MorphTxError::InsufficientEthForValue { .. })); + assert!(matches!( + err, + MorphTxValidationError::Invalid(MorphTxError::InsufficientEthForValue { .. }) + )); } #[test] @@ -378,6 +411,7 @@ mod tests { eth_balance: U256::from(10u128.pow(18)), // 1 ETH (sufficient) l1_data_fee: U256::from(1000u64), hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); @@ -421,11 +455,15 @@ mod tests { eth_balance: U256::from(100u64), // Way too low l1_data_fee: U256::from(1000u64), hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); let err = validate_morph_tx(&mut db, &input).unwrap_err(); - assert!(matches!(err, MorphTxError::InsufficientEthForValue { .. })); + assert!(matches!( + err, + MorphTxValidationError::Invalid(MorphTxError::InsufficientEthForValue { .. }) + )); } #[test] @@ -460,13 +498,17 @@ mod tests { eth_balance: U256::from(10u128.pow(18)), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; let mut db = EmptyDB::default(); // EmptyDB has no token registry state, so token lookup will fail let err = validate_morph_tx(&mut db, &input).unwrap_err(); assert!( - matches!(err, MorphTxError::TokenNotFound { token_id: 42 }), + matches!( + err, + MorphTxValidationError::Invalid(MorphTxError::TokenNotFound { token_id: 42 }) + ), "expected TokenNotFound {{ token_id: 42 }}, got {err:?}" ); } @@ -516,17 +558,16 @@ mod tests { eth_balance: U256::from(10u128.pow(18)), l1_data_fee: U256::from(1000u64), hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); let err = validate_morph_tx(&mut db, &input).unwrap_err(); - assert_eq!( + assert!(matches!( err, - MorphTxError::InvalidFormat { - reason: "MorphTx version 2 is not yet active (celadon fork not reached)" - .to_string(), - } - ); + MorphTxValidationError::Invalid(MorphTxError::InvalidFormat { ref reason }) + if reason == "MorphTx version 2 is not yet active (celadon fork not reached)" + )); } #[test] @@ -538,6 +579,7 @@ mod tests { eth_balance: U256::from(10u128.pow(18)), l1_data_fee: U256::from(1000u64), hardfork: MorphHardfork::Celadon, + evm_env: &test_evm_env(MorphHardfork::Celadon), }; let mut db = EmptyDB::default(); @@ -555,6 +597,7 @@ mod tests { eth_balance: U256::from(10u128.pow(18)), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Celadon, + evm_env: &test_evm_env(MorphHardfork::Celadon), }; let mut db = EmptyDB::default(); @@ -563,12 +606,10 @@ mod tests { input.hardfork = MorphHardfork::Jade; let err = validate_morph_tx(&mut db, &input).unwrap_err(); - assert_eq!( + assert!(matches!( err, - MorphTxError::InvalidFormat { - reason: "MorphTx version 2 is not yet active (celadon fork not reached)" - .to_string(), - } - ); + MorphTxValidationError::Invalid(MorphTxError::InvalidFormat { ref reason }) + if reason == "MorphTx version 2 is not yet active (celadon fork not reached)" + )); } } diff --git a/crates/txpool/src/transaction.rs b/crates/txpool/src/transaction.rs index 08690a73..dca2d3ab 100644 --- a/crates/txpool/src/transaction.rs +++ b/crates/txpool/src/transaction.rs @@ -1,7 +1,8 @@ //! Pool transaction type for Morph L2. use alloy_consensus::{ - BlobTransactionValidationError, Typed2718, transaction::Recovered, transaction::TxHashRef, + BlobTransactionValidationError, Transaction as _, Typed2718, transaction::Recovered, + transaction::TxHashRef, }; use alloy_eips::{ eip2930::AccessList, eip7594::BlobTransactionSidecarVariant, eip7702::SignedAuthorization, @@ -25,6 +26,13 @@ pub struct MorphPooledTransaction { #[deref] inner: EthPooledTransaction, + /// Maximum amount of **ETH** this transaction can debit from the sender. + /// + /// Equal to `inner.cost` for every transaction that pays gas in ETH. For a MorphTx + /// (`0x7F`) with `fee_token_id > 0` the gas fee is settled in an ERC20 token, so the + /// only ETH the sender must hold is `value`. See [`PoolTransaction::cost`]. + cost: U256, + /// Cached EIP-2718 encoded bytes of the transaction, lazily computed. encoded_2718: OnceLock, } @@ -32,8 +40,21 @@ pub struct MorphPooledTransaction { impl MorphPooledTransaction { /// Create a new instance of [`MorphPooledTransaction`]. pub fn new(transaction: Recovered, encoded_length: usize) -> Self { + let uses_token_fee = transaction.fee_token_id().is_some_and(|id| id > 0); + let inner = EthPooledTransaction::new(transaction, encoded_length); + // Gas is paid in an ERC20 token, so the ETH-denominated cost is `value` alone. + // Mirrors go-ethereum's `executableTxFilter`, which sets `txCost = nil` for + // `IsMorphTxWithAltFee()` and only requires `costLimit >= value` + // (core/tx_pool.go:1657-1698). + let cost = if uses_token_fee { + inner.transaction().value() + } else { + inner.cost + }; + Self { - inner: EthPooledTransaction::new(transaction, encoded_length), + inner, + cost, encoded_2718: Default::default(), } } @@ -94,8 +115,17 @@ impl PoolTransaction for MorphPooledTransaction { self.inner.transaction.signer_ref() } + /// Maximum amount of ETH this transaction can debit from the sender. + /// + /// The pool compares this against the sender's **ETH** balance to decide whether a + /// transaction is spendable (`ENOUGH_BALANCE` in `TxState`), which in turn decides + /// whether it lands in the pending or the queued sub-pool. Returning the inherited + /// `gas_limit * max_fee_per_gas + value` for a token-fee MorphTx would strand every + /// zero-ETH token payer in the queued sub-pool, where `best_transactions()` never + /// sees them — the exact user morph's ERC20 gas payment exists for. Token + /// affordability is validated separately by `MorphTransactionValidator`. fn cost(&self) -> &U256 { - &self.inner.cost + &self.cost } fn encoded_length(&self) -> usize { @@ -386,4 +416,67 @@ mod tests { // encoded_length is set during construction assert!(tx.encoded_length() > 0); } + + fn create_morph_pooled_tx_with(fee_token_id: u16, value: U256) -> MorphPooledTransaction { + use morph_primitives::TxMorph; + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(Address::repeat_byte(0x02)), + value, + access_list: Default::default(), + version: 0, + fee_token_id, + fee_limit: U256::from(1000u64), + reference: None, + memo: None, + authorization_list: Vec::new(), + input: Bytes::new(), + }; + let sig = Signature::test_signature(); + let envelope = MorphTxEnvelope::Morph(Signed::new_unhashed(tx, sig)); + let recovered = Recovered::new_unchecked(envelope, Address::repeat_byte(0xcc)); + let len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, len) + } + + /// A MorphTx paying gas in an ERC20 token must report an ETH cost of `value` only. + /// + /// The pool compares `cost()` against the sender's ETH balance to set the + /// `ENOUGH_BALANCE` state bit, which decides pending vs. queued. Charging the + /// ETH gas budget here would strand every zero-ETH token payer in the queued + /// sub-pool, where the block builder never sees them. + #[test] + fn token_fee_morph_tx_cost_excludes_eth_gas_budget() { + let value = U256::from(7u64); + let tx = create_morph_pooled_tx_with(1, value); + + assert_eq!(*tx.cost(), value); + + // The inherited ETH gas budget would have been orders of magnitude larger. + let eth_gas_budget = U256::from(21_000u64) * U256::from(2_000_000_000u64); + assert!(*tx.cost() < eth_gas_budget); + } + + /// `fee_token_id == 0` is the ETH-fee MorphTx path (reference/memo only), so it keeps + /// the inherited `gas_limit * max_fee_per_gas + value` cost. + #[test] + fn eth_fee_morph_tx_keeps_full_cost() { + let value = U256::from(7u64); + let tx = create_morph_pooled_tx_with(0, value); + + let expected = U256::from(21_000u64) * U256::from(2_000_000_000u64) + value; + assert_eq!(*tx.cost(), expected); + } + + /// Non-MorphTx transactions are untouched by the token-fee carve-out. + #[test] + fn legacy_tx_keeps_full_cost() { + let tx = create_legacy_pooled_tx(); + let expected = U256::from(21_000u64) * U256::from(1_000_000_000u64) + U256::from(100u64); + assert_eq!(*tx.cost(), expected); + } } diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index a6c1f955..5eae10b6 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -8,29 +8,26 @@ //! - L1 data fee validation //! - MorphTx (0x7F) ERC20 token balance validation -use crate::MorphTxError; -use alloy_consensus::{BlockHeader, Transaction}; +use crate::MorphTxValidationError; +use alloy_consensus::{BlockHeader, Sealable, Transaction}; use alloy_eips::{Encodable2718, Typed2718}; -use alloy_primitives::{Address, U256}; -use morph_chainspec::hardfork::MorphHardforks; +use alloy_primitives::{Address, B256, U256}; +use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; use morph_primitives::MorphTxEnvelope; -use morph_revm::L1BlockInfo; +use morph_revm::{L1BlockInfo, MorphBlockEnv, MorphEvmEnv}; use parking_lot::RwLock; use reth_chainspec::ChainSpecProvider; -use reth_evm::ConfigureEvm; +use reth_evm::{ConfigureEvm, EvmFactory, EvmFactoryFor}; use reth_primitives_traits::{ - Block, BlockTy, GotExpected, SealedBlock, transaction::error::InvalidTransactionError, + Block, BlockTy, GotExpected, HeaderTy, SealedBlock, transaction::error::InvalidTransactionError, }; use reth_revm::database::StateProviderDatabase; -use reth_storage_api::{BlockReaderIdExt, StateProviderFactory}; +use reth_storage_api::{BlockReaderIdExt, StateProviderBox, StateProviderFactory}; use reth_transaction_pool::{ EthPoolTransaction, EthTransactionValidator, PoolTransaction, TransactionOrigin, - TransactionValidationOutcome, TransactionValidator, -}; -use std::sync::{ - Arc, - atomic::{AtomicU64, Ordering}, + TransactionValidationOutcome, TransactionValidator, error::InvalidPoolTransactionError, }; +use std::sync::Arc; /// EIP-3860 max initcode size (`2 * MAX_CODE_SIZE = 2 * 24 576 = 49 152` bytes). /// @@ -41,59 +38,135 @@ use std::sync::{ /// `validate_one_with_state` for why we can't rely on reth's Shanghai-gated check. const MAX_INITCODE_SIZE: usize = reth_revm::revm::primitives::eip3860::MAX_INITCODE_SIZE; -/// Tracks L1 block info for the current chain head. +/// A complete set of fee-validation inputs for one block. +#[derive(Debug)] +pub(crate) struct MorphValidationHead { + hash: B256, + number: u64, + timestamp: u64, + base_fee_per_gas: Option, + pub(crate) l1_block_info: L1BlockInfo, + pub(crate) evm_env: MorphEvmEnv, +} + +/// Tracks L1 fee parameters and the matching block environment. /// -/// This is used to cache L1 fee parameters and update them when the chain head changes. +/// A complete head is published atomically. Readers retain an immutable snapshot while +/// later canonical updates prepare and publish a replacement. #[derive(Debug, Default)] pub struct MorphL1BlockInfo { - /// The current L1 block info. - l1_block_info: RwLock, - /// Current block base fee per gas. - base_fee_per_gas: RwLock>, - /// Current block timestamp. - timestamp: AtomicU64, - /// Current block number. - number: AtomicU64, + head: RwLock>>, } impl MorphL1BlockInfo { - /// Creates a new instance with default values. + /// Creates an uninitialized tracker; the validator publishes each canonical head into it. pub fn new() -> Self { Self::default() } - /// Returns the current L1 block info. + /// Returns the current L1 block info, or its default before initialization. pub fn l1_block_info(&self) -> L1BlockInfo { - *self.l1_block_info.read() + self.head + .read() + .as_ref() + .map(|head| head.l1_block_info) + .unwrap_or_default() } - /// Updates the L1 block info. - pub fn update( + /// Publishes fee parameters and the environment for the supplied header together. + /// + /// `info` must be read from this header's post-state and `evm_env` must be built + /// for the same header. Partial updates are not supported. + pub fn update( &self, info: L1BlockInfo, - timestamp: u64, - number: u64, - base_fee_per_gas: Option, + header: &H, + evm_env: MorphEvmEnv, ) { - *self.l1_block_info.write() = info; - *self.base_fee_per_gas.write() = base_fee_per_gas; - self.timestamp.store(timestamp, Ordering::Relaxed); - self.number.store(number, Ordering::Relaxed); + *self.head.write() = Some(Arc::new(MorphValidationHead { + hash: header.hash_slow(), + number: header.number(), + timestamp: header.timestamp(), + base_fee_per_gas: header.base_fee_per_gas(), + l1_block_info: info, + evm_env, + })); } - /// Returns the current block timestamp. + /// Returns the current block timestamp, or zero before initialization. pub fn timestamp(&self) -> u64 { - self.timestamp.load(Ordering::Relaxed) + self.head + .read() + .as_ref() + .map(|head| head.timestamp) + .unwrap_or_default() } - /// Returns the current block number. + /// Returns the current block number, or zero before initialization. pub fn number(&self) -> u64 { - self.number.load(Ordering::Relaxed) + self.head + .read() + .as_ref() + .map(|head| head.number) + .unwrap_or_default() } /// Returns the current block base fee per gas. pub fn base_fee_per_gas(&self) -> Option { - *self.base_fee_per_gas.read() + self.head + .read() + .as_ref() + .and_then(|head| head.base_fee_per_gas) + } +} + +/// State and fee-validation inputs pinned to one block for a transaction batch. +/// +/// Created lazily by [`MorphTransactionValidator::validate_one_with_state`]. Reusing it +/// keeps account reads, token reads and the EVM environment on the same block, even if +/// the canonical head advances. Start with `None` to validate a new batch at the new head. +pub struct MorphValidationState { + pub(crate) head: Arc, + pub(crate) provider: StateProviderBox, +} + +/// Opens state, EVM environment and L1 parameters for exactly one header. +pub(crate) fn validation_state_for_header( + client: &Client, + evm_config: &Evm, + header: &HeaderTy, +) -> Result> +where + Client: StateProviderFactory, + Evm: ConfigureEvm, + EvmFactoryFor: EvmFactory, +{ + let evm_env = evm_config + .evm_env(header) + .map_err(|err| std::io::Error::other(err.to_string()))?; + let provider = client.state_by_block_hash(header.hash_slow())?; + let l1_block_info = L1BlockInfo::try_fetch( + &mut StateProviderDatabase::new(&provider), + *evm_env.cfg_env.spec(), + )?; + Ok(MorphValidationState { + head: Arc::new(MorphValidationHead { + hash: header.hash_slow(), + number: header.number(), + timestamp: header.timestamp(), + base_fee_per_gas: header.base_fee_per_gas(), + l1_block_info, + evm_env, + }), + provider, + }) +} + +impl std::fmt::Debug for MorphValidationState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MorphValidationState") + .field("head", &self.head) + .finish_non_exhaustive() } } @@ -140,16 +213,6 @@ impl MorphTransactionValidator { self.inner.client() } - /// Returns the current block timestamp. - fn block_timestamp(&self) -> u64 { - self.block_info.timestamp() - } - - /// Returns the current block number. - fn block_number(&self) -> u64 { - self.block_info.number() - } - /// Returns a reference to the block info tracker. pub fn block_info(&self) -> &Arc { &self.block_info @@ -176,9 +239,14 @@ fn insufficient_funds_outcome( impl MorphTransactionValidator where - Client: ChainSpecProvider + StateProviderFactory + BlockReaderIdExt, + Client: ChainSpecProvider + + StateProviderFactory + + BlockReaderIdExt
>, Tx: EthPoolTransaction, Evm: ConfigureEvm, + // Pins the cached environment to Morph's, so the fee-token balance query runs in + // exactly what the execution layer would use. + EvmFactoryFor: EvmFactory, { /// Create a new [`MorphTransactionValidator`]. pub fn new(inner: EthTransactionValidator) -> Self { @@ -206,42 +274,32 @@ where } /// Update the L1 block info for the given header. - pub fn update_l1_block_info(&self, header: &H) - where - H: BlockHeader, - { - self.block_info - .timestamp - .store(header.timestamp(), Ordering::Relaxed); - self.block_info - .number - .store(header.number(), Ordering::Relaxed); - *self.block_info.base_fee_per_gas.write() = header.base_fee_per_gas(); - - let provider = match self - .client() - .state_by_block_number_or_tag(header.number().into()) - { - Ok(provider) => provider, + pub fn update_l1_block_info(&self, header: &HeaderTy) { + match validation_state_for_header(self.client(), self.inner.evm_config(), header) { + Ok(state) => *self.block_info.head.write() = Some(state.head), Err(err) => { - tracing::warn!(target: "morph::txpool", %err, "Failed to get state provider for L1 block info update"); - return; + tracing::warn!(target: "morph::txpool", %err, "Failed to refresh fee-validation head") } - }; - - let mut db = StateProviderDatabase::new(provider); - let hardfork = self - .chain_spec() - .morph_hardfork_at(header.number(), header.timestamp()); + } + } - match L1BlockInfo::try_fetch(&mut db, hardfork) { - Ok(l1_block_info) => { - *self.block_info.l1_block_info.write() = l1_block_info; - } - Err(err) => { - tracing::warn!(target: "morph::txpool", ?err, "Failed to fetch L1 block info"); - } + /// Opens the state of the head last published through `on_new_head_block`. + /// + /// Until a head is published, falls back to the provider's latest header without + /// publishing it, so it can never overwrite a newer canonical update. + fn current_validation_state( + &self, + ) -> Result> { + let head = self.block_info.head.read().clone(); + if let Some(head) = head { + let provider = self.client().state_by_block_hash(head.hash)?; + return Ok(MorphValidationState { head, provider }); } + let header = self + .client() + .latest_header()? + .ok_or_else(|| std::io::Error::other("latest validation header is unavailable"))?; + validation_state_for_header(self.client(), self.inner.evm_config(), header.header()) } /// Validates a single transaction. @@ -261,18 +319,16 @@ where self.validate_one_with_state(origin, transaction, &mut None) } - /// Validates a single transaction, reusing an optional state provider. + /// Validates a single transaction, reusing a state and head snapshot. /// - /// When `state` is `None`, a fresh provider is fetched from the database on - /// first use and stored back into `state` for reuse by subsequent calls. - /// This avoids creating a new [`StateProvider`] for every transaction in a - /// batch, which is the main source of txpool validation slowdown as the - /// state trie grows. + /// When `state` is `None`, validation opens the state of the head last published by + /// `on_new_head_block`. Both are reused for subsequent transactions in the batch. + /// Reset `state` to `None` to pick up a newer head. pub fn validate_one_with_state( &self, origin: TransactionOrigin, transaction: Tx, - state: &mut Option>, + state: &mut Option, ) -> TransactionValidationOutcome { // Reject EIP-4844 blob transactions - not supported on L2 if transaction.is_eip4844() { @@ -290,11 +346,20 @@ where ); } + if state.is_none() { + match self.current_validation_state() { + Ok(snapshot) => *state = Some(snapshot), + Err(err) => return TransactionValidationOutcome::Error(*transaction.hash(), err), + } + } + let state = state.as_ref().expect("validation state initialized above"); + let head = &state.head; + // Reject EIP-7702 transactions before Viridian hardfork (PRAGUE) if transaction.is_eip7702() && !self .chain_spec() - .is_viridian_active_at_timestamp(self.block_timestamp()) + .is_viridian_active_at_timestamp(head.timestamp) { return TransactionValidationOutcome::Invalid( transaction, @@ -310,7 +375,7 @@ where if is_morph_tx && !self .chain_spec() - .is_emerald_active_at_timestamp(self.block_timestamp()) + .is_emerald_active_at_timestamp(head.timestamp) { return TransactionValidationOutcome::Invalid( transaction, @@ -341,9 +406,35 @@ where return TransactionValidationOutcome::Invalid(transaction, err); } + // Token-fee MorphTx reports only its ETH value through cost(), so reth's + // cost() - value() fee-cap check sees zero. Preserve the configured local + // fee cap using the gas budget, independently of the pool's ETH budget. + if is_morph_tx + && self + .inner + .local_transactions_config() + .is_local(origin, transaction.sender_ref()) + && let Some(tx_fee_cap_wei) = self.inner.tx_fee_cap().filter(|cap| *cap != 0) + { + let max_tx_fee_wei = U256::from(transaction.gas_limit()) + .saturating_mul(U256::from(transaction.max_fee_per_gas())); + if max_tx_fee_wei > U256::from(tx_fee_cap_wei) { + return TransactionValidationOutcome::Invalid( + transaction, + InvalidPoolTransactionError::ExceedsFeeCap { + max_tx_fee_wei: max_tx_fee_wei.saturating_to(), + tx_fee_cap_wei, + }, + ); + } + } + + if let Err(err) = self.inner.validate_stateless(origin, &transaction) { + return TransactionValidationOutcome::Invalid(transaction, err); + } let outcome = self .inner - .validate_one_with_state(origin, transaction, state); + .validate_stateful(origin, transaction, &state.provider); if outcome.is_invalid() || outcome.is_error() { tracing::trace!(target: "morph::txpool", ?outcome, "tx pool validation failed"); return outcome; @@ -359,10 +450,8 @@ where authorities, } = outcome { - let l1_block_info = *self.block_info.l1_block_info.read(); - let hardfork = self - .chain_spec() - .morph_hardfork_at(self.block_number(), self.block_timestamp()); + let l1_block_info = head.l1_block_info; + let hardfork = *head.evm_env.cfg_env.spec(); // Calculate L1 data fee (always calculated for all transactions). // Clone consensus tx once — reused for both L1 fee encoding and MorphTx validation. @@ -381,12 +470,9 @@ where sender, balance, l1_data_fee, - hardfork, + state, ) { - return TransactionValidationOutcome::Invalid( - valid_tx.into_transaction(), - err.into(), - ); + return morph_tx_validation_outcome(valid_tx.into_transaction(), err); } } else { // Regular transaction: validate ETH balance covers cost + L1 fee @@ -426,26 +512,17 @@ where sender: Address, eth_balance: U256, l1_data_fee: U256, - hardfork: morph_chainspec::hardfork::MorphHardfork, - ) -> Result { - // Get state provider for token info lookup - let provider = self - .client() - .state_by_block_number_or_tag(self.block_number().into()) - .map_err(|err| MorphTxError::TokenInfoFetchFailed { - token_id: 0, // token_id not yet extracted - message: err.to_string(), - })?; - - let mut db = StateProviderDatabase::new(provider); - - // Use shared validation logic with unified API (includes ETH balance check) + state: &MorphValidationState, + ) -> Result> + { + let mut db = StateProviderDatabase::new(&state.provider); let input = crate::MorphTxValidationInput { consensus_tx, sender, eth_balance, l1_data_fee, - hardfork, + hardfork: *state.head.evm_env.cfg_env.spec(), + evm_env: &state.head.evm_env, }; let result = crate::validate_morph_tx(&mut db, &input)?; @@ -490,9 +567,14 @@ where impl TransactionValidator for MorphTransactionValidator where - Client: ChainSpecProvider + StateProviderFactory + BlockReaderIdExt, + Client: ChainSpecProvider + + StateProviderFactory + + BlockReaderIdExt
>, Tx: EthPoolTransaction, Evm: ConfigureEvm, + // Pins the cached environment to Morph's, so the fee-token balance query runs in + // exactly what the execution layer would use. + EvmFactoryFor: EvmFactory, { type Transaction = Tx; type Block = BlockTy; @@ -519,6 +601,31 @@ where } } +/// Maps a [`MorphTxValidationError`] onto the right validation outcome. +/// +/// [`TransactionValidationOutcome::Invalid`] is a verdict on the transaction: the pool +/// can record it as known-bad. Peer penalties are decided separately by the error type. +/// A failed state read is not such a verdict — the transaction may be perfectly valid and +/// simply could not be checked — so it is reported as +/// [`TransactionValidationOutcome::Error`], which discards this attempt without blaming +/// anyone and leaves the sender free to try again. +fn morph_tx_validation_outcome< + Tx: EthPoolTransaction, + E: std::error::Error + Send + Sync + 'static, +>( + transaction: Tx, + err: MorphTxValidationError, +) -> TransactionValidationOutcome { + match err { + MorphTxValidationError::State(err) => { + TransactionValidationOutcome::Error(*transaction.hash(), Box::new(err)) + } + MorphTxValidationError::Invalid(err) => { + TransactionValidationOutcome::Invalid(transaction, err.into()) + } + } +} + /// Helper function to check if a transaction is an L1 message. fn is_l1_message(tx: &impl Typed2718) -> bool { tx.ty() == morph_primitives::L1_TX_TYPE_ID @@ -532,7 +639,8 @@ fn is_morph_tx(tx: &impl Typed2718) -> bool { #[cfg(test)] mod tests { use super::*; - use alloy_consensus::{Signed, TxEip1559, TxLegacy}; + use crate::MorphTxError; + use alloy_consensus::{Sealable, Signed, TxEip1559, TxLegacy}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{B256, Signature, TxKind, address}; use morph_chainspec::{MORPH_MAINNET, MorphChainSpec}; @@ -544,6 +652,7 @@ mod tests { use reth_primitives_traits::Recovered; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; use reth_transaction_pool::{ + CoinbaseTipOrdering, LocalTransactionConfig, Pool, TransactionPool, blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, }; @@ -606,6 +715,352 @@ mod tests { ]) } + type TokenFeeValidator = MorphTransactionValidator< + MockEthProvider, + crate::MorphPooledTransaction, + MorphEvmConfig, + >; + + /// Registered token with a 1:1 price ratio at an Emerald-active head. + fn token_fee_validator( + eth_balance: U256, + token_balance: U256, + fee_cap: u128, + local_config: LocalTransactionConfig, + ) -> TokenFeeValidator { + let client = new_mock_provider(); + let signer = address!("0000000000000000000000000000000000000001"); + let token = address!("5300000000000000000000000000000000000042"); + let balance_slot = U256::from(7); + let header = morph_primitives::MorphHeader::from(alloy_consensus::Header { + number: 1, + timestamp: 1_767_765_600, + gas_limit: 30_000_000, + base_fee_per_gas: Some(10), + ..Default::default() + }); + client.add_block( + header.hash_slow(), + morph_primitives::Block { + header, + body: Default::default(), + }, + ); + client.add_account(signer, ExtendedAccount::new(0, eth_balance)); + client.add_account( + L2_TOKEN_REGISTRY_ADDRESS, + token_registry_account(1, token, balance_slot, token_balance), + ); + client.add_account( + token, + ExtendedAccount::new(0, U256::ZERO).extend_storage([( + storage_key(compute_mapping_slot_for_address(balance_slot, signer)), + token_balance, + )]), + ); + let inner = EthTransactionValidatorBuilder::new( + client, + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .set_tx_fee_cap(fee_cap) + .with_local_transactions_config(local_config) + .build::(InMemoryBlobStore::default()); + MorphTransactionValidator::new(inner) + } + + /// Maximum gas fee is 2,100,000 wei; value remains denominated in ETH. + fn token_fee_transaction(tx_nonce: u64, value: U256) -> crate::MorphPooledTransaction { + let tx = TxMorph { + chain_id: 2818, + nonce: tx_nonce, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value, + fee_token_id: 1, + fee_limit: U256::ZERO, + ..Default::default() + }; + let recovered = Recovered::new_unchecked( + MorphTxEnvelope::Morph(Signed::new_unhashed(tx, Signature::test_signature())), + address!("0000000000000000000000000000000000000001"), + ); + let len = recovered.encode_2718_len(); + crate::MorphPooledTransaction::new(recovered, len) + } + + fn timestamp_sensitive_validator() -> TokenFeeValidator { + let validator = + token_fee_validator(U256::ZERO, U256::from(10_000_000), 0, Default::default()); + let client = validator.client(); + let token = address!("5300000000000000000000000000000000000042"); + let base = compute_mapping_slot(U256::from(151), &token_id_key(1)); + client.add_account( + L2_TOKEN_REGISTRY_ADDRESS, + token_registry_account(1, token, U256::from(7), U256::ZERO) + .extend_storage([(storage_key(base + U256::from(1)), U256::ZERO)]), + ); + // Return 10,000,000 only at the old head's timestamp. The token state stays + // unchanged so both reads of the cached provider must use the old environment. + let old_timestamp = 1_767_765_600u32; + let mut code = vec![0x42, 0x63]; // TIMESTAMP PUSH4 + code.extend_from_slice(&old_timestamp.to_be_bytes()); + code.extend_from_slice(&[ + 0x14, 0x62, 0x98, 0x96, 0x80, 0x02, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3, + ]); + client.add_account( + token, + ExtendedAccount::new(0, U256::ZERO).with_bytecode(code.into()), + ); + + validator + } + + fn replacement_header() -> morph_primitives::MorphHeader { + morph_primitives::MorphHeader::from(alloy_consensus::Header { + number: 1, + timestamp: 1_767_765_601, + gas_limit: 30_000_000, + base_fee_per_gas: Some(10), + ..Default::default() + }) + } + + #[test] + fn a_head_published_during_validation_does_not_change_the_balance_query() { + let mut validator = timestamp_sensitive_validator(); + let block_info = validator.block_info().clone(); + let client = validator.client().clone(); + let replacement = replacement_header(); + let env = MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()) + .evm_env(&replacement) + .unwrap(); + // This extension runs after the account read and before Morph fee validation, + // forcing the interleaving of a concurrent canonical-head publication. + validator + .inner + .set_additional_stateful_validation(move |_, _, _| { + client + .blocks + .lock() + .retain(|_, block| block.header.number() != replacement.number()); + client + .headers + .lock() + .retain(|_, header| header.number() != replacement.number()); + client.add_block( + replacement.hash_slow(), + morph_primitives::Block { + header: replacement.clone(), + body: Default::default(), + }, + ); + block_info.update(L1BlockInfo::default(), &replacement, env.clone()); + Ok(()) + }); + let tx = token_fee_transaction(0, U256::ZERO); + let in_flight = validator.validate_one(TransactionOrigin::Local, tx.clone()); + assert!( + matches!(in_flight, TransactionValidationOutcome::Valid { .. }), + "an in-flight validation must retain its original head: {in_flight:?}" + ); + let fresh = validator.validate_one(TransactionOrigin::Local, tx); + assert!( + matches!(fresh, TransactionValidationOutcome::Invalid(..)), + "a fresh validation must see the published head: {fresh:?}" + ); + } + + #[test] + fn token_fee_transaction_above_local_fee_cap_is_rejected() { + // The effective gas price is 20, so only the maximum fee budget exceeds this cap. + let validator = token_fee_validator( + U256::ZERO, + U256::from(10_000_000), + 500_000, + Default::default(), + ); + let outcome = validator.validate_one( + TransactionOrigin::Local, + token_fee_transaction(0, U256::ZERO), + ); + assert!( + matches!( + outcome, + TransactionValidationOutcome::Invalid( + _, + InvalidPoolTransactionError::ExceedsFeeCap { + max_tx_fee_wei: 2_100_000, + tx_fee_cap_wei: 500_000, + } + ) + ), + "{outcome:?}" + ); + } + + #[test] + fn token_fee_cap_accepts_zero_or_sufficient_cap_without_counting_value() { + for cap in [0, 2_100_000, 2_100_001] { + let validator = token_fee_validator( + U256::from(7), + U256::from(10_000_000), + cap, + Default::default(), + ); + let outcome = validator.validate_one( + TransactionOrigin::Local, + token_fee_transaction(0, U256::from(7)), + ); + assert!( + matches!(outcome, TransactionValidationOutcome::Valid { .. }), + "cap={cap}: {outcome:?}" + ); + } + } + + #[test] + fn token_fee_cap_respects_local_transaction_configuration() { + let local_sender = LocalTransactionConfig { + local_addresses: [address!("0000000000000000000000000000000000000001")] + .into_iter() + .collect(), + ..Default::default() + }; + for (origin, config, should_reject) in [ + ( + TransactionOrigin::External, + LocalTransactionConfig::default(), + false, + ), + (TransactionOrigin::External, local_sender.clone(), true), + ( + TransactionOrigin::Local, + LocalTransactionConfig { + no_exemptions: true, + ..Default::default() + }, + false, + ), + ( + TransactionOrigin::External, + LocalTransactionConfig { + no_exemptions: true, + ..local_sender + }, + false, + ), + ] { + let validator = token_fee_validator(U256::ZERO, U256::from(10_000_000), 100, config); + let outcome = validator.validate_one(origin, token_fee_transaction(0, U256::ZERO)); + if should_reject { + assert!( + matches!( + outcome, + TransactionValidationOutcome::Invalid( + _, + InvalidPoolTransactionError::ExceedsFeeCap { .. } + ) + ), + "{outcome:?}" + ); + } else { + assert!( + matches!(outcome, TransactionValidationOutcome::Valid { .. }), + "{outcome:?}" + ); + } + } + } + + #[test] + fn token_fee_transaction_with_zero_eth_is_pending_and_selectable() { + let validator = token_fee_validator( + U256::ZERO, + U256::from(10_000_000), + 2_100_000, + Default::default(), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + let added = futures::executor::block_on(pool.add_transaction( + TransactionOrigin::Local, + token_fee_transaction(0, U256::ZERO), + )) + .unwrap(); + let all = pool.all_transactions(); + assert_eq!(all.pending.len(), 1); + assert!(all.queued.is_empty()); + let best: Vec<_> = pool.best_transactions().map(|tx| *tx.hash()).collect(); + assert_eq!(best, [added.hash]); + } + + #[test] + fn token_fee_transactions_still_reserve_cumulative_eth_value() { + let validator = token_fee_validator( + U256::from(10), + U256::from(10_000_000), + 2_100_000, + Default::default(), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + for nonce in [0, 1] { + futures::executor::block_on(pool.add_transaction( + TransactionOrigin::Local, + token_fee_transaction(nonce, U256::from(7)), + )) + .unwrap(); + } + let all = pool.all_transactions(); + assert_eq!(all.pending.len(), 1); + assert_eq!(all.pending[0].nonce(), 0); + assert_eq!(all.queued.len(), 1); + assert_eq!(all.queued[0].nonce(), 1); + let best: Vec<_> = pool.best_transactions().map(|tx| tx.nonce()).collect(); + assert_eq!(best, [0]); + } + + #[test] + fn an_unreadable_fee_token_state_is_an_error_not_an_invalid_transaction() { + let tx = token_fee_transaction(0, U256::ZERO); + let hash = *tx.hash(); + + let outcome = morph_tx_validation_outcome( + tx, + MorphTxValidationError::State(std::io::Error::other("provider unavailable")), + ); + assert!( + matches!(outcome, TransactionValidationOutcome::Error(reported, _) if reported == hash), + "a failed state read must not mark the transaction known-bad: {outcome:?}" + ); + } + + #[test] + fn a_real_fee_token_failure_is_still_an_invalid_transaction() { + let outcome = morph_tx_validation_outcome( + token_fee_transaction(0, U256::ZERO), + MorphTxValidationError::::Invalid(MorphTxError::TokenNotActive { + token_id: 1, + }), + ); + assert!( + matches!(outcome, TransactionValidationOutcome::Invalid(..)), + "{outcome:?}" + ); + } + #[test] fn test_morph_l1_block_info_default() { let info = MorphL1BlockInfo::new(); @@ -617,7 +1072,16 @@ mod tests { fn test_morph_l1_block_info_update() { let info = MorphL1BlockInfo::new(); let l1_info = L1BlockInfo::default(); - info.update(l1_info, 1234, 100, Some(42)); + let header = morph_primitives::MorphHeader::from(alloy_consensus::Header { + timestamp: 1234, + number: 100, + base_fee_per_gas: Some(42), + ..Default::default() + }); + let evm_env = MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()) + .evm_env(&header) + .unwrap(); + info.update(l1_info, &header, evm_env); assert_eq!(info.timestamp(), 1234); assert_eq!(info.number(), 100); @@ -885,42 +1349,9 @@ mod tests { #[test] fn validate_morph_tx_uses_max_fee_for_token_fee_admission() { - let client = new_mock_provider(); + let validator = token_fee_validator(U256::ZERO, U256::from(300_000), 0, Default::default()); let signer = address!("0000000000000000000000000000000000000001"); - let token = address!("5300000000000000000000000000000000000042"); - let balance_slot = U256::from(7); - - client.add_account(signer, ExtendedAccount::new(0, U256::ZERO)); - client.add_account( - L2_TOKEN_REGISTRY_ADDRESS, - token_registry_account(1, token, balance_slot, U256::from(300_000u64)), - ); - client.add_account( - token, - ExtendedAccount::new(0, U256::ZERO).extend_storage([( - storage_key(compute_mapping_slot_for_address(balance_slot, signer)), - U256::from(300_000u64), - )]), - ); - - let morph_evm_config = MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()); - let eth_validator: EthTransactionValidator< - _, - crate::MorphPooledTransaction, - MorphEvmConfig, - > = EthTransactionValidatorBuilder::new(client, morph_evm_config) - .no_shanghai() - .no_cancun() - .disable_balance_check() - .build::(InMemoryBlobStore::default()); - let validator = MorphTransactionValidator::new(eth_validator); - // Simulate an active chain head with base_fee_per_gas = 10. Execution - // would use effective gas price = min(100, 10 + 1) = 11, but txpool - // admission follows geth's conservative max_fee_per_gas budget. - validator - .block_info - .update(L1BlockInfo::default(), 0, 0, Some(10)); - + // Effective execution price is 11, but admission must reserve the maximum 100. let tx = TxMorph { chain_id: 2818, nonce: 0, @@ -944,23 +1375,14 @@ mod tests { B256::ZERO, )); let recovered = Recovered::new_unchecked(envelope, signer); - let err = validator - .validate_morph_tx_balance( - &recovered, - signer, - U256::ZERO, - U256::ZERO, - morph_chainspec::hardfork::MorphHardfork::Viridian, - ) - .expect_err("MorphTx should require the max-fee token budget in txpool admission"); - - assert!(matches!( - err, - crate::MorphTxError::InsufficientTokenBalance { - required, - balance, - .. - } if required == U256::from(2_100_000u64) && balance == U256::from(300_000u64) + let len = recovered.encode_2718_len(); + let outcome = validator.validate_one( + TransactionOrigin::Local, + crate::MorphPooledTransaction::new(recovered, len), + ); + assert!(matches!(outcome, + TransactionValidationOutcome::Invalid(_, InvalidPoolTransactionError::Overdraft { cost, balance }) + if cost == U256::from(2_100_000) && balance == U256::from(300_000) )); } } From aa465c7ccdbbffa13f9df8015440b50a741a87e8 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 23 Sep 2026 14:46:34 +0800 Subject: [PATCH 13/17] test(txpool): run the fee-token tests in EVM-call mode Fee tokens are paid in EVM-call mode only, yet the txpool tests mostly registered a direct-storage token, so the `balanceOf` path the pool relies on was barely exercised. The shared fixtures in `morph_tx_validation::tests` register the token with a zero `balanceSlot` word and deploy a `balanceOf` runtime reading an ERC20 `balances` mapping. Admission, maintenance and the shared validation tests all use them, and they now cover the call-mode-only outcomes: - a `balanceOf` that reverts, returns less than a word or halts rejects the transaction as `TokenBalanceQueryFailed`, which does not penalize the relaying peer, and maintenance removes it; - a storage or code read failure inside the call is a state error: admission reports it as such and maintenance keeps the transaction. --- crates/txpool/src/maintain.rs | 282 ++++++++--------------- crates/txpool/src/morph_tx_validation.rs | 271 +++++++++++++++++++++- crates/txpool/src/validator.rs | 122 +++++----- 3 files changed, 428 insertions(+), 247 deletions(-) diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index df0147fa..05832178 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -380,13 +380,15 @@ mod tests { // descendant handling that only the pool can show. // --------------------------------------------------------------------------------- + use crate::morph_tx_validation::tests::{ + BALANCE_OF_RUNTIME, FAILING_BALANCE_OF, TokenRead, UnreadableTokenDb, + call_mode_registry_storage, call_mode_token_state, token_balance_key, + }; use alloy_consensus::{Signed, transaction::Recovered}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{Signature, TxKind, address}; use morph_primitives::{MorphTxEnvelope, TxMorph}; - use morph_revm::{ - L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot, compute_mapping_slot_for_address, - }; + use morph_revm::L2_TOKEN_REGISTRY_ADDRESS; use reth_revm::revm; use reth_revm::revm::database::{CacheDB, EmptyDB}; use reth_revm::revm::state::AccountInfo; @@ -394,20 +396,30 @@ mod tests { const SIGNER: Address = address!("0000000000000000000000000000000000000001"); const FEE_TOKEN: Address = address!("5300000000000000000000000000000000000042"); const TOKEN_ID: u16 = 1; - const BALANCE_SLOT: u64 = 7; /// `gas_limit * max_fee_per_gas` of [`token_fee_tx`]; at a 1:1 price ratio this is also /// the per-transaction token requirement at admission and revalidation. const TX_TOKEN_BUDGET: u64 = 21_000 * 100; - fn token_id_key(token_id: u16) -> [u8; 32] { - let mut key = [0u8; 32]; - key[30..32].copy_from_slice(&token_id.to_be_bytes()); - key + /// State with [`TOKEN_ID`] registered as an active call-mode fee token at a 1:1 price + /// ratio, whose `balanceOf` reports `token_balance` for [`SIGNER`]. + fn test_state(account_nonce: u64, eth_balance: u64, token_balance: u64) -> CacheDB { + test_state_with_token_code( + account_nonce, + eth_balance, + token_balance, + BALANCE_OF_RUNTIME, + ) } - /// State with [`TOKEN_ID`] registered as an active slot-mode token at a 1:1 price ratio. - fn test_state(account_nonce: u64, eth_balance: u64, token_balance: u64) -> CacheDB { - let mut db = CacheDB::new(EmptyDB::default()); + /// [`test_state`] with the fee token's `balanceOf` replaced by `code`. + fn test_state_with_token_code( + account_nonce: u64, + eth_balance: u64, + token_balance: u64, + code: &'static [u8], + ) -> CacheDB { + let mut db = + call_mode_token_state(TOKEN_ID, FEE_TOKEN, code, SIGNER, U256::from(token_balance)); db.insert_account_info( SIGNER, AccountInfo { @@ -416,34 +428,6 @@ mod tests { ..Default::default() }, ); - - let token_key = token_id_key(TOKEN_ID); - let base = compute_mapping_slot(U256::from(151), &token_key); - let mut packed = [0u8; 32]; - packed[30] = 18; // decimals - packed[31] = 1; // isActive - for (slot, value) in [ - (base, U256::from_be_bytes(FEE_TOKEN.into_word().0)), - // `balanceSlot` is stored as the actual slot plus one. - (base + U256::from(1), U256::from(BALANCE_SLOT + 1)), - (base + U256::from(2), U256::from_be_bytes(packed)), - (base + U256::from(3), U256::from(1)), // scale - ( - compute_mapping_slot(U256::from(153), &token_key), - U256::from(1), // priceRatio - ), - ] { - db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) - .unwrap(); - } - - db.insert_account_storage( - FEE_TOKEN, - compute_mapping_slot_for_address(U256::from(BALANCE_SLOT), SIGNER), - U256::from(token_balance), - ) - .unwrap(); - db } @@ -584,36 +568,6 @@ mod tests { assert!(removable(&mut db, vec![&gapped]).is_empty()); } - /// Fails every storage read of the fee token, leaving the rest of the state readable. - #[derive(Debug)] - struct UnreadableToken(CacheDB); - - impl reth_revm::Database for UnreadableToken { - type Error = reth_provider::ProviderError; - - fn basic(&mut self, address: Address) -> Result, Self::Error> { - Ok(self.0.basic(address).unwrap()) - } - - fn code_by_hash( - &mut self, - code_hash: alloy_primitives::B256, - ) -> Result { - Ok(self.0.code_by_hash(code_hash).unwrap()) - } - - fn storage(&mut self, address: Address, index: U256) -> Result { - if address == FEE_TOKEN { - return Err(reth_provider::ProviderError::BestBlockNotFound); - } - Ok(self.0.storage(address, index).unwrap()) - } - - fn block_hash(&mut self, number: u64) -> Result { - Ok(self.0.block_hash(number).unwrap()) - } - } - #[derive(Debug)] struct CountingDb { inner: CacheDB, @@ -642,96 +596,85 @@ mod tests { #[test] fn token_cache_is_shared_within_a_round_and_refreshed_next_round() { - for call_mode in [false, true] { - let mut inner = test_state(0, 0, TX_TOKEN_BUDGET); - if call_mode { - let base = compute_mapping_slot(U256::from(151), &token_id_key(TOKEN_ID)); - inner - .insert_account_storage( - L2_TOKEN_REGISTRY_ADDRESS, - base + U256::from(1), - U256::ZERO, - ) - .unwrap(); - // balanceOf reads slot zero; count real EVM SLOADs as well as registry reads. - let code = revm::state::Bytecode::new_raw(alloy_primitives::Bytes::from_static(&[ - 0x5f, 0x54, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3, - ])); - inner.insert_account_info( - FEE_TOKEN, - AccountInfo { - code_hash: code.hash_slow(), - code: Some(code), - ..Default::default() - }, - ); - inner - .insert_account_storage(FEE_TOKEN, U256::ZERO, U256::from(TX_TOKEN_BUDGET)) - .unwrap(); - } - let mut db = CountingDb { - inner, - reads: HashMap::new(), - }; - let txs: Vec<_> = (0..3).map(token_fee_tx).collect(); - assert!( - collect_removable_transactions( - &mut db, - &L1BlockInfo::default(), - &test_evm_env(), - 30_000_000, - txs.iter().collect() - ) - .is_empty() - ); - assert_eq!(db.reads[&L2_TOKEN_REGISTRY_ADDRESS], 5); - assert_eq!(db.reads[&FEE_TOKEN], 1); - let balance_key = if call_mode { - U256::ZERO - } else { - compute_mapping_slot_for_address(U256::from(BALANCE_SLOT), SIGNER) - }; - db.inner - .insert_account_storage(FEE_TOKEN, balance_key, U256::ZERO) - .unwrap(); - assert_eq!( - collect_removable_transactions( - &mut db, - &L1BlockInfo::default(), - &test_evm_env(), - 30_000_000, - txs.iter().collect() - ), - vec![*txs[0].hash()] - ); - assert_eq!(db.reads[&L2_TOKEN_REGISTRY_ADDRESS], 10); - assert_eq!(db.reads[&FEE_TOKEN], 2); - } + let mut db = CountingDb { + inner: test_state(0, 0, TX_TOKEN_BUDGET), + reads: HashMap::new(), + }; + let txs: Vec<_> = (0..3).map(token_fee_tx).collect(); + assert!( + collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + txs.iter().collect() + ) + .is_empty() + ); + // Three transactions share one registry entry (five words) and one `balanceOf` SLOAD. + assert_eq!(db.reads[&L2_TOKEN_REGISTRY_ADDRESS], 5); + assert_eq!(db.reads[&FEE_TOKEN], 1); + + // The next round reads both again, so it sees the balance spent in between. + db.inner + .insert_account_storage(FEE_TOKEN, token_balance_key(SIGNER), U256::ZERO) + .unwrap(); + assert_eq!( + collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + txs.iter().collect() + ), + vec![*txs[0].hash()] + ); + assert_eq!(db.reads[&L2_TOKEN_REGISTRY_ADDRESS], 10); + assert_eq!(db.reads[&FEE_TOKEN], 2); } #[test] fn unreadable_token_state_does_not_remove_transactions() { let tx = token_fee_tx(0); - let mut db = UnreadableToken(test_state(0, 0, 10 * TX_TOKEN_BUDGET)); // Sanity check: the same transaction against readable state is kept as well, so the - // assertion below is about the read failure and not about affordability. + // assertions below are about the read failure and not about affordability. assert!( - removable(&mut db.0.clone(), vec![&tx]).is_empty(), + removable(&mut test_state(0, 0, 10 * TX_TOKEN_BUDGET), vec![&tx]).is_empty(), "transaction is affordable when the token balance can be read" ); - let to_remove = collect_removable_transactions( - &mut db, - &L1BlockInfo::default(), - &test_evm_env(), - 30_000_000, - vec![&tx], - ); - assert!( - to_remove.is_empty(), - "a transient state-read failure must not be treated as an invalid transaction" - ); + for failing in [TokenRead::Storage, TokenRead::Code] { + let mut db = UnreadableTokenDb { + inner: test_state(0, 0, 10 * TX_TOKEN_BUDGET), + token: FEE_TOKEN, + failing, + }; + let to_remove = collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + vec![&tx], + ); + assert!( + to_remove.is_empty(), + "a {failing:?} read failure inside balanceOf is not an invalid transaction" + ); + } + } + + #[test] + fn a_balance_query_without_a_balance_removes_the_transaction() { + let tx = token_fee_tx(0); + for code in FAILING_BALANCE_OF { + let mut db = test_state_with_token_code(0, 0, 10 * TX_TOKEN_BUDGET, code); + assert_eq!( + removable(&mut db, vec![&tx]), + vec![*tx.hash()], + "balanceOf code {code:02x?}" + ); + } } // --------------------------------------------------------------------------------- @@ -781,48 +724,27 @@ mod tests { client.add_block(head.header.hash_slow(), head); client.add_account(SIGNER, ExtendedAccount::new(0, U256::from(eth_balance))); - - let token_key = token_id_key(TOKEN_ID); - let base = compute_mapping_slot(U256::from(151), &token_key); - let mut packed = [0u8; 32]; - packed[30] = 18; - packed[31] = 1; client.add_account( L2_TOKEN_REGISTRY_ADDRESS, - ExtendedAccount::new(0, U256::ZERO).extend_storage([ - ( - storage_key(base), - U256::from_be_bytes(FEE_TOKEN.into_word().0), - ), - ( - storage_key(base + U256::from(1)), - U256::from(BALANCE_SLOT + 1), - ), - ( - storage_key(base + U256::from(2)), - U256::from_be_bytes(packed), - ), - (storage_key(base + U256::from(3)), U256::from(1)), - ( - storage_key(compute_mapping_slot(U256::from(153), &token_key)), - U256::from(1), - ), - ]), + ExtendedAccount::new(0, U256::ZERO).extend_storage( + call_mode_registry_storage(TOKEN_ID, FEE_TOKEN) + .map(|(slot, value)| (storage_key(slot), value)), + ), ); set_token_balance(&client, token_balance); client } + /// Deploys the call-mode fee token with `token_balance` recorded for [`SIGNER`]. fn set_token_balance(client: &TestProvider, token_balance: u64) { client.add_account( FEE_TOKEN, - ExtendedAccount::new(0, U256::ZERO).extend_storage([( - storage_key(compute_mapping_slot_for_address( - U256::from(BALANCE_SLOT), - SIGNER, - )), - U256::from(token_balance), - )]), + ExtendedAccount::new(0, U256::ZERO) + .with_bytecode(alloy_primitives::Bytes::from_static(BALANCE_OF_RUNTIME)) + .extend_storage([( + storage_key(token_balance_key(SIGNER)), + U256::from(token_balance), + )]), ); } diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index a7edd8bc..4d90a53b 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -196,7 +196,7 @@ pub(crate) fn validate_morph_tx_with_token_info( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; /// The environment the fee-token balance query is evaluated in. @@ -207,9 +207,167 @@ mod tests { ) } use alloy_consensus::Signed; - use alloy_primitives::{B256, Signature, TxKind, address}; + use alloy_primitives::{B256, Bytes, Signature, TxKind, address}; use morph_primitives::{TxMorph, transaction::morph_transaction::MORPH_TX_VERSION_1}; - use reth_revm::revm::database::EmptyDB; + use morph_revm::{ + L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot, compute_mapping_slot_for_address, + }; + use reth_revm::revm::database::{CacheDB, EmptyDB}; + use reth_revm::revm::state::{AccountInfo, Bytecode}; + + // --------------------------------------------------------------------------------- + // Fee-token fixtures, shared with the validator and maintenance tests. + // + // Fee tokens are paid in EVM-call mode only: the registry's `balanceSlot` word is zero, + // so every balance read runs the token's `balanceOf` in the EVM. + // --------------------------------------------------------------------------------- + + /// Storage slot of the `balances` mapping that [`BALANCE_OF_RUNTIME`] reads. + pub(crate) const TOKEN_BALANCES_SLOT: u8 = 7; + + /// A fee token answering every call as ERC20 `balanceOf(address)`: + /// `mstore(0, calldataload(4)) mstore(32, 7) mstore(0, sload(keccak256(0, 64))) + /// return(0, 32)`, i.e. `balances[account]` from the mapping at [`TOKEN_BALANCES_SLOT`]. + pub(crate) const BALANCE_OF_RUNTIME: &[u8] = &[ + 0x60, + 0x04, + 0x35, + 0x5f, + 0x52, // PUSH1 4, CALLDATALOAD, PUSH0, MSTORE + 0x60, + TOKEN_BALANCES_SLOT, + 0x60, + 0x20, + 0x52, // PUSH1 slot, PUSH1 32, MSTORE + 0x60, + 0x40, + 0x5f, + 0x20, + 0x54, // PUSH1 64, PUSH0, KECCAK256, SLOAD + 0x5f, + 0x52, + 0x60, + 0x20, + 0x5f, + 0xf3, // PUSH0, MSTORE, PUSH1 32, PUSH0, RETURN + ]; + + /// `balanceOf` implementations that do not produce a balance: one reverts, one returns + /// a single byte instead of a word, one halts on `INVALID`. + pub(crate) const FAILING_BALANCE_OF: [&[u8]; 3] = [ + &[0x5f, 0x5f, 0xfd], // PUSH0, PUSH0, REVERT + &[0x60, 0x01, 0x5f, 0xf3], // PUSH1 1, PUSH0, RETURN + &[0xfe], // INVALID + ]; + + /// Registry storage registering `token` as active fee token `token_id` in EVM-call mode, + /// with 18 decimals, a scale of 1 and a price ratio of 1. + pub(crate) fn call_mode_registry_storage(token_id: u16, token: Address) -> [(U256, U256); 5] { + let mut token_key = [0u8; 32]; + token_key[30..].copy_from_slice(&token_id.to_be_bytes()); + let base = compute_mapping_slot(U256::from(151), &token_key); + let mut active_with_decimals = [0u8; 32]; + active_with_decimals[30] = 18; + active_with_decimals[31] = 1; + [ + (base, U256::from_be_bytes(token.into_word().0)), + // A zero `balanceSlot` word selects EVM-call mode. + (base + U256::from(1), U256::ZERO), + ( + base + U256::from(2), + U256::from_be_bytes(active_with_decimals), + ), + (base + U256::from(3), U256::from(1)), + ( + compute_mapping_slot(U256::from(153), &token_key), + U256::from(1), + ), + ] + } + + /// Key of `account` in the `balances` mapping that [`BALANCE_OF_RUNTIME`] reads. + pub(crate) fn token_balance_key(account: Address) -> U256 { + compute_mapping_slot_for_address(U256::from(TOKEN_BALANCES_SLOT), account) + } + + /// State registering `token` as call-mode fee token `token_id`, whose `balanceOf` runs + /// `code`, with `balance` recorded for `holder` in its `balances` mapping. + pub(crate) fn call_mode_token_state( + token_id: u16, + token: Address, + code: &'static [u8], + holder: Address, + balance: U256, + ) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + for (slot, value) in call_mode_registry_storage(token_id, token) { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + } + let code = Bytecode::new_raw(Bytes::from_static(code)); + db.insert_account_info( + token, + AccountInfo { + code_hash: code.hash_slow(), + code: Some(code), + ..Default::default() + }, + ); + db.insert_account_storage(token, token_balance_key(holder), balance) + .unwrap(); + db + } + + /// Which read of the fee token [`UnreadableTokenDb`] fails. + #[derive(Debug, Clone, Copy)] + pub(crate) enum TokenRead { + /// Any storage slot of the token. + Storage, + /// The token's code, which the EVM then has to fetch by hash. + Code, + } + + /// Fails one kind of read of `token` with a provider error; everything else reads through. + #[derive(Debug)] + pub(crate) struct UnreadableTokenDb { + pub(crate) inner: CacheDB, + pub(crate) token: Address, + pub(crate) failing: TokenRead, + } + + impl reth_revm::Database for UnreadableTokenDb { + type Error = reth_provider::ProviderError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + let mut info = self.inner.basic(address).unwrap(); + if address == self.token && matches!(self.failing, TokenRead::Code) { + // A real provider returns only the code hash; the code is loaded separately. + if let Some(info) = info.as_mut() { + info.code = None; + } + } + Ok(info) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + let token_code_hash = self.inner.basic(self.token).unwrap().map(|i| i.code_hash); + if matches!(self.failing, TokenRead::Code) && token_code_hash == Some(code_hash) { + return Err(reth_provider::ProviderError::BestBlockNotFound); + } + Ok(self.inner.code_by_hash(code_hash).unwrap()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + if address == self.token && matches!(self.failing, TokenRead::Storage) { + return Err(reth_provider::ProviderError::BestBlockNotFound); + } + Ok(self.inner.storage(address, index).unwrap()) + } + + fn block_hash(&mut self, number: u64) -> Result { + Ok(self.inner.block_hash(number).unwrap()) + } + } #[test] fn test_morph_tx_validation_input_construction() { @@ -612,4 +770,111 @@ mod tests { if reason == "MorphTx version 2 is not yet active (celadon fork not reached)" )); } + + const FEE_TOKEN: Address = address!("5300000000000000000000000000000000000042"); + const TOKEN_PAYER: Address = address!("1000000000000000000000000000000000000001"); + /// `gas_limit * max_fee_per_gas` of [`token_fee_envelope`]; with the fixtures' 1:1 price + /// ratio and no L1 fee this is also its token requirement. + const TOKEN_FEE: u64 = 21_000 * 100; + + fn token_fee_envelope() -> MorphTxEnvelope { + let tx = TxMorph { + chain_id: 2818, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + fee_token_id: 1, + ..Default::default() + }; + MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )) + } + + /// Runs the MorphTx checks for [`token_fee_envelope`] from a sender without ETH. + fn validate_token_fee( + db: &mut DB, + ) -> Result> { + let envelope = token_fee_envelope(); + let evm_env = test_evm_env(MorphHardfork::Jade); + let input = MorphTxValidationInput { + consensus_tx: &envelope, + sender: TOKEN_PAYER, + eth_balance: U256::ZERO, + l1_data_fee: U256::ZERO, + hardfork: MorphHardfork::Jade, + evm_env: &evm_env, + }; + validate_morph_tx(db, &input) + } + + #[test] + fn a_call_mode_token_balance_is_read_through_balance_of() { + let fee = U256::from(TOKEN_FEE); + let mut db = call_mode_token_state(1, FEE_TOKEN, BALANCE_OF_RUNTIME, TOKEN_PAYER, fee); + let result = validate_token_fee(&mut db).unwrap(); + assert!(result.uses_token_fee); + assert_eq!(result.required_token_amount, fee); + assert_eq!(result.token_info.map(|info| info.balance), Some(fee)); + + let short = fee - U256::from(1); + let mut db = call_mode_token_state(1, FEE_TOKEN, BALANCE_OF_RUNTIME, TOKEN_PAYER, short); + assert_eq!( + validate_token_fee(&mut db).unwrap_err(), + MorphTxValidationError::Invalid(MorphTxError::InsufficientTokenBalance { + token_id: 1, + token_address: FEE_TOKEN, + balance: short, + required: fee, + }) + ); + } + + /// A `balanceOf` that reverts, answers with less than a word or halts yields no balance. + /// That is a verdict on the transaction, but not one that blames the relaying peer. + #[test] + fn a_balance_query_without_a_balance_is_an_invalid_transaction() { + use reth_transaction_pool::error::PoolTransactionError; + + for code in FAILING_BALANCE_OF { + let mut db = + call_mode_token_state(1, FEE_TOKEN, code, TOKEN_PAYER, U256::from(TOKEN_FEE)); + assert_eq!( + validate_token_fee(&mut db).unwrap_err(), + MorphTxValidationError::Invalid(MorphTxError::TokenBalanceQueryFailed { + token_id: 1 + }), + "balanceOf code {code:02x?}" + ); + } + assert!(!MorphTxError::TokenBalanceQueryFailed { token_id: 1 }.is_bad_transaction()); + } + + /// A read failure inside the `balanceOf` call says nothing about the transaction. + #[test] + fn an_unreadable_fee_token_is_a_state_error() { + for failing in [TokenRead::Storage, TokenRead::Code] { + let mut db = UnreadableTokenDb { + inner: call_mode_token_state( + 1, + FEE_TOKEN, + BALANCE_OF_RUNTIME, + TOKEN_PAYER, + U256::from(TOKEN_FEE), + ), + token: FEE_TOKEN, + failing, + }; + assert!( + matches!( + validate_token_fee(&mut db), + Err(MorphTxValidationError::State(_)) + ), + "{failing:?}" + ); + } + } } diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index 5eae10b6..df1d6999 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -640,15 +640,16 @@ fn is_morph_tx(tx: &impl Typed2718) -> bool { mod tests { use super::*; use crate::MorphTxError; + use crate::morph_tx_validation::tests::{ + BALANCE_OF_RUNTIME, FAILING_BALANCE_OF, call_mode_registry_storage, token_balance_key, + }; use alloy_consensus::{Sealable, Signed, TxEip1559, TxLegacy}; use alloy_eips::eip2718::Encodable2718; - use alloy_primitives::{B256, Signature, TxKind, address}; + use alloy_primitives::{B256, Bytes, Signature, TxKind, address}; use morph_chainspec::{MORPH_MAINNET, MorphChainSpec}; use morph_evm::MorphEvmConfig; use morph_primitives::{MorphPrimitives, TxL1Msg, TxMorph}; - use morph_revm::{ - L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot, compute_mapping_slot_for_address, - }; + use morph_revm::L2_TOKEN_REGISTRY_ADDRESS; use reth_primitives_traits::Recovered; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; use reth_transaction_pool::{ @@ -666,53 +667,23 @@ mod tests { B256::from(slot.to_be_bytes::<32>()) } - fn token_id_key(token_id: u16) -> [u8; 32] { - let mut key = [0u8; 32]; - key[30..32].copy_from_slice(&token_id.to_be_bytes()); - key + /// The registry account with `token` registered as call-mode fee token `token_id`. + fn token_registry_account(token_id: u16, token: alloy_primitives::Address) -> ExtendedAccount { + ExtendedAccount::new(0, U256::ZERO).extend_storage( + call_mode_registry_storage(token_id, token) + .map(|(slot, value)| (storage_key(slot), value)), + ) } - fn token_registry_account( - token_id: u16, - token_address: alloy_primitives::Address, - balance_slot: U256, - token_balance: U256, + /// A fee token whose `balanceOf` runs `code`, with `balance` recorded for `holder`. + fn fee_token_account( + code: &'static [u8], + holder: alloy_primitives::Address, + balance: U256, ) -> ExtendedAccount { - let token_registry_slot = U256::from(151); - let price_ratio_slot = U256::from(153); - let token_key = token_id_key(token_id); - let base = compute_mapping_slot(token_registry_slot, &token_key); - - let mut slot_2 = [0u8; 32]; - slot_2[30] = 18; - slot_2[31] = 1; - - ExtendedAccount::new(0, U256::ZERO).extend_storage([ - ( - storage_key(base), - U256::from_be_bytes(token_address.into_word().0), - ), - ( - storage_key(base + U256::from(1)), - balance_slot + U256::from(1), - ), - ( - storage_key(base + U256::from(2)), - U256::from_be_bytes(slot_2), - ), - (storage_key(base + U256::from(3)), U256::from(1)), - ( - storage_key(compute_mapping_slot(price_ratio_slot, &token_key)), - U256::from(1), - ), - ( - storage_key(compute_mapping_slot_for_address( - balance_slot, - address!("0000000000000000000000000000000000000001"), - )), - token_balance, - ), - ]) + ExtendedAccount::new(0, U256::ZERO) + .with_bytecode(Bytes::from_static(code)) + .extend_storage([(storage_key(token_balance_key(holder)), balance)]) } type TokenFeeValidator = MorphTransactionValidator< @@ -721,7 +692,7 @@ mod tests { MorphEvmConfig, >; - /// Registered token with a 1:1 price ratio at an Emerald-active head. + /// Registered call-mode token with a 1:1 price ratio at an Emerald-active head. fn token_fee_validator( eth_balance: U256, token_balance: U256, @@ -731,7 +702,6 @@ mod tests { let client = new_mock_provider(); let signer = address!("0000000000000000000000000000000000000001"); let token = address!("5300000000000000000000000000000000000042"); - let balance_slot = U256::from(7); let header = morph_primitives::MorphHeader::from(alloy_consensus::Header { number: 1, timestamp: 1_767_765_600, @@ -747,16 +717,10 @@ mod tests { }, ); client.add_account(signer, ExtendedAccount::new(0, eth_balance)); - client.add_account( - L2_TOKEN_REGISTRY_ADDRESS, - token_registry_account(1, token, balance_slot, token_balance), - ); + client.add_account(L2_TOKEN_REGISTRY_ADDRESS, token_registry_account(1, token)); client.add_account( token, - ExtendedAccount::new(0, U256::ZERO).extend_storage([( - storage_key(compute_mapping_slot_for_address(balance_slot, signer)), - token_balance, - )]), + fee_token_account(BALANCE_OF_RUNTIME, signer, token_balance), ); let inner = EthTransactionValidatorBuilder::new( client, @@ -797,12 +761,6 @@ mod tests { token_fee_validator(U256::ZERO, U256::from(10_000_000), 0, Default::default()); let client = validator.client(); let token = address!("5300000000000000000000000000000000000042"); - let base = compute_mapping_slot(U256::from(151), &token_id_key(1)); - client.add_account( - L2_TOKEN_REGISTRY_ADDRESS, - token_registry_account(1, token, U256::from(7), U256::ZERO) - .extend_storage([(storage_key(base + U256::from(1)), U256::ZERO)]), - ); // Return 10,000,000 only at the old head's timestamp. The token state stays // unchanged so both reads of the cached provider must use the old environment. let old_timestamp = 1_767_765_600u32; @@ -874,6 +832,42 @@ mod tests { ); } + /// A `balanceOf` that yields no balance rejects the transaction without blaming the + /// peer that relayed it: the fault lies with the token, not the sender. + #[test] + fn a_failing_balance_query_is_rejected_without_penalizing_the_peer() { + for code in FAILING_BALANCE_OF { + let validator = + token_fee_validator(U256::ZERO, U256::from(10_000_000), 0, Default::default()); + validator.client().add_account( + address!("5300000000000000000000000000000000000042"), + fee_token_account( + code, + address!("0000000000000000000000000000000000000001"), + U256::from(10_000_000), + ), + ); + let outcome = validator.validate_one( + TransactionOrigin::External, + token_fee_transaction(0, U256::ZERO), + ); + let err = match outcome { + TransactionValidationOutcome::Invalid(_, err) => err, + other => panic!("balanceOf code {code:02x?}: {other:?}"), + }; + assert_eq!( + err.downcast_other_ref::(), + Some(&MorphTxError::TokenBalanceQueryFailed { token_id: 1 }), + "balanceOf code {code:02x?}" + ); + assert_eq!( + err.as_other().map(|other| other.is_bad_transaction()), + Some(false), + "balanceOf code {code:02x?}" + ); + } + } + #[test] fn token_fee_transaction_above_local_fee_cap_is_rejected() { // The effective gas price is 20, so only the maximum fee budget exceeds this cap. From 83f4b7423b4d760f707502bafe3f2e8b5e4adc97 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 23 Sep 2026 14:46:34 +0800 Subject: [PATCH 14/17] test(txpool): pin the mined-nonce skip and descendant parking Two behaviours could be broken without failing any test: - Maintenance skips transactions the new block already executed instead of reading them as a nonce gap. The test now leaves the next nonce unpayable, so ending the walk early would keep it. - The parking test asserts that the unpayable transaction is removed and its successor queued, so an early return of the maintenance round no longer passes it. --- crates/txpool/src/maintain.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 05832178..9a0cecf9 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -476,16 +476,14 @@ mod tests { } #[test] - fn transactions_already_executed_by_the_block_do_not_consume_the_budget_again() { - // The block executed nonce 0, which cost far less than the `TX_TOKEN_BUDGET` it - // reserved, so the post-state still affords nonce 1 — but not both at max fee. - let mut db = test_state(1, 0, TX_TOKEN_BUDGET + TX_TOKEN_BUDGET / 2); + fn transactions_already_executed_by_the_block_are_skipped_not_taken_for_a_gap() { + // The new block executed nonce 0, but this task can still see it: reth's own maintenance + // removes it on the same notification, in no guaranteed order. Reading it as a nonce gap + // would end the walk before nonce 1, which the post-state can no longer pay for. + let mut db = test_state(1, 0, TX_TOKEN_BUDGET - 1); let (tx0, tx1) = (token_fee_tx(0), token_fee_tx(1)); - assert!( - removable(&mut db, vec![&tx0, &tx1]).is_empty(), - "nonce 1 is affordable against the post-state and nonce 0 is already mined" - ); + assert_eq!(removable(&mut db, vec![&tx0, &tx1]), vec![*tx1.hash()]); } #[test] @@ -1308,11 +1306,12 @@ mod tests { // nonce 0 pays in tokens, nonce 1 is a plain ETH transaction that only depends on // nonce 0 through the nonce sequence. - futures::executor::block_on(pool.add_transaction( + let unpayable = futures::executor::block_on(pool.add_transaction( reth_transaction_pool::TransactionOrigin::Local, token_fee_tx(0), )) - .unwrap(); + .unwrap() + .hash; let descendant = futures::executor::block_on(pool.add_transaction( reth_transaction_pool::TransactionOrigin::Local, legacy_tx(1), @@ -1330,8 +1329,15 @@ mod tests { futures::stream::iter([event]), )); + // Without the removal, every early return of the maintenance round would pass the + // descendant check below as well. + assert!( + pool.get(&unpayable).is_none(), + "the transaction the sender can no longer pay for must be removed" + ); + let queued = pool.all_transactions().queued; assert!( - pool.get(&descendant).is_some(), + queued.iter().any(|tx| *tx.hash() == descendant), "an independently affordable ETH-fee successor must be parked, not deleted" ); } From accd362c82c6aeff43dc05e9da151c328bc9a122 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 23 Sep 2026 14:47:39 +0800 Subject: [PATCH 15/17] test(node): cover a token-fee sender that holds no ETH end to end A fresh account receives fee tokens and no ETH, then sends a v0 and a v2 MorphTx paying gas in the token. Both must be pending straight away and mined in the next block, leaving the ETH balance at zero and charging the token. Without the pool's token-fee `cost()` both sit in `queued`, where the builder never sees them and the network never announces them. --- crates/node/tests/it/morph_tx.rs | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs index 6070ce3d..3c240a63 100644 --- a/crates/node/tests/it/morph_tx.rs +++ b/crates/node/tests/it/morph_tx.rs @@ -425,6 +425,74 @@ async fn morph_tx_v0_token_balance_decreases() -> eyre::Result<()> { Ok(()) } +/// A sender holding fee tokens but no ETH must reach `pending` and be mined. +/// +/// The pool used to charge a token-fee MorphTx's `gas_limit * max_fee_per_gas` against +/// the sender's ETH balance, which left every such transaction in `queued`: never built +/// into a block, and never propagated, since reth only announces pending transactions. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_token_fee_from_zero_eth_sender_is_pending_and_mined() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use reth_provider::{AccountReader, StateProviderFactory}; + use reth_transaction_pool::TransactionPool; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let token = morph_node::test_utils::TEST_TOKEN_ADDRESS; + + // Hand a fresh account ten fee tokens and no ETH. + let payer = alloy_signer_local::PrivateKeySigner::random(); + let payer_address = payer.address(); + let token_grant = U256::from(10u128.pow(19)); + let grant = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v0_token_fee(TEST_TOKEN_ID) + .with_to(token) + .with_data(erc20_transfer_calldata(payer_address, token_grant)) + .build_signed()?; + node.rpc.inject_tx(grant).await?; + node.advance_block().await?; + + let balance_slot = token_balance_slot(payer_address); + let state = node.inner.provider.latest()?; + let eth_balance = state + .basic_account(&payer_address)? + .map(|account| account.balance); + assert_eq!(eth_balance.unwrap_or_default(), U256::ZERO); + assert_eq!(state.storage(token, balance_slot)?, Some(token_grant)); + + // A v0 and a v2 MorphTx, both paying gas in the fee token. + let v0 = MorphTxBuilder::new(wallet.chain_id, payer.clone(), 0) + .with_v0_token_fee(TEST_TOKEN_ID) + .build_signed()?; + let v2 = MorphTxBuilder::new(wallet.chain_id, payer, 1) + .with_v2_token_fee(TEST_TOKEN_ID) + .build_signed()?; + node.rpc.inject_tx(v0).await?; + node.rpc.inject_tx(v2).await?; + assert_eq!( + node.inner.pool.pending_and_queued_txn_count(), + (2, 0), + "token-fee transactions from a sender without ETH must be pending" + ); + + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 2); + + let state = node.inner.provider.latest()?; + let account = state + .basic_account(&payer_address)? + .expect("the payer exists once its transactions are mined"); + assert_eq!(account.nonce, 2); + assert_eq!(account.balance, U256::ZERO); + let tokens_left = state.storage(token, balance_slot)?.unwrap_or_default(); + assert!( + tokens_left < token_grant, + "gas must be charged in the fee token" + ); + + Ok(()) +} + /// Regression for the mainnet block 19720219 shape: /// /// - tx `0xc267450129e51457a280fa82c74364d312e47885c09d15c78f6a0895844913c9` From 46711f12f34eaea85cc8a8079d0d19b1caa3e5c5 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 23 Sep 2026 22:32:45 +0800 Subject: [PATCH 16/17] fix(txpool): re-check removals at the canonical head before applying them A maintenance round judges the pool at the block its notification named and applied the removals unless a newer notification was already queued. That check read the stream with `now_or_never`, which reports an empty stream once a poll has received 128 items under tokio's cooperative budget. With more notifications queued, the round removed transactions that the newer head could pay for. Honouring the check would not be enough either: abandoning every round that a new block overtakes starves the pool of removals when blocks arrive faster than a round runs. Before removing anything, a round now judges the candidates' senders again at the provider's canonical head and removes only the candidates that fail there too. Skipping ahead to the newest notification stays as an optimisation. The loop reads the chain through a small `FeeStateSource` trait, so a test can give each block its own state, which `MockEthProvider` cannot. The new test queues 128 notifications of an older block ahead of the head's and runs the loop the way the node does, `Handle::block_on` on a blocking thread. It fails without the re-check and with the previous supersede check. --- Cargo.lock | 2 + crates/txpool/Cargo.toml | 2 + crates/txpool/src/maintain.rs | 347 ++++++++++++++++++++++++---------- 3 files changed, 255 insertions(+), 96 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e96a416..cfc94643 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5526,6 +5526,8 @@ dependencies = [ "reth-revm", "reth-storage-api", "reth-transaction-pool", + "tokio", + "tokio-stream", "tracing", ] diff --git a/crates/txpool/Cargo.toml b/crates/txpool/Cargo.toml index ce50efe4..8b2a31a1 100644 --- a/crates/txpool/Cargo.toml +++ b/crates/txpool/Cargo.toml @@ -41,5 +41,7 @@ parking_lot.workspace = true tracing.workspace = true [dev-dependencies] +tokio.workspace = true +tokio-stream = { workspace = true, features = ["sync"] } reth-evm-ethereum.workspace = true reth-provider = { workspace = true, features = ["test-utils"] } diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 9a0cecf9..0651e025 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -39,21 +39,59 @@ //! and `demoteUnexecutables` (tx_pool.go), but implemented as a separate //! maintenance task since we cannot modify reth's internal pool logic. -use crate::{MorphPooledTransaction, MorphTxValidationError}; +use crate::{MorphPooledTransaction, MorphTxValidationError, MorphValidationState}; use alloy_consensus::Transaction; use alloy_consensus::Typed2718; -use alloy_primitives::{Address, TxHash}; +use alloy_primitives::{Address, B256, TxHash}; use futures::{FutureExt, StreamExt}; use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; use morph_revm::{L1BlockInfo, MorphBlockEnv, MorphEvmEnv}; use reth_chainspec::ChainSpecProvider; use reth_evm::{ConfigureEvm, EvmFactory, EvmFactoryFor}; -use reth_primitives_traits::AlloyBlockHeader; +use reth_primitives_traits::{AlloyBlockHeader, HeaderTy, NodePrimitives, SealedHeader}; use reth_provider::CanonStateSubscriptions; use reth_revm::database::StateProviderDatabase; -use reth_storage_api::StateProviderFactory; +use reth_storage_api::{BlockReaderIdExt, StateProviderFactory}; use reth_transaction_pool::{PoolTransaction, TransactionPool}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; + +type BoxError = Box; + +/// Chain access for the maintenance loop. +/// +/// The node reads everything from its provider ([`ProviderFeeState`]). Tests supply a +/// separate state per block, which the mock provider cannot do. +trait FeeStateSource { + /// Opens the state, L1 fee parameters and EVM environment of `header`. + fn state_for(&self, header: &HeaderTy) -> Result; + + /// Returns the current canonical head. + fn canonical_head(&self) -> Result>>, BoxError>; +} + +/// [`FeeStateSource`] backed by the node's provider. +struct ProviderFeeState { + client: Client, + evm_config: Evm, +} + +impl FeeStateSource for ProviderFeeState +where + Client: StateProviderFactory + BlockReaderIdExt
>, + Evm: ConfigureEvm, + EvmFactoryFor: EvmFactory, +{ + fn state_for( + &self, + header: &HeaderTy, + ) -> Result { + crate::validator::validation_state_for_header(&self.client, &self.evm_config, header) + } + + fn canonical_head(&self) -> Result>>, BoxError> { + Ok(self.client.latest_header()?) + } +} fn exceeds_block_gas_limit(tx_gas_limit: u64, block_gas_limit: u64) -> bool { tx_gas_limit > block_gas_limit @@ -214,6 +252,76 @@ fn collect_removable_transactions( to_remove } +/// Keeps the removal candidates that are still removable at the canonical head. +/// +/// A round judges the pool at the block its notification named. By the time it ends, the +/// canonical head can be newer: blocks keep arriving while it runs, and the skip-ahead can +/// stop short of the newest notification. A verdict about an older block must not remove a +/// transaction the head can pay for, so the candidates' senders are judged again at the head +/// and only candidates that fail there too are kept. Anything only the head would remove is +/// left for the round of that head. Nothing is removed if the head cannot be read. +fn recheck_at_canonical_head( + pool: &Pool, + source: &Source, + judged_at: B256, + candidates: Vec, +) -> Vec +where + Pool: TransactionPool, + N: NodePrimitives, + Source: FeeStateSource, +{ + if candidates.is_empty() { + return candidates; + } + let head = match source.canonical_head() { + Ok(Some(head)) => head, + Ok(None) => { + tracing::warn!(target: "morph::txpool::maintain", "No canonical head; skipping removals"); + return Vec::new(); + } + Err(err) => { + tracing::warn!(target: "morph::txpool::maintain", %err, "Failed to read the canonical head; skipping removals"); + return Vec::new(); + } + }; + if head.hash() == judged_at { + return candidates; + } + let state = match source.state_for(head.header()) { + Ok(state) => state, + Err(err) => { + tracing::warn!(target: "morph::txpool::maintain", %err, "Failed to open the canonical head; skipping removals"); + return Vec::new(); + } + }; + + let senders: HashSet
= candidates + .iter() + .filter_map(|hash| pool.get(hash)) + .map(|tx| tx.sender()) + .collect(); + let sender_txs: Vec<_> = senders + .into_iter() + .flat_map(|sender| pool.get_transactions_by_sender(sender)) + .collect(); + let mut db = StateProviderDatabase::new(state.provider); + let still_removable: HashSet = collect_removable_transactions( + &mut db, + &state.head.l1_block_info, + &state.head.evm_env, + head.gas_limit(), + sender_txs.iter().map(|tx| &tx.transaction).collect(), + ) + .into_iter() + .collect(); + + candidates + .into_iter() + .filter(|hash| still_removable.contains(hash)) + .collect() +} + /// Maintains the Morph transaction pool by revalidating L1 fees and token balances. /// /// This task runs continuously and: @@ -223,12 +331,14 @@ fn collect_removable_transactions( /// - Re-validates L1 fee affordability for every sender, including ordinary-only senders /// - Removes ordinary transactions whose L1 fees make them individually unaffordable, /// parking their descendants +/// - Re-checks every removal at the canonical head right before applying it /// pub async fn maintain_morph_pool(pool: Pool, client: Client, evm_config: Evm) where Pool: TransactionPool + Clone, Client: ChainSpecProvider + StateProviderFactory + + BlockReaderIdExt
> + CanonStateSubscriptions + Clone + 'static, @@ -239,35 +349,22 @@ where tracing::info!(target: "morph::txpool::maintain", "Starting Morph fee maintenance task"); - maintain_morph_pool_with(pool, client, evm_config, chain_events).await; + maintain_morph_pool_with(pool, ProviderFeeState { client, evm_config }, chain_events).await; } -/// [`maintain_morph_pool`] with an explicit canonical event stream. -async fn maintain_morph_pool_with( +/// [`maintain_morph_pool`] with an explicit state source and canonical event stream. +async fn maintain_morph_pool_with( pool: Pool, - client: Client, - evm_config: Evm, + source: Source, mut chain_events: Events, ) where Pool: TransactionPool + Clone, - Client: ChainSpecProvider - + StateProviderFactory - + CanonStateSubscriptions - + Clone - + 'static, - Evm: ConfigureEvm::Primitives>, - EvmFactoryFor: EvmFactory, - Events: - futures::Stream> + Unpin, + N: NodePrimitives, + Source: FeeStateSource, + Events: futures::Stream> + Unpin, { - let mut pending_event = None; loop { - // Reuse a newer notification that superseded the previous scan. - let event = match pending_event.take() { - Some(event) => Some(event), - None => chain_events.next().await, - }; - let Some(mut event) = event else { + let Some(mut event) = chain_events.next().await else { tracing::debug!(target: "morph::txpool::maintain", "Chain event stream ended"); break; }; @@ -275,7 +372,9 @@ async fn maintain_morph_pool_with( // Skip ahead to the newest queued notification. A round reads each sender's account // and any fee-token state, so the chain can advance while we are working; the verdicts // this task produces are a pure function of the latest state, which makes every - // intermediate block wasted work against a stale view of the pool. + // intermediate block wasted work against a stale view of the pool. This is only an + // optimisation: under tokio's cooperative budget `now_or_never` reports an empty + // stream after 128 items, so removals are re-checked at the canonical head below. while let Some(next) = chain_events.next().now_or_never().flatten() { event = next; } @@ -304,11 +403,7 @@ async fn maintain_morph_pool_with( continue; } - let state = match crate::validator::validation_state_for_header( - &client, - &evm_config, - new_tip.header(), - ) { + let state = match source.state_for(new_tip.header()) { Ok(state) => state, Err(err) => { tracing::warn!(target: "morph::txpool::maintain", %err, "Failed to prepare fee revalidation state"); @@ -333,12 +428,7 @@ async fn maintain_morph_pool_with( pool_txs, ); - // A new block may arrive during this synchronous scan. Its balance changes - // supersede the verdicts we just calculated; re-scan before deleting anything. - if let Some(event) = chain_events.next().now_or_never().flatten() { - pending_event = Some(event); - continue; - } + let to_remove = recheck_at_canonical_head(&pool, &source, new_tip.hash(), to_remove); // Remove the offending transactions. `remove_transactions` *parks* each removed // transaction's descendants instead of deleting them (upstream @@ -748,7 +838,13 @@ mod tests { /// A canonical commit of [`head_block`]. fn commit_event() -> reth_provider::CanonStateNotification { - let block = head_block(); + commit_event_of(head_block()) + } + + /// A canonical commit of `block`. + fn commit_event_of( + block: morph_primitives::Block, + ) -> reth_provider::CanonStateNotification { reth_provider::CanonStateNotification::Commit { new: std::sync::Arc::new(reth_provider::Chain::new( [reth_primitives_traits::RecoveredBlock::new_unhashed( @@ -784,12 +880,67 @@ mod tests { MorphPooledTransaction::new(recovered, encoded_len) } - #[test] - fn a_new_head_arriving_during_a_scan_supersedes_its_removals() { - let client = mock_provider(0, TX_TOKEN_BUDGET); + /// Runs the maintenance loop against the provider, as the node does. + fn provider_state(client: TestProvider) -> ProviderFeeState { + ProviderFeeState { + client, + evm_config: MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + } + } + + /// A [`FeeStateSource`] with a separate state per block, unlike [`MockEthProvider`], + /// whose state ignores the block hash. `head` is the canonical head. + struct BlockStates { + states: HashMap, + head: SealedHeader, + } + + impl FeeStateSource for BlockStates { + fn state_for( + &self, + header: &morph_primitives::MorphHeader, + ) -> Result { + let client = self + .states + .get(&header.hash_slow()) + .ok_or("no state for this block")?; + crate::validator::validation_state_for_header( + client, + &MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + header, + ) + } + + fn canonical_head( + &self, + ) -> Result>, BoxError> { + Ok(Some(self.head.clone())) + } + } + + /// The block after [`head_block`]. + fn next_block() -> morph_primitives::Block { + let mut block = head_block(); + block.header.inner.number = 2; + block.header.inner.timestamp += 1; + block + } + + /// Admits [`token_fee_tx`] 0 while [`SIGNER`] can pay, then runs maintenance on `events` + /// with the sender's tokens drained at [`head_block`] and `head_token_balance` at the + /// canonical head, [`next_block`]. Returns whether the transaction is still pooled. + /// + /// The events go through a tokio broadcast channel, like reth's canonical stream, and the + /// loop runs the way `spawn_critical_blocking_task` runs it: `Handle::block_on` on a + /// blocking thread, where tokio's cooperative budget applies. + fn survives_a_stale_round( + head_token_balance: u64, + events: Vec>, + ) -> bool { + let judged = mock_provider(0, TX_TOKEN_BUDGET); let validator = crate::MorphTransactionValidator::new( EthTransactionValidatorBuilder::new( - client.clone(), + judged.clone(), MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), ) .disable_balance_check() @@ -808,46 +959,57 @@ mod tests { )) .unwrap() .hash; - set_token_balance(&client, 0); - let mut polls = 0; - let events = futures::stream::poll_fn(|_| { - let poll = polls; - polls += 1; - match poll { - 0 => std::task::Poll::Ready(Some(commit_event())), - // The queue is empty immediately before the synchronous scan. - 1 => std::task::Poll::Pending, - // The next block restores funds while the scan runs. - 2 => { - set_token_balance(&client, TX_TOKEN_BUDGET); - let mut block = head_block(); - block.header.inner.number = 2; - block.header.inner.timestamp += 1; - client.add_block(block.header.hash_slow(), block.clone()); - std::task::Poll::Ready(Some(reth_provider::CanonStateNotification::Commit { - new: std::sync::Arc::new(reth_provider::Chain::new( - [reth_primitives_traits::RecoveredBlock::new_unhashed( - block, - Vec::new(), - )], - Default::default(), - Default::default(), - )), - })) - } - _ => std::task::Poll::Ready(None), - } + set_token_balance(&judged, 0); + let head = next_block().header; + let source = BlockStates { + states: HashMap::from([ + (head_block().header.hash_slow(), judged), + (head.hash_slow(), mock_provider(0, head_token_balance)), + ]), + head: SealedHeader::seal_slow(head), + }; + + let (sender, receiver) = tokio::sync::broadcast::channel(events.len()); + for event in events { + sender.send(event).unwrap(); + } + drop(sender); + let events = tokio_stream::wrappers::BroadcastStream::new(receiver) + .map(|event| event.expect("the channel holds every event")); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let handle = runtime.handle().clone(); + let loop_pool = pool.clone(); + let task = runtime.spawn_blocking(move || { + handle.block_on(maintain_morph_pool_with(loop_pool, source, events)) }); - futures::executor::block_on(maintain_morph_pool_with( - pool.clone(), - client.clone(), - MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), - events, - )); - assert!( - pool.get(&hash).is_some(), - "do not apply a verdict superseded by a queued head" - ); + runtime.block_on(task).unwrap(); + pool.get(&hash).is_some() + } + + #[test] + fn removals_judged_at_an_older_block_are_rechecked_at_the_canonical_head() { + for backlog in [false, true] { + // Without a backlog, the head moved on before its notification arrived. With one, + // 128 notifications exhaust tokio's cooperative budget within a single poll, so the + // loop cannot see the head's notification behind them before it removes anything. + let events = || { + let mut events: Vec<_> = std::iter::repeat_with(commit_event) + .take(if backlog { 128 } else { 1 }) + .collect(); + if backlog { + events.push(commit_event_of(next_block())); + } + events + }; + assert!( + survives_a_stale_round(TX_TOKEN_BUDGET, events()), + "backlog={backlog}: payable at the canonical head, so it must stay" + ); + assert!( + !survives_a_stale_round(0, events()), + "backlog={backlog}: still unpayable at the canonical head, so it must go" + ); + } } #[test] @@ -891,8 +1053,7 @@ mod tests { assert_eq!(pool.all_transactions().pending.len(), 3); futures::executor::block_on(maintain_morph_pool_with( pool.clone(), - client, - MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + provider_state(client), futures::stream::iter([commit_event()]), )); assert!(hashes.iter().all(|hash| pool.get(hash).is_some())); @@ -987,8 +1148,7 @@ mod tests { for _ in 0..3 { futures::executor::block_on(maintain_morph_pool_with( pool.clone(), - client.clone(), - MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + provider_state(client.clone()), futures::stream::iter([event.clone()]), )); } @@ -1072,8 +1232,7 @@ mod tests { // Run Morph first to cover a pool snapshot that still contains a mined nonce. futures::executor::block_on(maintain_morph_pool_with( pool.clone(), - client.clone(), - MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + provider_state(client.clone()), futures::stream::iter([event.clone()]), )); pool.on_canonical_state_change(reth_transaction_pool::CanonicalStateUpdate { @@ -1091,8 +1250,7 @@ mod tests { // And run after reth parks/removes transactions, covering either task order. futures::executor::block_on(maintain_morph_pool_with( pool.clone(), - client, - MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + provider_state(client), futures::stream::iter([event]), )); let all = pool.all_transactions(); @@ -1143,8 +1301,7 @@ mod tests { set_token_balance(&client, 0); futures::executor::block_on(maintain_morph_pool_with( pool.clone(), - client, - MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + provider_state(client), futures::stream::iter([commit_event()]), )); @@ -1250,8 +1407,7 @@ mod tests { for _ in 0..3 { futures::executor::block_on(maintain_morph_pool_with( pool.clone(), - client.clone(), - MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + provider_state(client.clone()), futures::stream::iter([event.clone()]), )); } @@ -1324,8 +1480,7 @@ mod tests { let event = commit_event(); futures::executor::block_on(maintain_morph_pool_with( pool.clone(), - client, - MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + provider_state(client), futures::stream::iter([event]), )); From 27bbdb68754808e43bdeb6a9e8a66b656e174e7f Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 25 Sep 2026 16:20:17 +0800 Subject: [PATCH 17/17] fix(txpool): revalidate transactions parked behind a nonce gap The maintenance round stopped walking a sender at the first nonce gap, so a transaction parked behind one was never judged again: not its token balance, not its L1 fee, not its gas limit. reth promotes parked transactions the moment the gap is filled without running the validator (`AllTransactions::insert_tx` only recomputes the ETH cost bits), so a token-fee MorphTx whose sender had spent every token while it waited went straight to pending, was offered to the builder and announced to peers, until the next round removed it. Every check the round applies is per transaction against the sender's current balances, the same as admission and as go-ethereum's `promoteExecutables`, which filters its whole queue through `FilterF` before `Ready` promotes anything. Nothing accumulates the missing predecessors' costs, so the gap no longer ends the walk. Mined nonces are still skipped and the first offender still parks its descendants; parked descendants that cannot pay either are removed by later rounds. Also state plainly that the canonical-head re-check narrows the window between judging and removing to the re-check itself rather than closing it. --- crates/txpool/src/maintain.rs | 171 ++++++++++++++++++++++++++-------- 1 file changed, 132 insertions(+), 39 deletions(-) diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 0651e025..6ae73871 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -13,7 +13,8 @@ //! //! This maintenance task solves this by: //! 1. Listening to canonical state changes (new blocks) -//! 2. Re-validating each sender's contiguous nonce sequence against current account balances +//! 2. Re-validating every pooled transaction, including those parked behind a nonce gap, +//! against the sender's current account balances //! 3. Removing the first transaction with an L1 fee or token shortfall that reth cannot //! handle, letting the pool park its descendants //! @@ -140,9 +141,6 @@ fn collect_removable_transactions( } }; - // The nonce the next executable transaction of this sender must carry. - let mut next_nonce_in_line = account.nonce; - for tx in sender_txs { // Access the consensus tx by reference (via Deref chain) instead of // cloning. Use the pool tx's cached EIP-2718 encoding for L1 fee. @@ -151,23 +149,20 @@ fn collect_removable_transactions( // Already executed by the new block. reth's own maintenance task removes these // when it applies the same canonical update; both tasks subscribe to the // canonical stream independently, so this one can still observe them here. - // Charging them would consume a budget the sender no longer owes and strand the - // sender's next, genuinely affordable transaction. + // Judging them is pointless, and removing one here would park the sender's next + // transaction until reth's own update re-promotes it. if consensus_tx.nonce() < account.nonce { continue; } - // Nonce gap: the transactions filling it are not in the pool, so how much of - // this sender's balance is still owed by the time this one executes is unknown, - // and nothing from here on is executable anyway. Upstream's - // `AllTransactions::update` short-circuits the sender on a gap for the same - // reason, and go-ethereum only ever applies a per-transaction cost check to its - // queue, never a cumulative one. Anything left behind the gap sits in the queued - // sub-pool, where reth's own stale eviction reaps it. - if consensus_tx.nonce() != next_nonce_in_line { - break; - } - next_nonce_in_line = next_nonce_in_line.saturating_add(1); + // A nonce gap does not stop the walk. Every check below is per transaction + // against the sender's current balances, which is what admission applies and + // what go-ethereum's `promoteExecutables` applies to its whole queue (`FilterF`) + // before `Ready` promotes anything. Nothing here accumulates the costs of the + // missing predecessors, so a transaction parked behind a gap can be judged on its + // own. Left unchecked, reth would promote it unvalidated the moment the gap is + // filled: `AllTransactions::insert_tx` only recomputes the ETH cost bits, which + // for a token-fee MorphTx cover its value alone. // Reth only sets its block-gas-limit flag at insertion, so a later // limit reduction needs the same explicit removal for both tx types. @@ -256,10 +251,15 @@ fn collect_removable_transactions( /// /// A round judges the pool at the block its notification named. By the time it ends, the /// canonical head can be newer: blocks keep arriving while it runs, and the skip-ahead can -/// stop short of the newest notification. A verdict about an older block must not remove a +/// stop short of the newest notification. A verdict about an older block should not remove a /// transaction the head can pay for, so the candidates' senders are judged again at the head /// and only candidates that fail there too are kept. Anything only the head would remove is /// left for the round of that head. Nothing is removed if the head cannot be read. +/// +/// This narrows the window to the re-check itself; it does not close it. The head can move +/// again during the few milliseconds the re-check takes, and a candidate that block makes +/// payable is still removed. That is the exposure any pool has between reading a state and +/// acting on it, and the sender resolves it by resubmitting. fn recheck_at_canonical_head( pool: &Pool, source: &Source, @@ -331,6 +331,7 @@ where /// - Re-validates L1 fee affordability for every sender, including ordinary-only senders /// - Removes ordinary transactions whose L1 fees make them individually unaffordable, /// parking their descendants +/// - Judges each transaction on its own, including those parked behind a nonce gap /// - Re-checks every removal at the canonical head right before applying it /// pub async fn maintain_morph_pool(pool: Pool, client: Client, evm_config: Evm) @@ -389,8 +390,8 @@ async fn maintain_morph_pool_with( "Processing new block for pool fee validation" ); - // Preserve each sender's complete nonce sequence, including ordinary ETH-fee - // transactions between MorphTx. Filtering first would create false nonce gaps. + // Every transaction type is revalidated (ordinary ones for their L1 fee), and a + // sender's full nonce order decides which offender is removed first. let all_txs = pool.all_transactions(); let pool_txs: Vec<&MorphPooledTransaction> = all_txs .pending @@ -523,14 +524,23 @@ mod tests { /// A token-fee MorphTx requiring [`TX_TOKEN_BUDGET`] tokens and no ETH. fn token_fee_tx(tx_nonce: u64) -> MorphPooledTransaction { - token_fee_tx_with_value(tx_nonce, U256::ZERO) + token_fee_tx_with(tx_nonce, U256::ZERO, 21_000) } fn token_fee_tx_with_value(tx_nonce: u64, value: U256) -> MorphPooledTransaction { + token_fee_tx_with(tx_nonce, value, 21_000) + } + + /// [`token_fee_tx`] with `gas_limit`, so its token requirement scales with it. + fn token_fee_tx_with_gas_limit(tx_nonce: u64, gas_limit: u64) -> MorphPooledTransaction { + token_fee_tx_with(tx_nonce, U256::ZERO, gas_limit) + } + + fn token_fee_tx_with(tx_nonce: u64, value: U256, gas_limit: u64) -> MorphPooledTransaction { let tx = TxMorph { chain_id: 2818, nonce: tx_nonce, - gas_limit: 21_000, + gas_limit, max_fee_per_gas: 100, max_priority_fee_per_gas: 10, to: TxKind::Call(address!("0000000000000000000000000000000000000002")), @@ -635,25 +645,36 @@ mod tests { } #[test] - fn transactions_behind_nonce_gaps_are_left_queued() { - // Future nonces stay queued; missing predecessors may alter fee balances. + fn a_payable_transaction_behind_a_nonce_gap_is_kept() { + // The gap only decides where the transaction sits; affordability is judged on its own. let mut db = test_state(0, 0, TX_TOKEN_BUDGET); let (tx0, gapped) = (token_fee_tx(0), token_fee_tx(10)); - assert!( - removable(&mut db, vec![&tx0, &gapped]).is_empty(), - "transactions behind a gap are left to queued-pool maintenance" + assert!(removable(&mut db, vec![&tx0, &gapped]).is_empty()); + } + + #[test] + fn an_unpayable_transaction_behind_a_nonce_gap_is_removed() { + // Nonce 0 needs one budget, nonce 10 needs two, and the sender holds one. Stopping at + // the gap would leave nonce 10 in place for reth to promote unvalidated once nonces + // 1 to 9 arrive. + let mut db = test_state(0, 0, TX_TOKEN_BUDGET); + let (tx0, gapped) = (token_fee_tx(0), token_fee_tx_with_gas_limit(10, 2 * 21_000)); + + assert_eq!( + removable(&mut db, vec![&tx0, &gapped]), + vec![*gapped.hash()] ); } #[test] - fn a_sender_holding_only_future_nonces_is_left_alone() { - // Nothing this sender holds is executable at the current state nonce, so there is no - // executable front to evaluate — not even for a sender that now holds no tokens. + fn a_sender_holding_only_future_nonces_is_still_revalidated() { + // go-ethereum's `promoteExecutables` filters the whole queue per transaction before + // promoting anything; a sender that no longer holds tokens loses its parked MorphTx. let mut db = test_state(0, 0, 0); let gapped = token_fee_tx(5); - assert!(removable(&mut db, vec![&gapped]).is_empty()); + assert_eq!(removable(&mut db, vec![&gapped]), vec![*gapped.hash()]); } #[derive(Debug)] @@ -1144,7 +1165,8 @@ mod tests { "standard maintenance cannot see the L1 fee shortfall" ); - // Later rounds must retain parked descendants behind the removed nonce. + // Later rounds judge the parked descendants on their own, one per round; none + // of them can cover the same L1 fee either. for _ in 0..3 { futures::executor::block_on(maintain_morph_pool_with( pool.clone(), @@ -1171,7 +1193,13 @@ mod tests { "remove the first L1-unaffordable ordinary transaction; unrelated MorphTx={unrelated_morph}" ); assert_eq!(pending, ordinary[..index]); - assert_eq!(queued, ordinary[index + 1..]); + assert!( + ordinary[index..] + .iter() + .all(|hash| pool.get(hash).is_none()), + "parked descendants that cannot pay either are removed by later rounds" + ); + assert!(queued.is_empty()); } else { assert_eq!(pending, ordinary); assert!(queued.is_empty()); @@ -1186,7 +1214,9 @@ mod tests { for (nonces, state_nonce, eth_balance, pending_nonces, queued_nonces) in [ (vec![0, 2], 0, 3_100_000u64, vec![0], vec![2]), - (vec![5], 0, 2_100_000, vec![], vec![5]), + (vec![5], 0, 3_100_000, vec![], vec![5]), + // Parked behind a gap and unable to cover its L1 fee: removed, not left for later. + (vec![5], 0, 2_100_000, vec![], vec![]), (vec![0, 1], 0, 2_000_000, vec![], vec![0, 1]), (vec![0, 1], 1, 3_100_000, vec![1], vec![]), ] { @@ -1432,11 +1462,19 @@ mod tests { ); assert!(affordable.iter().all(|hash| pool.get(hash).is_some())); assert_eq!(all.pending.len(), affordable.len()); - assert_eq!( - all.queued.iter().map(|tx| *tx.hash()).collect::>(), - [token_tx], - "preserve the successor in queued, including when it still has tokens" - ); + if token_balance == 0 { + assert!( + pool.get(&token_tx).is_none(), + "a parked successor the sender cannot pay for is removed by a later round" + ); + assert!(all.queued.is_empty()); + } else { + assert_eq!( + all.queued.iter().map(|tx| *tx.hash()).collect::>(), + [token_tx], + "preserve the successor in queued while it still has tokens" + ); + } } } } @@ -1496,4 +1534,59 @@ mod tests { "an independently affordable ETH-fee successor must be parked, not deleted" ); } + + #[test] + fn a_morph_tx_parked_behind_a_nonce_gap_is_removed_before_the_gap_is_filled() { + let client = mock_provider(10_000_000, 10 * TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + + // Admitted while the sender could pay, then parked: nonce 0 is missing. + let parked = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(1), + )) + .unwrap() + .hash; + assert_eq!(pool.all_transactions().queued.len(), 1); + + // The sender spends every token before nonce 0 shows up. + set_token_balance(&client, 0); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + provider_state(client), + futures::stream::iter([commit_event()]), + )); + assert!( + pool.get(&parked).is_none(), + "a parked MorphTx the sender can no longer pay for must be removed" + ); + + // Filling the gap promotes whatever is parked without running the validator again, so + // an unpayable nonce 1 left in place would now be pending and offered to the builder. + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(0), + )) + .unwrap(); + let all = pool.all_transactions(); + assert_eq!( + all.pending.iter().map(|tx| tx.nonce()).collect::>(), + [0] + ); + assert!(all.queued.is_empty()); + } }