From 8e478df13b0d48fb99e3c1313e43782e1f13c079 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:32:49 +0800 Subject: [PATCH 01/13] chore(ci): bump taiki-e/install-action in the actions-weekly group (#209) Bumps the actions-weekly group with 1 update: [taiki-e/install-action](https://github.com/taiki-e/install-action). Updates `taiki-e/install-action` from 2.87.5 to 2.87.11 - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2.87.5...v2.87.11) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.87.11 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-weekly ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cd1be31..055cb26 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: cache-on-failure: true - name: Install nextest - uses: taiki-e/install-action@v2.87.5 + uses: taiki-e/install-action@v2.87.11 with: tool: cargo-nextest @@ -86,7 +86,7 @@ jobs: cache-on-failure: true - name: Install nextest - uses: taiki-e/install-action@v2.87.5 + uses: taiki-e/install-action@v2.87.11 with: tool: cargo-nextest From 5b6c2d87677fc595111c3322fe243ba58e443a89 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 17:12:34 +0800 Subject: [PATCH 02/13] 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 94036ec..c943baa 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 44e0192..a4e687d 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 6f0904d..1670ff8 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 98a2b93..c37448f 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 e13a4b9..b5ddf38 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 6477747..1776323 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 063d7db..30412e5 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 347c826..298ef3b 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 b61b580..f477311 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 09cc751..9d80160 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 cef8669..121861e 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 ceb66cd..1e80e20 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 97cef34..4bca3e5 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 a55232c..f2bae6c 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 e60943d..bf236ca 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 4515a2a..3b7c81f 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 a0d9f2e..b502ac8 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 cce783f..e1ed422 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 bddd276..bcf4afd 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 e0a19ad..6d7735a 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 e666c29..022092f 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 21411db..08690a7 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 1afea64..a6c1f95 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 03/13] 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 c943baa..3097665 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 04/13] 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 1670ff8..38fe9b5 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 4bca3e5..2391077 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 bf236ca..510327e 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 05/13] 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 30412e5..dc26c2f 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 f2bae6c..f1425fc 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 510327e..674b9fb 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 6d7735a..dfcf238 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 4b541a0..9932466 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 06/13] 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 38fe9b5..ee9661a 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 c37448f..4ebc8f8 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 b5ddf38..5c3234e 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 1776323..0db76c4 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 dc26c2f..4033b47 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 f477311..47e12cf 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 9d80160..aa47938 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 121861e..b1e488a 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 2391077..d522a44 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 f1425fc..6613e46 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 674b9fb..116300b 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 3b7c81f..4237d68 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 b502ac8..cae3138 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 e1ed422..d6a578a 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 022092f..64d5e01 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 4955f7c134cb6ed1343930e0b4ece4204297fbb4 Mon Sep 17 00:00:00 2001 From: panos Date: Sun, 20 Sep 2026 10:09:22 +0800 Subject: [PATCH 07/13] fix(revm): align call-mode fee-token execution with go-ethereum (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(revm): align call-mode fee-token execution with go-ethereum A fee token registered without a `balanceSlot` takes the EVM-call path: the protocol resolves the payer's balance with `balanceOf` and moves the fee with an ERC20 `transfer`. morph-reth diverged from morph-geth on that path in ways that change `gasUsed`, receipts and state roots, so a follower rejects blocks a geth block producer accepts. Measured against the golden fixtures added in the next commit, main fails 8 of 12 case templates on both Emerald and Jade: - The fee `transfer()` frame's SSTORE refund was discarded, so a fee that clears the payer's balance slot earned the user no refund (the receipt `gasUsed` mismatch reported in #207). go-ethereum runs that call through `evm.Call` inside `buyAltTokenGas()` before `StateDB.Prepare`, which does not reset the refund counter, so the refund reaches `refundGas()`. The net counter is now carried on `MorphEvm` and recorded before the EIP-3529 cap, so it can also be cancelled by a negative refund from main execution. - Internal calls ran with a default transaction environment and their own storage, so `balanceOf`/`transfer` saw ORIGIN = 0x0 and an effective gas price of 0. A guard on either read the wrong value and the payer's balance resolved to zero, rejecting an affordable transaction. Internal frames now keep the outer transaction's ORIGIN and GASPRICE, run in the executing block's environment, and `balanceOf` executes as a genuine static frame under the same 200k gas allowance geth uses. - A successful transfer whose return value or balance delta failed a later business check was rolled back with its logs. go-ethereum keeps the state and logs and only reports the failure. The frame now owns its checkpoint: a VM revert still rolls back, a business failure does not, and a database failure stays fatal rather than becoming a verdict about the token. - Zero fees skipped neither `transfer(0)` nor the initial `balanceOf`; geth skips both transfer modes but still performs the balance query. Nonce and cache updates, and call-mode access-list/transient cleanup, still happen. `TokenFeeInfo::effective_fee_limit` replaces the two hand-rolled fee-limit clamps the execution and pool paths each carried, so they cannot drift. `MorphEvm::from_env` gives execution and pool queries one constructor. The receipt builder now reads registry metadata with `load_storage_only`, which never builds a temporary EVM to resolve a balance it does not use. The pool's single call site is adapted to the new `load_for_caller` signature while keeping its previous behaviour: admission still evaluates a call-mode `balanceOf` under the hardfork's defaults. Threading the real head environment through admission is txpool work and is deliberately not part of this change. * test(statetest): add geth-derived golden fixtures for fee-token calls Twelve cases across Emerald and Jade, with state roots, logs roots and transaction gas generated by morph-geth 5744b8f66. Every case registers its token with `balanceSlot = 0`, so all twelve exercise the EVM-call path: deduction clearing the payer's ERC20 balance slot, the one-unit balance control, main-frame revert and OOG, a main call that restores the cleared slot, negative-refund cancellation, ORIGIN and GASPRICE guards, static-call violations, a successful transfer whose refund returns false, a refund that reverts, and zero-fee storage warmth. Note that "balance slot" in these case names is the ERC20's own storage slot that the fee `transfer()` clears, not the registry's optional `balanceSlot` field; the storage-slot fee path is not covered by these fixtures. Replaying them against main (v1.3.0) fails 8 of the 12 templates on both forks. With the preceding commit all 24 outcomes pass. * docs(revm): document the transaction-id invariant behind the mid-tx finalize The call-mode fee path commits its deduction by calling `evm.finalize()` mid-transaction, then re-marks every account and slot cold to reproduce the warmth go-ethereum's `StateDB.Prepare` would leave behind. Nothing explained why the whole journal is discarded there, or which part of that reset the correctness depends on. The load-bearing property is that the reset must not advance the transaction id. Warming a slot runs through `EvmStorageSlot::mark_warm_with_transaction_id`, which re-baselines the EIP-2200 `original_value` to the present value whenever the slot's id differs from the journal's. Had that fired on the slot the deduction just cleared, the main frame's SSTORE would be a create rather than a recreate and the `SubRefund` cancelling the deduction's `+4800` would be lost — measured on `main_restores_cleared_slot`, 23_291 gas becomes 38_391. It cannot fire here because ids stay equal across execution. revm advances the id only when a transaction finishes — `commit_tx()` from `execution_result`, or `discard_tx()` on the error path — both after the main frame; `ExecuteEvm::finalize` then resets it to ZERO before the next transaction. `finalize()` at this point is therefore idempotent for the id, while `commit_tx()` would leave the deduction-warmed slots holding 0 against a journal holding 1. Verified by substitution: swapping `commit_tx()` in fails `main_restores_cleared_slot` with a state root mismatch. * fix(revm): return the fee frames' shared memory to the transaction The fee-token frames are top-level frames that run in the middle of a transaction, so neither of revm's truncation points covers them: `free_child_context` only releases a child frame's region, and `LocalContext::clear` only runs once the whole transaction is done. The frames therefore left their bytes on the context's shared buffer and the main transaction frame started on top of them. Measured on a call-mode MorphTx: the main frame entered with `MSIZE == 32`, and `MLOAD(0)` returned 9_000_000, the payer's post-fee token balance left behind by the internal `balanceOf`. go-ethereum allocates a fresh `Memory` for every interpreter run (core/vm/interpreter.go), so both read zero there, and both read zero here on the ETH-fee control. Any contract that reads memory it never wrote, or branches on MSIZE, produced a different result on morph-reth than on morph-geth. Carve each fee frame's memory out above whatever the buffer already holds, and release it on the way out, including on the error path. The frames keep writing into the context's buffer rather than one of their own: a nested call hands its callee a `CallInput::SharedBuffer` range, and while a contract callee resolves that range against its own frame memory, a precompile callee resolves it against the context's buffer (`CallInput::as_bytes`). A private buffer would hand every precompile called from a fee frame empty calldata — `fee_token_frames_reach_a_precompile_through_memory` fails with `InsufficientTokenBalance { available: 0 }` under that variant. Gas is unaffected, since memory expansion is charged from the per-frame `Gas` counter, and the leak did not cross transactions, since `local_mut().clear()` runs at the end of each one. The 24 geth-derived golden fixtures cannot see any of this: ten of their twelve templates call a codeless EOA and the other two call bytecode that writes before it reads. * docs(revm): correct what the pool's balanceOf query aligns with The comment justified setting the query's ORIGIN to the queried account by claiming go-ethereum's pool does the same. It does not: `getBalanceFunc` builds its EVM on an empty `vm.TxContext{}` (core/tx_pool.go:341), so its ORIGIN is the zero address — and go-ethereum's own execution layer resolves the same `balanceOf` with ORIGIN set to the sender, so its pool disagrees with its own execution. Setting ORIGIN to the account is still the right call, for the opposite reason to the one recorded: admission exists to predict what the builder will be able to include, so it follows this client's execution layer rather than the other client's pool. Say that, and record the one input the query still cannot match — GASPRICE, which stays at the `TxEnv` default of zero because the effective price depends on the next block's base fee. * test(node): run the e2e fee-token path in EVM-call mode Every registered fee token on mainnet now has its `balanceSlot` cleared, so the fee is moved by real `balanceOf` and `transfer` calls into the token contract. The e2e genesis registered `balanceSlot = 2` instead, which put all 128 integration tests on the direct-storage path: the mode that is scheduled to be disabled by a hardfork, and the one production does not use. The node-level behaviour of the mode production does run — receipts, log ordering, pool admission, the replay RPCs — had no integration coverage at all, while the 24 statetest golden fixtures cover only its state effects. Give the test token the ERC20 runtime the gas-regression test already carried inline, and clear the registry's `balanceSlot`. The runtime keeps `balanceOf` at slot 1, so `test_token_balance_slot` still derives the same slot independently and remains a test oracle rather than a second copy of the code under test. Both gas regressions hold unchanged at 48_128 and 50_428: the fee frames run on their own 200k budget, and the deduction books no SSTORE refund while the payer keeps a balance. What does change is the receipt, which now carries the fee deduction and the fee reimbursement around the transaction's own transfer. Assert that ordering — deduction, main, refund — since it is what go-ethereum produces and what indexers read. * fix(revm): make the fee path's load-bearing invariants local and true Six items a review of this branch turned up, each verified against revm 42 and go-ethereum before being acted on. `reimburse_caller_token_fee`'s slot branch reaches `sload`/`sstore` on the token directly, which panic rather than error when the account is absent from `journal.state` (`sload_assume_account_present` -> `ColdLoadSkipped` -> `unwrap_db_error`). It relied on the deduction having loaded it, but the deduction skips both transfer modes for a zero fee. The two cannot disagree today — `eth_to_token_amount` rounds up, so a zero token fee means a zero ETH fee, which returns before the transfer — but that proof lives in another function. Load it where it is needed instead; today the load is a no-op. `evm_call` has the same shape of hidden dependency: its `CallValue::Transfer` frame runs `Journal::transfer_loaded`, whose zero-value path is `self.state.get_mut(&to).unwrap()`. An ordinary CALL is safe because the opcode's `load_acc_and_calc_gas` loaded the account; an internal call has no opcode, so `internal_call_code` is the only load. Its doc comment described itself purely as a warmth-preserving code read. Say what it is also for. `MorphBlockExecutor::hardfork` became write-only when `get_morph_tx_fields` stopped taking a hardfork, leaving a doc comment claiming it is "reused in `commit_transaction`". Removing it leaves `spec` dead as well — it existed only to compute it. Drop both, and the constructor argument with them. Two comments claimed things that are not true of the pinned revm or of the code they describe. `load_token_fee_info` blamed a "30M gas limit" on the previous path, which went through `system_call_one` and so already capped at go-ethereum's 200k; the divergence was the environment and the sender. And `ExecutionResult::Revert` does carry a `logs` field in revm 42 — the fee logs need their side channel because the mid-transaction `finalize()` clears the journal's logs, not because the variant cannot hold them. The pool restated the fee-limit clamp by hand under a "Match REVM semantics" comment, although `TokenFeeInfo::effective_fee_limit` was added to be the one copy. Use it. Finally, `transfer_erc20_with_evm`'s affordability check built its error message with `ok_or`, rendering two U256s and allocating a String on every successful call-mode fee transfer; `ok_or_else` defers it. * test(statetest): cover uninitialized memory and the direct-slot path The twelve golden cases all target either a codeless EOA or bytecode that writes before it reads, and all twelve register the token in EVM-call mode. Two consensus-relevant behaviours were therefore invisible to them. `main_reads_uninitialized_memory` commits MSIZE and MLOAD(0) to storage from the transaction's own frame, before writing either. go-ethereum allocates a fresh `Memory` for every interpreter run, so both read zero and neither SSTORE changes state; a client whose fee frames leave their bytes on the transaction's shared memory writes two non-zero slots and misses the root. Verified to have teeth: reverting the fee frames to a checkpoint-zero `SharedMemory` fails it with a state root mismatch. `slot_deduct_keep` and `slot_deduct_clear` are the first coverage of the registry's direct-slot path, which has to keep working for replaying blocks produced before every mainnet token moved to the call path. `slot_deduct_clear` is byte-for-byte the transaction `deduct_clear` runs and costs 21_000 against its 16_800: clearing the payer's balance through a real `transfer` books a `+4800` SSTORE refund that reaches the transaction, while `SetState` books nothing. That 4_200 is the only way the two modes bill differently, and it is now pinned from both sides. Roots and logs hashes come from morph-geth 4012f174b, which reproduces all twelve existing cases unchanged. * test(node): assert the fee logs survive a reverting main frame `receipt.rs` caches the fee `Transfer` events outside the journal because go-ethereum's `StateDB.logs` is not part of the state snapshot/revert mechanism: when the main frame reverts, the deduction's log must still be in the receipt. Nothing asserted that. Every reverting golden case used a token that emits no logs, so its expected `logs` hash is the empty hash and a client that dropped `pre_fee_logs` on the floor would produce the same value. The property decides the receipt's logs and therefore the block's receipts root. `morph_tx_v0_token_fee_still_charged_on_revert` already reverts the main frame against the real ERC20 test token and already runs through `MorphBlockExecutor` and the production receipt builder. Assert the two fee transfers it must carry, in go-ethereum's order, and that the deduction moved a non-zero fee. Verified to have teeth: not extending `pre_fee_logs` in `build_receipt` fails it. One comment described the code wrongly and is corrected: - The call-mode deduction comment said re-marking accounts and slots cold "reproduces the warmth `Prepare` would have left behind". It does not, and must not: the coinbase and access list are re-warmed later by upstream `pre_execution::load_accounts`, which runs after this deduction because the deduction happens in `validate()`. Say so, and say what breaks if a future change reorders those phases. `load_token_fee_info`'s claim that the old path "capped at `SYSTEM_CALL_GAS_LIMIT`, which is go-ethereum's 200k" reads wrong, because revm's `SYSTEM_CALL_GAS_LIMIT` is 30_000_000. It is right, though: this crate defines its own 200_000 in `exec.rs` and sets it in the `SystemCallEvm` impl, shadowing revm's. Name that shadowing, since the bare constant reads as a mistake and invites exactly the wrong "fix". `expectException` stays presence-only, which reads like an oversight. It is deliberate: go-ethereum's own statetest harness returns early on `len(ExpectException) > 0` under a standing "TODO check error string", so matching the text here would make this runner stricter than the client the fixtures come from. A comment now records that. * refactor(revm): keep the fee-token helpers' invariants inside them Four cleanups from a review of this branch. None of them changes execution. `transfer_erc20_with_slot` needs the token account in `journal.state`, because the journal's `sload`/`sstore` panic rather than error when it is absent, and both callers loaded it themselves with a comment apiece saying why. The helper now loads and touches the token ahead of its checkpoint. That is exactly what the deduction did before. The refund's extra touch is a no-op: a refund only runs after a non-zero deduction, which already touched the token in the same transaction. The slot-path golden fixtures pass unchanged, and the unit test that exercises the helper no longer pre-loads the token. `reimburse_caller_token_fee` built its missing-cache error with `ok_or`, allocating the message on every token refund. It now uses `ok_or_else`. `TokenRegistryEntry`, its `load` and its `load_for_caller` had become `pub` and re-exported with no user outside this crate, and `load_for_caller` hands back a `TokenFeeInfo` without going through `ensure_usable`. They are `pub(crate)` again. The pool keeps using `TokenFeeInfo::load_for_caller`. The handler and token-fee tests each carried an identical database that fails storage reads of one token. A single copy now lives in the token-fee test module, which is `pub(crate)` so the handler tests can use it. * docs(evm): give the real reason fee logs are kept out of the result The receipt builder said fee logs are cached apart from the journal because revm's `ExecutionResult::Revert` carries no logs. In revm 42 it does. The real reason is that the call-mode deduction runs a mid-transaction `finalize()` that clears the journal's logs, so the handler moves them out first, and it drains the refund's logs the same way. `result` then holds only the main frame's logs, which a revert has already discarded. The same wrong claim was corrected in `handler.rs` earlier on this branch; this is the copy that was left behind. * test(statetest): replay a mainnet slot-mode fee-token transaction Slot mode is being retired on mainnet, but blocks that already ran it must keep replaying identically, and only two synthetic golden cases exercised the registry's direct-slot path. The new case replays transaction 0x9ebfdac9040d7c2a8739ffdaae8baf5e7aa22fdb48585f80592de4b4cf39ed44 from block 26836567: a V0 MorphTx paying its fee in token 1 through the direct slot, sent by an EIP-7702-delegated account holding no ETH, whose call transfers that same token. One transaction covers the deduction, the main frame writing the payer's already-debited balance slot, and the slot-mode refund. go-ethereum's state-test runner only signs with `secretKey` and fixes the chain id to 1, so the sender moves to the harness account, carrying its nonce, delegation code and re-keyed token balance, and the fee vault's balance is re-keyed to the harness vault. The prestate tracer reports zero for the balance slots the fee logic reads straight from state, so those, the registry entry and the L1 gas price oracle slots are taken from the parent block. That is exact here because the transaction is alone in its block. Roots come from morph-geth 4012f174b, which passes the fixture, as does this runner. The gas used equals the on-chain receipt's 51_257, and the logs root equals the on-chain logs with the sender topic substituted. Verified to have teeth: swapping the slot-mode refund's direction fails it with a state root mismatch. --- bin/morph-statetest/src/runner.rs | 9 + .../tests/fee_token_internal_calls.rs | 76 + .../fixtures/fee_token_internal_calls.json | 1324 +++++++++++++++++ .../fixtures/mainnet_slot_mode_fee_token.json | 96 ++ crates/evm/src/block/factory.rs | 2 +- crates/evm/src/block/mod.rs | 37 +- crates/evm/src/block/receipt.rs | 8 +- crates/evm/src/evm.rs | 13 +- crates/node/src/test_utils.rs | 27 +- crates/node/tests/assets/test-genesis.json | 4 +- crates/node/tests/it/morph_tx.rs | 76 +- crates/revm/src/error.rs | 5 + crates/revm/src/evm.rs | 12 + crates/revm/src/handler.rs | 1180 ++++++++++++--- crates/revm/src/lib.rs | 4 +- crates/revm/src/token_fee.rs | 274 +++- crates/txpool/src/morph_tx_validation.rs | 23 +- 17 files changed, 2838 insertions(+), 332 deletions(-) create mode 100644 bin/morph-statetest/tests/fee_token_internal_calls.rs create mode 100644 bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json create mode 100644 bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json diff --git a/bin/morph-statetest/src/runner.rs b/bin/morph-statetest/src/runner.rs index a3c650b..1fed0dc 100644 --- a/bin/morph-statetest/src/runner.rs +++ b/bin/morph-statetest/src/runner.rs @@ -246,6 +246,15 @@ fn validation_error( where E: std::fmt::Display, { + // `expectException` is checked for presence, deliberately not for its text. + // go-ethereum's own statetest harness does the same -- `tests/state_test.go` + // returns early on `len(ExpectException) > 0` under a standing + // "TODO check error string" -- so matching on the text here would make this + // runner stricter than the client the fixtures are generated from, and a + // fixture imported from go-ethereum could fail on wording alone. The string + // stays in the JSON as documentation of which failure the case is meant to + // provoke; the assertion is that the transaction is *rejected*, which is what + // both clients agree on. match (&test.expect_exception, exec_result) { (Some(_), Err(_)) => return None, (Some(expected), Ok(_)) => { diff --git a/bin/morph-statetest/tests/fee_token_internal_calls.rs b/bin/morph-statetest/tests/fee_token_internal_calls.rs new file mode 100644 index 0000000..16958ba --- /dev/null +++ b/bin/morph-statetest/tests/fee_token_internal_calls.rs @@ -0,0 +1,76 @@ +//! Golden roots/logs generated by morph-geth (Emerald and Jade): the original twelve +//! cases on 5744b8f66, the three later ones on 4012f174b, which differs only in the +//! transaction-size limit in `core/tx_pool.go` and reproduces all twelve unchanged. +//! The gas constants are transaction totals; geth's statetest tool subtracts +//! intrinsic gas when the total is at least the intrinsic cost. +use morph_statetest::runner::run_suite_str; + +#[test] +fn fee_token_calls_match_geth() { + let outcomes = run_suite_str(include_str!("fixtures/fee_token_internal_calls.json")).unwrap(); + assert_eq!(outcomes.len(), 30); + for outcome in outcomes { + assert!( + outcome.pass, + "{} / {}: {}", + outcome.test, outcome.fork, outcome.error_msg + ); + let gas = match outcome.test.as_str() { + "deduct_clear" => 16_800, + "main_revert" => 16_804, + "main_oog" => 95_200, + "main_restores_cleared_slot" => 23_291, + "balance_writes" => 0, + "zero_fee_reads_balance" => 23_574, + // The transaction's own frame reads MSIZE and MLOAD(0) into storage before + // writing either. go-ethereum allocates a fresh `Memory` per interpreter run, + // so it sees zeros and neither SSTORE changes state; a client whose fee frames + // leave their bytes on the transaction's memory writes two non-zero slots and + // misses this root by two SSTORE_SETs. + "main_reads_uninitialized_memory" => 25_417, + // The registry's direct-slot path. `slot_deduct_clear` is the same transaction + // as `deduct_clear`, which costs 16_800: clearing the payer's balance through a + // real `transfer` books a `+4800` SSTORE refund that reaches the transaction, + // while `SetState` books nothing. That 4_200 is the only way the two modes bill + // differently. + "slot_deduct_keep" | "slot_deduct_clear" => 21_000, + "deduct_keep" + | "origin_guard" + | "gasprice_guard" + | "refund_false_keeps_transfer" + | "zero_fee" + | "refund_revert_rolls_back_transfer" => 21_000, + name => panic!("missing gas expectation for {name}"), + }; + assert_eq!(outcome.gas_used, gas, "{} / {}", outcome.test, outcome.fork); + } +} + +/// Replays mainnet transaction `0x9ebfdac9040d7c2a8739ffdaae8baf5e7aa22fdb48585f80592de4b4cf39ed44` +/// (block 26836567, Jade): a MorphTx V0 that pays its fee in token 1, which the registry still +/// resolves through the direct-slot path, and whose call transfers that same token. The fixtures +/// above are synthetic; this is slot mode as mainnet actually ran it, which every node must keep +/// replaying identically after the registry moves off slot mode. +/// +/// go-ethereum's state-test runner signs with `secretKey` and fixes the chain id to 1, so the +/// sender is the harness account `0xa94f…6ebf0b`, carrying the real sender's nonce, EIP-7702 +/// delegation code and token balance under its own balance slot, and the fee vault's balance sits +/// under the harness vault. The prestate tracer does not see the slots the fee logic reads straight +/// from state, so the token balances, the registry entry and the L1 gas price oracle come from the +/// parent block, which is exact here because the transaction is alone in its block. Roots are from +/// morph-geth 4012f174b. The logs root equals the on-chain receipt's logs with the sender topic +/// substituted, and the gas matches the on-chain receipt: the substitution changes the L1 data fee, +/// and with it the token amount charged, but not the gas burned. +#[test] +fn mainnet_slot_mode_fee_token_transfer_matches_geth() { + let outcomes = + run_suite_str(include_str!("fixtures/mainnet_slot_mode_fee_token.json")).unwrap(); + assert_eq!(outcomes.len(), 1); + let outcome = &outcomes[0]; + assert!( + outcome.pass, + "{} / {}: {}", + outcome.test, outcome.fork, outcome.error_msg + ); + assert_eq!(outcome.gas_used, 51_257); +} diff --git a/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json b/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json new file mode 100644 index 0000000..c8ca134 --- /dev/null +++ b/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json @@ -0,0 +1,1324 @@ +{ + "balance_writes": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x60015f55620f42405f5260205ff3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x7c1c360fabaec3c980927e916a02d97a6ef79b93d601e0a1616e4a8014ce5669", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "expectException": "TokenBalanceQueryFailed" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x7c1c360fabaec3c980927e916a02d97a6ef79b93d601e0a1616e4a8014ce5669", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "expectException": "TokenBalanceQueryFailed" + } + ] + } + }, + "deduct_clear": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xfb0ac627f1b92b10d0f1c60ef87667dc42cd3eb7a919ceb84e9427c63f9b6125", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xfb0ac627f1b92b10d0f1c60ef87667dc42cd3eb7a919ceb84e9427c63f9b6125", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "deduct_keep": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xe1a1a7e57eaf4aee439d65814e0a688a2775ee81fbdc2c4d77f06534d7088eac", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xe1a1a7e57eaf4aee439d65814e0a688a2775ee81fbdc2c4d77f06534d7088eac", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "gasprice_guard": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x3a600a14600a575f5ffd5b36604414601e576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0d1c7d12041d949d73f15b8d08dbdcacecdf844675d40d41632a0bfb463ef36d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0d1c7d12041d949d73f15b8d08dbdcacecdf844675d40d41632a0bfb463ef36d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_oog": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0xfe", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x1b9c3313759258951e68ab4e7754f0859f0b049431eb3d1e7a2ff460ceafe82b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x1b9c3313759258951e68ab4e7754f0859f0b049431eb3d1e7a2ff460ceafe82b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_reads_uninitialized_memory": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x5960005560005160015500", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xb05c0ccb41d7ad9b13f5e8ac1b93f42dc8c8b8bbd312e0af5f91fa5525f47a49", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xb05c0ccb41d7ad9b13f5e8ac1b93f42dc8c8b8bbd312e0af5f91fa5525f47a49", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_restores_cleared_slot": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x3660041460385736604414601a576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f35b6001325500", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x3000000000000000000000000000000000000003", + "value": [ + "0x0" + ], + "data": [ + "0xdeadbeef" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdda0e3d5c58b32c62b9168cf468233be793eb5d1d0ab886dacdaf490b84a72e2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdda0e3d5c58b32c62b9168cf468233be793eb5d1d0ab886dacdaf490b84a72e2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_revert": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x5f5ffd", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x4bfefc48ad519124f544514a1d413329e38fce98ee522a763a79fdd96b68fdc2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x4bfefc48ad519124f544514a1d413329e38fce98ee522a763a79fdd96b68fdc2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "origin_guard": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x3273a94f5374fce5edbc8e2a8697c15331677e6ebf0b14601d575f5ffd5b366044146031576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x3ed6676fa11fc0e4294ed8a446546a0c3c43f5b664bfa28f4104489d6390102a", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x3ed6676fa11fc0e4294ed8a446546a0c3c43f5b664bfa28f4104489d6390102a", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "refund_false_keeps_transfer": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b602435803354033355600435805482019055505f5fa03373a94f5374fce5edbc8e2a8697c15331677e6ebf0b145f5260205ff3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x02e23c9ed829c4eb474097e4babff09ad0214f7d1f4affe7f661f4927ecba46c", + "logs": "0x773911b8633886efdf9360b2936f4f0ee4ae34b8cd1c0ffd0c06861d1c751aeb" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x02e23c9ed829c4eb474097e4babff09ad0214f7d1f4affe7f661f4927ecba46c", + "logs": "0x773911b8633886efdf9360b2936f4f0ee4ae34b8cd1c0ffd0c06861d1c751aeb" + } + ] + } + }, + "refund_revert_rolls_back_transfer": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b602435803354033355600435805482019055505f5fa03373a94f5374fce5edbc8e2a8697c15331677e6ebf0b146047575f5ffd5b600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0b438cf7a8bddc48e5097828159eed83ccdf052406ad296ec69cb8e194de0737", + "logs": "0x16ecc5d734ad798966d0ece89ce9fc365a53ed550661fb7607afee69c831daab" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0b438cf7a8bddc48e5097828159eed83ccdf052406ad296ec69cb8e194de0737", + "logs": "0x16ecc5d734ad798966d0ece89ce9fc365a53ed550661fb7607afee69c831daab" + } + ] + } + }, + "slot_deduct_clear": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "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": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x84e1019fa81970b8488a94f2651174f4b8d901e6d71c51a3f2d43bc1b2d2736f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x84e1019fa81970b8488a94f2651174f4b8d901e6d71c51a3f2d43bc1b2d2736f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "slot_deduct_keep": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x9734b052146069605dcf2a05300c1dd5cd5852a2844e5491b2eb25d6daa909bc": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "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": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xd4d266ff3372ea95e9fd510e2e3be04679fdb72b83671acf29222402bdb4e0e8", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xd4d266ff3372ea95e9fd510e2e3be04679fdb72b83671acf29222402bdb4e0e8", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "zero_fee": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x0", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b602435803354033355600435805482019055505f5fa0600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0x0", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x316b400ab83009d30ab6a987f74a061de29c2f3116918ca244b7267a69b75be2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x316b400ab83009d30ab6a987f74a061de29c2f3116918ca244b7267a69b75be2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "zero_fee_reads_balance": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x0", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0x0", + "gasLimit": [ + "0x186a0" + ], + "to": "0x3000000000000000000000000000000000000003", + "value": [ + "0x0" + ], + "data": [ + "0x70a08231000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xeb101e868f55079346e3fc2d78473e1ebf0b446070185d3a266f8f0f81f534a0", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xeb101e868f55079346e3fc2d78473e1ebf0b446070185d3a266f8f0f81f534a0", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + } +} diff --git a/bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json b/bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json new file mode 100644 index 0000000..30868ac --- /dev/null +++ b/bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json @@ -0,0 +1,96 @@ +{ + "mainnet_26836567_slot_mode_fee_token_transfer": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x2aea540", + "currentNumber": "0x1997e57", + "currentTimestamp": "0x6aa9b017", + "currentBaseFee": "0xf4240", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0x0", + "nonce": "0x1", + "code": "0xef0100a845c74344fc9405b1fcf712f04668979573c1bf", + "storage": {} + }, + "0xc7d67a9cbb121b3b0b9c053dd9f469523243379a": { + "balance": "0x0", + "nonce": "0x1", + "code": "0x363d3d373d3d3d363d73530000000000000000000000000000000000000d5af43d82803e903d91602b57fd5bf3", + "storage": { + "0x079b50c9ea8b3523e07d83295bfee8283b220c82526d7e046695778db0093d05": "0x00000000000000000000000000000000000000000000000000000000002d4c65", + "0x660c1bc2d47d7d811c3cf3324a889eebebd8edca5e38a70b3ba2905b38e251d3": "0x000000000000000000000000000000000000000000000000000000000011c927" + } + }, + "0x530000000000000000000000000000000000000d": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x608060405234801561000f575f80fd5b506004361061016e575f3560e01c806370a08231116100d25780639dc29fac11610088578063c820f14611610063578063c820f14614610354578063d505accf14610367578063dd62ed3e1461037a575f80fd5b80639dc29fac1461031b578063a457c2d71461032e578063a9059cbb14610341575f80fd5b80637ecebe00116100b85780637ecebe00146102e557806384b0196e146102f857806395d89b4114610313575f80fd5b806370a0823114610290578063797594b0146102c5575f80fd5b8063313ce56711610127578063395093511161010d57806339509351146102555780634000aea01461026857806340c10f191461027b575f80fd5b8063313ce5671461021d5780633644e5151461024d575f80fd5b8063116191b611610157578063116191b6146101b357806318160ddd146101f857806323b872dd1461020a575f80fd5b806306fdde0314610172578063095ea7b314610190575b5f80fd5b61017a6103bf565b60405161018791906119ce565b60405180910390f35b6101a361019e366004611a0f565b61044f565b6040519015158152602001610187565b60cc546101d39073ffffffffffffffffffffffffffffffffffffffff1681565b60405173ffffffffffffffffffffffffffffffffffffffff9091168152602001610187565b6035545b604051908152602001610187565b6101a3610218366004611a37565b610468565b60cd5474010000000000000000000000000000000000000000900460ff1660405160ff9091168152602001610187565b6101fc61048b565b6101a3610263366004611a0f565b610499565b6101a3610276366004611a70565b6104e4565b61028e610289366004611a0f565b61054d565b005b6101fc61029e366004611af0565b73ffffffffffffffffffffffffffffffffffffffff165f9081526033602052604090205490565b60cd546101d39073ffffffffffffffffffffffffffffffffffffffff1681565b6101fc6102f3366004611af0565b6105c7565b6103006105f1565b6040516101879796959493929190611b09565b61017a6106ae565b61028e610329366004611a0f565b6106bd565b6101a361033c366004611a0f565b61072e565b6101a361034f366004611a0f565b6107e4565b61028e610362366004611cad565b6107f1565b61028e610375366004611d3d565b610a08565b6101fc610388366004611da2565b73ffffffffffffffffffffffffffffffffffffffff9182165f90815260346020908152604080832093909416825291909152205490565b6060603680546103ce90611dd3565b80601f01602080910402602001604051908101604052809291908181526020018280546103fa90611dd3565b80156104455780601f1061041c57610100808354040283529160200191610445565b820191905f5260205f20905b81548152906001019060200180831161042857829003601f168201915b5050505050905090565b5f3361045c818585610b90565b60019150505b92915050565b5f33610475858285610d0f565b610480858585610dcb565b506001949350505050565b5f610494610ff1565b905090565b335f81815260346020908152604080832073ffffffffffffffffffffffffffffffffffffffff8716845290915281205490919061045c90829086906104df908790611e1e565b610b90565b5f6104ef85856107e4565b5073ffffffffffffffffffffffffffffffffffffffff85163b1561048057610480858585858080601f0160208091040260200160405190810160405280939291908181526020018383808284375f92019190915250610ffa92505050565b60cc5473ffffffffffffffffffffffffffffffffffffffff1633146105b95760405162461bcd60e51b815260206004820152600c60248201527f4f6e6c792047617465776179000000000000000000000000000000000000000060448201526064015b60405180910390fd5b6105c38282611085565b5050565b73ffffffffffffffffffffffffffffffffffffffff81165f90815260996020526040812054610462565b5f6060805f805f60606065545f801b14801561060d5750606654155b6106595760405162461bcd60e51b815260206004820152601560248201527f4549503731323a20556e696e697469616c697a6564000000000000000000000060448201526064016105b0565b61066161115e565b61066961116d565b604080515f808252602082019092527f0f000000000000000000000000000000000000000000000000000000000000009b939a50919850469750309650945092509050565b6060603780546103ce90611dd3565b60cc5473ffffffffffffffffffffffffffffffffffffffff1633146107245760405162461bcd60e51b815260206004820152600c60248201527f4f6e6c792047617465776179000000000000000000000000000000000000000060448201526064016105b0565b6105c3828261117c565b335f81815260346020908152604080832073ffffffffffffffffffffffffffffffffffffffff87168452909152812054909190838110156107d75760405162461bcd60e51b815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f7760448201527f207a65726f00000000000000000000000000000000000000000000000000000060648201526084016105b0565b6104808286868403610b90565b5f3361045c818585610dcb565b5f54610100900460ff161580801561080f57505f54600160ff909116105b806108285750303b15801561082857505f5460ff166001145b61089a5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201527f647920696e697469616c697a656400000000000000000000000000000000000060648201526084016105b0565b5f80547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0016600117905580156108f6575f80547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff166101001790555b6108ff86611309565b61090986866113c7565b60cd805460cc805473ffffffffffffffffffffffffffffffffffffffff8088167fffffffffffffffffffffffff00000000000000000000000000000000000000009283161790925590851660ff88167401000000000000000000000000000000000000000002919091167fffffffffffffffffffffff000000000000000000000000000000000000000000909216919091171790558015610a00575f80547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b505050505050565b83421115610a585760405162461bcd60e51b815260206004820152601d60248201527f45524332305065726d69743a206578706972656420646561646c696e6500000060448201526064016105b0565b5f7f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9888888610a868c61144d565b60408051602081019690965273ffffffffffffffffffffffffffffffffffffffff94851690860152929091166060840152608083015260a082015260c0810186905260e0016040516020818303038152906040528051906020012090505f610aed82611481565b90505f610afc828787876114c8565b90508973ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614610b795760405162461bcd60e51b815260206004820152601e60248201527f45524332305065726d69743a20696e76616c6964207369676e6174757265000060448201526064016105b0565b610b848a8a8a610b90565b50505050505050505050565b73ffffffffffffffffffffffffffffffffffffffff8316610c185760405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f2061646460448201527f726573730000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8216610ca15760405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f20616464726560448201527f737300000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8381165f8181526034602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92591015b60405180910390a3505050565b73ffffffffffffffffffffffffffffffffffffffff8381165f908152603460209081526040808320938616835292905220547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8114610dc55781811015610db85760405162461bcd60e51b815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e636500000060448201526064016105b0565b610dc58484848403610b90565b50505050565b73ffffffffffffffffffffffffffffffffffffffff8316610e545760405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f20616460448201527f647265737300000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8216610edd5760405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201527f657373000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff83165f9081526033602052604090205481811015610f785760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e742065786365656473206260448201527f616c616e6365000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8085165f8181526033602052604080822086860390559286168082529083902080548601905591517fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef90610fe49086815260200190565b60405180910390a3610dc5565b5f6104946114ee565b6040517fa4c0ed36000000000000000000000000000000000000000000000000000000008152839073ffffffffffffffffffffffffffffffffffffffff82169063a4c0ed369061105290339087908790600401611e56565b5f604051808303815f87803b158015611069575f80fd5b505af115801561107b573d5f803e3d5ffd5b5050505050505050565b73ffffffffffffffffffffffffffffffffffffffff82166110e85760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f20616464726573730060448201526064016105b0565b8060355f8282546110f99190611e1e565b909155505073ffffffffffffffffffffffffffffffffffffffff82165f818152603360209081526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b6060606780546103ce90611dd3565b6060606880546103ce90611dd3565b73ffffffffffffffffffffffffffffffffffffffff82166112055760405162461bcd60e51b815260206004820152602160248201527f45524332303a206275726e2066726f6d20746865207a65726f2061646472657360448201527f730000000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff82165f90815260336020526040902054818110156112a05760405162461bcd60e51b815260206004820152602260248201527f45524332303a206275726e20616d6f756e7420657863656564732062616c616e60448201527f636500000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff83165f8181526033602090815260408083208686039055603580548790039055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9101610d02565b505050565b5f54610100900460ff166113855760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b6113c4816040518060400160405280600181526020017f3100000000000000000000000000000000000000000000000000000000000000815250611561565b50565b5f54610100900460ff166114435760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b6105c38282611604565b73ffffffffffffffffffffffffffffffffffffffff81165f9081526099602052604090208054600181018255905b50919050565b5f61046261148d610ff1565b836040517f19010000000000000000000000000000000000000000000000000000000000008152600281019290925260228201526042902090565b5f805f6114d787878787611699565b915091506114e481611781565b5095945050505050565b5f7f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f6115186118e5565b61152061193d565b60408051602081019490945283019190915260608201524660808201523060a082015260c00160405160208183030381529060405280519060200120905090565b5f54610100900460ff166115dd5760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b60676115e98382611ede565b5060686115f68282611ede565b50505f606581905560665550565b5f54610100900460ff166116805760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b603661168c8382611ede565b5060376113048282611ede565b5f807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a08311156116ce57505f90506003611778565b604080515f8082526020820180845289905260ff881692820192909252606081018690526080810185905260019060a0016020604051602081039080840390855afa15801561171f573d5f803e3d5ffd5b50506040517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0015191505073ffffffffffffffffffffffffffffffffffffffff8116611772575f60019250925050611778565b91505f90505b94509492505050565b5f81600481111561179457611794611ff6565b0361179c5750565b60018160048111156117b0576117b0611ff6565b036117fd5760405162461bcd60e51b815260206004820152601860248201527f45434453413a20696e76616c6964207369676e6174757265000000000000000060448201526064016105b0565b600281600481111561181157611811611ff6565b0361185e5760405162461bcd60e51b815260206004820152601f60248201527f45434453413a20696e76616c6964207369676e6174757265206c656e6774680060448201526064016105b0565b600381600481111561187257611872611ff6565b036113c45760405162461bcd60e51b815260206004820152602260248201527f45434453413a20696e76616c6964207369676e6174757265202773272076616c60448201527f756500000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b5f806118ef61115e565b805190915015611906578051602090910120919050565b60655480156119155792915050565b7fc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4709250505090565b5f8061194761116d565b80519091501561195e578051602090910120919050565b60665480156119155792915050565b5f81518084525f5b8181101561199157602081850181015186830182015201611975565b505f6020828601015260207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f83011685010191505092915050565b602081525f6119e0602083018461196d565b9392505050565b803573ffffffffffffffffffffffffffffffffffffffff81168114611a0a575f80fd5b919050565b5f8060408385031215611a20575f80fd5b611a29836119e7565b946020939093013593505050565b5f805f60608486031215611a49575f80fd5b611a52846119e7565b9250611a60602085016119e7565b9150604084013590509250925092565b5f805f8060608587031215611a83575f80fd5b611a8c856119e7565b935060208501359250604085013567ffffffffffffffff80821115611aaf575f80fd5b818701915087601f830112611ac2575f80fd5b813581811115611ad0575f80fd5b886020828501011115611ae1575f80fd5b95989497505060200194505050565b5f60208284031215611b00575f80fd5b6119e0826119e7565b7fff00000000000000000000000000000000000000000000000000000000000000881681525f602060e06020840152611b4560e084018a61196d565b8381036040850152611b57818a61196d565b6060850189905273ffffffffffffffffffffffffffffffffffffffff8816608086015260a0850187905284810360c0860152855180825260208088019350909101905f5b81811015611bb757835183529284019291840191600101611b9b565b50909c9b505050505050505050505050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b5f82601f830112611c05575f80fd5b813567ffffffffffffffff80821115611c2057611c20611bc9565b604051601f83017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f01168101908282118183101715611c6657611c66611bc9565b81604052838152866020858801011115611c7e575f80fd5b836020870160208301375f602085830101528094505050505092915050565b803560ff81168114611a0a575f80fd5b5f805f805f60a08688031215611cc1575f80fd5b853567ffffffffffffffff80821115611cd8575f80fd5b611ce489838a01611bf6565b96506020880135915080821115611cf9575f80fd5b50611d0688828901611bf6565b945050611d1560408701611c9d565b9250611d23606087016119e7565b9150611d31608087016119e7565b90509295509295909350565b5f805f805f805f60e0888a031215611d53575f80fd5b611d5c886119e7565b9650611d6a602089016119e7565b95506040880135945060608801359350611d8660808901611c9d565b925060a0880135915060c0880135905092959891949750929550565b5f8060408385031215611db3575f80fd5b611dbc836119e7565b9150611dca602084016119e7565b90509250929050565b600181811c90821680611de757607f821691505b60208210810361147b577f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b80820180821115610462577f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b73ffffffffffffffffffffffffffffffffffffffff84168152826020820152606060408201525f611e8a606083018461196d565b95945050505050565b601f82111561130457805f5260205f20601f840160051c81016020851015611eb85750805b601f840160051c820191505b81811015611ed7575f8155600101611ec4565b5050505050565b815167ffffffffffffffff811115611ef857611ef8611bc9565b611f0c81611f068454611dd3565b84611e93565b602080601f831160018114611f5e575f8415611f285750858301515b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600386901b1c1916600185901b178555610a00565b5f858152602081207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08616915b82811015611faa57888601518255948401946001909101908401611f8b565b5085821015611fe657878501517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600388901b60f8161c191681555b5050505050600190811b01905550565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602160045260245ffdfea164736f6c6343000818000a", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x000000000000000000000000c7d67a9cbb121b3b0b9c053dd9f469523243379a", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000034", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000000601", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x00000000000000000000000000000000000000000000000000000000000f4240", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x00000000000000000000000000000000000000000000000000017969711b4fae" + } + }, + "0x530000000000000000000000000000000000000f": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000000": "0x000000000000000000000000f4a82c4ab398771c6c7a0c0dedf0b34fc0161192", + "0x0000000000000000000000000000000000000000000000000000000000000001": "0x000000000000000000000000000000000000000000000000000000000c669869", + "0x0000000000000000000000000000000000000000000000000000000000000002": "0x00000000000000000000000000000000000000000000000000000000000009c4", + "0x0000000000000000000000000000000000000000000000000000000000000003": "0x000000000000000000000000000000000000000000000000000000003b9aca00", + "0x0000000000000000000000000000000000000000000000000000000000000004": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000006": "0x00000000000000000000000000000000000000000000000000000000005cc6c6", + "0x0000000000000000000000000000000000000000000000000000000000000007": "0x000000000000000000000000000000000000000000000000000001e6a77be7ac", + "0x0000000000000000000000000000000000000000000000000000000000000008": "0x0000000000000000000000000000000000000000000000000000000034faa1da", + "0x0000000000000000000000000000000000000000000000000000000000000009": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x1", + "gasPrice": "0xf4240", + "gasLimit": [ + "0x181b1" + ], + "to": "0xc7d67a9cbb121b3b0b9c053dd9f469523243379a", + "value": [ + "0x0" + ], + "data": [ + "0xa9059cbb0000000000000000000000007ef77f9a8cbf84d1becf98b7768d49c7b4fef6ed00000000000000000000000000000000000000000000000000000000002d2a80" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x78bee0ec231c3fc7b87ebd7ec48c3d3c61a35338749c8727315fd2c8507daaf1", + "logs": "0x735e75fcb5f5511a4bf02e5f79e7b123630ef4b1b7208a71bf2eaf451221bd59" + } + ] + } + } +} diff --git a/crates/evm/src/block/factory.rs b/crates/evm/src/block/factory.rs index 0ba6689..a121946 100644 --- a/crates/evm/src/block/factory.rs +++ b/crates/evm/src/block/factory.rs @@ -74,6 +74,6 @@ impl BlockExecutorFactory for MorphBlockExecutorFactory { DB: StateDB, I: Inspector>, { - MorphBlockExecutor::new(evm, self.spec.clone(), self.receipt_builder) + MorphBlockExecutor::new(evm, self.receipt_builder) } } diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index bb03205..fbdecf0 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -23,7 +23,6 @@ use alloy_evm::{ }, }; use alloy_primitives::{Address, Log, U256}; -use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; use morph_primitives::{MorphReceipt, MorphTxEnvelope}; use morph_revm::{L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, TokenFeeInfo, evm::MorphContext}; use reth_primitives_traits::Recovered; @@ -84,8 +83,6 @@ impl TxResult for MorphTxResult { pub struct MorphBlockExecutor { /// The EVM used by executor (owned, not a reference) evm: MorphEvm, - /// Chain specification - spec: std::sync::Arc, /// Receipt builder receipt_builder: DefaultMorphReceiptBuilder, /// Receipts of executed transactions @@ -97,9 +94,6 @@ pub struct MorphBlockExecutor { /// Unlike receipt gas, L1 messages reserve their full gas limit because Morph geth does not /// return their unused gas to the block gas pool. gas_pool_used: u64, - /// Cached hardfork for this block (constant across all transactions). - /// Set in `apply_pre_execution_changes`, reused in `commit_transaction`. - hardfork: MorphHardfork, } impl MorphBlockExecutor @@ -111,21 +105,14 @@ where /// /// # Arguments /// * `evm` - The EVM instance configured for Morph execution - /// * `spec` - Chain specification containing hardfork information /// * `receipt_builder` - Builder for constructing transaction receipts - pub(crate) fn new( - evm: MorphEvm, - spec: std::sync::Arc, - receipt_builder: DefaultMorphReceiptBuilder, - ) -> Self { + pub(crate) fn new(evm: MorphEvm, receipt_builder: DefaultMorphReceiptBuilder) -> Self { Self { evm, - spec, receipt_builder, receipts: Vec::new(), gas_used: 0, gas_pool_used: 0, - hardfork: MorphHardfork::default(), } } @@ -138,7 +125,6 @@ where &mut self, tx: &MorphTxEnvelope, sender: Address, - hardfork: MorphHardfork, ) -> Result, BlockExecutionError> { if !tx.is_morph_tx() { return Ok(None); @@ -169,12 +155,13 @@ where let token_info = match self.evm.cached_token_fee_info() { Some(info) => Some(info), - None => { - TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork) - .map_err(|e| { - BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) - })? - } + // Only `price_ratio` and `scale` are read below, and both come straight from + // registry storage. `load_storage_only` reads exactly that and never builds a + // temporary EVM to resolve a balance this receipt has no use for. + None => TokenFeeInfo::load_storage_only(self.evm.db_mut(), fee_token_id, sender) + .map_err(|e| { + BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) + })?, }; Ok(token_info.map(|info| MorphReceiptTxFields { @@ -218,12 +205,6 @@ where .basic(L1_GAS_PRICE_ORACLE_ADDRESS) .map_err(BlockExecutionError::other)?; - let block_number: u64 = self.evm.block().number.to(); - let hardfork = self - .spec - .morph_hardfork_at(block_number, self.evm.block().timestamp.to::()); - self.hardfork = hardfork; - Ok(()) } @@ -300,7 +281,7 @@ where // are tracing-only — the trait API no longer permits us to surface errors // from `commit_transaction`. let (tx, signer) = recovered.into_parts(); - let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer, self.hardfork) { + let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer) { Ok(fields) => fields, Err(err) => { tracing::error!( diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 347c826..4d791b3 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -161,8 +161,12 @@ impl MorphReceiptBuilder for DefaultMorphReceiptBuilder { // Assemble logs in chronological order matching go-ethereum: // [deduct Transfer] + [main tx logs] + [refund Transfer] - // Fee logs are cached separately from the journal so they survive - // main tx revert (revm's ExecutionResult::Revert carries no logs). + // The fee logs cannot come from `result`. The call-mode deduction runs a + // mid-transaction `finalize()` that clears the journal's logs, so the handler + // moves them out first, and it drains the refund's logs the same way. `result` + // carries only the main frame's logs, which a revert has already discarded, + // while the fee logs survive it as they do in go-ethereum, whose `StateDB.logs` + // sit outside the snapshot/revert mechanism. let is_success = result.is_success(); let main_logs = result.into_logs(); let mut logs = diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index e2454e4..368f605 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -13,7 +13,6 @@ use alloy_evm::{ use alloy_primitives::{Address, Bytes}; use morph_chainspec::hardfork::MorphHardfork; use morph_revm::{MorphHaltReason, MorphInvalidTransaction, MorphTxEnv, evm::MorphContext}; -use reth_revm::MainContext; use std::ops::{Deref, DerefMut}; use crate::MorphBlockEnv; @@ -66,17 +65,7 @@ pub struct MorphEvm { impl MorphEvm { /// Create a new [`MorphEvm`] instance. pub fn new(db: DB, input: EvmEnv) -> Self { - let ctx = Context::mainnet() - .with_db(db) - .with_block(input.block_env) - .with_cfg(input.cfg_env) - .with_tx(Default::default()) - .with_chain(morph_revm::l1block::L1BlockInfo::default()); - - // Build the inner MorphEvm which creates precompiles once. - // Derive the PrecompilesMap from the inner's precompiles to avoid - // a second MorphPrecompiles::new_with_spec call. - let inner = morph_revm::MorphEvm::new(ctx, NoOpInspector {}); + let inner = morph_revm::MorphEvm::from_env(db, input, NoOpInspector {}); let precompiles_map = PrecompilesMap::from_static(inner.precompiles.precompiles()); Self { diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs index b61b580..24d2256 100644 --- a/crates/node/src/test_utils.rs +++ b/crates/node/src/test_utils.rs @@ -828,12 +828,33 @@ impl L1MessageBuilder { /// - token_address = `TEST_TOKEN_ADDRESS` /// - price_ratio = 1e18 (1:1 with ETH) /// - decimals = 18, isActive = true +/// - balanceSlot = 0, i.e. EVM-call mode +/// +/// Call mode is what mainnet runs: every registered fee token there has its +/// `balanceSlot` cleared, so the fee is moved by real `balanceOf` / `transfer` +/// calls into the token contract and the receipt carries their `Transfer` events. pub const TEST_TOKEN_ID: u16 = 1; /// Address of the test ERC20 token deployed in the test genesis. /// /// Pre-funded with 1000 tokens (1e21 wei) for test accounts 0 and 1. /// Address: `0x5300000000000000000000000000000000000022` +/// +/// The genesis gives it the optimized runtime of: +/// +/// ```solidity +/// contract Slot1Token { +/// uint256 private dummy; +/// mapping(address => uint256) public balanceOf; // slot 1 +/// event Transfer(address indexed from, address indexed to, uint256 value); +/// function transfer(address to, uint256 amount) external returns (bool) { ... } +/// } +/// ``` +/// +/// Real code is what makes the registry's EVM-call mode usable: the fee path calls +/// `balanceOf` and `transfer` on this contract rather than writing its storage +/// directly. Keeping `balanceOf` at slot 1 also lets [`test_token_balance_slot`] +/// derive the same slot independently as a test oracle. pub const TEST_TOKEN_ADDRESS: Address = Address::new([ 0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x22, @@ -849,9 +870,9 @@ pub const TEST_FEE_VAULT_ADDRESS: Address = Address::new([ /// Base slot of the test token's `balances` mapping. /// -/// The registry stores this one-based so that zero means "unknown", and -/// `morph_revm`'s token-fee reader subtracts one. The test genesis registers `2` -/// for `TEST_TOKEN_ID`, so the effective base slot is `1`. +/// The registry's own `balanceSlot` is zero (call mode), so this is not read from +/// the registry — it mirrors the layout of the token contract's bytecode so tests +/// can check balances without going through the fee-token code under test. const TEST_TOKEN_BALANCE_BASE_SLOT: u64 = 1; /// Storage slot holding `account`'s balance of the test ERC20 token. diff --git a/crates/node/tests/assets/test-genesis.json b/crates/node/tests/assets/test-genesis.json index 09cc751..c23a20b 100644 --- a/crates/node/tests/assets/test-genesis.json +++ b/crates/node/tests/assets/test-genesis.json @@ -67,7 +67,7 @@ "code": "0x00", "storage": { "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000005300000000000000000000000000000000000022", - "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" @@ -75,7 +75,7 @@ }, "0x5300000000000000000000000000000000000022": { "balance": "0x0", - "code": "0x00", + "code": "0x608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806370a0823114610038578063a9059cbb1461006a575b5f5ffd5b61005761004636600461015e565b60016020525f908152604090205481565b6040519081526020015b60405180910390f35b61007d61007836600461017e565b61008d565b6040519015158152602001610061565b335f90815260016020526040812054828110156100da5760405162461bcd60e51b815260206004820152600760248201526662616c616e636560c81b604482015260640160405180910390fd5b335f81815260016020908152604080832087860390556001600160a01b03881680845292819020805488019055518681529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b80356001600160a01b0381168114610159575f5ffd5b919050565b5f6020828403121561016e575f5ffd5b61017782610143565b9392505050565b5f5f6040838503121561018f575f5ffd5b61019883610143565b94602093909301359350505056", "storage": { "0xa3c1274aadd82e4d12c8004c33fb244ca686dad4fcc8957fc5668588c11d9502": "0x00000000000000000000000000000000000000000000003635c9adc5dea00000", "0x3c8e904cdb19937d60d41c8d984b1a8803ad6e0891b4f9e032dcec2a22c2c7f5": "0x00000000000000000000000000000000000000000000003635c9adc5dea00000" diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs index 97cef34..7fe7d58 100644 --- a/crates/node/tests/it/morph_tx.rs +++ b/crates/node/tests/it/morph_tx.rs @@ -376,21 +376,6 @@ fn address_topic(address: Address) -> B256 { B256::from(topic) } -/// Optimized runtime for: -/// -/// ```solidity -/// contract Slot1Token { -/// uint256 private dummy; -/// mapping(address => uint256) public balanceOf; // slot 1 -/// event Transfer(address indexed from, address indexed to, uint256 value); -/// function transfer(address to, uint256 amount) external returns (bool) { ... } -/// } -/// ``` -/// -/// Keeping `balanceOf` at slot 1 lets the test token use the same storage layout -/// as `tests/assets/test-genesis.json` and the token registry's direct-slot path. -const SLOT1_ERC20_RUNTIME_CODE: &str = "0x608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806370a0823114610038578063a9059cbb1461006a575b5f5ffd5b61005761004636600461015e565b60016020525f908152604090205481565b6040519081526020015b60405180910390f35b61007d61007836600461017e565b61008d565b6040519015158152602001610061565b335f90815260016020526040812054828110156100da5760405162461bcd60e51b815260206004820152600760248201526662616c616e636560c81b604482015260640160405180910390fd5b335f81815260016020908152604080832087860390556001600160a01b03881680845292819020805488019055518681529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b80356001600160a01b0381168114610159575f5ffd5b919050565b5f6020828403121561016e575f5ffd5b61017782610143565b9392505050565b5f5f6040838503121561018f575f5ffd5b61019883610143565b94602093909301359350505056"; - /// After a successful MorphTx v0 with ERC20 fee, the sender's token balance /// must decrease (fee was charged from tokens, not ETH). #[tokio::test(flavor = "multi_thread")] @@ -492,7 +477,6 @@ async fn token_fee_transfer_gas_regression( let token_addr = morph_node::test_utils::TEST_TOKEN_ADDRESS; let (mut nodes, wallet) = TestNodeBuilder::new() .with_schedule(schedule) - .with_account_code(token_addr, SLOT1_ERC20_RUNTIME_CODE) .build() .await?; let mut node = nodes.pop().unwrap(); @@ -560,17 +544,24 @@ async fn token_fee_transfer_gas_regression( .iter() .filter(|log| log.address == token_addr && log.topics().first() == Some(&transfer_topic)) .collect(); + // In call mode the fee is moved by real ERC20 calls, so the transaction's own + // transfer arrives bracketed by them, in go-ethereum's order: deduction, main, + // reimbursement. assert_eq!( transfer_logs.len(), - 1, - "the main ERC20 transfer should execute against the fee token contract" + 3, + "receipt should carry the fee deduction, the main transfer and the fee refund" ); assert_eq!(transfer_logs[0].topics()[1], address_topic(sender)); - assert_eq!(transfer_logs[0].topics()[2], address_topic(recipient)); + assert_eq!(transfer_logs[0].topics()[2], address_topic(fee_vault)); + assert_eq!(transfer_logs[1].topics()[1], address_topic(sender)); + assert_eq!(transfer_logs[1].topics()[2], address_topic(recipient)); assert_eq!( - transfer_logs[0].data.data.as_ref(), + transfer_logs[1].data.data.as_ref(), amount.to_be_bytes::<32>() ); + assert_eq!(transfer_logs[2].topics()[1], address_topic(fee_vault)); + assert_eq!(transfer_logs[2].topics()[2], address_topic(sender)); let state_after = node.inner.provider.latest()?; let sender_after = state_after @@ -640,10 +631,21 @@ const RUNTIME_REVERT_INIT: &[u8] = &[ /// 1. Block 1: Deploy a contract whose runtime always reverts (EIP-1559 tx) /// 2. Block 2: Call that contract with MorphTx v0 (ERC20 fee) /// 3. Verify: receipt.status = false, but token balance decreased +/// 4. Verify: the receipt still carries both fee `Transfer` events /// /// This exercises the handler's `validate_and_deduct_token_fee` (charges fee /// upfront) and `reimburse_caller_token_fee` (partial refund for unused gas) /// paths when the main transaction execution reverts. +/// +/// The log assertion is the point of running the fee path on a *reverting* main +/// frame. go-ethereum keeps `StateDB.logs` outside the state snapshot/revert +/// mechanism, so the deduction's `Transfer` survives a main-frame revert; that +/// is the entire reason morph-reth caches fee logs in `pre_fee_logs` / +/// `post_fee_logs` instead of leaving them in the journal (`crates/evm/src/block/receipt.rs`). +/// A regression there -- the fee logs dropped, or restored into the reverted +/// frame -- changes the receipt's logs and therefore the block's receipts root, +/// and no state assertion in this test would notice. This is the only test that +/// runs the production receipt builder against a reverting main frame. #[tokio::test(flavor = "multi_thread")] async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { reth_tracing::init_test_tracing(); @@ -720,6 +722,40 @@ async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { before={bal_before}, after={bal_after}" ); + // Both fee transfers must survive the main frame's revert: go-ethereum keeps + // `StateDB.logs` outside the state snapshot/revert mechanism, so the deduction's + // `Transfer` is still in the receipt while the reverted main frame contributes + // none. Order is go-ethereum's: deduction, (empty) main frame, reimbursement. + let fee_vault = morph_node::test_utils::TEST_FEE_VAULT_ADDRESS; + let transfer_topic = erc20_transfer_topic(); + let transfer_logs: Vec<_> = receipt + .logs() + .iter() + .filter(|log| log.address == token_addr && log.topics().first() == Some(&transfer_topic)) + .collect(); + assert_eq!( + transfer_logs.len(), + 2, + "receipt must carry the fee deduction and the fee reimbursement even though \ + the main frame reverted; dropping the deduction's log changes the receipts root. \ + got {transfer_logs:?}" + ); + assert_eq!( + (transfer_logs[0].topics()[1], transfer_logs[0].topics()[2]), + (address_topic(sender), address_topic(fee_vault)), + "first log must be the fee deduction (sender -> fee vault)" + ); + assert_ne!( + transfer_logs[0].data.data.as_ref(), + [0u8; 32], + "the deduction must move a non-zero fee" + ); + assert_eq!( + (transfer_logs[1].topics()[1], transfer_logs[1].topics()[2]), + (address_topic(fee_vault), address_topic(sender)), + "second log must be the fee reimbursement (fee vault -> sender)" + ); + // The receipt should carry MorphTx-specific fee fields match &receipt { morph_primitives::MorphReceipt::Morph(morph_receipt) => { diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 4515a2a..8ed377e 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -27,6 +27,11 @@ pub enum MorphInvalidTransaction { #[error("Token with ID {0} has invalid fee configuration")] InvalidTokenConfig(u16), + /// The token balance call reverted, violated static execution, or returned malformed data. + #[error("Token balance query failed")] + TokenBalanceQueryFailed, + + /// The transfer failed or its return value/balance delta was invalid. #[error("Token transfer failed: {reason}")] TokenTransferFailed { /// Token transfer failure reason. diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index a096969..eda44e6 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -100,6 +100,9 @@ pub struct MorphEvm { /// receipt-building path (the handler already has the encoded bytes via /// `MorphTxEnv.rlp_bytes`). pub(crate) cached_l1_data_fee: U256, + /// Signed refund counter from a successful fee deduction call. + /// Applied before the final refund cap; refund-transfer refunds are excluded. + pub(crate) pre_fee_refund: i64, /// Transfer event logs from token fee deduction (pre-execution phase). /// /// In go-ethereum, `buyAltTokenGas()` emits Transfer events into `StateDB.logs` @@ -113,6 +116,14 @@ pub struct MorphEvm { } impl MorphEvm { + /// Constructs an EVM from the full environment used by both execution and pool queries. + pub fn from_env(db: DB, env: crate::MorphEvmEnv, inspector: I) -> Self { + let ctx = MorphContext::new(db, *env.cfg_env.spec()) + .with_cfg(env.cfg_env) + .with_block(env.block_env); + Self::new(ctx, inspector) + } + /// Create a new Morph EVM. /// /// The precompiles are automatically selected based on the hardfork spec @@ -173,6 +184,7 @@ impl MorphEvm { inner, cached_token_fee_info: None, cached_l1_data_fee: U256::ZERO, + pre_fee_refund: 0, pre_fee_logs: Vec::new(), post_fee_logs: Vec::new(), } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index a0d9f2e..c2b04d8 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -17,11 +17,14 @@ use revm::{ }; use crate::{ - MorphEvm, MorphInvalidTransaction, MorphTxEnv, + MorphEvm, MorphInvalidTransaction, error::MorphHaltReason, evm::MorphContext, l1block::L1BlockInfo, - token_fee::{TokenRegistryEntry, compute_mapping_slot_for_address, encode_balance_of_calldata}, + token_fee::{ + TokenFeeInfo, TokenRegistryEntry, compute_mapping_slot_for_address, + encode_balance_of_calldata, read_balance_from_storage, + }, tx::MorphTxExt, }; @@ -101,6 +104,7 @@ where ) -> Result<(), Self::Error> { // Reset per-transaction caches from the previous iteration. evm.cached_l1_data_fee = U256::ZERO; + evm.pre_fee_refund = 0; evm.cached_token_fee_info = None; evm.pre_fee_logs.clear(); evm.post_fee_logs.clear(); @@ -178,6 +182,7 @@ where exec_result.gas_mut().set_refund(0); return Ok(()); } + exec_result.gas_mut().record_refund(evm.pre_fee_refund); post_execution::refund( evm.ctx().cfg().gas_params(), exec_result.gas_mut(), @@ -422,11 +427,11 @@ where // This ensures the same price_ratio/scale is used for both deduction and reimbursement. // The cache is kept populated (not taken) so the block executor's receipt builder // can also read it without re-querying the DB. - let token_fee_info = - evm.cached_token_fee_info - .ok_or(MorphInvalidTransaction::TokenTransferFailed { - reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), - })?; + let token_fee_info = evm.cached_token_fee_info.ok_or_else(|| { + MorphInvalidTransaction::TokenTransferFailed { + reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), + } + })?; // Calculate token amount required for total fee let token_amount_required = token_fee_info.eth_to_token_amount(reimburse_eth); @@ -464,10 +469,16 @@ where .drain(log_count_before..) .collect(); evm.post_fee_logs = refund_logs; - result + result.map(|_| ()) }; if let Err(err) = refund_result { + // A contract may reject a refund, but unavailable state is not a verdict + // about the contract. Internal calls have already taken the context error, + // so it must reach the executor here rather than disappearing at finalization. + if matches!(err, EVMError::Database(_)) { + return Err(err); + } tracing::error!( target: "morph::evm", token_id = ?evm.ctx_ref().tx().fee_token_id, @@ -555,11 +566,7 @@ where let hardfork = *evm.ctx_ref().cfg().spec(); - let token_fee_info = token_registry_entry.load_for_caller( - evm.ctx_mut().journal_mut().db_mut(), - caller_addr, - hardfork, - )?; + let token_fee_info = load_token_fee_info(evm, token_registry_entry, caller_addr)?; let beneficiary = evm.ctx_ref().block().beneficiary(); let rlp_bytes = evm.ctx_ref().tx().rlp_bytes.clone().unwrap_or_default(); @@ -584,11 +591,7 @@ where // Calculate token amount required for total fee let token_amount_required = token_fee_info.eth_to_token_amount(total_eth_fee); - // Determine fee limit - let mut fee_limit = fee_limit_from_tx; - if fee_limit.is_zero() || fee_limit > token_fee_info.balance { - fee_limit = token_fee_info.balance - } + let fee_limit = token_fee_info.effective_fee_limit(fee_limit_from_tx); // Check if caller has sufficient token balance if fee_limit < token_amount_required { @@ -599,13 +602,12 @@ where .into()); } - if let Some(balance_slot) = token_fee_info.balance_slot { + if token_amount_required.is_zero() { + // Geth skips both transfer modes for a zero fee. Nonce and caches below + // still need their normal per-transaction updates. + } else if let Some(balance_slot) = token_fee_info.balance_slot { // Transfer with token slot. - // Ensure token account is loaded into the journal state, because `sload`/`sstore` - // assume the account is present. let journal = evm.ctx_mut().journal_mut(); - let _ = journal.load_account_mut(token_fee_info.token_address)?; - journal.touch(token_fee_info.token_address); let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( journal, caller_addr, @@ -627,7 +629,7 @@ where } } else { // Transfer with evm call (from=caller, balance known from token registry). - transfer_erc20_with_evm( + evm.pre_fee_refund = transfer_erc20_with_evm( evm, caller_addr, beneficiary, @@ -635,17 +637,60 @@ where token_amount_required, Some(token_fee_info.balance), )?; + } + if token_fee_info.balance_slot.is_none() { + // balanceOf runs even for a zero fee. Geth Prepare clears its access + // list/transient storage before the main transaction in that case too. // Cache fee Transfer logs separately from the journal. // // go-ethereum's StateDB.logs is independent of the state snapshot/revert - // mechanism — fee logs survive regardless of main tx result. revm's - // ExecutionResult::Revert has no logs field, so we keep fee logs out of - // the handler pipeline entirely and merge them in the receipt builder. + // mechanism — fee logs survive regardless of main tx result. In revm they + // would not: the `finalize()` below clears the journal's logs, and whatever + // survived would still be dropped when `execution_result` commits the + // transaction. So the fee logs are kept out of the handler pipeline entirely + // and merged back in the receipt builder. evm.pre_fee_logs = std::mem::take(&mut evm.ctx_mut().journal_mut().logs); // State changes should be marked cold to avoid warm access in the main tx execution. - // finalize() clears journal state (including logs, which we already took above). + // Fee deduction ran a real EVM frame, so its state writes must survive while the + // frame's metadata must not: go-ethereum's `StateDB.Prepare` rebuilds the access + // list and resets transient storage before the main transaction + // (core/state/statedb.go:1066). `finalize()` is the nearest revm equivalent — it + // commits the deduction's state and drops the journal, undo history, logs and + // transient storage. + // + // `mark_cold` below only has to *drop* the warmth this frame's own CALL created; it + // does not restore what `Prepare` would have left warm, and must not try to. That + // warmth arrives later from upstream, which is why the two cannot be swapped: + // `run_without_catch_error` runs this deduction inside `validate()`, then + // `pre_execution()` → `pre_execution::load_accounts` re-warms the coinbase + // (EIP-3651) and the transaction's access list, and the nonce bump just below + // re-loads the caller. If a future change reorders those phases, a main frame that + // reads `COINBASE` would be charged 2600 instead of go-ethereum's 100; nothing here + // would catch it, because no fixture's main frame touches the coinbase. + // + // The `transaction_id` handling inside `finalize()` is load-bearing, not incidental. + // Warming a slot goes through `EvmStorageSlot::mark_warm_with_transaction_id`, which + // re-baselines the EIP-2200 `original_value` to the present value whenever the slot's + // transaction id differs from the journal's (revm-state/src/lib.rs). That must not + // happen to the slot the deduction just cleared: re-baselining it to zero would make + // the main frame's SSTORE a *create* (SSTORE_SET, 20000) rather than a *recreate* + // (100), and would drop the `SubRefund` that cancels the deduction frame's `+4800`. + // Measured on `main_restores_cleared_slot`: 23_291 gas becomes 38_391 (+19_900 + // -4_800), and the state root moves with the fee it implies. + // + // It does not happen because ids stay equal throughout execution. revm advances the + // id only when a transaction finishes — `commit_tx()` from `execution_result`, or + // `discard_tx()` on the error path — both after the main frame is done; + // `ExecuteEvm::finalize` then resets it to ZERO before the next transaction. So + // across this deduction and the main frame the journal's id is 0 — and this + // `finalize()` keeps it at 0 rather than advancing it. Swapping in `commit_tx()` here + // would leave the deduction-warmed slots holding 0 while the journal held 1, and the + // main frame's first touch of them would re-baseline `original_value`; the call-path + // fixtures under `bin/morph-statetest` catch exactly that. An explicit `mark_cold` + // carries no such risk: it drives only the warm/cold gas decision, never the + // re-baseline. let mut state = evm.finalize(); state.iter_mut().for_each(|(_, acc)| { acc.mark_cold(); @@ -697,57 +742,13 @@ where } } -/// Execute `f` within a journal checkpoint, saving and restoring `evm.tx`. -/// -/// On `Ok` the checkpoint is committed; on `Err` it is reverted. -/// `evm.tx` is always restored to its original value regardless of the outcome, -/// so callers of [`evm_call`] inside `f` do not need to manage `evm.tx` themselves. -#[inline] -fn with_evm_checkpoint( - evm: &mut MorphEvm, - f: impl FnOnce(&mut MorphEvm) -> Result>, -) -> Result> -where - DB: alloy_evm::Database, -{ - let tx_origin = std::mem::take(&mut evm.tx); - let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); - let result = f(evm); - evm.tx = tx_origin; - match result { - Ok(val) => { - evm.ctx_mut().journal_mut().checkpoint_commit(); - Ok(val) - } - Err(err) => { - evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); - Err(err) - } - } -} - -/// Execute `f` within a journal snapshot that always reverts, saving and restoring `evm.tx`. -/// -/// This gives `f` read-only (StaticCall-like) semantics: any state changes made by -/// [`evm_call`] inside `f` are discarded when `f` returns. -#[inline] -fn with_evm_snapshot( - evm: &mut MorphEvm, - f: impl FnOnce(&mut MorphEvm) -> T, -) -> T -where - DB: alloy_evm::Database, -{ - let tx_origin = std::mem::take(&mut evm.tx); - let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); - let result = f(evm); - evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); - evm.tx = tx_origin; - result -} - /// Performs an ERC20 balance transfer by directly `sload`/`sstore`-ing the token contract storage /// using the known `balance` mapping base slot, returning the computed storage slots for `from`/`to`. +/// +/// The token account is loaded and touched here, ahead of the checkpoint, rather than by the +/// callers. The journal's `sload`/`sstore` panic instead of erroring when the account is absent +/// from `journal.state`, and touching keeps the token among the transaction's state changes even +/// for a self-transfer that writes no slot, as go-ethereum's `SetState` still marks it dirty. #[inline] fn transfer_erc20_with_slot( journal: &mut revm::Journal, @@ -760,6 +761,8 @@ fn transfer_erc20_with_slot( where DB: alloy_evm::Database, { + let _ = journal.load_account_mut(token)?; + journal.touch(token); with_journal_checkpoint(journal, |journal| { // Sub amount (checked: reject if insufficient, matching go-ethereum's // changeAltTokenBalanceByState which returns an error on underflow) @@ -796,69 +799,179 @@ where /// Gas limit for internal EVM calls (ERC20 transfer, balanceOf). const EVM_CALL_GAS_LIMIT: u64 = 200_000; -/// Execute an internal EVM call, matching go-ethereum's `evm.Call()` semantics. -/// -/// Unlike `system_call_one_with_caller`, this only runs the handler's `execution()` -/// phase — NOT `execution_result()`. This means: -/// - Logs emitted during the call (e.g., ERC20 Transfer events) remain in the journal -/// - State changes remain in the journal +/// Loads internal-call code without changing the account's access-list temperature. +/// Geth's direct Call/StaticCall resolve code without executing a CALL opcode. /// -/// **Caller is responsible for saving/restoring `evm.tx` if needed.** +/// This is also what puts `address` into `journal.state`, which [`evm_call`] depends on: +/// a `CallValue::Transfer` frame runs `Journal::transfer_loaded`, and its zero-value path +/// is `self.state.get_mut(&to).unwrap()` — a panic, not an error. In an ordinary CALL the +/// account is there because the opcode's `load_acc_and_calc_gas` put it there; an internal +/// call has no opcode, so this is the only load. Resolving the bytecode some other way +/// (caching it, hoisting it, short-circuiting on a known code hash) must keep the load. +fn internal_call_code( + journal: &mut revm::Journal, + address: Address, +) -> Result<(alloy_primitives::B256, revm::state::Bytecode), DB::Error> { + let account = journal.load_account_with_code(address)?; + let was_cold = account.is_cold; + let code = ( + account.info.code_hash(), + account.info.code.clone().unwrap_or_default(), + ); + if was_cold { + journal + .state + .get_mut(&address) + .expect("account was loaded") + .mark_cold(); + } + Ok(code) +} + +/// Executes a fee-token frame while retaining the outer transaction's ORIGIN/GASPRICE. +/// The frame owns its VM checkpoint; a successful call is not rolled back merely +/// because the token's return value or balance delta fails a later business check. fn evm_call( evm: &mut MorphEvm, caller: Address, target: Address, calldata: Bytes, + is_static: bool, ) -> Result> where DB: alloy_evm::Database, { - evm.tx = MorphTxEnv { - inner: revm::context::TxEnv { - caller, - kind: target.into(), - data: calldata, + use revm::context_interface::LocalContextTr; + use revm::interpreter::interpreter_action::FrameInit; + use revm::interpreter::{ + CallInput, CallInputs, CallScheme, CallValue, FrameInput, SharedMemory, + }; + + // Frame execution reports database failures through ctx.error. Check both before + // and after so a refund cannot overwrite an error from the main transaction. + take_context_error(evm)?; + let mut known_bytecode = internal_call_code(evm.ctx_mut().journal_mut(), target)?; + if let Some(delegate) = known_bytecode.1.eip7702_address() { + known_bytecode = internal_call_code(evm.ctx_mut().journal_mut(), delegate)?; + } + // Fee frames are top-level frames that run in the middle of a transaction, so + // neither of revm's truncation points covers them: `free_child_context` only + // releases a *child* frame's region, and `LocalContext::clear` only runs once the + // whole transaction is done. Carve this frame's memory out above whatever the + // shared buffer already holds and release it on the way out, so the main + // transaction frame still starts on zeroed memory the way go-ethereum's + // per-run `NewMemory()` guarantees. The frame keeps using the context's buffer + // rather than one of its own because a nested call hands its callee a + // `CallInput::SharedBuffer` range, and a precompile callee resolves that range + // against the context's buffer (`CallInput::as_bytes`) rather than against the + // calling frame's memory. + let mut fee_frame_memory = + SharedMemory::new_with_buffer(evm.ctx_ref().local().shared_memory_buffer().clone()); + let mut memory = fee_frame_memory.new_child_context(); + memory.set_memory_limit(evm.ctx_ref().cfg().memory_limit()); + let frame = FrameInit { + depth: 0, + memory, + frame_input: FrameInput::Call(Box::new(CallInputs { + input: CallInput::Bytes(calldata), + return_memory_offset: 0..0, gas_limit: EVM_CALL_GAS_LIMIT, - ..Default::default() - }, - ..Default::default() + reservoir: 0, + bytecode_address: target, + known_bytecode, + target_address: target, + caller, + // A zero transfer also performs geth StaticCall's legacy account touch. + value: CallValue::Transfer(U256::ZERO), + scheme: if is_static { + CallScheme::StaticCall + } else { + CallScheme::Call + }, + is_static, + charged_new_account_state_gas: false, + })), }; - let mut h = MorphEvmHandler::::new(); - let init_and_floor_gas = InitialAndFloorGas::new(0, 0); - let mut gas = h.tx_gas(evm, &init_and_floor_gas); - // `execution` owns this checkpoint: it commits once the runtime gas phase is done, or - // unwinds to it when that phase runs out of gas. The `None` arm is only reachable - // under EIP-2780 (AMSTERDAM), which Morph never enables, so it is unreachable today; - // it is kept faithful to upstream so a future hardfork mapping cannot silently skip it. - let checkpoint = evm.ctx().journal_mut().checkpoint(); - match h.execution(evm, checkpoint, &mut gas)? { - Some(res) => Ok(res), - None => h.runtime_oog_result(evm, &init_and_floor_gas, &mut gas), + let result = MorphEvmHandler::::new().run_exec_loop(evm, frame); + fee_frame_memory.free_child_context(); + let result = result?; + take_context_error(evm)?; + Ok(result) +} + +/// Moves a database failure recorded on the context into the return path. +#[inline] +fn take_context_error( + evm: &mut MorphEvm, +) -> Result<(), EVMError> +where + DB: alloy_evm::Database, +{ + revm::context_interface::context::take_error::< + EVMError, + DB::Error, + >(&mut evm.ctx_mut().error) +} + +/// Queries the token using a genuine static frame, as geth's StaticCall does. +/// Successful reads retain access-list warming; writes and malformed results fail. +pub(crate) fn evm_call_balance_of( + evm: &mut MorphEvm, + token: Address, + account: Address, +) -> Result> +where + DB: alloy_evm::Database, +{ + let result = evm_call( + evm, + account, + token, + encode_balance_of_calldata(account), + true, + )?; + let output = &result.interpreter_result().output; + if !result.instruction_result().is_ok() || output.len() < 32 { + return Err(MorphInvalidTransaction::TokenBalanceQueryFailed.into()); } + Ok(U256::from_be_slice(&output[..32])) } -/// Query ERC20 `balanceOf(address)` via an internal EVM call. +/// Resolves the caller's fee-token balance against the **executing** EVM. /// -/// Uses [`with_evm_snapshot`] to match go-ethereum's StaticCall semantics: -/// all state changes and `evm.tx` mutations are reverted after the call. -fn evm_call_balance_of(evm: &mut MorphEvm, token: Address, account: Address) -> U256 +/// go-ethereum reads it through `st.evm` (`GetAltTokenBalanceHybrid`, core/token_gas.go:43), +/// so the `balanceOf` call sees the real block context, the real chain config and the user as +/// `msg.sender`. Building a throwaway EVM here instead — as this path used to, through +/// `system_call_one` — answers under `BlockEnv::default()` and `CfgEnv::default()`: block 0, +/// timestamp 1, chain id 1, zero coinbase and base fee, with `SYSTEM_ADDRESS` as the sender. +/// For any token whose `balanceOf` reads that context the two clients would charge different +/// fees for the same transaction. The gas budget was never the problem: this crate's +/// `system_call_one` set the limit to its own `SYSTEM_CALL_GAS_LIMIT` — `exec.rs`, 200_000, +/// which deliberately shadows revm's `SYSTEM_CALL_GAS_LIMIT` of 30_000_000 at the +/// `SystemCallEvm` impl — and that 200k is go-ethereum's `maxGas`. [`EVM_CALL_GAS_LIMIT`] +/// carries the same number forward. +fn load_token_fee_info( + evm: &mut MorphEvm, + entry: TokenRegistryEntry, + caller: Address, +) -> Result> where DB: alloy_evm::Database, { - with_evm_snapshot(evm, |evm| { - let calldata = encode_balance_of_calldata(account); - match evm_call(evm, Address::ZERO, token, calldata) { - Ok(ref result) if result.instruction_result().is_ok() => { - let output = &result.interpreter_result().output; - if output.len() >= 32 { - U256::from_be_slice(&output[..32]) - } else { - U256::ZERO - } - } - _ => U256::ZERO, - } - }) + let balance = match entry.balance_slot() { + // Slot mode is a plain storage read with no environment to get wrong. It goes + // through the database rather than the journal deliberately: the journal is empty + // at this point in the transaction, and an `sload` here would warm a slot that the + // fee deduction below is careful to leave cold. + Some(slot) => read_balance_from_storage( + evm.ctx_mut().journal_mut().db_mut(), + entry.token_address(), + caller, + slot, + )?, + None => evm_call_balance_of(evm, entry.token_address(), caller)?, + }; + Ok(entry.into_fee_info(caller, balance)) } /// Matches go-ethereum's `transferAltTokenByEVM` validation: @@ -872,6 +985,8 @@ where /// /// `from_balance_before` is the sender's balance before the transfer. If `None`, /// the balance is queried via EVM call (matching go-eth's nil `userBalanceBefore`). +/// Returns the signed refund counter for successful transfers; the deduction phase +/// carries it into transaction gas accounting, while reimbursement ignores it. fn transfer_erc20_with_evm( evm: &mut MorphEvm, from: Address, @@ -879,70 +994,73 @@ fn transfer_erc20_with_evm( token_address: Address, token_amount: U256, from_balance_before: Option, -) -> Result<(), EVMError> +) -> Result> where DB: alloy_evm::Database, { + if token_amount.is_zero() { + return Ok(0); + } // Read sender balance before transfer if not provided. - // This uses with_evm_snapshot internally, so evm.tx is safe. let from_balance_before = match from_balance_before { Some(b) => b, - None => evm_call_balance_of(evm, token_address, from), + None => evm_call_balance_of(evm, token_address, from)?, }; - with_evm_checkpoint(evm, |evm| { - let calldata = build_transfer_calldata(to, token_amount); - let frame_result = evm_call(evm, from, token_address, calldata).map_err(|e| { - EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { + // Geth checks affordability before executing the token contract. + let expected_balance = from_balance_before + .checked_sub(token_amount) + .ok_or_else(|| MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance {from_balance_before} less than token amount {token_amount}" + ), + })?; + + let calldata = build_transfer_calldata(to, token_amount); + let frame_result = + evm_call(evm, from, token_address, calldata, false).map_err(|e| match e { + EVMError::Database(_) => e, + _ => EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { reason: format!("Error: {e:?}"), - }) + }), })?; - if !frame_result.instruction_result().is_ok() { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!("{:?}", frame_result.interpreter_result()), - } - .into()); + if !frame_result.instruction_result().is_ok() { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("{:?}", frame_result.interpreter_result()), } + .into()); + } - // Validate ABI bool return value, matching go-ethereum behavior: - // - No return data: accepted (old tokens that don't return bool) - // - 32+ bytes with last byte == 1: accepted (standard ERC20) - // - Otherwise: rejected - let output = &frame_result.interpreter_result().output; - if !output.is_empty() && (output.len() < 32 || output[31] != 1) { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: "alt token transfer returned failure".to_string(), - } - .into()); + // Validate ABI bool return value, matching go-ethereum behavior: + // - No return data: accepted (old tokens that don't return bool) + // - 32+ bytes with last byte == 1: accepted (standard ERC20) + // - Otherwise: rejected + let output = &frame_result.interpreter_result().output; + if !output.is_empty() && (output.len() < 32 || output[31] != 1) { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: "alt token transfer returned failure".to_string(), } + .into()); + } - // Verify sender balance changed by the expected amount, matching go-ethereum. - // evm_call_balance_of uses with_evm_snapshot, so evm.tx is safe here too. - let from_balance_after = evm_call_balance_of(evm, token_address, from); - - // Verify sender balance decreased by exactly the transfer amount. - // Matches go-ethereum's transferAltTokenByEVM which always checks this, - // even for self-transfers (from == to), where it would fail because the - // net balance change is zero but the expected decrease is `token_amount`. - let expected_balance = from_balance_before.checked_sub(token_amount).ok_or( - MorphInvalidTransaction::TokenTransferFailed { - reason: format!( - "sender balance {from_balance_before} less than token amount {token_amount}" - ), - }, - )?; - if from_balance_after != expected_balance { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!( - "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" - ), - } - .into()); + // Verify sender balance changed by the expected amount, matching go-ethereum. + let from_balance_after = evm_call_balance_of(evm, token_address, from)?; + + // Verify sender balance decreased by exactly the transfer amount. + // Matches go-ethereum's transferAltTokenByEVM which always checks this, + // even for self-transfers (from == to), where it would fail because the + // net balance change is zero but the expected decrease is `token_amount`. + if from_balance_after != expected_balance { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" + ), } + .into()); + } - Ok(()) - }) + Ok(frame_result.gas().refunded()) } /// Build the calldata for ERC20 `transfer(address,uint256)` call. @@ -1032,6 +1150,8 @@ fn calculate_caller_fee_with_l1_cost( #[cfg(test)] mod tests { use super::*; + use crate::MorphTxEnv; + use crate::token_fee::tests::{TokenReadFailure, UnreadableTokenDb}; use crate::{ MorphBlockEnv, token_fee::{L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot}, @@ -1051,6 +1171,197 @@ mod tests { atomic::{AtomicBool, Ordering}, }; + fn finish_transaction_with_refund( + code: Bytes, + ) -> Result, EVMError> + { + let caller = address!("1000000000000000000000000000000000000001"); + let beneficiary = address!("2000000000000000000000000000000000000002"); + let token = address!("3000000000000000000000000000000000000003"); + let target = address!("4000000000000000000000000000000000000004"); + let mut inner = CacheDB::new(EmptyDB::default()); + inner.insert_account_info( + token, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + let mut evm = MorphEvm::new( + MorphContext::new(UnreadableTokenDb { inner, token }, MorphHardfork::Emerald), + NoOpInspector, + ); + evm.block.inner.beneficiary = beneficiary; + // Produce a real successful main frame. The remainder of this probe enters the + // normal reimbursement and result-finalization phases with an unused gas budget. + let frame = evm_call(&mut evm, caller, target, Bytes::new(), false).unwrap(); + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller, + gas_price: 1, + gas_limit: 30_000, + kind: TxKind::Call(target), + ..Default::default() + }, + fee_token_id: Some(1), + ..Default::default() + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: token, + is_active: true, + price_ratio: U256::from(1), + scale: U256::from(1), + caller, + balance: U256::from(100_000), + balance_slot: None, + ..Default::default() + }); + let mut handler = MorphEvmHandler::<_, NoOpInspector>::default(); + handler + .reimburse_caller_token_fee(&mut evm, &Gas::new(1_000)) + .and_then(|_| handler.execution_result(&mut evm, frame, ResultGas::default())) + } + + /// A readable token under [`UnreadableTokenDb`], so any failure reported below comes + /// from the context, not from a read. + fn readable_token_evm() -> MorphEvm { + let token = address!("3000000000000000000000000000000000000003"); + let mut inner = CacheDB::new(EmptyDB::default()); + insert_contract( + &mut inner, + token, + alloy_primitives::bytes!("6000545f5260205ff3"), + ); + inner + .insert_account_storage(token, U256::ZERO, U256::from(42)) + .unwrap(); + MorphEvm::new( + MorphContext::new( + UnreadableTokenDb { + inner, + // Nothing reads this address, so every storage read succeeds. + token: address!("9000000000000000000000000000000000000009"), + }, + MorphHardfork::Emerald, + ), + NoOpInspector, + ) + } + + #[test] + fn a_nested_call_does_not_run_in_a_context_the_main_frame_already_poisoned() { + let token = address!("3000000000000000000000000000000000000003"); + let account = address!("1000000000000000000000000000000000000001"); + let mut evm = readable_token_evm(); + assert_eq!( + evm_call_balance_of(&mut evm, token, account).unwrap(), + U256::from(42), + "sanity: the token is readable" + ); + + // The main frame halted on a failed read; post-execution then reaches this call. + evm.ctx_mut().error = Err(revm::context_interface::context::ContextError::Db( + TokenReadFailure, + )); + let result = evm_call_balance_of(&mut evm, token, account); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "the main frame's failure must be reported, not overwritten by a nested call: {result:?}" + ); + assert!( + evm.ctx_ref().error.is_ok(), + "the failure has been moved into the return path" + ); + } + + #[test] + fn a_refund_after_a_poisoned_main_frame_reports_the_main_frame_failure() { + let caller = address!("1000000000000000000000000000000000000001"); + let token = address!("3000000000000000000000000000000000000003"); + let mut evm = readable_token_evm(); + evm.block.inner.beneficiary = address!("2000000000000000000000000000000000000002"); + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller, + gas_price: 1, + gas_limit: 30_000, + ..Default::default() + }, + fee_token_id: Some(1), + ..Default::default() + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: token, + is_active: true, + price_ratio: U256::from(1), + scale: U256::from(1), + caller, + balance: U256::from(100_000), + balance_slot: None, + ..Default::default() + }); + evm.ctx_mut().error = Err(revm::context_interface::context::ContextError::Db( + TokenReadFailure, + )); + + let result = MorphEvmHandler::<_, NoOpInspector>::default() + .reimburse_caller_token_fee(&mut evm, &Gas::new(1_000)); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "{result:?}" + ); + } + + #[test] + fn refund_database_failure_aborts_final_execution_result() { + let result = finish_transaction_with_refund(alloy_primitives::bytes!("6000545f5260205ff3")); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "refund I/O must abort execution, not finalize success without a refund: {result:?}" + ); + } + + #[test] + fn refund_contract_revert_still_allows_transaction_to_finish() { + let result = finish_transaction_with_refund(alloy_primitives::bytes!("5f5ffd")); + assert!( + matches!(result, Ok(ExecutionResult::Success { .. })), + "{result:?}" + ); + } + + #[test] + fn token_transfer_database_failure_is_not_transaction_invalidity() { + let token = address!("3000000000000000000000000000000000000003"); + let from = address!("1000000000000000000000000000000000000001"); + let to = address!("2000000000000000000000000000000000000002"); + let mut inner = CacheDB::new(EmptyDB::default()); + insert_contract( + &mut inner, + token, + alloy_primitives::bytes!("6000545f5260205ff3"), + ); + let mut evm = MorphEvm::new( + MorphContext::new(UnreadableTokenDb { inner, token }, MorphHardfork::Emerald), + NoOpInspector, + ); + let result = transfer_erc20_with_evm( + &mut evm, + from, + to, + token, + U256::from(1), + Some(U256::from(10)), + ); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "{result:?}" + ); + } + fn mutating_return_code(write_value: u8, return_value: u8) -> Bytes { Bytes::from(vec![ 0x60, @@ -1104,6 +1415,77 @@ mod tests { } } + /// ` PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN` — a `balanceOf` that reports one piece + /// of its environment instead of a balance, so a call made under the wrong environment + /// shows up in the value that comes back. + fn code_returning(opcode: u8) -> Bytes { + Bytes::from(vec![opcode, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3]) + } + + fn insert_contract(db: &mut CacheDB, address: Address, code: Bytes) { + db.insert_account_info( + address, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + } + + /// Loads token 1's registry entry and resolves `caller`'s balance against `evm`. + fn probe_fee_token_balance(db: CacheDB, block: BlockEnv, caller: Address) -> U256 { + let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Emerald), NoOpInspector); + evm.block = MorphBlockEnv { inner: block }; + + let entry = TokenRegistryEntry::load(evm.ctx_mut().journal_mut().db_mut(), 1) + .unwrap() + .unwrap(); + load_token_fee_info(&mut evm, entry, caller) + .unwrap() + .balance + } + + #[test] + fn fee_token_balance_is_read_under_the_executing_block_environment() { + const TIMESTAMP: u64 = 1_767_765_600; + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("1000000000000000000000000000000000000001"); + + let mut db = CacheDB::new(EmptyDB::default()); + insert_test_fee_token(&mut db, 1, token, true); + insert_contract(&mut db, token, code_returning(0x42)); // TIMESTAMP + + let balance = probe_fee_token_balance( + db, + BlockEnv { + timestamp: U256::from(TIMESTAMP), + ..Default::default() + }, + caller, + ); + + // `BlockEnv::default()` reports timestamp 1, which is what a throwaway EVM would + // have answered with regardless of the block being executed. + assert_eq!(balance, U256::from(TIMESTAMP)); + } + + #[test] + fn fee_token_balance_query_names_the_queried_account_as_the_caller() { + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("1000000000000000000000000000000000000001"); + + let mut db = CacheDB::new(EmptyDB::default()); + insert_test_fee_token(&mut db, 1, token, true); + insert_contract(&mut db, token, code_returning(0x33)); // CALLER + + let balance = probe_fee_token_balance(db, BlockEnv::default(), caller); + + // go-ethereum queries as the account being asked about, not as the zero address and + // not as `SYSTEM_ADDRESS`. + assert_eq!(balance, U256::from_be_bytes(caller.into_word().0)); + } + fn insert_test_fee_token( db: &mut CacheDB, token_id: u16, @@ -1251,7 +1633,7 @@ mod tests { } #[test] - fn transfer_erc20_with_evm_reverts_state_on_validation_failure() { + fn transfer_erc20_with_evm_keeps_state_on_post_call_validation_failure() { let from = address!("1000000000000000000000000000000000000001"); let to = address!("2000000000000000000000000000000000000002"); let token = address!("3000000000000000000000000000000000000003"); @@ -1306,7 +1688,7 @@ mod tests { .get(&token) .and_then(|account| account.storage.get(&U256::ZERO)) .unwrap(); - assert_eq!(slot_state.present_value, original_balance); + assert_eq!(slot_state.present_value, U256::from(1)); } #[test] @@ -1352,14 +1734,7 @@ mod tests { err, EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { .. }) )); - let slot_state = evm - .ctx_ref() - .journal() - .state - .get(&token) - .and_then(|account| account.storage.get(&U256::ZERO)) - .unwrap(); - assert_eq!(slot_state.present_value, original_balance); + assert!(evm.ctx_ref().journal().state.is_empty()); } #[test] @@ -1386,10 +1761,8 @@ mod tests { inner: BlockEnv::default(), }; + // Nothing loads the token first: the helper has to put it in `journal.state` itself. let journal = evm.ctx_mut().journal_mut(); - let _ = journal.load_account_mut(token).unwrap(); - journal.touch(token); - let err = transfer_erc20_with_slot(journal, from, to, token, U256::from(1), balance_slot) .unwrap_err(); @@ -1432,17 +1805,12 @@ mod tests { inner: BlockEnv::default(), }; - let balance = evm_call_balance_of(&mut evm, token, account); - - assert_eq!(balance, U256::from(42)); - let slot_state = evm - .ctx_ref() - .journal() - .state - .get(&token) - .and_then(|acct| acct.storage.get(&U256::ZERO)) - .unwrap(); - assert_eq!(slot_state.present_value, original_balance); + assert!(evm_call_balance_of(&mut evm, token, account).is_err()); + assert_eq!( + revm::Database::storage(evm.ctx_mut().journal_mut().db_mut(), token, U256::ZERO) + .unwrap(), + original_balance + ); } /// `disable_fee_charge` must leave the caller balance untouched. @@ -1588,4 +1956,432 @@ mod tests { "simulation must not query the fee-token contract or its balance storage" ); } + const FEE_REFUND_TOKEN_ID: u16 = 1; + const FEE_REFUND_GAS_LIMIT: u64 = 100_000; + const FEE_REFUND_GAS_PRICE: u128 = 10; + /// `gas_limit * effective_gas_price` (the L1 data fee is zero with an empty + /// gas-price oracle), converted at scale 1 / price_ratio 1. + const FEE_REFUND_TOKEN_FEE: u64 = 1_000_000; + const FEE_REFUND_CALLER: Address = address!("1000000000000000000000000000000000000001"); + const FEE_REFUND_TOKEN: Address = address!("3000000000000000000000000000000000000003"); + const FEE_REFUND_BENEFICIARY: Address = address!("530000000000000000000000000000000000000a"); + /// Plain EOA target: the main frame does nothing beyond intrinsic gas. + const FEE_REFUND_TARGET: Address = address!("4200000000000000000000000000000000000042"); + + /// Minimal call-mode ERC20: `balance[addr]` lives at slot `uint256(addr)` (no + /// keccak), so `CALLER`, `calldataload(4)` and `balanceOf`'s argument all name the + /// same slot. Dispatch is on `CALLDATASIZE`: + /// - 68 bytes => `transfer(address,uint256)`: SSTORE(caller, SLOAD(caller) - amount), + /// SSTORE(to, SLOAD(to) + amount), return `true`. + /// - anything else => `balanceOf(address)`: return SLOAD(calldataload(4)). + fn fee_refund_slotless_erc20_code() -> Bytes { + Bytes::from(vec![ + 0x36, // CALLDATASIZE + 0x60, 0x44, // PUSH1 68 + 0x14, // EQ + 0x60, 0x13, // PUSH1 19 (transfer JUMPDEST) + 0x57, // JUMPI + // balanceOf(address) + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD + 0x54, // SLOAD + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xf3, // RETURN + // transfer(address,uint256) + 0x5b, // JUMPDEST (pc 19) + 0x60, 0x24, // PUSH1 36 + 0x35, // CALLDATALOAD -> amount + 0x80, // DUP1 -> amount amount + 0x33, // CALLER -> caller amount amount + 0x54, // SLOAD -> bal_from amount amount + 0x03, // SUB -> bal_from-amount amount + 0x33, // CALLER -> caller new_from amount + 0x55, // SSTORE -> amount + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD -> to amount + 0x80, // DUP1 -> to to amount + 0x54, // SLOAD -> bal_to to amount + 0x82, // DUP3 -> amount bal_to to amount + 0x01, // ADD -> new_to to amount + 0x90, // SWAP1 -> to new_to amount + 0x55, // SSTORE -> amount + 0x50, // POP + 0x60, 0x01, // PUSH1 1 + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xf3, // RETURN + ]) + } + + fn fee_refund_balance_slot(account: Address) -> U256 { + U256::from_be_bytes(account.into_word().0) + } + + fn fee_refund_evm(payer_token_balance: U256) -> MorphEvm, NoOpInspector> { + let code = fee_refund_slotless_erc20_code(); + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(FEE_REFUND_CALLER, AccountInfo::default()); + db.insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + db.insert_account_storage( + FEE_REFUND_TOKEN, + fee_refund_balance_slot(FEE_REFUND_CALLER), + payer_token_balance, + ) + .unwrap(); + // `balanceSlot` word left at zero => call mode (`balance_slot == None`). + insert_test_fee_token(&mut db, FEE_REFUND_TOKEN_ID, FEE_REFUND_TOKEN, true); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.block = MorphBlockEnv { + inner: BlockEnv { + basefee: 1, + beneficiary: FEE_REFUND_BENEFICIARY, + gas_limit: 30_000_000, + ..Default::default() + }, + }; + // Production Morph configuration disables the Ethereum calldata gas floor. + evm.cfg.disable_eip7623 = true; + evm + } + + fn fee_refund_present_value( + evm: &MorphEvm, NoOpInspector>, + account: Address, + ) -> U256 { + evm.ctx_ref() + .journal() + .state + .get(&FEE_REFUND_TOKEN) + .and_then(|acct| acct.storage.get(&fee_refund_balance_slot(account))) + .map(|slot| slot.present_value) + .expect("fee-token slot must be in the journal") + } + + /// Runs one call-mode token-fee MorphTx (plain call to an EOA) and returns + /// `(gas_used, final refund applied to the main frame, payer token balance after + /// reimbursement)`. + fn fee_refund_run_token_fee_tx(payer_token_balance: U256) -> (u64, u64, U256) { + let mut evm = fee_refund_evm(payer_token_balance); + let tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: FEE_REFUND_CALLER, + gas_limit: FEE_REFUND_GAS_LIMIT, + gas_price: FEE_REFUND_GAS_PRICE, + kind: TxKind::Call(FEE_REFUND_TARGET), + ..Default::default() + }, + fee_token_id: Some(FEE_REFUND_TOKEN_ID), + ..Default::default() + }; + + let result = evm + .transact_one(tx) + .expect("token-fee MorphTx must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + let gas_used = result.tx_gas_used(); + let gas_refunded = result.gas().final_refunded(); + + // Sanity: the fee was charged in call mode and equals exactly FEE_REFUND_TOKEN_FEE. + let info = evm + .cached_token_fee_info() + .expect("token fee info is cached"); + assert_eq!( + info.balance_slot, None, + "token must be registered in call mode" + ); + assert_eq!( + info.balance, payer_token_balance, + "balanceOf must see the seeded balance" + ); + assert_eq!( + info.eth_to_token_amount(U256::from( + FEE_REFUND_GAS_LIMIT as u128 * FEE_REFUND_GAS_PRICE + )), + U256::from(FEE_REFUND_TOKEN_FEE) + ); + + ( + gas_used, + gas_refunded, + fee_refund_present_value(&evm, FEE_REFUND_CALLER), + ) + } + + #[test] + fn deduction_sstore_refund_reaches_transaction_gas() { + let fee = U256::from(FEE_REFUND_TOKEN_FEE); + let (gas, refund, balance) = fee_refund_run_token_fee_tx(fee); + assert_eq!((gas, refund), (16_800, 4_200)); + assert_eq!(balance, fee - U256::from(168_000)); + let (gas, refund, balance) = fee_refund_run_token_fee_tx(fee + U256::from(1)); + assert_eq!((gas, refund), (21_000, 0)); + assert_eq!(balance, fee + U256::from(1) - U256::from(210_000)); + } + + #[test] + fn balance_queries_reject_state_writes() { + let mut evm = fee_refund_evm(U256::from(FEE_REFUND_TOKEN_FEE)); + let code = mutating_return_code(1, 1); + evm.ctx_mut().journal_mut().db_mut().insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(&code), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + assert!(evm_call_balance_of(&mut evm, FEE_REFUND_TOKEN, FEE_REFUND_CALLER).is_err()); + } + + #[test] + fn internal_calls_preserve_origin_and_effective_gas_price() { + for (opcode, expected) in [ + (0x32, U256::from_be_slice(FEE_REFUND_CALLER.as_slice())), + (0x3a, U256::from(3)), + ] { + let mut evm = fee_refund_evm(U256::from(FEE_REFUND_TOKEN_FEE)); + evm.tx.inner.caller = FEE_REFUND_CALLER; + evm.tx.inner.tx_type = MORPH_TX_TYPE_ID; + evm.tx.inner.gas_price = 10; + evm.tx.inner.gas_priority_fee = Some(2); + let code = Bytes::from(vec![opcode, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3]); + evm.ctx_mut().journal_mut().db_mut().insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(&code), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + assert_eq!( + evm_call_balance_of(&mut evm, FEE_REFUND_TOKEN, FEE_REFUND_BENEFICIARY).unwrap(), + expected + ); + assert_eq!(evm.tx.inner.caller, FEE_REFUND_CALLER); + } + } + + #[test] + fn zero_token_transfer_does_not_call_the_contract() { + let mut evm = fee_refund_evm(U256::ZERO); + let code = mutating_return_code(1, 0); + evm.ctx_mut().journal_mut().db_mut().insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(&code), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + transfer_erc20_with_evm( + &mut evm, + FEE_REFUND_CALLER, + FEE_REFUND_BENEFICIARY, + FEE_REFUND_TOKEN, + U256::ZERO, + Some(U256::ZERO), + ) + .unwrap(); + assert!(evm.ctx_ref().journal().state.is_empty()); + } + + /// Runs one transaction against `code` deployed at [`FEE_REFUND_TARGET`] and + /// returns what the main frame returned. `fee_token_id` selects the call-mode + /// token-fee path or the ordinary ETH-fee path. + fn fee_refund_run_probe(code: Bytes, fee_token_id: Option) -> Bytes { + let mut evm = fee_refund_evm(U256::from(FEE_REFUND_TOKEN_FEE)); + let db = evm.ctx_mut().journal_mut().db_mut(); + insert_contract(db, FEE_REFUND_TARGET, code); + db.insert_account_info( + FEE_REFUND_CALLER, + AccountInfo { + balance: U256::from(FEE_REFUND_GAS_LIMIT as u128 * FEE_REFUND_GAS_PRICE), + ..Default::default() + }, + ); + let tx = MorphTxEnv { + inner: TxEnv { + tx_type: if fee_token_id.is_some() { + MORPH_TX_TYPE_ID + } else { + 0 + }, + caller: FEE_REFUND_CALLER, + gas_limit: FEE_REFUND_GAS_LIMIT, + gas_price: FEE_REFUND_GAS_PRICE, + kind: TxKind::Call(FEE_REFUND_TARGET), + ..Default::default() + }, + fee_token_id, + ..Default::default() + }; + let result = evm.transact_one(tx).expect("probe must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + result.output().cloned().unwrap_or_default() + } + + /// Minimal proxy: copies its calldata into memory and `DELEGATECALL`s + /// `implementation`, the way mainnet's call-mode fee tokens reach theirs. + fn delegating_proxy_code(implementation: Address) -> Bytes { + let mut code = vec![ + 0x36, // CALLDATASIZE (size) + 0x5f, // PUSH0 (offset) + 0x5f, // PUSH0 (destOffset) + 0x37, // CALLDATACOPY + 0x5f, // PUSH0 (retSize) + 0x5f, // PUSH0 (retOffset) + 0x36, // CALLDATASIZE (argsSize) + 0x5f, // PUSH0 (argsOffset) + 0x73, // PUSH20 implementation + ]; + code.extend_from_slice(implementation.as_slice()); + code.extend_from_slice(&[ + 0x5a, // GAS + 0xf4, // DELEGATECALL + 0x3d, // RETURNDATASIZE (size) + 0x5f, // PUSH0 (offset) + 0x5f, // PUSH0 (destOffset) + 0x3e, // RETURNDATACOPY + 0x50, // POP (DELEGATECALL success flag) + 0x3d, // RETURNDATASIZE (size) + 0x5f, // PUSH0 (offset) + 0xf3, // RETURN + ]); + Bytes::from(code) + } + + /// Same ERC20, except `balanceOf` returns its result through the identity + /// precompile, reading the precompile's arguments out of memory. + fn fee_refund_precompile_erc20_code() -> Bytes { + let mut code = vec![ + 0x36, // CALLDATASIZE + 0x60, 0x44, // PUSH1 68 + 0x14, // EQ + 0x60, 0x1e, // PUSH1 30 (transfer JUMPDEST) + 0x57, // JUMPI + // balanceOf(address), answered by identity(mem[0..32]) + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD + 0x54, // SLOAD + 0x5f, // PUSH0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 (retSize) + 0x60, 0x20, // PUSH1 32 (retOffset) + 0x60, 0x20, // PUSH1 32 (argsSize) + 0x5f, // PUSH0 (argsOffset) + 0x60, 0x04, // PUSH1 4 (identity precompile) + 0x5a, // GAS + 0xfa, // STATICCALL + 0x50, // POP + 0x60, 0x20, // PUSH1 32 (size) + 0x60, 0x20, // PUSH1 32 (offset) + 0xf3, // RETURN + ]; + assert_eq!(code.len(), 30, "the transfer JUMPDEST moved"); + code.extend_from_slice(&fee_refund_slotless_erc20_code()[19..]); + Bytes::from(code) + } + + /// A precompile called from inside a fee frame resolves its arguments against + /// the context's shared buffer, so the fee frames have to keep writing into that + /// buffer rather than into one of their own. + #[test] + fn fee_token_frames_reach_a_precompile_through_memory() { + let fee = U256::from(FEE_REFUND_TOKEN_FEE); + let mut evm = fee_refund_evm(fee); + let db = evm.ctx_mut().journal_mut().db_mut(); + insert_contract(db, FEE_REFUND_TOKEN, fee_refund_precompile_erc20_code()); + assert_eq!( + fee_refund_run_fee_tx(&mut evm), + fee_refund_run_token_fee_tx(fee) + ); + } + + /// Mainnet's call-mode fee tokens are proxies, so a fee frame's nested + /// `DELEGATECALL` reads its calldata back out of the frame's memory. + #[test] + fn fee_token_frames_reach_a_delegating_proxy_implementation() { + const IMPLEMENTATION: Address = address!("3000000000000000000000000000000000000004"); + let fee = U256::from(FEE_REFUND_TOKEN_FEE); + let mut evm = fee_refund_evm(fee); + let db = evm.ctx_mut().journal_mut().db_mut(); + insert_contract(db, IMPLEMENTATION, fee_refund_slotless_erc20_code()); + insert_contract(db, FEE_REFUND_TOKEN, delegating_proxy_code(IMPLEMENTATION)); + + // Indistinguishable from the same transaction against an unproxied token: + // the implementation saw exactly the calldata the fee frame wrote. + assert_eq!( + fee_refund_run_fee_tx(&mut evm), + fee_refund_run_token_fee_tx(fee) + ); + } + + /// Runs the standard call-mode token-fee MorphTx on an already-built `evm` and + /// reports it the way [`fee_refund_run_token_fee_tx`] does. + fn fee_refund_run_fee_tx( + evm: &mut MorphEvm, NoOpInspector>, + ) -> (u64, u64, U256) { + let result = evm + .transact_one(MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: FEE_REFUND_CALLER, + gas_limit: FEE_REFUND_GAS_LIMIT, + gas_price: FEE_REFUND_GAS_PRICE, + kind: TxKind::Call(FEE_REFUND_TARGET), + ..Default::default() + }, + fee_token_id: Some(FEE_REFUND_TOKEN_ID), + ..Default::default() + }) + .expect("token-fee MorphTx must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + ( + result.tx_gas_used(), + result.gas().final_refunded(), + fee_refund_present_value(evm, FEE_REFUND_CALLER), + ) + } + + /// go-ethereum allocates a fresh `Memory` for every interpreter run + /// (`core/vm/interpreter.go`), so a transaction's frame always starts on zeroed + /// memory. The fee-token frames run on the transaction's shared memory buffer, + /// so they have to hand it back the length they found it at. + #[test] + fn fee_token_frames_do_not_leak_memory_into_the_main_frame() { + // `MSIZE` and `MLOAD(0)`, each returned as the frame's 32-byte output. + for probe in [ + vec![0x59, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3], + vec![0x5f, 0x51, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3], + ] { + let code = Bytes::from(probe); + assert_eq!( + U256::from_be_slice(&fee_refund_run_probe(code.clone(), None)), + U256::ZERO, + "ETH-fee control" + ); + assert_eq!( + U256::from_be_slice(&fee_refund_run_probe(code, Some(FEE_REFUND_TOKEN_ID))), + U256::ZERO, + "a token-fee main frame must start on zeroed memory too" + ); + } + } } diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 00aea49..a887c91 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, TokenFeeInfo, compute_mapping_slot, - compute_mapping_slot_for_address, encode_balance_of_calldata, query_erc20_balance, + L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, compute_mapping_slot, + compute_mapping_slot_for_address, encode_balance_of_calldata, }; pub use tx::{MorphTxEnv, MorphTxExt}; diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index c32827c..7b1b657 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -9,12 +9,18 @@ use alloy_evm::Database; use alloy_primitives::{Address, Bytes, U256, address, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::Database as RevmDatabase; -use revm::SystemCallEvm; use revm::{context_interface::result::EVMError, inspector::NoOpInspector}; -use crate::evm::MorphContext; use crate::{MorphEvm, MorphInvalidTransaction}; +/// The environment a fee-token `balanceOf` call is evaluated in. +/// +/// Produced by `ConfigureEvm::evm_env` for the block whose state is being read, so the pool +/// resolves the same balance the execution layer would. go-ethereum builds the equivalent +/// `vm.BlockContext` from the header before querying a call-mode token +/// (`pool.getBalanceFunc`, core/tx_pool.go:330). +pub type MorphEvmEnv = alloy_evm::EvmEnv; + /// L2 Token Registry contract address on Morph L2. /// Reference: pub const L2_TOKEN_REGISTRY_ADDRESS: Address = address!("5300000000000000000000000000000000000021"); @@ -60,6 +66,18 @@ pub(crate) struct TokenRegistryEntry { } impl TokenRegistryEntry { + /// The registered ERC20 contract. + pub(crate) const fn token_address(&self) -> Address { + self.token_address + } + + /// The caller's balance storage slot, when the registry declares one. + /// + /// `None` means call mode: the balance has to be read by calling `balanceOf`. + pub(crate) const fn balance_slot(&self) -> Option { + self.balance_slot + } + /// Load fee-token metadata without reading a caller's token balance. pub(crate) fn load( db: &mut DB, @@ -84,14 +102,14 @@ impl TokenRegistryEntry { self, db: &mut DB, caller: Address, - hardfork: MorphHardfork, - ) -> Result { + env: &MorphEvmEnv, + ) -> Result> { let balance = read_token_balance_with_fallback( db, self.token_address, caller, self.balance_slot, - hardfork, + env, )?; Ok(self.into_fee_info(caller, balance)) } @@ -109,7 +127,7 @@ impl TokenRegistryEntry { Ok(self.into_fee_info(caller, balance)) } - fn into_fee_info(self, caller: Address, balance: U256) -> TokenFeeInfo { + pub(crate) fn into_fee_info(self, caller: Address, balance: U256) -> TokenFeeInfo { TokenFeeInfo { token_address: self.token_address, is_active: self.is_active, @@ -124,6 +142,16 @@ impl TokenRegistryEntry { } impl TokenFeeInfo { + /// Maximum permitted token debit, bounded by the available balance. + /// A zero fee limit means that the whole balance is available. + pub fn effective_fee_limit(&self, fee_limit: U256) -> U256 { + if fee_limit.is_zero() { + self.balance + } else { + self.balance.min(fee_limit) + } + } + /// Load token fee information with EVM call fallback. /// /// Reads token parameters from L2 Token Registry storage. If the token's @@ -133,14 +161,14 @@ impl TokenFeeInfo { db: &mut DB, token_id: u16, caller: Address, - hardfork: MorphHardfork, - ) -> Result, DB::Error> { + env: &MorphEvmEnv, + ) -> Result, EVMError> { let entry = match TokenRegistryEntry::load(db, token_id)? { Some(e) => e, None => return Ok(None), }; - entry.load_for_caller(db, caller, hardfork).map(Some) + entry.load_for_caller(db, caller, env).map(Some) } /// Storage-only variant of [`Self::load_for_caller`]. @@ -292,26 +320,33 @@ fn read_token_balance_with_fallback( token: Address, account: Address, balance_slot: Option, - hardfork: MorphHardfork, -) -> Result { + env: &MorphEvmEnv, +) -> Result> { if let Some(slot) = balance_slot { - return read_balance_from_storage(db, token, account, slot); + return Ok(read_balance_from_storage(db, token, account, slot)?); } - // EVM fallback: construct temporary MorphEvm for balanceOf call + // Call mode: stand the EVM up in the caller's environment rather than a default one, + // and make the same `balanceOf` call the execution layer makes, so both reach the same + // answer for a token whose balance depends on block context or `msg.sender`. let db: &mut dyn Database = db; - let mut evm = MorphEvm::new(MorphContext::new(db, hardfork), NoOpInspector {}); - - match query_balance_via_system_call(&mut evm, token, account) { - Ok(balance) => Ok(balance), - Err(EVMError::Database(e)) => Err(e), - Err(_) => Ok(U256::ZERO), // Non-DB errors → zero (safe fallback) - } + let mut evm = MorphEvm::from_env(db, env.clone(), NoOpInspector {}); + // ORIGIN follows this client's own execution layer, which resolves the same + // `balanceOf` against the transaction's `caller`. go-ethereum's pool instead builds + // its query on an empty `vm.TxContext{}` (core/tx_pool.go:341), leaving ORIGIN at the + // zero address and disagreeing with go-ethereum's own execution layer. Admission + // exists to predict what the builder will be able to include, so it follows execution + // rather than the other client's pool. GASPRICE is the one input this query still + // cannot match: it stays at the `TxEnv` default of zero because the effective price + // depends on the next block's base fee, which admission does not know. go-ethereum's + // pool has the same gap. + evm.tx.inner.caller = account; + crate::handler::evm_call_balance_of(&mut evm, token, account) } /// Read ERC20 balance directly from storage slot. #[inline] -fn read_balance_from_storage( +pub(crate) fn read_balance_from_storage( db: &mut DB, token: Address, account: Address, @@ -322,44 +357,6 @@ fn read_balance_from_storage( read_mapping_value(db, token, balance_slot, &key) } -/// Execute EVM `balanceOf(address)` call. -fn query_balance_via_system_call( - evm: &mut MorphEvm, - token: Address, - account: Address, -) -> Result> -where - DB: Database, -{ - let calldata = encode_balance_of_calldata(account); - match evm.system_call_one(token, calldata) { - Ok(result) if result.is_success() => { - if let Some(output) = result.output() - && output.len() >= 32 - { - return Ok(U256::from_be_slice(&output[..32])); - } - Ok(U256::ZERO) - } - Ok(_) => Ok(U256::ZERO), - Err(_) => Ok(U256::ZERO), - } -} - -/// Query ERC20 balance via EVM call. -/// -/// Use this when you have a `MorphEvm` instance and need to call `balanceOf`. -pub fn query_erc20_balance( - evm: &mut MorphEvm, - token: Address, - account: Address, -) -> Result> -where - DB: Database, -{ - query_balance_via_system_call(evm, token, account) -} - /// Encode ERC20 `balanceOf(address)` calldata. /// /// Function selector: `0x70a08231` @@ -373,9 +370,166 @@ pub fn encode_balance_of_calldata(account: Address) -> Bytes { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + use alloy_primitives::{B256, address, bytes}; + use revm::bytecode::Bytecode; + use revm::database::{CacheDB, EmptyDB}; + use revm::state::AccountInfo; + + /// The storage read failure injected by [`UnreadableTokenDb`], distinguishable from any + /// error a real database would report. Shared with the handler tests. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) struct TokenReadFailure; + + impl core::fmt::Display for TokenReadFailure { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("injected token storage read failure") + } + } + + impl core::error::Error for TokenReadFailure {} + + impl revm::database_interface::DBErrorMarker for TokenReadFailure {} + + /// Fails every storage read of `token` with [`TokenReadFailure`]; everything else reads + /// normally, so a failure a test observes comes from that token's storage. Shared with the + /// handler tests. + #[derive(Debug)] + pub(crate) struct UnreadableTokenDb { + pub(crate) inner: CacheDB, + pub(crate) token: Address, + } + + impl RevmDatabase for UnreadableTokenDb { + type Error = TokenReadFailure; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + Ok(self.inner.basic(address).unwrap()) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + Ok(self.inner.code_by_hash(code_hash).unwrap()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + if address == self.token { + return Err(TokenReadFailure); + } + Ok(self.inner.storage(address, index).unwrap()) + } + + fn block_hash(&mut self, number: u64) -> Result { + Ok(self.inner.block_hash(number).unwrap()) + } + } + + /// Registry state for a call-mode token (no `balanceSlot`) whose `balanceOf` returns + /// storage slot 0, so reading it is a storage read of the token contract. + fn call_mode_token_state(token: Address, balance: u64) -> CacheDB { + call_mode_token_state_with_code(token, balance, bytes!("6000545f5260205ff3")) + } + + /// As [`call_mode_token_state`], with an explicit `balanceOf` implementation. + fn call_mode_token_state_with_code( + token: Address, + balance: u64, + code: Bytes, + ) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + let mut token_id_bytes = [0u8; 32]; + token_id_bytes[31] = 1; + let base = compute_mapping_slot(TOKEN_REGISTRY_SLOT, &token_id_bytes); + + let mut packed = [0u8; 32]; + packed[30] = 18; // decimals + packed[31] = 1; // isActive + for (slot, value) in [ + (base, U256::from_be_bytes(token.into_word().0)), + // Zero means "no known balance slot": the EVM `balanceOf` fallback is used. + (base + U256::from(1), U256::ZERO), + (base + U256::from(2), U256::from_be_bytes(packed)), + (base + U256::from(3), U256::from(1)), // scale + ( + compute_mapping_slot(PRICE_RATIO_SLOT, &token_id_bytes), + U256::from(1), // priceRatio + ), + ] { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + } + + db.insert_account_info( + token, + AccountInfo { + code_hash: alloy_primitives::keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, U256::ZERO, U256::from(balance)) + .unwrap(); + db + } + + #[test] + fn call_mode_balance_is_read_under_the_supplied_block_environment() { + const TIMESTAMP: u64 = 1_767_765_600; + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("0000000000000000000000000000000000000001"); + + // TIMESTAMP PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN — a `balanceOf` that reports the + // block time, so an answer produced under the wrong environment is visible. + let mut db = call_mode_token_state_with_code(token, 0, bytes!("425f5260205ff3")); + + let env = MorphEvmEnv::new( + revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + crate::MorphBlockEnv { + inner: revm::context::BlockEnv { + timestamp: U256::from(TIMESTAMP), + ..Default::default() + }, + }, + ); + + let info = TokenFeeInfo::load_for_caller(&mut db, 1, caller, &env) + .unwrap() + .unwrap(); + + // `BlockEnv::default()` reports timestamp 1, which is what the pool answered with + // regardless of the block it was validating against. + assert_eq!(info.balance, U256::from(TIMESTAMP)); + } + + #[test] + fn balance_of_fallback_reports_a_failed_state_read_instead_of_a_zero_balance() { + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("0000000000000000000000000000000000000001"); + let env = MorphEvmEnv::new( + revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + crate::MorphBlockEnv::default(), + ); + + // Readable state: the fallback reaches the token and reads the balance. + let mut readable = call_mode_token_state(token, 10_000_000); + let info = TokenFeeInfo::load_for_caller(&mut readable, 1, caller, &env) + .unwrap() + .unwrap(); + assert_eq!(info.balance, U256::from(10_000_000)); + + // Same state, but the token's storage cannot be read. Reporting a zero balance here + // would be indistinguishable from an account that genuinely cannot pay. + let mut unreadable = UnreadableTokenDb { + inner: call_mode_token_state(token, 10_000_000), + token, + }; + assert_eq!( + TokenFeeInfo::load_for_caller(&mut unreadable, 1, caller, &env).unwrap_err(), + EVMError::Database(TokenReadFailure) + ); + } + #[test] fn test_token_fee_info_default() { let info = TokenFeeInfo::default(); diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index e666c29..b6abf57 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -8,7 +8,7 @@ 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_revm::TokenFeeInfo; +use morph_revm::{MorphEvmEnv, TokenFeeInfo}; use crate::MorphTxError; @@ -107,7 +107,15 @@ pub fn validate_morph_tx( }); } - let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, input.hardfork) + // 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:?}"), @@ -136,14 +144,9 @@ pub fn validate_morph_tx( let total_token_fee = token_gas_fee.saturating_add(input.l1_data_fee); let required_token_amount = token_info.eth_to_token_amount(total_token_fee); - // Match REVM semantics: - // - fee_limit == 0 => use token balance as effective limit - // - fee_limit > balance => cap by token balance - let effective_limit = if fee_limit.is_zero() || fee_limit > token_info.balance { - token_info.balance - } else { - fee_limit - }; + // Share the execution layer's clamp rather than restating it: a zero `fee_limit` + // means the whole token balance, and a larger one is capped by it. + let effective_limit = token_info.effective_fee_limit(fee_limit); // Check token balance against effective limit. if effective_limit < required_token_amount { From c391f5c21bd5580c8697de1f64384305d672acb4 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 18 Sep 2026 17:30:41 +0800 Subject: [PATCH 08/13] 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 eda44e6..e8034f2 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 a1c5ccf..c1eeaa6 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 7b1b657..445035c 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 09/13] 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 0000000..4d57f66 --- /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 0000000..9f70bea --- /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 10/13] 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 c1eeaa6..be10eea 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 11/13] 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 4d57f66..4762795 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 9f70bea..b4b85e9 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 12/13] 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 3097665..0e96a41 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 13/13] 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 fc3bd3c..44c19b4 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 116300b..8789362 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 be10eea..27fc611 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), ))