diff --git a/Cargo.lock b/Cargo.lock index 94036ec6..cfc94643 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]] @@ -1640,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" @@ -2467,7 +2462,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]] @@ -3898,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", @@ -3914,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" @@ -5501,6 +5493,7 @@ dependencies = [ "eyre", "morph-chainspec", "morph-evm", + "morph-primitives", "morph-revm", "revm", "revm-statetest-types", @@ -5533,6 +5526,8 @@ dependencies = [ "reth-revm", "reth-storage-api", "reth-transaction-pool", + "tokio", + "tokio-stream", "tracing", ] @@ -10064,9 +10059,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", @@ -10150,9 +10145,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", diff --git a/bin/morph-statetest/Cargo.toml b/bin/morph-statetest/Cargo.toml index 44e0192b..a4e687de 100644 --- a/bin/morph-statetest/Cargo.toml +++ b/bin/morph-statetest/Cargo.toml @@ -16,6 +16,7 @@ eyre.workspace = true morph-chainspec.workspace = true morph-evm.workspace = true morph-revm.workspace = true +morph-primitives.workspace = true revm = { workspace = true, features = ["tracer"] } revm-statetest-types.workspace = true serde.workspace = true diff --git a/bin/morph-statetest/src/schema.rs b/bin/morph-statetest/src/schema.rs index 6f0904d6..ee9661a5 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,12 @@ 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() { + // 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 { tx = tx.with_fee_token_id(fee_token_id); @@ -358,7 +365,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)`). + "celadon" | "osaka" => Ok(MorphHardfork::Celadon), "cancun" => Ok(MorphHardfork::Morph203), _ => Err(SchemaError::UnknownFork(name.to_string())), } @@ -487,6 +497,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": { + "Celadon": [{ + "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["Celadon"][0]; + let tx = unit + .morph_tx_env(post, MorphHardfork::Celadon) + .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_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); + } + #[test] fn blob_tx_without_txbytes_errors_instead_of_silently_zeroing_l1_fee() { let suite: MorphTestSuite = serde_json::from_str( diff --git a/bin/morph-statetest/tests/celadon_alt_token_refund.rs b/bin/morph-statetest/tests/celadon_alt_token_refund.rs new file mode 100644 index 00000000..47627950 --- /dev/null +++ b/bin/morph-statetest/tests/celadon_alt_token_refund.rs @@ -0,0 +1,50 @@ +//! 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. Each fork runs three calldata +//! lengths against three consecutive gas limits: +//! +//! - 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. +//! +//! Tokens collected, per gas limit: +//! +//! | 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(), + 27, + "3 forks × 3 calldata lengths × 3 gas limits" + ); + for outcome in outcomes { + assert!( + outcome.pass, + "{} / {}: {}", + outcome.test, outcome.fork, outcome.error_msg + ); + } +} diff --git a/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json b/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json new file mode 100644 index 00000000..b4b85e92 --- /dev/null +++ b/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json @@ -0,0 +1,321 @@ +{ + "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", + "0x0101", + "0x010101" + ], + "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" + }, + { + "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": [ + { + "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" + }, + { + "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": [ + { + "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" + }, + { + "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" + } + ] + } + } +} diff --git a/crates/chainspec/src/genesis.rs b/crates/chainspec/src/genesis.rs index 98a2b93e..4ebc8f81 100644 --- a/crates/chainspec/src/genesis.rs +++ b/crates/chainspec/src/genesis.rs @@ -40,7 +40,7 @@ impl TryFrom<&OtherFields> for MorphGenesisInfo { /// the Morph hardforks were activated. /// /// Note: Bernoulli and Curie use block-based activation, while Morph203, Viridian, -/// Emerald, and Jade 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,6 +62,9 @@ pub struct MorphHardforkInfo { /// Jade hardfork timestamp. #[serde(skip_serializing_if = "Option::is_none")] pub jade_fork_time: Option, + /// Celadon hardfork timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub celadon_time: Option, } impl MorphHardforkInfo { @@ -136,7 +139,8 @@ mod tests { "morph203Time": 3000, "viridianTime": 4000, "emeraldTime": 5000, - "jadeForkTime": 6000 + "jadeForkTime": 6000, + "celadonTime": 7000 } "#; @@ -152,10 +156,32 @@ mod tests { viridian_time: Some(4000), emerald_time: Some(5000), jade_fork_time: Some(6000), + celadon_time: Some(7000), } ); } + #[test] + fn test_extract_morph_hardfork_info_without_celadon() { + // 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.celadon_time, None); + } + #[test] fn test_extract_morph_chain_config() { let config_str = r#" diff --git a/crates/chainspec/src/hardfork.rs b/crates/chainspec/src/hardfork.rs index e13a4b9f..5c3234e0 100644 --- a/crates/chainspec/src/hardfork.rs +++ b/crates/chainspec/src/hardfork.rs @@ -29,7 +29,7 @@ //! ## Current State //! //! Bernoulli and Curie use block-based activation, while Morph203, Viridian, -//! Emerald, and Jade 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, and Jade 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,6 +56,10 @@ hardfork!( /// Jade hardfork (timestamp-based). #[default] Jade, + /// Celadon hardfork (timestamp-based). + /// + /// Activates MorphTx version 2, which carries an EIP-7702 authorization list. + Celadon, } ); @@ -95,6 +99,12 @@ impl MorphHardfork { pub fn is_jade(self) -> bool { self >= Self::Jade } + + /// Returns `true` if this hardfork is Celadon or later. + #[inline] + pub fn is_celadon(self) -> bool { + self >= Self::Celadon + } } /// Trait for querying Morph-specific hardfork activations. @@ -141,12 +151,20 @@ pub trait MorphHardforks: EthereumHardforks { .active_at_timestamp(timestamp) } + /// 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) hardforks. + /// timestamp-based (Morph203, Viridian, Emerald, Jade, Celadon) hardforks. fn morph_hardfork_at(&self, block_number: u64, timestamp: u64) -> MorphHardfork { - if self.is_jade_active_at_timestamp(timestamp) { + 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) { 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/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 => Self::OSAKA, + MorphHardfork::Emerald | MorphHardfork::Jade | MorphHardfork::Celadon => 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::Celadon } 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::Celadon), SpecId::OSAKA); } #[test] @@ -227,6 +246,7 @@ mod tests { MorphHardfork::Viridian, MorphHardfork::Emerald, MorphHardfork::Jade, + MorphHardfork::Celadon, ]; 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::Celadon); + } + + #[test] + 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). @@ -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 -> Celadon (latest OSAKA hardfork) let spec = SpecId::from(MorphHardfork::Emerald); - assert_eq!(MorphHardfork::from(spec), MorphHardfork::Jade); + assert_eq!(MorphHardfork::from(spec), MorphHardfork::Celadon); + + // Jade -> OSAKA -> Celadon (latest OSAKA hardfork) + let spec = SpecId::from(MorphHardfork::Jade); + assert_eq!(MorphHardfork::from(spec), MorphHardfork::Celadon); } } diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 6477747c..0db76c4b 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, 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::Celadon, hardfork_info.celadon_time), ] .into_iter() .filter_map(|(fork, time)| time.map(|t| (fork, ForkCondition::Timestamp(t)))); @@ -644,6 +645,63 @@ mod tests { ); } + #[test] + fn test_celadon_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, + "celadonTime": 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::Celadon), + ForkCondition::Timestamp(7000) + ); + assert!(!chainspec.is_celadon_active_at_timestamp(6999)); + assert!(chainspec.is_celadon_active_at_timestamp(7000)); + + // 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::Celadon); + } + + #[test] + 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_celadon_active_at_timestamp(0)); + assert!(!chainspec.is_celadon_active_at_timestamp(u64::MAX)); + } + #[test] fn test_chainspec_from_genesis() { let genesis_json = json!({ diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index 063d7db5..4033b47d 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_celadon = self + .chain_spec + .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 @@ -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 Celadon 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_celadon: 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 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 (celadon 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_celadon_rejected() { + let txs = [create_morph_tx_v2()]; + let result = validate_morph_txs(&txs, true, true, false); + assert!( + result + .unwrap_err() + .to_string() + .contains("celadon fork not reached") + ); + } + + #[test] + 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 Celadon (and still Celadon-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("celadon 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("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_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()]); + + let err = consensus + .validate_block_pre_execution(&block) + .unwrap_err() + .to_string(); + assert!( + err.contains("celadon fork not reached"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_validate_block_pre_execution_uses_chainspec_celadon_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, + "celadonTime": 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("celadon 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 4d791b3d..6a9d415b 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -344,6 +344,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/components/pool.rs b/crates/node/src/components/pool.rs index d3ec9799..ae4d9199 100644 --- a/crates/node/src/components/pool.rs +++ b/crates/node/src/components/pool.rs @@ -41,14 +41,14 @@ where // Use in-memory blob store (Morph doesn't support EIP-4844 blobs) let blob_store = InMemoryBlobStore::default(); - // Build the Morph-specific EVM config for the validator + // Build the Morph-specific EVM config for the validator and the maintenance task let morph_evm_config = MorphEvmConfig::new(ctx.chain_spec(), morph_evm::MorphEvmFactory::default()); // Build the transaction validator with Morph-specific checks let validator = TransactionValidationTaskExecutor::eth_builder( ctx.provider().clone(), - morph_evm_config, + morph_evm_config.clone(), ) .with_max_tx_input_bytes(ctx.config().txpool.max_tx_input_bytes) .with_local_transactions_config(pool_config.local_transactions_config.clone()) @@ -83,12 +83,15 @@ where // Spawn standard pool maintenance tasks (from reth) spawn_maintenance_tasks(ctx, pool.clone(), &pool_config)?; - // Spawn Morph-specific maintenance task for MorphTx (0x7F) revalidation - // This handles ERC20 token balance changes that reth's standard maintenance - // cannot track (reth only tracks ETH balance via SenderInfo) - ctx.task_executor().spawn_critical_task( + // Revalidate L1 fees for all senders and ERC20 balances for MorphTx (0x7F). + // Reth's standard maintenance only tracks ETH costs without L1 data fees. + ctx.task_executor().spawn_critical_blocking_task( "txpool maintenance - morph pool", - morph_txpool::maintain_morph_pool(pool.clone(), ctx.provider().clone()), + morph_txpool::maintain_morph_pool( + pool.clone(), + ctx.provider().clone(), + morph_evm_config, + ), ); info!(target: "morph::node", "Transaction pool initialized"); diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs index 24d22560..44c19b4e 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. + /// Celadon is NOT active; all other forks are active at t=0. + /// + /// Use this to test pre-Celadon behavior: MorphTx v2 (authorization list) rejected. + PreCeladon, + + /// 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, and Jade 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, @@ -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::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")), } @@ -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` + /// - `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. @@ -125,16 +131,23 @@ impl HardforkSchedule { Self::AllActive => { // nothing to do — test genesis has all forks 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("celadonTime".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("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("celadonTime".to_string(), serde_json::json!(u64::MAX)); } Self::Hoodi | Self::Mainnet => { let reference_json = self.reference_genesis_json().unwrap(); @@ -536,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)), @@ -563,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, @@ -588,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; @@ -597,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()) @@ -606,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, @@ -626,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}; @@ -638,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, @@ -655,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), @@ -902,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()?; /// ``` @@ -910,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()?; /// ``` @@ -930,6 +955,7 @@ pub struct MorphTxBuilder { access_list: alloy_eips::eip2930::AccessList, reference: Option, memo: Option, + authorization_list: Vec, } impl MorphTxBuilder { @@ -937,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, @@ -954,9 +980,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) @@ -1011,6 +1069,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; @@ -1073,6 +1138,7 @@ impl MorphTxBuilder { fee_limit: self.fee_limit, reference: self.reference, memo: self.memo, + authorization_list: self.authorization_list, input: self.input, }; @@ -1085,3 +1151,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. +/// +/// `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, + auth_nonce: u64, +) -> eyre::Result { + use alloy_signer::SignerSync; + + let authorization = alloy_eips::eip7702::Authorization { + chain_id: U256::from(chain_id), + address: delegate, + nonce: auth_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 c23a20b8..455e798a 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, + "celadonTime": 0, "morph": { "feeVaultAddress": "0x530000000000000000000000000000000000000a" } diff --git a/crates/node/tests/it/hardfork.rs b/crates/node/tests/it/hardfork.rs index cef86694..b1e488a1 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 Celadon disabled (pre-Celadon schedule), blocks are still built correctly. +/// +/// Only MorphTx v2 is gated on Celadon; everything else behaves as under Jade. +#[tokio::test(flavor = "multi_thread")] +async fn pre_celadon_chain_advances() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreCeladon) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + let wallet = wallet_to_arc(wallet); + + let payloads = advance_chain(3, &mut node, wallet).await?; + assert_eq!(payloads.len(), 3); + + for (i, payload) in payloads.iter().enumerate() { + let block = payload.block(); + assert_eq!(block.header().inner.number, (i + 1) as u64); + } + + Ok(()) +} + /// Verify that an empty block can be produced under pre-Jade schedule. #[tokio::test(flavor = "multi_thread")] async fn pre_jade_empty_block() -> eyre::Result<()> { diff --git a/crates/node/tests/it/helpers.rs b/crates/node/tests/it/helpers.rs index ceb66cd4..1e80e204 100644 --- a/crates/node/tests/it/helpers.rs +++ b/crates/node/tests/it/helpers.rs @@ -525,3 +525,24 @@ pub(crate) async fn expect_payload_build_failure( } } } + +/// Init code that deploys `runtime` as-is (CODECOPY + RETURN; runtime must be < 256 bytes). +pub(crate) fn init_code_for(runtime: &[u8]) -> Vec { + assert!(runtime.len() < 256, "runtime must fit a PUSH1 length"); + let len = runtime.len() as u8; + // PUSH1 len PUSH1 12 PUSH1 0 CODECOPY PUSH1 len PUSH1 0 RETURN — 12 bytes, runtime at offset 12. + let mut code = vec![ + 0x60, len, 0x60, 0x0c, 0x60, 0x00, 0x39, 0x60, len, 0x60, 0x00, 0xf3, + ]; + code.extend_from_slice(runtime); + code +} + +/// Runtime that returns the 32-byte word `0x42`. +pub(crate) const RETURN_WORD_42_RUNTIME: &[u8] = + &[0x60, 0x42, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3]; + +/// Runtime that emits `LOG0` with the 32-byte word `0x42` as data, then stops. +pub(crate) const LOG_WORD_42_RUNTIME: &[u8] = &[ + 0x60, 0x42, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xa0, 0x00, +]; diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs index 7fe7d58d..3c240a63 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; // ============================================================================= @@ -422,6 +425,74 @@ async fn morph_tx_v0_token_balance_decreases() -> eyre::Result<()> { Ok(()) } +/// A sender holding fee tokens but no ETH must reach `pending` and be mined. +/// +/// The pool used to charge a token-fee MorphTx's `gas_limit * max_fee_per_gas` against +/// the sender's ETH balance, which left every such transaction in `queued`: never built +/// into a block, and never propagated, since reth only announces pending transactions. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_token_fee_from_zero_eth_sender_is_pending_and_mined() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use reth_provider::{AccountReader, StateProviderFactory}; + use reth_transaction_pool::TransactionPool; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let token = morph_node::test_utils::TEST_TOKEN_ADDRESS; + + // Hand a fresh account ten fee tokens and no ETH. + let payer = alloy_signer_local::PrivateKeySigner::random(); + let payer_address = payer.address(); + let token_grant = U256::from(10u128.pow(19)); + let grant = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v0_token_fee(TEST_TOKEN_ID) + .with_to(token) + .with_data(erc20_transfer_calldata(payer_address, token_grant)) + .build_signed()?; + node.rpc.inject_tx(grant).await?; + node.advance_block().await?; + + let balance_slot = token_balance_slot(payer_address); + let state = node.inner.provider.latest()?; + let eth_balance = state + .basic_account(&payer_address)? + .map(|account| account.balance); + assert_eq!(eth_balance.unwrap_or_default(), U256::ZERO); + assert_eq!(state.storage(token, balance_slot)?, Some(token_grant)); + + // A v0 and a v2 MorphTx, both paying gas in the fee token. + let v0 = MorphTxBuilder::new(wallet.chain_id, payer.clone(), 0) + .with_v0_token_fee(TEST_TOKEN_ID) + .build_signed()?; + let v2 = MorphTxBuilder::new(wallet.chain_id, payer, 1) + .with_v2_token_fee(TEST_TOKEN_ID) + .build_signed()?; + node.rpc.inject_tx(v0).await?; + node.rpc.inject_tx(v2).await?; + assert_eq!( + node.inner.pool.pending_and_queued_txn_count(), + (2, 0), + "token-fee transactions from a sender without ETH must be pending" + ); + + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 2); + + let state = node.inner.provider.latest()?; + let account = state + .basic_account(&payer_address)? + .expect("the payer exists once its transactions are mined"); + assert_eq!(account.nonce, 2); + assert_eq!(account.balance, U256::ZERO); + let tokens_left = state.storage(token, balance_slot)?.unwrap_or_default(); + assert!( + tokens_left < token_grant, + "gas must be charged in the fee token" + ); + + Ok(()) +} + /// Regression for the mainnet block 19720219 shape: /// /// - tx `0xc267450129e51457a280fa82c74364d312e47885c09d15c78f6a0895844913c9` @@ -781,3 +852,774 @@ async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { Ok(()) } + +// ============================================================================= +// MorphTx v2 (EIP-7702 authorization list) — Celadon 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 Celadon is not active. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_rejected_before_celadon() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreCeladon) + .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 err = node + .rpc + .inject_tx(raw_tx) + .await + .expect_err("MorphTx v2 should be rejected by pool before Celadon"); + assert!( + err.to_string().contains("not yet active"), + "unexpected error: {err}" + ); + + Ok(()) +} + +/// MorphTx v1 keeps working after Celadon (only v2 is new). +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v1_still_accepted_after_celadon() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v1_eth_fee() + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 1); + + Ok(()) +} + +/// MorphTx v2 without authorizations is accepted and executes exactly like a +/// v1 transaction: plain call cost, no delegation, receipt `version` 0x2, and +/// `authorizationList: []` in the RPC transaction object. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_without_authorizations_executes_like_v1() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use jsonrpsee::core::client::ClientT; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let sender = wallet.inner.address(); + + let raw_tx = MorphTxBuilder::new(wallet.chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_to(Address::with_last_byte(0x99)) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let block = payload.block(); + assert_eq!( + block.body().transactions.len(), + 1, + "MorphTx v2 without authorizations should be included in block" + ); + let tx = block.body().transactions.first().unwrap(); + assert!(tx.is_morph_tx()); + assert_eq!(tx.version(), Some(2)); + + let receipt = node + .inner + .provider + .receipt_by_hash(*tx.tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status(), "plain v2 call must succeed"); + assert_eq!( + receipt.cumulative_gas_used(), + 21_000, + "no authorizations: plain call cost, no 7702 gas or refund" + ); + let morph_primitives::MorphReceipt::Morph(morph_receipt) = &receipt else { + panic!("expected a Morph receipt"); + }; + assert_eq!(morph_receipt.version, Some(2)); + + // Nothing was delegated: the sender stays a plain EOA. + let state = node.inner.provider.latest()?; + assert!( + state + .account_code(&sender)? + .is_none_or(|code| code.is_empty()), + "sender must not carry any code" + ); + + // RPC transaction object: version 0x2 and an explicit empty list, the same + // shape go-ethereum returns. + let client = node + .rpc_client() + .ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; + let rpc_tx: serde_json::Value = client + .request("eth_getTransactionByHash", (*tx.tx_hash(),)) + .await?; + assert_eq!(rpc_tx["type"].as_str(), Some("0x7f")); + assert_eq!(rpc_tx["version"].as_str(), Some("0x2")); + assert_eq!( + rpc_tx["authorizationList"], + serde_json::json!([]), + "an empty v2 list is emitted as [] in the RPC transaction object" + ); + + Ok(()) +} + +/// Without authorizations a v2 keeps v1's ability to create contracts: the +/// no-CREATE rule only applies to a non-empty authorization list, and the same +/// CREATE with an authorization attached is rejected by the pool. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_without_authorizations_can_create_contract() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use super::helpers::{RETURN_WORD_42_RUNTIME, init_code_for}; + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 0) + .with_v2_eth_fee() + .with_create(init_code_for(RETURN_WORD_42_RUNTIME)) + .with_gas_limit(200_000) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + + let payload = node.advance_block().await?; + let tx = payload + .block() + .body() + .transactions + .first() + .expect("v2 CREATE without authorizations should be included"); + assert_eq!(tx.version(), Some(2)); + let receipt = node + .inner + .provider + .receipt_by_hash(*tx.tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status(), "v2 CREATE must succeed"); + + let contract = sender.create(0); + let state = node.inner.provider.latest()?; + let code = state + .account_code(&contract)? + .expect("contract code must be deployed"); + assert_eq!(code.original_bytes().as_ref(), RETURN_WORD_42_RUNTIME); + + // The same CREATE carrying an authorization is rejected up front. + let authority_signer = wallet_at_index(1, chain_id); + let authorization = sign_authorization( + &authority_signer, + chain_id, + Address::with_last_byte(0x42), + 0, + )?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 1) + .with_v2_eth_fee() + .with_create(init_code_for(RETURN_WORD_42_RUNTIME)) + .with_gas_limit(200_000) + .with_authorization_list(vec![authorization]) + .build_signed()?; + let err = node + .rpc + .inject_tx(raw_tx) + .await + .expect_err("v2 CREATE with authorizations must be rejected"); + assert!( + err.to_string().contains("cannot create a contract"), + "unexpected error: {err}" + ); + + Ok(()) +} + +/// A self-delegating V2 whose call targets the sender itself runs the delegate's +/// code in the same transaction: the list is applied (after the tx nonce bump) +/// before the call frame, so the sender already carries `0xef0100 || delegate` +/// when it is called, and the delegate's log is emitted from the sender's address. +#[tokio::test(flavor = "multi_thread")] +async fn morph_tx_v2_self_delegation_executes_delegate_code_in_same_tx() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + use super::helpers::{LOG_WORD_42_RUNTIME, init_code_for}; + use alloy_consensus::TxReceipt; + use alloy_consensus::transaction::TxHashRef; + use morph_node::test_utils::make_deploy_tx; + use reth_provider::{ReceiptProvider, StateProviderFactory}; + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + let chain_id = wallet.chain_id; + let sender = wallet.inner.address(); + + // Block 1: deploy the logging delegate. + let deploy_tx = make_deploy_tx( + chain_id, + wallet.inner.clone(), + 0, + init_code_for(LOG_WORD_42_RUNTIME), + )?; + node.rpc.inject_tx(deploy_tx).await?; + node.advance_block().await?; + let delegate = Address::create(&sender, 0); + + // Block 2: tx nonce 1, authorization nonce 2, call the sender itself. + let authorization = sign_authorization(&wallet.inner, chain_id, delegate, 2)?; + let raw_tx = MorphTxBuilder::new(chain_id, wallet.inner.clone(), 1) + .with_v2_eth_fee() + .with_authorization_list(vec![authorization]) + .with_to(sender) + .build_signed()?; + node.rpc.inject_tx(raw_tx).await?; + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions.len(), 1); + + let receipt = node + .inner + .provider + .receipt_by_hash(*payload.block().body().transactions[0].tx_hash())? + .expect("receipt must exist"); + assert!(receipt.status(), "delegated code must run successfully"); + let logs = receipt.logs(); + assert_eq!( + logs.len(), + 1, + "delegate code must have run inside the same tx" + ); + assert_eq!( + logs[0].address, sender, + "delegated code executes in the sender's own context" + ); + assert_eq!( + logs[0].data.data.as_ref(), + U256::from(0x42u64).to_be_bytes::<32>() + ); + + // deploy (0 → 1), V2 tx nonce (1 → 2), self-authorization (2 → 3) + let state = node.inner.provider.latest()?; + assert_eq!(assert_delegated(&*state, sender, delegate)?, 3); + + Ok(()) +} diff --git a/crates/node/tests/it/rpc.rs b/crates/node/tests/it/rpc.rs index a55232cb..6613e466 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,303 @@ 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. 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(); + + 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", + "memo": "0x6d", + }); + + 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["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")); + + // 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 + .request("eth_estimateGas", (empty_v2_request.clone(),)) + .await?; + assert_eq!( + empty_v2_estimate, v1_estimate, + "an empty authorization list 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 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_celadon() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new() + .with_schedule(HardforkSchedule::PreCeladon) + .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", + "memo": "0x6d", + "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-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")); + + // 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 Celadon"); + 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", + "memo": "0x6d", + "maxFeePerGas": "0x4a817c800", + "maxPriorityFeePerGas": "0x4a817c800", + "authorizationList": [serde_json::to_value(&authorization)?], + })) + }; + + // state nonce 1 → bumped to 2 before the list is applied → tuple nonce 2 applies. + let result: Value = client + .request("eth_call", (call_with_auth_nonce(2)?, "latest")) + .await?; + assert_eq!( + result.as_str(), + Some(format!("0x{:064x}", 0x42).as_str()), + "the call must execute the delegate's code via the sender" + ); + + // A tuple signed with the un-bumped nonce is skipped: the sender stays an EOA. + let result: Value = client + .request("eth_call", (call_with_auth_nonce(1)?, "latest")) + .await?; + assert_eq!(result.as_str(), Some("0x")); + + // Nothing leaked from the simulation into the canonical state. + let state = node.inner.provider.latest()?; + assert!( + state + .account_code(&sender)? + .is_none_or(|code| code.is_empty()) + ); + + Ok(()) +} + /// Produces a simple one-transaction block on the standard Jade profile and returns the /// node and identifiers needed by the replay-based debug / trace RPCs. async fn build_standard_jade_block_for_debug_trace() -> eyre::Result<(MorphTestNode, B256, B256)> { diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index e60943df..8789362e 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, 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])` +//! - 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,20 @@ 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("MorphTx with an authorization list cannot create a contract"); + } } _ => { return Err("unsupported MorphTx version"); @@ -266,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 @@ -276,6 +406,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 +457,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 +498,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 +544,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), } } @@ -407,13 +599,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). @@ -423,17 +609,69 @@ 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 { - // Need to decode RLP header first + 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 { + 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_v1_inner(buf) + 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). 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 +704,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 +721,12 @@ impl TxMorph { value, input, access_list, - version: MORPH_TX_VERSION_1, + version, fee_token_id, fee_limit, reference, memo, + authorization_list, }) } @@ -520,16 +766,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 +788,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 +822,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 +830,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 +841,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 +856,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 +869,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 +947,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 +971,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 +1008,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 +1041,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 +1086,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 +1106,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 +1135,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 +1193,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 +1214,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 +1246,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 +1279,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 +1534,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 +1605,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: Some(reference), memo: Some(memo), + authorization_list: Vec::new(), }; // Encode @@ -1368,6 +1651,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 +1697,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; // Encode @@ -1442,6 +1727,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let mut buf = Vec::new(); @@ -1474,6 +1760,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; // Encode the transaction @@ -1552,6 +1839,7 @@ mod tests { fee_limit: U256::ZERO, reference: None, memo: None, + authorization_list: Vec::new(), }; let size = tx.size(); @@ -1575,6 +1863,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let fields_len = tx.fields_len(); @@ -1602,6 +1891,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let mut buf = Vec::new(); @@ -1644,6 +1934,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let hash = tx.signature_hash(); @@ -1670,6 +1961,7 @@ mod tests { fee_limit: U256::ZERO, reference: Some(reference), memo: Some(memo.clone()), + authorization_list: Vec::new(), }; // Test trait methods @@ -1791,6 +2083,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 +2124,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), }; let mut buf = Vec::new(); @@ -1901,13 +2195,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 +2355,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 +2458,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 +2573,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 +2604,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 +2615,627 @@ 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(auth_nonce: u64) -> SignedAuthorization { + Authorization { + chain_id: U256::from(2818), + address: address!("2222222222222222222222222222222222222222"), + nonce: auth_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(), + "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()); + } + + /// 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); + 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:?}" + ); + 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); + 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}" + ); + } + + /// `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] + 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 8ed377e6..7f9190ca 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -48,6 +48,23 @@ pub enum MorphInvalidTransaction { /// Available token balance. available: U256, }, + + /// A MorphTx below version 2 carries an EIP-7702 authorization list. + /// + /// 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")] + 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/evm.rs b/crates/revm/src/evm.rs index eda44e68..e8034f29 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -95,6 +95,13 @@ pub struct MorphEvm { /// Ensures consistent price_ratio/scale between deduct and reimburse, /// matching go-ethereum's `st.feeRate`/`st.tokenScale` caching pattern. pub(crate) cached_token_fee_info: Option, + /// Token-unit numerator the fee deduction overcharged by rounding up. + /// + /// From Celadon on, the unused-gas refund adds this back before rounding + /// down, so the caller pays `ceil` of the net fee instead of + /// `ceil(prepaid) - ceil(refund)`, which under-collects. Mirrors + /// go-ethereum's `st.altFeeRoundingCredit`. + pub(crate) cached_alt_fee_rounding_credit: U256, /// Cached L1 data fee calculated during handler validation. /// Avoids re-encoding the full transaction RLP in the block executor's /// receipt-building path (the handler already has the encoded bytes via @@ -183,6 +190,7 @@ impl MorphEvm { Self { inner, cached_token_fee_info: None, + cached_alt_fee_rounding_credit: U256::ZERO, cached_l1_data_fee: U256::ZERO, pre_fee_refund: 0, pre_fee_logs: Vec::new(), diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index c2b04d81..27fc6118 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::{ @@ -87,13 +89,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] @@ -106,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(); @@ -256,6 +296,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(()) } @@ -349,6 +397,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( @@ -413,6 +504,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( @@ -433,8 +525,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 @@ -588,8 +697,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); @@ -714,6 +826,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(()) @@ -1597,6 +1710,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, + auth_nonce: u64, + ) -> Either { + Either::Right(RecoveredAuthorization::new_unchecked( + Authorization { + chain_id: U256::from(chain_id), + address: delegate, + nonce: auth_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::Celadon); + 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::Celadon); + 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::Celadon); + 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::Celadon); + 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::Celadon); + 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 (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( + 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::Celadon); + 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::Celadon), 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::Celadon); + 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::Celadon); + 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::Celadon), 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::Celadon); + 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::Celadon); + 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::Celadon); + 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::Celadon); + 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::Celadon); + 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::Celadon); + 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( @@ -2385,3 +3057,390 @@ 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. + /// 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]; + 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/lib.rs b/crates/revm/src/lib.rs index a887c914..5e420dda 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -73,7 +73,7 @@ pub use l1block::{ }; pub use precompiles::MorphPrecompiles; pub use token_fee::{ - L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, compute_mapping_slot, + L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, TokenRegistryEntry, compute_mapping_slot, compute_mapping_slot_for_address, encode_balance_of_calldata, }; pub use tx::{MorphTxEnv, MorphTxExt}; diff --git a/crates/revm/src/precompiles.rs b/crates/revm/src/precompiles.rs index cce783fa..d6a578a1 100644 --- a/crates/revm/src/precompiles.rs +++ b/crates/revm/src/precompiles.rs @@ -116,7 +116,7 @@ impl MorphPrecompiles { // Morph203 and Viridian share the same precompile set MorphHardfork::Morph203 | MorphHardfork::Viridian => morph203(), // Emerald and Jade share the same precompile set. - MorphHardfork::Emerald | MorphHardfork::Jade => emerald(), + MorphHardfork::Emerald | MorphHardfork::Jade | MorphHardfork::Celadon => emerald(), hardfork => unreachable!("unsupported Morph hardfork: {hardfork:?}"), }; diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index 7b1b6574..43b17022 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -56,7 +56,7 @@ pub struct TokenFeeInfo { /// Fee-token registry metadata without any caller-specific balance state. #[derive(Clone, Copy, Debug)] -pub(crate) struct TokenRegistryEntry { +pub struct TokenRegistryEntry { token_address: Address, is_active: bool, decimals: u8, @@ -79,10 +79,7 @@ impl TokenRegistryEntry { } /// Load fee-token metadata without reading a caller's token balance. - pub(crate) fn load( - db: &mut DB, - token_id: u16, - ) -> Result, DB::Error> { + pub fn load(db: &mut DB, token_id: u16) -> Result, DB::Error> { read_registry_entry(db, token_id) } @@ -98,7 +95,7 @@ impl TokenRegistryEntry { } /// Resolve the caller's balance to produce complete fee information. - pub(crate) fn load_for_caller( + pub fn load_for_caller( self, db: &mut DB, caller: Address, @@ -199,15 +196,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 +227,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 +627,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"); diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index bddd2768..bcf4afdb 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -9,7 +9,10 @@ use alloy_eips::eip2718::Encodable2718; use alloy_eips::eip2930::AccessList; use alloy_eips::eip7702::RecoveredAuthority; use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; -use morph_primitives::{L1_TX_TYPE_ID, MORPH_TX_TYPE_ID, MorphTxEnvelope, TxMorph}; +use morph_primitives::{ + L1_TX_TYPE_ID, MORPH_TX_TYPE_ID, MorphTxEnvelope, TxMorph, + transaction::morph_transaction::{MORPH_TX_VERSION_1, MORPH_TX_VERSION_2}, +}; use reth_evm::{FromRecoveredTx, FromTxWithEncoded, ToTxEnv, TransactionEnvMut}; use revm::context::{Transaction, TxEnv}; use revm::context_interface::transaction::{ @@ -115,7 +118,7 @@ impl MorphTxEnv { // 64 bytes of 0xff followed by yParity=1. let placeholder_signature = Signature::new(U256::MAX, U256::MAX, true); - match self.build_morph_tx_for_l1_fee(fallback_chain_id) { + match self.build_morph_tx_for_l1_fee(fallback_chain_id, placeholder_signature) { Some(morph_tx) => { let signed = morph_tx.into_signed(placeholder_signature); MorphTxEnvelope::Morph(signed).rlp() @@ -126,11 +129,43 @@ impl MorphTxEnv { } } - fn build_morph_tx_for_l1_fee(&self, fallback_chain_id: u64) -> Option { + fn build_morph_tx_for_l1_fee( + &self, + fallback_chain_id: u64, + placeholder_signature: Signature, + ) -> Option { if !self.is_morph_tx() { return None; } + let version = self.version.unwrap_or_else(|| { + // If version is missing (e.g. from an older transaction type), fall back to + // the smallest version that can represent the request so the L1 fee is not + // underpriced: V2 when an authorization list is present, otherwise V1. + let fallback = if self.inner.authorization_list.is_empty() { + MORPH_TX_VERSION_1 + } else { + MORPH_TX_VERSION_2 + }; + tracing::debug!( + target: "morph::evm", + fallback, + "MorphTx version not set, falling back for L1 fee calculation to safely overestimate" + ); + fallback + }); + + // V2 carries the authorization list in its RLP payload (an empty list + // still encodes as `0xc0`); leaving it out would under-size the L1 data + // fee by the whole list (geth's `asUnsignedMorphTx` sizes it too). + // Recovered authorizations no longer carry their signature and reuse + // the fee-sizing placeholder. + let authorization_list = if version == MORPH_TX_VERSION_2 { + self.signed_authorizations_for_l1_fee(placeholder_signature) + } else { + Vec::new() + }; + Some(TxMorph { chain_id: self.chain_id().unwrap_or(fallback_chain_id), nonce: self.inner.nonce, @@ -143,17 +178,10 @@ impl MorphTxEnv { input: self.input().clone(), fee_token_id: self.fee_token_id.unwrap_or_default(), fee_limit: self.fee_limit.unwrap_or_default(), - version: self.version.unwrap_or_else(|| { - // If version is missing (e.g. from an older transaction type), we fallback to V1 - // to safely overestimate the L1 fee, ensuring we don't underprice the transaction. - tracing::debug!( - target: "morph::evm", - "MorphTx version not set, falling back to V1 for L1 fee calculation to safely overestimate" - ); - morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 - }), + version, reference: self.reference, memo: self.memo.clone(), + authorization_list, }) } @@ -900,4 +928,161 @@ mod tests { }; assert!(!tx.encode_for_l1_fee(53077).is_empty()); } + + fn sample_signed_authorization() -> SignedAuthorization { + alloy_eips::eip7702::Authorization { + chain_id: U256::from(53077), + address: Address::with_last_byte(0x42), + nonce: 7, + } + .into_signed(Signature::new(U256::from(1), U256::from(2), true)) + } + + fn morph_v2_tx_env( + version: Option, + authorization: Either, + ) -> MorphTxEnv { + MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + chain_id: Some(53077), + gas_limit: 100_000, + gas_price: 20_000_000_000, + gas_priority_fee: Some(1_000_000_000), + nonce: 1, + kind: TxKind::Call(Address::with_last_byte(0xf1)), + authorization_list: vec![authorization], + ..Default::default() + }, + version, + fee_token_id: Some(1), + fee_limit: Some(U256::from(1000)), + ..Default::default() + } + } + + /// The simulated MorphTx V2 envelope must carry the authorization list so + /// `eth_estimateGas` / `eth_call` size the L1 data fee like geth's + /// `asUnsignedMorphTx`; dropping it would under-price the transaction. + #[test] + fn encode_for_l1_fee_morph_tx_v2_includes_authorization_list() { + let signed_authorization = sample_signed_authorization(); + let tx = morph_v2_tx_env( + Some(MORPH_TX_VERSION_2), + Either::Left(signed_authorization.clone()), + ); + + let encoded = tx.encode_for_l1_fee(53077); + assert_eq!(encoded[0], MORPH_TX_TYPE_ID); + assert_eq!(encoded[1], MORPH_TX_VERSION_2); + + let decoded = MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).unwrap(); + let MorphTxEnvelope::Morph(decoded) = decoded else { + panic!("expected MorphTx envelope"); + }; + assert_eq!(decoded.tx().version, MORPH_TX_VERSION_2); + assert_eq!(decoded.tx().fee_token_id, 1); + assert_eq!(decoded.tx().authorization_list, vec![signed_authorization]); + + // Sanity: the list actually contributes bytes versus the V1 shape. + let v1 = MorphTxEnv { + version: Some(MORPH_TX_VERSION_1), + inner: TxEnv { + authorization_list: vec![], + ..tx.inner.clone() + }, + ..tx + }; + assert!(encoded.len() > v1.encode_for_l1_fee(53077).len()); + } + + #[test] + fn encode_for_l1_fee_morph_tx_version_fallback_picks_v2_with_authorizations() { + let tx = morph_v2_tx_env(None, Either::Left(sample_signed_authorization())); + + let encoded = tx.encode_for_l1_fee(53077); + let decoded = MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).unwrap(); + let MorphTxEnvelope::Morph(decoded) = decoded else { + panic!("expected MorphTx envelope"); + }; + assert_eq!(decoded.tx().version, MORPH_TX_VERSION_2); + assert_eq!(decoded.tx().authorization_list.len(), 1); + + // Without a list the fallback stays V1. + let no_list = MorphTxEnv { + inner: TxEnv { + authorization_list: vec![], + ..tx.inner.clone() + }, + ..tx + }; + let encoded = no_list.encode_for_l1_fee(53077); + assert_eq!(encoded[1], MORPH_TX_VERSION_1); + } + + #[test] + fn encode_for_l1_fee_morph_tx_v2_recovered_authorization_uses_placeholder_signature() { + let recovered = RecoveredAuthorization::new_unchecked( + alloy_eips::eip7702::Authorization { + chain_id: U256::from(53077), + address: Address::with_last_byte(0x42), + nonce: 7, + }, + RecoveredAuthority::Valid(Address::with_last_byte(0x99)), + ); + let tx = morph_v2_tx_env(Some(MORPH_TX_VERSION_2), Either::Right(recovered)); + + let encoded = tx.encode_for_l1_fee(53077); + let decoded = MorphTxEnvelope::decode_2718(&mut encoded.as_ref()).unwrap(); + let MorphTxEnvelope::Morph(decoded) = decoded else { + panic!("expected MorphTx envelope"); + }; + let list = decoded.tx().authorization_list.clone(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].nonce, 7); + // go-ethereum's placeholder: r = s = 0xff..ff, yParity = 1 + assert_eq!(list[0].r(), U256::MAX); + assert_eq!(list[0].s(), U256::MAX); + } + + #[test] + fn from_recovered_tx_morph_v2_populates_authorization_list_and_version() { + use alloy_consensus::Signed; + + let signed_authorization = sample_signed_authorization(); + let morph_tx = TxMorph { + chain_id: 53077, + nonce: 1, + gas_limit: 100_000, + max_fee_per_gas: 20_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(Address::with_last_byte(0xf1)), + value: U256::ZERO, + access_list: AccessList::default(), + input: Bytes::new(), + version: MORPH_TX_VERSION_2, + fee_token_id: 0, + fee_limit: U256::ZERO, + reference: None, + memo: None, + authorization_list: vec![signed_authorization.clone()], + }; + let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked( + morph_tx, + Signature::new(U256::from(1), U256::from(2), false), + B256::ZERO, + )); + + let env = MorphTxEnv::from_recovered_tx(&envelope, Address::with_last_byte(0x01)); + assert_eq!(env.version, Some(MORPH_TX_VERSION_2)); + assert_eq!(env.inner.tx_type, MORPH_TX_TYPE_ID); + assert_eq!(env.inner.authorization_list.len(), 1); + match &env.inner.authorization_list[0] { + Either::Right(recovered) => { + let (authorization, _authority) = recovered.clone().into_parts(); + assert_eq!(&authorization, signed_authorization.inner()); + } + other => panic!("expected a recovered authorization, got {other:?}"), + } + } } diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index e0a19ad1..dfcf238d 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -3,7 +3,7 @@ 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; @@ -15,7 +15,8 @@ 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 @@ -23,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(), ); @@ -41,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(), }) @@ -57,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, @@ -68,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, ); @@ -98,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; @@ -108,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; @@ -117,6 +115,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 @@ -125,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()); @@ -143,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 @@ -151,11 +152,15 @@ 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( - explicit_version, - reference.as_ref(), - memo.as_ref(), - )); + // 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 with an authorization list cannot create a contract".to_string(), + )); + } + tx_env.version = Some(TxMorph::inferred_version(has_authorizations)); } // Required by `MorphEthApi::caller_gas_allowance` (eth/call.rs) to @@ -187,17 +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` +/// +/// 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); @@ -206,20 +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()); - - 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()); + 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 @@ -256,45 +264,17 @@ 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)) } -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_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 - | morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1), - ) => Ok(Some(version)), - _ => Err("unsupported MorphTx version"), - } -} - -fn morph_tx_version( - explicit_version: Option, - reference: Option<&B256>, - memo: Option<&Bytes>, -) -> 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 - } else { - morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_0 - } -} - fn is_nonzero_reference(reference: Option<&B256>) -> bool { reference.is_some_and(|reference| *reference != B256::ZERO) } @@ -311,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}; @@ -372,7 +355,6 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, }; @@ -404,7 +386,6 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, }; @@ -437,7 +418,6 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, }; @@ -473,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()), }; @@ -525,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, }; @@ -541,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"); - - 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:?}"), - } + .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()); + + 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 @@ -611,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, }; @@ -649,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, }; @@ -678,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")), }; @@ -718,7 +721,6 @@ mod tests { inner: create_basic_transaction_request(), fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, }; @@ -845,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()); } @@ -853,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()); @@ -891,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)); @@ -908,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); } @@ -930,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)); @@ -946,32 +912,378 @@ 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()); } + // ========================================================================= + // 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_rejects_unsupported_explicit_version() { - let req = create_morph_transaction_request(); - let result = try_build_morph_tx_from_request( + 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) + .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); + assert!(result.unwrap().is_none()); + } + + #[test] + 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, - Some(U64::from(2)), - 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"))); + } + + /// 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_without_authorizations_is_v1_never_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, + None, + Some(Bytes::from("memo")), + ) + .unwrap() + .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 MorphTx may create a contract. + #[test] + 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), 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_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) + .unwrap() + .unwrap(); + assert_eq!(tx.version, MORPH_TX_VERSION_1); + 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); + assert_eq!( + result.unwrap_err(), + "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)), + 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" ); + } + + /// An ETH-fee request with a memo and a list simulates as a V2 (vector 2). + #[test] + 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, + reference: None, + memo: Some(Bytes::from("memo")), + }; + + let tx_env = request + .try_into_tx_env(&create_evm_env(false)) + .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_eq!(tx_env.fee_token_id, None); + assert_eq!(tx_env.inner.authorization_list.len(), 1); + } + + /// `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()); + } + } - assert_eq!(result.unwrap_err(), "unsupported MorphTx version"); + /// Without authorizations a simulated MorphTx may create a contract. + #[test] + 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])); + let request = MorphTransactionRequest { + inner, + fee_token_id: Some(U64::from(1)), + fee_limit: Some(U256::from(1000)), + reference: None, + memo: None, + }; + + let tx_env = request + .try_into_tx_env(&create_evm_env(false)) + .expect("CREATE without authorizations is valid"); + assert_eq!(tx_env.version, Some(MORPH_TX_VERSION_1)); + 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)), + reference: None, + memo: None, + }; + + let err = request.try_into_tx_env(&create_evm_env(false)).unwrap_err(); + assert!( + err.to_string() + .contains("MorphTx 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, + 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] @@ -979,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")); } @@ -993,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"); } @@ -1004,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"); } @@ -1016,7 +1328,6 @@ mod tests { &req, U64::from(2), U256::from(500_000), - None, Some(B256::random()), Some(Bytes::from("memo")), ); @@ -1134,6 +1445,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/rpc/src/types/request.rs b/crates/rpc/src/types/request.rs index 4b541a0b..99324664 100644 --- a/crates/rpc/src/types/request.rs +++ b/crates/rpc/src/types/request.rs @@ -9,11 +9,12 @@ use serde::{Deserialize, Serialize}; /// Extends standard Ethereum transaction request with: /// - `feeTokenID`: Token ID for ERC20 gas payment /// - `feeLimit`: Maximum token amount willing to pay for fees -/// - `version`: Explicit MorphTx version selector /// - `reference`: 32-byte reference key for transaction indexing /// - `memo`: Arbitrary memo data (up to 64 bytes) /// -/// When omitted, MorphTx version is inferred from Morph-specific fields. +/// The MorphTx version is not part of the request: it is derived from the +/// content (V1 unless a non-empty `authorizationList` makes it V2). A legacy +/// `version` key is ignored like any other unknown key. #[derive( Debug, Clone, @@ -45,10 +46,6 @@ pub struct MorphTransactionRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub fee_limit: Option, - /// Explicit MorphTx version selector (only for MorphTx type 0x7F). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, - /// Reference key for transaction indexing (32 bytes). /// Used for looking up transactions by external systems. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -76,14 +73,13 @@ impl AsMut for MorphTransactionRequest { /// Creates a [`MorphTransactionRequest`] from a standard [`TransactionRequest`]. /// -/// Sets `fee_token_id`, `fee_limit`, `version`, `reference`, and `memo` to `None`. +/// Sets `fee_token_id`, `fee_limit`, `reference`, and `memo` to `None`. impl From for MorphTransactionRequest { fn from(value: TransactionRequest) -> Self { Self { inner: value, fee_token_id: None, fee_limit: None, - version: None, reference: None, memo: None, } @@ -120,7 +116,6 @@ mod tests { assert_eq!(morph_req.inner, inner); assert!(morph_req.fee_token_id.is_none()); assert!(morph_req.fee_limit.is_none()); - assert!(morph_req.version.is_none()); assert!(morph_req.reference.is_none()); assert!(morph_req.memo.is_none()); } @@ -131,7 +126,6 @@ mod tests { inner: basic_inner_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(500)), - version: Some(U64::from(1)), reference: Some(b256!( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" )), @@ -186,7 +180,6 @@ mod tests { inner: basic_inner_request(), fee_token_id: Some(U64::from(5)), fee_limit: Some(U256::from(999)), - version: Some(U64::from(1)), reference: Some(b256!( "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" )), @@ -203,13 +196,11 @@ mod tests { inner: basic_inner_request(), fee_token_id: Some(U64::from(1)), fee_limit: Some(U256::from(100)), - version: Some(U64::from(1)), ..Default::default() }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("\"feeTokenID\"")); assert!(json.contains("\"feeLimit\"")); - assert!(json.contains("\"version\"")); } #[test] @@ -226,13 +217,32 @@ mod tests { assert!(!json.contains("memo")); } + /// A `version` key from older clients is not an error; the version is + /// derived from the content instead. + #[test] + fn serde_ignores_legacy_version_key() { + let with_key: MorphTransactionRequest = serde_json::from_value(serde_json::json!({ + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "feeTokenID": "0x1", + "version": "0x2" + })) + .expect("a legacy version key must not break deserialization"); + let without_key: MorphTransactionRequest = serde_json::from_value(serde_json::json!({ + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "feeTokenID": "0x1" + })) + .unwrap(); + assert_eq!(with_key, without_key); + } + #[test] fn default_creates_empty_request() { let req = MorphTransactionRequest::default(); assert_eq!(req.inner, TransactionRequest::default()); assert!(req.fee_token_id.is_none()); assert!(req.fee_limit.is_none()); - assert!(req.version.is_none()); assert!(req.reference.is_none()); assert!(req.memo.is_none()); } diff --git a/crates/txpool/Cargo.toml b/crates/txpool/Cargo.toml index ce50efe4..8b2a31a1 100644 --- a/crates/txpool/Cargo.toml +++ b/crates/txpool/Cargo.toml @@ -41,5 +41,7 @@ parking_lot.workspace = true tracing.workspace = true [dev-dependencies] +tokio.workspace = true +tokio-stream = { workspace = true, features = ["sync"] } reth-evm-ethereum.workspace = true reth-provider = { workspace = true, features = ["test-utils"] } diff --git a/crates/txpool/src/error.rs b/crates/txpool/src/error.rs index d676b0bb..f3d09afa 100644 --- a/crates/txpool/src/error.rs +++ b/crates/txpool/src/error.rs @@ -54,12 +54,10 @@ pub enum MorphTxError { value: U256, }, - /// Failed to fetch token information from state. - TokenInfoFetchFailed { - /// The token ID. + /// The token's balanceOf call reverted or returned malformed data. + TokenBalanceQueryFailed { + /// Token whose balance could not be evaluated. token_id: u16, - /// Error message. - message: String, }, /// MorphTx format validation failed (version, memo length, gas fee ordering). @@ -105,8 +103,8 @@ impl fmt::Display for MorphTxError { "insufficient ETH balance for transaction value: balance {balance}, value {value}" ) } - Self::TokenInfoFetchFailed { token_id, message } => { - write!(f, "failed to fetch token info for ID {token_id}: {message}") + Self::TokenBalanceQueryFailed { token_id } => { + write!(f, "balanceOf failed for token ID {token_id}") } Self::InvalidFormat { reason } => { write!(f, "invalid MorphTx format: {reason}") @@ -129,11 +127,9 @@ impl PoolTransactionError for MorphTxError { // Token not found or not active - could be due to temporary state, not penalizable Self::TokenNotFound { .. } | Self::TokenNotActive { .. } => false, // Invalid price ratio - configuration issue, not penalizable - Self::InvalidPriceRatio { .. } => false, + Self::InvalidPriceRatio { .. } | Self::TokenBalanceQueryFailed { .. } => false, // Insufficient balance or fee limit - normal validation failure Self::InsufficientTokenBalance { .. } | Self::InsufficientEthForValue { .. } => false, - // Fetch failures - infrastructure issue, not penalizable - Self::TokenInfoFetchFailed { .. } => false, } } @@ -160,6 +156,22 @@ impl From for InvalidPoolTransactionError { } } +/// Separates a transaction verdict from an unavailable validation state. +/// Only `Invalid` can become an `InvalidPoolTransactionError`. +#[derive(Debug, PartialEq, Eq)] +pub enum MorphTxValidationError { + /// The state was read successfully and the transaction failed validation. + Invalid(MorphTxError), + /// Validation could not read the required state; retry without blaming the transaction. + State(E), +} + +impl From for MorphTxValidationError { + fn from(error: MorphTxError) -> Self { + Self::Invalid(error) + } +} + #[cfg(test)] mod tests { use super::*; @@ -258,13 +270,6 @@ mod tests { assert!(!MorphTxError::TokenNotFound { token_id: 1 }.is_bad_transaction()); assert!(!MorphTxError::TokenNotActive { token_id: 1 }.is_bad_transaction()); assert!(!MorphTxError::InvalidPriceRatio { token_id: 1 }.is_bad_transaction()); - assert!( - !MorphTxError::TokenInfoFetchFailed { - token_id: 1, - message: "error".into() - } - .is_bad_transaction() - ); } #[test] @@ -285,10 +290,6 @@ mod tests { balance: U256::from(5u64), value: U256::from(10u64), }, - MorphTxError::TokenInfoFetchFailed { - token_id: 5, - message: "db error".into(), - }, MorphTxError::InvalidFormat { reason: "bad version".into(), }, diff --git a/crates/txpool/src/lib.rs b/crates/txpool/src/lib.rs index 52ca426b..a0f29dfb 100644 --- a/crates/txpool/src/lib.rs +++ b/crates/txpool/src/lib.rs @@ -33,13 +33,13 @@ #![cfg_attr(docsrs, feature(doc_cfg), allow(unexpected_cfgs))] mod error; -pub use error::MorphTxError; +pub use error::{MorphTxError, MorphTxValidationError}; mod transaction; pub use transaction::MorphPooledTransaction; mod validator; -pub use validator::{MorphL1BlockInfo, MorphTransactionValidator}; +pub use validator::{MorphL1BlockInfo, MorphTransactionValidator, MorphValidationState}; mod maintain; pub use maintain::maintain_morph_pool; diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 7f54f551..6ae73871 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -1,19 +1,38 @@ //! Transaction pool maintenance tasks for Morph L2. //! //! This module provides maintenance tasks for the Morph transaction pool, -//! specifically for revalidating MorphTx (0x7F) transactions when the chain -//! state changes. +//! revalidating L1 fee affordability and MorphTx (0x7F) token balances when the +//! chain state changes. //! //! # Background //! //! MorphTx allows users to pay gas fees using ERC20 tokens. Since reth's txpool //! only tracks ETH balance changes (via `SenderInfo`), it cannot automatically -//! demote MorphTx transactions when the token balance decreases. +//! demote MorphTx transactions when the token balance decreases. Its transaction +//! cost also excludes L1 data fees, so every sender needs L1 fee revalidation. //! //! This maintenance task solves this by: //! 1. Listening to canonical state changes (new blocks) -//! 2. Re-validating all MorphTx transactions in the pool -//! 3. Removing transactions that no longer have sufficient token balance +//! 2. Re-validating every pooled transaction, including those parked behind a nonce gap, +//! against the sender's current account balances +//! 3. Removing the first transaction with an L1 fee or token shortfall that reth cannot +//! handle, letting the pool park its descendants +//! +//! # Relationship with reth's own maintenance task +//! +//! This task runs *alongside* [`reth_transaction_pool::maintain::maintain_transaction_pool`], +//! and both subscribe to the canonical state stream independently — there is no ordering +//! guarantee between them. Everything reth's task already understands (ETH balance, nonces, +//! base fee, mined transactions) stays its responsibility. This task also checks the +//! sender's **ERC20 token** balance and the **L1 data fees** missing from reth's cost. +//! An ordinary transaction that cannot cover L1 fees is +//! removed so its descendants are parked instead of remaining unchecked in pending. +//! +//! Because the ordering is not guaranteed, this task must tolerate seeing a pool snapshot +//! that still contains transactions the new block already executed. It does so by reading +//! the sender's on-chain nonce and skipping everything below it, mirroring +//! `AllTransactions::update`, which discards those transactions before any affordability +//! check, and go-ethereum's `demoteUnexecutables`, which calls `list.Forward(nonce)` first. //! //! # Reference //! @@ -21,311 +40,413 @@ //! and `demoteUnexecutables` (tx_pool.go), but implemented as a separate //! maintenance task since we cannot modify reth's internal pool logic. -use crate::MorphPooledTransaction; +use crate::{MorphPooledTransaction, MorphTxValidationError, MorphValidationState}; use alloy_consensus::Transaction; use alloy_consensus::Typed2718; -use alloy_primitives::{Address, TxHash, U256}; -use futures::StreamExt; -use morph_chainspec::hardfork::MorphHardforks; -use morph_revm::L1BlockInfo; +use alloy_primitives::{Address, B256, TxHash}; +use futures::{FutureExt, StreamExt}; +use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; +use morph_revm::{L1BlockInfo, MorphBlockEnv, MorphEvmEnv}; use reth_chainspec::ChainSpecProvider; -use reth_primitives_traits::AlloyBlockHeader; +use reth_evm::{ConfigureEvm, EvmFactory, EvmFactoryFor}; +use reth_primitives_traits::{AlloyBlockHeader, HeaderTy, NodePrimitives, SealedHeader}; use reth_provider::CanonStateSubscriptions; -use reth_revm::Database; use reth_revm::database::StateProviderDatabase; -use reth_storage_api::StateProviderFactory; +use reth_storage_api::{BlockReaderIdExt, StateProviderFactory}; use reth_transaction_pool::{PoolTransaction, TransactionPool}; -use std::collections::HashMap; - -/// Sender-level rolling affordability budget used during maintenance revalidation. -#[derive(Debug, Clone, Default)] -struct SenderBudget { - /// Remaining ETH budget for this sender. - eth_balance: U256, - /// Remaining token budget per `fee_token_id`. - token_balances: HashMap, -} +use std::collections::{HashMap, HashSet}; -/// Applies cumulative sender-budget check for the ETH-fee path and consumes budget on success. -/// -/// Returns `true` if the transaction can be afforded under the current rolling ETH budget. -fn consume_eth_budget( - budget: &mut SenderBudget, - tx_value: U256, - gas_limit: u64, - max_fee_per_gas: u128, - l1_data_fee: U256, -) -> bool { - let gas_fee = U256::from(gas_limit).saturating_mul(U256::from(max_fee_per_gas)); - let total_eth_cost = gas_fee.saturating_add(l1_data_fee).saturating_add(tx_value); - if total_eth_cost > budget.eth_balance { - return false; - } - budget.eth_balance = budget.eth_balance.saturating_sub(total_eth_cost); - true -} +type BoxError = Box; -/// Applies cumulative sender-budget check for the token-fee path and consumes budget on success. +/// Chain access for the maintenance loop. /// -/// Returns `true` if the transaction can be afforded under the current rolling token/ETH budget. -fn consume_token_budget( - budget: &mut SenderBudget, - tx_value: U256, - token_id: Option, - fee_limit: Option, - required_token_amount: U256, - state_token_balance: Option, -) -> bool { - let (token_id, fee_limit) = match (token_id, fee_limit) { - (Some(token_id), Some(fee_limit)) => (token_id, fee_limit), - _ => return false, - }; +/// The node reads everything from its provider ([`ProviderFeeState`]). Tests supply a +/// separate state per block, which the mock provider cannot do. +trait FeeStateSource { + /// Opens the state, L1 fee parameters and EVM environment of `header`. + fn state_for(&self, header: &HeaderTy) -> Result; - let token_budget = budget - .token_balances - .entry(token_id) - .or_insert(state_token_balance.unwrap_or(U256::ZERO)); - - // Match REVM semantics with rolling sender budget: - // - fee_limit == 0 => use remaining token budget - // - fee_limit > remaining => cap by remaining token budget - let effective_limit = if fee_limit.is_zero() || fee_limit > *token_budget { - *token_budget - } else { - fee_limit - }; + /// Returns the current canonical head. + fn canonical_head(&self) -> Result>>, BoxError>; +} - if effective_limit < required_token_amount || tx_value > budget.eth_balance { - return false; +/// [`FeeStateSource`] backed by the node's provider. +struct ProviderFeeState { + client: Client, + evm_config: Evm, +} + +impl FeeStateSource for ProviderFeeState +where + Client: StateProviderFactory + BlockReaderIdExt
>, + Evm: ConfigureEvm, + EvmFactoryFor: EvmFactory, +{ + fn state_for( + &self, + header: &HeaderTy, + ) -> Result { + crate::validator::validation_state_for_header(&self.client, &self.evm_config, header) } - *token_budget = (*token_budget).saturating_sub(required_token_amount); - budget.eth_balance = budget.eth_balance.saturating_sub(tx_value); - true + fn canonical_head(&self) -> Result>>, BoxError> { + Ok(self.client.latest_header()?) + } } fn exceeds_block_gas_limit(tx_gas_limit: u64, block_gas_limit: u64) -> bool { tx_gas_limit > block_gas_limit } -/// Maintains the Morph transaction pool by revalidating MorphTx transactions. +/// Determines which transactions to remove while revalidating all senders' fee affordability. +/// +/// Returns the hashes to remove from the pool. Only the first offending transaction of a +/// sender is returned: the pool parks the rest of that sender's transactions on its own when +/// the returned hash is removed (see [`maintain_morph_pool`]). +fn collect_removable_transactions( + db: &mut DB, + l1_block_info: &L1BlockInfo, + evm_env: &MorphEvmEnv, + block_gas_limit: u64, + pool_txs: Vec<&MorphPooledTransaction>, +) -> Vec { + let hardfork = *evm_env.cfg_env.spec(); + // These caches live for exactly this provider/environment snapshot. Cache successful + // reads only; a later block must re-read changed registry parameters and balances. + let mut token_entries = HashMap::new(); + let mut token_balances = HashMap::new(); + // Group by sender and process in nonce order so removing a transaction parks its descendants. + let mut txs_by_sender: HashMap> = HashMap::new(); + for tx in pool_txs { + txs_by_sender.entry(tx.sender()).or_default().push(tx); + } + + let mut to_remove: Vec = Vec::new(); + + for (sender, mut sender_txs) in txs_by_sender { + sender_txs.sort_by_key(|tx| tx.transaction().nonce()); + + // Read one account per sender. Affordability is per transaction, matching + // admission and geth; cumulative ETH parking remains owned by reth. + let account = match db.basic(sender) { + Ok(account) => account.unwrap_or_default(), + Err(err) => { + tracing::warn!( + target: "morph::txpool::maintain", + ?sender, + ?err, + "Failed to get account info; skipping sender" + ); + continue; + } + }; + + for tx in sender_txs { + // Access the consensus tx by reference (via Deref chain) instead of + // cloning. Use the pool tx's cached EIP-2718 encoding for L1 fee. + let consensus_tx = tx.transaction(); + + // Already executed by the new block. reth's own maintenance task removes these + // when it applies the same canonical update; both tasks subscribe to the + // canonical stream independently, so this one can still observe them here. + // Judging them is pointless, and removing one here would park the sender's next + // transaction until reth's own update re-promotes it. + if consensus_tx.nonce() < account.nonce { + continue; + } + + // A nonce gap does not stop the walk. Every check below is per transaction + // against the sender's current balances, which is what admission applies and + // what go-ethereum's `promoteExecutables` applies to its whole queue (`FilterF`) + // before `Ready` promotes anything. Nothing here accumulates the costs of the + // missing predecessors, so a transaction parked behind a gap can be judged on its + // own. Left unchecked, reth would promote it unvalidated the moment the gap is + // filled: `AllTransactions::insert_tx` only recomputes the ETH cost bits, which + // for a token-fee MorphTx cover its value alone. + + // Reth only sets its block-gas-limit flag at insertion, so a later + // limit reduction needs the same explicit removal for both tx types. + if exceeds_block_gas_limit(consensus_tx.gas_limit(), block_gas_limit) { + to_remove.push(*tx.hash()); + break; + } + + // Reth knows this ETH cost (only value for token-fee MorphTx) and can + // park the transaction until a balance update makes it affordable again. + if *tx.cost() > account.balance { + break; + } + + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(tx.encoded_2718(), hardfork); + if consensus_tx.ty() != morph_primitives::MORPH_TX_TYPE_ID { + if tx.cost().saturating_add(l1_data_fee) > account.balance { + to_remove.push(*tx.hash()); + break; + } + continue; + } + + // Validate each transaction against the same chain balance used at admission. + let input = crate::MorphTxValidationInput { + consensus_tx, + sender, + eth_balance: account.balance, + l1_data_fee, + hardfork, + evm_env, + }; + + match crate::morph_tx_validation::validate_morph_tx_with_token_info( + &input, + |token_id| { + use morph_revm::TokenRegistryEntry; + let entry = match token_entries.entry(token_id) { + std::collections::hash_map::Entry::Occupied(entry) => *entry.get(), + std::collections::hash_map::Entry::Vacant(entry) => { + *entry.insert(TokenRegistryEntry::load(db, token_id)?) + } + }; + let Some(entry) = entry else { + return Ok(None); + }; + let info = match token_balances.entry((sender, token_id)) { + std::collections::hash_map::Entry::Occupied(info) => *info.get(), + std::collections::hash_map::Entry::Vacant(info) => { + *info.insert(entry.load_for_caller(db, sender, evm_env)?) + } + }; + Ok(Some(info)) + }, + ) { + Ok(_) => {} + Err(MorphTxValidationError::State(err)) => { + tracing::warn!( + target: "morph::txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + ?err, + "Could not read token state; leaving sender's MorphTx in the pool" + ); + break; + } + Err(MorphTxValidationError::Invalid(err)) => { + tracing::debug!( + target: "morph::txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + ?err, + "Removing MorphTx: validation failed" + ); + to_remove.push(*tx.hash()); + break; + } + }; + } + } + + to_remove +} + +/// Keeps the removal candidates that are still removable at the canonical head. +/// +/// A round judges the pool at the block its notification named. By the time it ends, the +/// canonical head can be newer: blocks keep arriving while it runs, and the skip-ahead can +/// stop short of the newest notification. A verdict about an older block should not remove a +/// transaction the head can pay for, so the candidates' senders are judged again at the head +/// and only candidates that fail there too are kept. Anything only the head would remove is +/// left for the round of that head. Nothing is removed if the head cannot be read. +/// +/// This narrows the window to the re-check itself; it does not close it. The head can move +/// again during the few milliseconds the re-check takes, and a candidate that block makes +/// payable is still removed. That is the exposure any pool has between reading a state and +/// acting on it, and the sender resolves it by resubmitting. +fn recheck_at_canonical_head( + pool: &Pool, + source: &Source, + judged_at: B256, + candidates: Vec, +) -> Vec +where + Pool: TransactionPool, + N: NodePrimitives, + Source: FeeStateSource, +{ + if candidates.is_empty() { + return candidates; + } + let head = match source.canonical_head() { + Ok(Some(head)) => head, + Ok(None) => { + tracing::warn!(target: "morph::txpool::maintain", "No canonical head; skipping removals"); + return Vec::new(); + } + Err(err) => { + tracing::warn!(target: "morph::txpool::maintain", %err, "Failed to read the canonical head; skipping removals"); + return Vec::new(); + } + }; + if head.hash() == judged_at { + return candidates; + } + let state = match source.state_for(head.header()) { + Ok(state) => state, + Err(err) => { + tracing::warn!(target: "morph::txpool::maintain", %err, "Failed to open the canonical head; skipping removals"); + return Vec::new(); + } + }; + + let senders: HashSet
= candidates + .iter() + .filter_map(|hash| pool.get(hash)) + .map(|tx| tx.sender()) + .collect(); + let sender_txs: Vec<_> = senders + .into_iter() + .flat_map(|sender| pool.get_transactions_by_sender(sender)) + .collect(); + let mut db = StateProviderDatabase::new(state.provider); + let still_removable: HashSet = collect_removable_transactions( + &mut db, + &state.head.l1_block_info, + &state.head.evm_env, + head.gas_limit(), + sender_txs.iter().map(|tx| &tx.transaction).collect(), + ) + .into_iter() + .collect(); + + candidates + .into_iter() + .filter(|hash| still_removable.contains(hash)) + .collect() +} + +/// Maintains the Morph transaction pool by revalidating L1 fees and token balances. /// /// This task runs continuously and: /// - Listens for new canonical blocks /// - Re-validates MorphTx (0x7F) transactions in the pool /// - Removes transactions that no longer have sufficient token balance +/// - Re-validates L1 fee affordability for every sender, including ordinary-only senders +/// - Removes ordinary transactions whose L1 fees make them individually unaffordable, +/// parking their descendants +/// - Judges each transaction on its own, including those parked behind a nonce gap +/// - Re-checks every removal at the canonical head right before applying it /// -pub async fn maintain_morph_pool(pool: Pool, client: Client) +pub async fn maintain_morph_pool(pool: Pool, client: Client, evm_config: Evm) where Pool: TransactionPool + Clone, Client: ChainSpecProvider + StateProviderFactory + + BlockReaderIdExt
> + CanonStateSubscriptions + Clone + 'static, + Evm: ConfigureEvm::Primitives>, + EvmFactoryFor: EvmFactory, { - let mut chain_events = client.canonical_state_stream(); + let chain_events = client.canonical_state_stream(); - tracing::info!(target: "morph::txpool::maintain", "Starting MorphTx maintenance task"); + tracing::info!(target: "morph::txpool::maintain", "Starting Morph fee maintenance task"); + maintain_morph_pool_with(pool, ProviderFeeState { client, evm_config }, chain_events).await; +} + +/// [`maintain_morph_pool`] with an explicit state source and canonical event stream. +async fn maintain_morph_pool_with( + pool: Pool, + source: Source, + mut chain_events: Events, +) where + Pool: TransactionPool + Clone, + N: NodePrimitives, + Source: FeeStateSource, + Events: futures::Stream> + Unpin, +{ loop { - // Wait for the next canonical state change - let Some(event) = chain_events.next().await else { + let Some(mut event) = chain_events.next().await else { tracing::debug!(target: "morph::txpool::maintain", "Chain event stream ended"); break; }; + // Skip ahead to the newest queued notification. A round reads each sender's account + // and any fee-token state, so the chain can advance while we are working; the verdicts + // this task produces are a pure function of the latest state, which makes every + // intermediate block wasted work against a stale view of the pool. This is only an + // optimisation: under tokio's cooperative budget `now_or_never` reports an empty + // stream after 128 items, so removals are re-checked at the canonical head below. + while let Some(next) = chain_events.next().now_or_never().flatten() { + event = next; + } + let new_tip = event.tip(); let block_number = new_tip.number(); - let block_timestamp = new_tip.timestamp(); let block_gas_limit = new_tip.gas_limit(); tracing::trace!( target: "morph::txpool::maintain", block_number, - "Processing new block for MorphTx validation" + "Processing new block for pool fee validation" ); - // Get the hardfork at this block - let hardfork = client - .chain_spec() - .morph_hardfork_at(block_number, block_timestamp); - - // Collect all MorphTx transactions from the pool + // Every transaction type is revalidated (ordinary ones for their L1 fee), and a + // sender's full nonce order decides which offender is removed first. let all_txs = pool.all_transactions(); - let morph_txs: Vec<_> = all_txs + let pool_txs: Vec<&MorphPooledTransaction> = all_txs .pending .iter() .chain(all_txs.queued.iter()) - .filter(|tx| tx.transaction.ty() == morph_primitives::MORPH_TX_TYPE_ID) + .map(|tx| &tx.transaction) .collect(); - if morph_txs.is_empty() { + if pool_txs.is_empty() { continue; } - // Get state provider for the new tip - let state_provider = match client.state_by_block_hash(new_tip.hash()) { - Ok(provider) => provider, + let state = match source.state_for(new_tip.header()) { + Ok(state) => state, Err(err) => { - tracing::warn!( - target: "morph::txpool::maintain", - %err, - "Failed to get state provider for MorphTx revalidation" - ); - continue; - } - }; - - let mut db = StateProviderDatabase::new(state_provider); - - // Fetch L1 block info for fee calculation - let l1_block_info = match L1BlockInfo::try_fetch(&mut db, hardfork) { - Ok(info) => info, - Err(err) => { - tracing::warn!( - target: "morph::txpool::maintain", - ?err, - "Failed to fetch L1 block info for MorphTx revalidation" - ); + tracing::warn!(target: "morph::txpool::maintain", %err, "Failed to prepare fee revalidation state"); continue; } }; + let mut db = StateProviderDatabase::new(state.provider); + let l1_block_info = state.head.l1_block_info; + let evm_env = &state.head.evm_env; tracing::trace!( target: "morph::txpool::maintain", - count = morph_txs.len(), - "Revalidating MorphTx transactions" + count = pool_txs.len(), + "Revalidating pooled transaction fees" ); - // Group by sender and process in nonce order so affordability is validated cumulatively. - let mut txs_by_sender: HashMap> = HashMap::new(); - for pooled_tx in morph_txs { - let sender = pooled_tx.transaction.sender(); - txs_by_sender.entry(sender).or_default().push(pooled_tx); - } - - // Revalidate each sender's MorphTx set and collect invalid ones - let mut to_remove: Vec = Vec::new(); - - for (sender, mut sender_txs) in txs_by_sender { - sender_txs.sort_by_key(|pooled_tx| pooled_tx.transaction.nonce()); - - // Initialize sender ETH budget once. - let eth_balance = match db.basic(sender) { - Ok(Some(account)) => account.balance, - Ok(None) => U256::ZERO, - Err(err) => { - tracing::warn!( - target: "morph::txpool::maintain", - ?sender, - ?err, - "Failed to get account balance" - ); - continue; - } - }; - - let mut budget = SenderBudget { - eth_balance, - token_balances: HashMap::new(), - }; - - for pooled_tx in sender_txs { - let tx = &pooled_tx.transaction; - // Access the consensus tx by reference (via Deref chain) instead of - // cloning. Use the pool tx's cached EIP-2718 encoding for L1 fee. - let consensus_tx = tx.transaction(); + let to_remove = collect_removable_transactions( + &mut db, + &l1_block_info, + evm_env, + block_gas_limit, + pool_txs, + ); - if exceeds_block_gas_limit(consensus_tx.gas_limit(), block_gas_limit) { - tracing::debug!( - target: "morph::txpool::maintain", - tx_hash = ?tx.hash(), - ?sender, - tx_gas_limit = consensus_tx.gas_limit(), - block_gas_limit, - "Removing MorphTx: gas limit exceeds current block gas limit" - ); - to_remove.push(*tx.hash()); - break; - } + let to_remove = recheck_at_canonical_head(&pool, &source, new_tip.hash(), to_remove); - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(tx.encoded_2718(), hardfork); - - // Use shared validation logic first with current sender ETH budget. - let input = crate::MorphTxValidationInput { - consensus_tx, - sender, - eth_balance: budget.eth_balance, - l1_data_fee, - hardfork, - }; - - let validation = match crate::validate_morph_tx(&mut db, &input) { - Ok(v) => v, - Err(err) => { - tracing::debug!( - target: "morph::txpool::maintain", - tx_hash = ?tx.hash(), - ?sender, - ?err, - "Removing MorphTx: validation failed" - ); - to_remove.push(*tx.hash()); - break; - } - }; - - let fields = consensus_tx.morph_fields(); - let state_token_balance = validation.token_info.as_ref().map(|info| info.balance); - let token_id = fields.as_ref().map(|f| f.fee_token_id); - let fee_limit = fields.as_ref().map(|f| f.fee_limit); - - let affordable = if validation.uses_token_fee { - consume_token_budget( - &mut budget, - consensus_tx.value(), - token_id, - fee_limit, - validation.required_token_amount, - state_token_balance, - ) - } else { - consume_eth_budget( - &mut budget, - consensus_tx.value(), - consensus_tx.gas_limit(), - consensus_tx.max_fee_per_gas(), - l1_data_fee, - ) - }; - if !affordable { - tracing::debug!( - target: "morph::txpool::maintain", - tx_hash = ?tx.hash(), - ?sender, - uses_token_fee = validation.uses_token_fee, - token_id = ?token_id, - required_token_amount = ?validation.required_token_amount, - "Removing MorphTx: insufficient cumulative sender budget" - ); - to_remove.push(*tx.hash()); - break; - } - } - } - - // Remove invalid transactions and all higher-nonce descendants from the same sender. - // Using remove_transactions_and_descendants ensures that nonce-dependent txs are cleaned - // up immediately rather than becoming orphans that are re-validated every block. + // Remove the offending transactions. `remove_transactions` *parks* each removed + // transaction's descendants instead of deleting them (upstream + // `remove_transaction_by_hash` calls `park_descendant_transactions`), so a + // higher-nonce transaction that is still affordable on its own — a plain ETH-fee + // transaction, say — survives in the queued sub-pool and becomes executable again + // once a replacement for the removed nonce arrives. go-ethereum's + // `demoteUnexecutables` does the same thing by re-enqueueing its `invalids` + // (core/tx_pool.go:1888) rather than dropping them. if !to_remove.is_empty() { let count = to_remove.len(); - pool.remove_transactions_and_descendants(to_remove); + pool.remove_transactions(to_remove); tracing::info!( target: "morph::txpool::maintain", count, block_number, - "Removed invalid MorphTx transactions" + "Removed transactions during pool fee revalidation" ); } } @@ -334,146 +455,1138 @@ where #[cfg(test)] mod tests { use super::*; + use alloy_primitives::U256; #[test] - fn consume_eth_fee_path_updates_budget_and_rejects_when_exhausted() { - let mut budget = SenderBudget { - eth_balance: U256::from(100u64), - token_balances: HashMap::new(), + fn gas_limit_check_rejects_transactions_above_block_limit() { + assert!(exceeds_block_gas_limit(30_000_001, 30_000_000)); + assert!(!exceeds_block_gas_limit(30_000_000, 30_000_000)); + } + + // --------------------------------------------------------------------------------- + // Revalidation round tests + // + // These drive `collect_removable_transactions` against a hand-built state so the + // removal verdict can be asserted without a pool, and one pool-level test covers the + // descendant handling that only the pool can show. + // --------------------------------------------------------------------------------- + + use crate::morph_tx_validation::tests::{ + BALANCE_OF_RUNTIME, FAILING_BALANCE_OF, TokenRead, UnreadableTokenDb, + call_mode_registry_storage, call_mode_token_state, token_balance_key, + }; + use alloy_consensus::{Signed, transaction::Recovered}; + use alloy_eips::eip2718::Encodable2718; + use alloy_primitives::{Signature, TxKind, address}; + use morph_primitives::{MorphTxEnvelope, TxMorph}; + use morph_revm::L2_TOKEN_REGISTRY_ADDRESS; + use reth_revm::revm; + use reth_revm::revm::database::{CacheDB, EmptyDB}; + use reth_revm::revm::state::AccountInfo; + + const SIGNER: Address = address!("0000000000000000000000000000000000000001"); + const FEE_TOKEN: Address = address!("5300000000000000000000000000000000000042"); + const TOKEN_ID: u16 = 1; + /// `gas_limit * max_fee_per_gas` of [`token_fee_tx`]; at a 1:1 price ratio this is also + /// the per-transaction token requirement at admission and revalidation. + const TX_TOKEN_BUDGET: u64 = 21_000 * 100; + + /// State with [`TOKEN_ID`] registered as an active call-mode fee token at a 1:1 price + /// ratio, whose `balanceOf` reports `token_balance` for [`SIGNER`]. + fn test_state(account_nonce: u64, eth_balance: u64, token_balance: u64) -> CacheDB { + test_state_with_token_code( + account_nonce, + eth_balance, + token_balance, + BALANCE_OF_RUNTIME, + ) + } + + /// [`test_state`] with the fee token's `balanceOf` replaced by `code`. + fn test_state_with_token_code( + account_nonce: u64, + eth_balance: u64, + token_balance: u64, + code: &'static [u8], + ) -> CacheDB { + let mut db = + call_mode_token_state(TOKEN_ID, FEE_TOKEN, code, SIGNER, U256::from(token_balance)); + db.insert_account_info( + SIGNER, + AccountInfo { + nonce: account_nonce, + balance: U256::from(eth_balance), + ..Default::default() + }, + ); + db + } + + /// A token-fee MorphTx requiring [`TX_TOKEN_BUDGET`] tokens and no ETH. + fn token_fee_tx(tx_nonce: u64) -> MorphPooledTransaction { + token_fee_tx_with(tx_nonce, U256::ZERO, 21_000) + } + + fn token_fee_tx_with_value(tx_nonce: u64, value: U256) -> MorphPooledTransaction { + token_fee_tx_with(tx_nonce, value, 21_000) + } + + /// [`token_fee_tx`] with `gas_limit`, so its token requirement scales with it. + fn token_fee_tx_with_gas_limit(tx_nonce: u64, gas_limit: u64) -> MorphPooledTransaction { + token_fee_tx_with(tx_nonce, U256::ZERO, gas_limit) + } + + fn token_fee_tx_with(tx_nonce: u64, value: U256, gas_limit: u64) -> MorphPooledTransaction { + let tx = TxMorph { + chain_id: 2818, + nonce: tx_nonce, + gas_limit, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value, + fee_token_id: TOKEN_ID, + fee_limit: U256::ZERO, + ..Default::default() }; + let recovered = Recovered::new_unchecked( + MorphTxEnvelope::Morph(Signed::new_unhashed(tx, Signature::test_signature())), + SIGNER, + ); + let encoded_len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, encoded_len) + } - let first = consume_eth_budget(&mut budget, U256::from(20u64), 10, 3, U256::from(5u64)); - assert!(first); - // total cost = value(20) + gas(30) + l1(5) = 55 - assert_eq!(budget.eth_balance, U256::from(45u64)); + /// The environment the revalidation round is evaluated in. + fn test_evm_env() -> MorphEvmEnv { + MorphEvmEnv::new( + reth_revm::revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + MorphBlockEnv::default(), + ) + } - let second = consume_eth_budget(&mut budget, U256::from(20u64), 10, 3, U256::from(5u64)); - assert!(!second); - assert_eq!(budget.eth_balance, U256::from(45u64)); + fn removable(db: &mut CacheDB, txs: Vec<&MorphPooledTransaction>) -> Vec { + collect_removable_transactions( + db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + txs, + ) } #[test] - fn consume_token_fee_path_tracks_cumulative_token_budget() { - let mut budget = SenderBudget { - eth_balance: U256::from(10u64), - token_balances: HashMap::new(), - }; + fn transactions_already_executed_by_the_block_are_skipped_not_taken_for_a_gap() { + // The new block executed nonce 0, but this task can still see it: reth's own maintenance + // removes it on the same notification, in no guaranteed order. Reading it as a nonce gap + // would end the walk before nonce 1, which the post-state can no longer pay for. + let mut db = test_state(1, 0, TX_TOKEN_BUDGET - 1); + let (tx0, tx1) = (token_fee_tx(0), token_fee_tx(1)); + + assert_eq!(removable(&mut db, vec![&tx0, &tx1]), vec![*tx1.hash()]); + } + + #[test] + fn individually_affordable_token_transactions_are_retained() { + // Each transaction passes admission against the same account balance. + // Maintenance must not evict one merely because their maximum costs add up. + let mut db = test_state(0, 0, TX_TOKEN_BUDGET + TX_TOKEN_BUDGET / 2); + let (tx0, tx1) = (token_fee_tx(0), token_fee_tx(1)); + + assert!(removable(&mut db, vec![&tx0, &tx1]).is_empty()); + } - let first = consume_token_budget( - &mut budget, - U256::ZERO, - Some(7), - Some(U256::ZERO), // fee_limit=0 => use full remaining budget - U256::from(60u64), - Some(U256::from(100u64)), + #[test] + fn an_ordinary_transaction_between_morph_txs_does_not_hide_the_successor() { + let mut db = test_state(0, 10_000_000, TX_TOKEN_BUDGET - 1); + let (first, middle, last) = (legacy_tx(0), legacy_tx(1), token_fee_tx(2)); + assert_eq!( + removable(&mut db, vec![&first, &middle, &last]), + vec![*last.hash()] ); - assert!(first); + } + + #[test] + fn ordinary_predecessors_do_not_cause_a_morph_tx_value_to_be_evicted() { + let mut db = test_state(0, TX_TOKEN_BUDGET + 6, 10 * TX_TOKEN_BUDGET); + let (first, last) = (legacy_tx(0), token_fee_tx_with_value(1, U256::from(7))); + assert!(removable(&mut db, vec![&first, &last]).is_empty()); + } + + #[test] + fn morph_eth_shortfalls_remain_owned_by_standard_maintenance() { + let mut db = test_state(0, 6, 10 * TX_TOKEN_BUDGET); + let tx = token_fee_tx_with_value(0, U256::from(7)); + assert!(removable(&mut db, vec![&tx]).is_empty()); + } + + #[test] + fn block_gas_limit_decreases_remove_both_transaction_types() { + for tx in [legacy_tx(0), token_fee_tx(0)] { + let mut db = test_state(0, 10_000_000, 10 * TX_TOKEN_BUDGET); + assert_eq!( + collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 20_000, + vec![&tx] + ), + vec![*tx.hash()] + ); + } + } + + #[test] + fn unaffordable_ordinary_predecessors_remain_owned_by_standard_maintenance() { + let mut db = test_state(0, 0, 10 * TX_TOKEN_BUDGET); + let (first, last) = (legacy_tx(0), token_fee_tx(1)); + assert!(removable(&mut db, vec![&first, &last]).is_empty()); + } + + #[test] + fn a_payable_transaction_behind_a_nonce_gap_is_kept() { + // The gap only decides where the transaction sits; affordability is judged on its own. + let mut db = test_state(0, 0, TX_TOKEN_BUDGET); + let (tx0, gapped) = (token_fee_tx(0), token_fee_tx(10)); + + assert!(removable(&mut db, vec![&tx0, &gapped]).is_empty()); + } + + #[test] + fn an_unpayable_transaction_behind_a_nonce_gap_is_removed() { + // Nonce 0 needs one budget, nonce 10 needs two, and the sender holds one. Stopping at + // the gap would leave nonce 10 in place for reth to promote unvalidated once nonces + // 1 to 9 arrive. + let mut db = test_state(0, 0, TX_TOKEN_BUDGET); + let (tx0, gapped) = (token_fee_tx(0), token_fee_tx_with_gas_limit(10, 2 * 21_000)); + assert_eq!( - budget.token_balances.get(&7).copied(), - Some(U256::from(40u64)) + removable(&mut db, vec![&tx0, &gapped]), + vec![*gapped.hash()] ); + } + + #[test] + fn a_sender_holding_only_future_nonces_is_still_revalidated() { + // go-ethereum's `promoteExecutables` filters the whole queue per transaction before + // promoting anything; a sender that no longer holds tokens loses its parked MorphTx. + let mut db = test_state(0, 0, 0); + let gapped = token_fee_tx(5); + + assert_eq!(removable(&mut db, vec![&gapped]), vec![*gapped.hash()]); + } + + #[derive(Debug)] + struct CountingDb { + inner: CacheDB, + reads: HashMap, + } - let second = consume_token_budget( - &mut budget, - U256::ZERO, - Some(7), - Some(U256::ZERO), - U256::from(50u64), - None, + impl revm::Database for CountingDb { + type Error = core::convert::Infallible; + fn basic(&mut self, address: Address) -> Result, Self::Error> { + self.inner.basic(address) + } + fn code_by_hash( + &mut self, + hash: alloy_primitives::B256, + ) -> Result { + self.inner.code_by_hash(hash) + } + fn storage(&mut self, address: Address, slot: U256) -> Result { + *self.reads.entry(address).or_default() += 1; + self.inner.storage(address, slot) + } + fn block_hash(&mut self, number: u64) -> Result { + self.inner.block_hash(number) + } + } + + #[test] + fn token_cache_is_shared_within_a_round_and_refreshed_next_round() { + let mut db = CountingDb { + inner: test_state(0, 0, TX_TOKEN_BUDGET), + reads: HashMap::new(), + }; + let txs: Vec<_> = (0..3).map(token_fee_tx).collect(); + assert!( + collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + txs.iter().collect() + ) + .is_empty() ); - assert!(!second); + // Three transactions share one registry entry (five words) and one `balanceOf` SLOAD. + assert_eq!(db.reads[&L2_TOKEN_REGISTRY_ADDRESS], 5); + assert_eq!(db.reads[&FEE_TOKEN], 1); + + // The next round reads both again, so it sees the balance spent in between. + db.inner + .insert_account_storage(FEE_TOKEN, token_balance_key(SIGNER), U256::ZERO) + .unwrap(); assert_eq!( - budget.token_balances.get(&7).copied(), - Some(U256::from(40u64)) + collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + txs.iter().collect() + ), + vec![*txs[0].hash()] + ); + assert_eq!(db.reads[&L2_TOKEN_REGISTRY_ADDRESS], 10); + assert_eq!(db.reads[&FEE_TOKEN], 2); + } + + #[test] + fn unreadable_token_state_does_not_remove_transactions() { + let tx = token_fee_tx(0); + + // Sanity check: the same transaction against readable state is kept as well, so the + // assertions below are about the read failure and not about affordability. + assert!( + removable(&mut test_state(0, 0, 10 * TX_TOKEN_BUDGET), vec![&tx]).is_empty(), + "transaction is affordable when the token balance can be read" ); + + for failing in [TokenRead::Storage, TokenRead::Code] { + let mut db = UnreadableTokenDb { + inner: test_state(0, 0, 10 * TX_TOKEN_BUDGET), + token: FEE_TOKEN, + failing, + }; + let to_remove = collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + &test_evm_env(), + 30_000_000, + vec![&tx], + ); + assert!( + to_remove.is_empty(), + "a {failing:?} read failure inside balanceOf is not an invalid transaction" + ); + } } #[test] - fn consume_token_fee_path_honors_fee_limit_and_eth_value() { - let mut budget = SenderBudget { - eth_balance: U256::from(5u64), - token_balances: HashMap::new(), + fn a_balance_query_without_a_balance_removes_the_transaction() { + let tx = token_fee_tx(0); + for code in FAILING_BALANCE_OF { + let mut db = test_state_with_token_code(0, 0, 10 * TX_TOKEN_BUDGET, code); + assert_eq!( + removable(&mut db, vec![&tx]), + vec![*tx.hash()], + "balanceOf code {code:02x?}" + ); + } + } + + // --------------------------------------------------------------------------------- + // Pool-level test: only the pool can show what happens to a removed transaction's + // descendants, so this one drives the maintenance loop against a real pool. + // --------------------------------------------------------------------------------- + + use alloy_consensus::TxLegacy; + use alloy_primitives::Sealable; + use morph_chainspec::{MORPH_MAINNET, MorphChainSpec}; + use morph_evm::MorphEvmConfig; + use morph_primitives::MorphPrimitives; + use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; + use reth_transaction_pool::{ + CoinbaseTipOrdering, Pool, blobstore::InMemoryBlobStore, + validate::EthTransactionValidatorBuilder, + }; + + type TestProvider = MockEthProvider; + + fn storage_key(slot: U256) -> alloy_primitives::B256 { + alloy_primitives::B256::from(slot.to_be_bytes::<32>()) + } + + /// The chain head the pool validates against: an empty Emerald-active block. + fn head_block() -> morph_primitives::Block { + morph_primitives::Block { + header: morph_primitives::MorphHeader::from(alloy_consensus::Header { + number: 1, + timestamp: 1_767_765_600, + gas_limit: 30_000_000, + base_fee_per_gas: Some(10), + ..Default::default() + }), + body: Default::default(), + } + } + + /// Mirrors [`test_state`] for [`MockEthProvider`], which the pool's validator needs. + fn mock_provider(eth_balance: u64, token_balance: u64) -> TestProvider { + let client = MockEthProvider::::new() + .with_chain_spec((**MORPH_MAINNET).clone()) + .with_genesis_block(); + + // MorphTx is only accepted from Emerald onwards, so the head must be past it. + let head = head_block(); + client.add_block(head.header.hash_slow(), head); + + client.add_account(SIGNER, ExtendedAccount::new(0, U256::from(eth_balance))); + client.add_account( + L2_TOKEN_REGISTRY_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage( + call_mode_registry_storage(TOKEN_ID, FEE_TOKEN) + .map(|(slot, value)| (storage_key(slot), value)), + ), + ); + set_token_balance(&client, token_balance); + client + } + + /// Deploys the call-mode fee token with `token_balance` recorded for [`SIGNER`]. + fn set_token_balance(client: &TestProvider, token_balance: u64) { + client.add_account( + FEE_TOKEN, + ExtendedAccount::new(0, U256::ZERO) + .with_bytecode(alloy_primitives::Bytes::from_static(BALANCE_OF_RUNTIME)) + .extend_storage([( + storage_key(token_balance_key(SIGNER)), + U256::from(token_balance), + )]), + ); + } + + /// A canonical commit of [`head_block`]. + fn commit_event() -> reth_provider::CanonStateNotification { + commit_event_of(head_block()) + } + + /// A canonical commit of `block`. + fn commit_event_of( + block: morph_primitives::Block, + ) -> reth_provider::CanonStateNotification { + reth_provider::CanonStateNotification::Commit { + new: std::sync::Arc::new(reth_provider::Chain::new( + [reth_primitives_traits::RecoveredBlock::new_unhashed( + block, + Vec::new(), + )], + Default::default(), + Default::default(), + )), + } + } + + /// A plain ETH-fee transaction, affordable on its own. + fn legacy_tx(tx_nonce: u64) -> MorphPooledTransaction { + legacy_tx_for_sender(tx_nonce, SIGNER) + } + + fn legacy_tx_for_sender(tx_nonce: u64, sender: Address) -> MorphPooledTransaction { + let tx = TxLegacy { + chain_id: Some(2818), + nonce: tx_nonce, + gas_limit: 21_000, + gas_price: 100, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + ..Default::default() }; + let recovered = Recovered::new_unchecked( + MorphTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())), + sender, + ); + let encoded_len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, encoded_len) + } + + /// Runs the maintenance loop against the provider, as the node does. + fn provider_state(client: TestProvider) -> ProviderFeeState { + ProviderFeeState { + client, + evm_config: MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + } + } + + /// A [`FeeStateSource`] with a separate state per block, unlike [`MockEthProvider`], + /// whose state ignores the block hash. `head` is the canonical head. + struct BlockStates { + states: HashMap, + head: SealedHeader, + } + + impl FeeStateSource for BlockStates { + fn state_for( + &self, + header: &morph_primitives::MorphHeader, + ) -> Result { + let client = self + .states + .get(&header.hash_slow()) + .ok_or("no state for this block")?; + crate::validator::validation_state_for_header( + client, + &MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + header, + ) + } + + fn canonical_head( + &self, + ) -> Result>, BoxError> { + Ok(Some(self.head.clone())) + } + } + + /// The block after [`head_block`]. + fn next_block() -> morph_primitives::Block { + let mut block = head_block(); + block.header.inner.number = 2; + block.header.inner.timestamp += 1; + block + } - // fee_limit caps the payment below required amount => reject - let limited = consume_token_budget( - &mut budget, - U256::ZERO, - Some(9), - Some(U256::from(30u64)), - U256::from(40u64), - Some(U256::from(100u64)), + /// Admits [`token_fee_tx`] 0 while [`SIGNER`] can pay, then runs maintenance on `events` + /// with the sender's tokens drained at [`head_block`] and `head_token_balance` at the + /// canonical head, [`next_block`]. Returns whether the transaction is still pooled. + /// + /// The events go through a tokio broadcast channel, like reth's canonical stream, and the + /// loop runs the way `spawn_critical_blocking_task` runs it: `Handle::block_on` on a + /// blocking thread, where tokio's cooperative budget applies. + fn survives_a_stale_round( + head_token_balance: u64, + events: Vec>, + ) -> bool { + let judged = mock_provider(0, TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + judged.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), ); - assert!(!limited); - - // Enough token, but ETH value exceeds remaining ETH budget => reject - let eth_value_fail = consume_token_budget( - &mut budget, - U256::from(6u64), - Some(9), - Some(U256::from(100u64)), - U256::from(10u64), - Some(U256::from(100u64)), + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), ); - assert!(!eth_value_fail); + let hash = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(0), + )) + .unwrap() + .hash; + set_token_balance(&judged, 0); + let head = next_block().header; + let source = BlockStates { + states: HashMap::from([ + (head_block().header.hash_slow(), judged), + (head.hash_slow(), mock_provider(0, head_token_balance)), + ]), + head: SealedHeader::seal_slow(head), + }; + + let (sender, receiver) = tokio::sync::broadcast::channel(events.len()); + for event in events { + sender.send(event).unwrap(); + } + drop(sender); + let events = tokio_stream::wrappers::BroadcastStream::new(receiver) + .map(|event| event.expect("the channel holds every event")); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let handle = runtime.handle().clone(); + let loop_pool = pool.clone(); + let task = runtime.spawn_blocking(move || { + handle.block_on(maintain_morph_pool_with(loop_pool, source, events)) + }); + runtime.block_on(task).unwrap(); + pool.get(&hash).is_some() } #[test] - fn consume_mixed_path_sequence_tracks_eth_and_token_together() { - let mut budget = SenderBudget { - eth_balance: U256::from(100u64), - token_balances: HashMap::new(), - }; + fn removals_judged_at_an_older_block_are_rechecked_at_the_canonical_head() { + for backlog in [false, true] { + // Without a backlog, the head moved on before its notification arrived. With one, + // 128 notifications exhaust tokio's cooperative budget within a single poll, so the + // loop cannot see the head's notification behind them before it removes anything. + let events = || { + let mut events: Vec<_> = std::iter::repeat_with(commit_event) + .take(if backlog { 128 } else { 1 }) + .collect(); + if backlog { + events.push(commit_event_of(next_block())); + } + events + }; + assert!( + survives_a_stale_round(TX_TOKEN_BUDGET, events()), + "backlog={backlog}: payable at the canonical head, so it must stay" + ); + assert!( + !survives_a_stale_round(0, events()), + "backlog={backlog}: still unpayable at the canonical head, so it must go" + ); + } + } - // Tx1: token-fee path, consumes token only for fee and ETH for value. - let tx1 = consume_token_budget( - &mut budget, - U256::from(10u64), // value in ETH - Some(3), - Some(U256::ZERO), // unlimited by tx field => bounded by remaining token budget - U256::from(70u64), - Some(U256::from(100u64)), + #[test] + fn unchanged_head_does_not_evict_newly_admitted_transactions() { + let client = mock_provider(6_300_000, 10_000_000); + client.add_account( + morph_revm::L1_GAS_PRICE_ORACLE_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + (storage_key(U256::from(1)), U256::from(1)), + ( + storage_key(U256::from(7)), + U256::from(2_000_000_000_000_000u64), + ), + ]), ); - assert!(tx1); - assert_eq!(budget.eth_balance, U256::from(90u64)); - assert_eq!( - budget.token_balances.get(&3).copied(), - Some(U256::from(30u64)) + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), ); + let hashes: Vec<_> = (0..3) + .map(|nonce| { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(nonce), + )) + .unwrap() + .hash + }) + .collect(); + assert_eq!(pool.all_transactions().pending.len(), 3); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + provider_state(client), + futures::stream::iter([commit_event()]), + )); + assert!(hashes.iter().all(|hash| pool.get(hash).is_some())); + assert_eq!(pool.all_transactions().pending.len(), 3); + } + + #[test] + fn ordinary_only_senders_are_revalidated_when_l1_fees_rise() { + use reth_transaction_pool::TransactionPoolExt; + + let sender = address!("0000000000000000000000000000000000000009"); + // Each ordinary transaction costs 2,100,000 wei before L1 fees. + // A 2,000,000 L1 fee still fits individually at 6,300,000; + // a 5,000,000 L1 fee does not. + for unrelated_morph in [false, true] { + for (l1_fee, eth_balance, first_unaffordable) in [ + (0u64, 6_300_000u64, None), + (2_000_000, 12_300_000, None), + (2_000_000, 6_300_000, None), + (5_000_000, 6_300_000, Some(0usize)), + ] { + let client = mock_provider(10_000_000, 100_000_000); + client.add_account(sender, ExtendedAccount::new(0, U256::from(20_000_000))); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + let ordinary: Vec<_> = (0..3) + .map(|nonce| { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx_for_sender(nonce, sender), + )) + .unwrap() + .hash + }) + .collect(); + if unrelated_morph { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(0), + )) + .unwrap(); + } + assert_eq!( + pool.all_transactions().pending.len(), + 3 + usize::from(unrelated_morph) + ); + + client.add_account(sender, ExtendedAccount::new(0, U256::from(eth_balance))); + client.add_account( + morph_revm::L1_GAS_PRICE_ORACLE_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + (storage_key(U256::from(1)), U256::from(1)), + ( + storage_key(U256::from(7)), + U256::from(l1_fee) * U256::from(1_000_000_000), + ), + ]), + ); + let event = commit_event(); + pool.on_canonical_state_change(reth_transaction_pool::CanonicalStateUpdate { + new_tip: event.tip(), + pending_block_base_fee: 10, + pending_block_blob_fee: None, + changed_accounts: vec![reth_provider::ChangedAccount { + address: sender, + nonce: 0, + balance: U256::from(eth_balance), + }], + mined_transactions: Vec::new(), + update_kind: reth_transaction_pool::PoolUpdateKind::Commit, + }); + assert_eq!( + pool.all_transactions().pending.len(), + 3 + usize::from(unrelated_morph), + "standard maintenance cannot see the L1 fee shortfall" + ); + + // Later rounds judge the parked descendants on their own, one per round; none + // of them can cover the same L1 fee either. + for _ in 0..3 { + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + provider_state(client.clone()), + futures::stream::iter([event.clone()]), + )); + } + let all = pool.all_transactions(); + let pending: Vec<_> = all + .pending + .iter() + .filter(|tx| tx.sender() == sender) + .map(|tx| *tx.hash()) + .collect(); + let queued: Vec<_> = all + .queued + .iter() + .filter(|tx| tx.sender() == sender) + .map(|tx| *tx.hash()) + .collect(); + if let Some(index) = first_unaffordable { + assert!( + pool.get(&ordinary[index]).is_none(), + "remove the first L1-unaffordable ordinary transaction; unrelated MorphTx={unrelated_morph}" + ); + assert_eq!(pending, ordinary[..index]); + assert!( + ordinary[index..] + .iter() + .all(|hash| pool.get(hash).is_none()), + "parked descendants that cannot pay either are removed by later rounds" + ); + assert!(queued.is_empty()); + } else { + assert_eq!(pending, ordinary); + assert!(queued.is_empty()); + } + } + } + } + + #[test] + fn ordinary_only_senders_keep_nonce_gaps_and_eth_parked_transactions() { + use reth_transaction_pool::TransactionPoolExt; + + for (nonces, state_nonce, eth_balance, pending_nonces, queued_nonces) in [ + (vec![0, 2], 0, 3_100_000u64, vec![0], vec![2]), + (vec![5], 0, 3_100_000, vec![], vec![5]), + // Parked behind a gap and unable to cover its L1 fee: removed, not left for later. + (vec![5], 0, 2_100_000, vec![], vec![]), + (vec![0, 1], 0, 2_000_000, vec![], vec![0, 1]), + (vec![0, 1], 1, 3_100_000, vec![1], vec![]), + ] { + let client = mock_provider(10_000_000, 0); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + for nonce in nonces { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(nonce), + )) + .unwrap(); + } + client.add_account( + SIGNER, + ExtendedAccount::new(state_nonce, U256::from(eth_balance)), + ); + // Every ordinary transaction now owes 1,000,000 wei in L1 fees. + client.add_account( + morph_revm::L1_GAS_PRICE_ORACLE_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + (storage_key(U256::from(1)), U256::from(1)), + ( + storage_key(U256::from(7)), + U256::from(1_000_000_000_000_000u64), + ), + ]), + ); + let event = commit_event(); + // Run Morph first to cover a pool snapshot that still contains a mined nonce. + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + provider_state(client.clone()), + futures::stream::iter([event.clone()]), + )); + pool.on_canonical_state_change(reth_transaction_pool::CanonicalStateUpdate { + new_tip: event.tip(), + pending_block_base_fee: 10, + pending_block_blob_fee: None, + changed_accounts: vec![reth_provider::ChangedAccount { + address: SIGNER, + nonce: state_nonce, + balance: U256::from(eth_balance), + }], + mined_transactions: Vec::new(), + update_kind: reth_transaction_pool::PoolUpdateKind::Commit, + }); + // And run after reth parks/removes transactions, covering either task order. + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + provider_state(client), + futures::stream::iter([event]), + )); + let all = pool.all_transactions(); + assert_eq!( + all.pending.iter().map(|tx| tx.nonce()).collect::>(), + pending_nonces + ); + assert_eq!( + all.queued.iter().map(|tx| tx.nonce()).collect::>(), + queued_nonces + ); + } + } - // Tx2: ETH-fee path, consumes full ETH cost. - let tx2 = consume_eth_budget( - &mut budget, - U256::from(20u64), // value - 5, // gas_limit - 4, // max_fee_per_gas => gas fee = 20 - U256::from(10u64), // l1 fee + #[test] + fn morph_after_legacy_nonce_is_revalidated_after_token_balance_drops() { + let client = mock_provider(10_000_000, 10 * TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), ); - assert!(tx2); - // total eth cost = 20(value) + 20(gas) + 10(l1) = 50 - assert_eq!(budget.eth_balance, U256::from(40u64)); - - // Tx3: token-fee path should now fail because remaining token budget is only 30. - let tx3 = consume_token_budget( - &mut budget, - U256::ZERO, - Some(3), - Some(U256::ZERO), - U256::from(35u64), - None, + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), ); - assert!(!tx3); - // Budgets stay unchanged on failed consumption. - assert_eq!(budget.eth_balance, U256::from(40u64)); - assert_eq!( - budget.token_balances.get(&3).copied(), - Some(U256::from(30u64)) + + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(0), + )) + .unwrap(); + let token_tx = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(1), + )) + .unwrap() + .hash; + assert_eq!(pool.all_transactions().pending.len(), 2); + assert!(pool.all_transactions().queued.is_empty()); + + set_token_balance(&client, 0); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + provider_state(client), + futures::stream::iter([commit_event()]), + )); + + assert!( + pool.get(&token_tx).is_none(), + "a pending MorphTx following a legacy nonce must still be checked after its token balance becomes zero" ); } #[test] - fn gas_limit_check_rejects_transactions_above_block_limit() { - assert!(exceeds_block_gas_limit(30_000_001, 30_000_000)); - assert!(!exceeds_block_gas_limit(30_000_000, 30_000_000)); + fn ordinary_l1_fee_shortfall_parks_the_morph_successor() { + use reth_transaction_pool::TransactionPoolExt; + + // Two individually affordable predecessors must both survive, even if + // their combined maximum gas and L1 costs exceed the account balance. + for (ordinary_count, l1_fee, eth_balance, token_balance) in [ + (1, 0u64, 2_100_000u64, 0u64), + (1, 1_000_000, 2_100_000, 0), + (1, 1_000_000, 2_100_000, 21_000_000), + (2, 1_000_000, 4_200_000, 21_000_000), + ] { + let client = mock_provider(10_000_000, 10 * TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + let ordinary: Vec<_> = (0..ordinary_count) + .map(|nonce| { + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(nonce), + )) + .unwrap() + .hash + }) + .collect(); + let token_tx = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(ordinary_count), + )) + .unwrap() + .hash; + assert_eq!(pool.all_transactions().pending.len(), ordinary.len() + 1); + + // All were affordable at admission. The new state still covers reth's + // ordinary transaction cost, but cannot cover the new L1 fee as well. + client.add_account(SIGNER, ExtendedAccount::new(0, U256::from(eth_balance))); + set_token_balance(&client, token_balance); + client.add_account( + morph_revm::L1_GAS_PRICE_ORACLE_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + (storage_key(U256::from(1)), U256::from(1)), + ( + storage_key(U256::from(7)), + U256::from(l1_fee) * U256::from(1_000_000_000), + ), + ]), + ); + let mut block = head_block(); + block.header.inner.number = 2; + block.header.inner.timestamp += 1; + client.add_block(block.header.hash_slow(), block.clone()); + let event = reth_provider::CanonStateNotification::Commit { + new: std::sync::Arc::new(reth_provider::Chain::new( + [reth_primitives_traits::RecoveredBlock::new_unhashed( + block, + Vec::new(), + )], + Default::default(), + Default::default(), + )), + }; + // Exercise the same public canonical update used by standard maintenance. + pool.on_canonical_state_change(reth_transaction_pool::CanonicalStateUpdate { + new_tip: event.tip(), + pending_block_base_fee: 10, + pending_block_blob_fee: None, + changed_accounts: vec![reth_provider::ChangedAccount { + address: SIGNER, + nonce: 0, + balance: U256::from(eth_balance), + }], + mined_transactions: Vec::new(), + update_kind: reth_transaction_pool::PoolUpdateKind::Commit, + }); + assert_eq!( + pool.all_transactions().pending.len(), + ordinary.len() + 1, + "standard maintenance does not see L1 costs" + ); + + for _ in 0..3 { + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + provider_state(client.clone()), + futures::stream::iter([event.clone()]), + )); + } + let all = pool.all_transactions(); + if l1_fee == 0 { + assert!(ordinary.iter().all(|hash| pool.get(hash).is_some())); + assert!( + pool.get(&token_tx).is_none(), + "the unfunded MorphTx is removed" + ); + assert!(all.queued.is_empty()); + } else if ordinary_count == 2 { + assert!(ordinary.iter().all(|hash| pool.get(hash).is_some())); + assert!(pool.get(&token_tx).is_some()); + assert_eq!(all.pending.len(), 3); + assert!(all.queued.is_empty()); + } else { + let (unaffordable, affordable) = ordinary.split_last().unwrap(); + assert!( + pool.get(unaffordable).is_none(), + "the first L1-unaffordable predecessor must be removed" + ); + assert!(affordable.iter().all(|hash| pool.get(hash).is_some())); + assert_eq!(all.pending.len(), affordable.len()); + if token_balance == 0 { + assert!( + pool.get(&token_tx).is_none(), + "a parked successor the sender cannot pay for is removed by a later round" + ); + assert!(all.queued.is_empty()); + } else { + assert_eq!( + all.queued.iter().map(|tx| *tx.hash()).collect::>(), + [token_tx], + "preserve the successor in queued while it still has tokens" + ); + } + } + } + } + + #[test] + fn removing_a_morph_tx_parks_its_descendants_instead_of_deleting_them() { + let client = mock_provider(10_000_000, 10 * TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + + // nonce 0 pays in tokens, nonce 1 is a plain ETH transaction that only depends on + // nonce 0 through the nonce sequence. + let unpayable = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(0), + )) + .unwrap() + .hash; + let descendant = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(1), + )) + .unwrap() + .hash; + + // The sender spends its whole token balance elsewhere, so nonce 0 is no longer payable. + set_token_balance(&client, 0); + let event = commit_event(); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + provider_state(client), + futures::stream::iter([event]), + )); + + // Without the removal, every early return of the maintenance round would pass the + // descendant check below as well. + assert!( + pool.get(&unpayable).is_none(), + "the transaction the sender can no longer pay for must be removed" + ); + let queued = pool.all_transactions().queued; + assert!( + queued.iter().any(|tx| *tx.hash() == descendant), + "an independently affordable ETH-fee successor must be parked, not deleted" + ); + } + + #[test] + fn a_morph_tx_parked_behind_a_nonce_gap_is_removed_before_the_gap_is_filled() { + let client = mock_provider(10_000_000, 10 * TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + + // Admitted while the sender could pay, then parked: nonce 0 is missing. + let parked = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(1), + )) + .unwrap() + .hash; + assert_eq!(pool.all_transactions().queued.len(), 1); + + // The sender spends every token before nonce 0 shows up. + set_token_balance(&client, 0); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + provider_state(client), + futures::stream::iter([commit_event()]), + )); + assert!( + pool.get(&parked).is_none(), + "a parked MorphTx the sender can no longer pay for must be removed" + ); + + // Filling the gap promotes whatever is parked without running the validator again, so + // an unpayable nonce 1 left in place would now be pending and offered to the builder. + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(0), + )) + .unwrap(); + let all = pool.all_transactions(); + assert_eq!( + all.pending.iter().map(|tx| tx.nonce()).collect::>(), + [0] + ); + assert!(all.queued.is_empty()); } } diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index b6abf576..4d90a53b 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -7,10 +7,14 @@ 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::{MorphEvmEnv, TokenFeeInfo}; +use morph_primitives::{ + MorphTxEnvelope, + transaction::morph_transaction::{MORPH_TX_VERSION_1, MORPH_TX_VERSION_2}, +}; +use morph_revm::{MorphEvmEnv, MorphInvalidTransaction, TokenFeeInfo}; +use reth_revm::revm::context::result::EVMError; -use crate::MorphTxError; +use crate::{MorphTxError, MorphTxValidationError}; /// High-level input for MorphTx validation. /// @@ -27,6 +31,11 @@ pub struct MorphTxValidationInput<'a> { pub l1_data_fee: U256, /// Current hardfork pub hardfork: MorphHardfork, + /// The environment a call-mode fee token's `balanceOf` is evaluated in. + /// + /// Must be the environment of the block whose state `db` exposes, so admission and + /// maintenance resolve the same balance the execution layer would. + pub evm_env: &'a MorphEvmEnv, } /// Result of MorphTx validation. @@ -38,8 +47,6 @@ pub struct MorphTxValidationResult { pub token_info: Option, /// The required token amount pub required_token_amount: U256, - /// The amount that will be paid (min of fee_limit and required) - pub amount_to_pay: U256, } /// Validates a MorphTx transaction's token-related fields. @@ -53,24 +60,49 @@ pub struct MorphTxValidationResult { pub fn validate_morph_tx( db: &mut DB, input: &MorphTxValidationInput<'_>, -) -> Result { +) -> Result> { + validate_morph_tx_with_token_info(input, |token_id| { + TokenFeeInfo::load_for_caller(db, token_id, input.sender, input.evm_env) + }) +} + +/// Shared checks with a caller-provided, fixed-state token lookup. +/// Maintenance supplies a per-round cache; admission performs a fresh lookup. +pub(crate) fn validate_morph_tx_with_token_info( + input: &MorphTxValidationInput<'_>, + load_token: impl FnOnce(u16) -> Result, EVMError>, +) -> Result> { // Keep MorphTx structural validation in the shared path so both initial // admission and background revalidation enforce the same invariants. let morph_tx = match input.consensus_tx { MorphTxEnvelope::Morph(signed) => signed.tx(), - _ => return Err(MorphTxError::InvalidTokenId), + _ => return Err(MorphTxError::InvalidTokenId.into()), }; if !input.hardfork.is_jade() && morph_tx.version == MORPH_TX_VERSION_1 { return Err(MorphTxError::InvalidFormat { reason: "MorphTx version 1 is not yet active (jade fork not reached)".to_string(), - }); + } + .into()); + } + + // V2 (EIP-7702 authorization list) is gated on Celadon. The list itself is + // 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_celadon() && morph_tx.version == MORPH_TX_VERSION_2 { + return Err(MorphTxError::InvalidFormat { + reason: "MorphTx version 2 is not yet active (celadon fork not reached)".to_string(), + } + .into()); } if let Err(reason) = morph_tx.validate() { return Err(MorphTxError::InvalidFormat { reason: reason.to_string(), - }); + } + .into()); } let tx_value = morph_tx.value; @@ -78,7 +110,8 @@ pub fn validate_morph_tx( return Err(MorphTxError::InsufficientEthForValue { balance: input.eth_balance, value: tx_value, - }); + } + .into()); } let fee_token_id = morph_tx.fee_token_id; @@ -97,28 +130,22 @@ pub fn validate_morph_tx( return Err(MorphTxError::InsufficientEthForValue { balance: input.eth_balance, value: total_eth_cost, - }); + } + .into()); } return Ok(MorphTxValidationResult { uses_token_fee: false, token_info: None, required_token_amount: U256::ZERO, - amount_to_pay: U256::ZERO, }); } - // Pool admission has no block environment, so a call-mode token's `balanceOf` - // is evaluated under the hardfork's defaults. That matches the pool's previous - // behaviour; threading the real head environment through admission is txpool - // work and does not belong in this change. - let env = MorphEvmEnv::new( - reth_revm::revm::context::CfgEnv::new_with_spec(input.hardfork), - morph_revm::MorphBlockEnv::default(), - ); - let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, &env) - .map_err(|err| MorphTxError::TokenInfoFetchFailed { - token_id: fee_token_id, - message: format!("{err:?}"), + let token_info = load_token(fee_token_id) + .map_err(|err| match err { + EVMError::Database(err) => MorphTxValidationError::State(err), + _ => MorphTxValidationError::Invalid(MorphTxError::TokenBalanceQueryFailed { + token_id: fee_token_id, + }), })? .ok_or(MorphTxError::TokenNotFound { token_id: fee_token_id, @@ -128,14 +155,16 @@ pub fn validate_morph_tx( if !token_info.is_active { return Err(MorphTxError::TokenNotActive { token_id: fee_token_id, - }); + } + .into()); } // Check price ratio is valid if token_info.price_ratio.is_zero() { return Err(MorphTxError::InvalidPriceRatio { token_id: fee_token_id, - }); + } + .into()); } // Txpool admission follows geth's conservative budget check and requires @@ -155,24 +184,190 @@ pub fn validate_morph_tx( token_address: token_info.token_address, balance: effective_limit, required: required_token_amount, - }); + } + .into()); } Ok(MorphTxValidationResult { uses_token_fee: true, token_info: Some(token_info), required_token_amount, - amount_to_pay: required_token_amount, }) } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + + /// The environment the fee-token balance query is evaluated in. + fn test_evm_env(hardfork: MorphHardfork) -> MorphEvmEnv { + MorphEvmEnv::new( + reth_revm::revm::context::CfgEnv::new_with_spec(hardfork), + morph_revm::MorphBlockEnv::default(), + ) + } use alloy_consensus::Signed; - use alloy_primitives::{B256, Signature, TxKind, address}; + use alloy_primitives::{B256, Bytes, Signature, TxKind, address}; use morph_primitives::{TxMorph, transaction::morph_transaction::MORPH_TX_VERSION_1}; - use reth_revm::revm::database::EmptyDB; + use morph_revm::{ + L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot, compute_mapping_slot_for_address, + }; + use reth_revm::revm::database::{CacheDB, EmptyDB}; + use reth_revm::revm::state::{AccountInfo, Bytecode}; + + // --------------------------------------------------------------------------------- + // Fee-token fixtures, shared with the validator and maintenance tests. + // + // Fee tokens are paid in EVM-call mode only: the registry's `balanceSlot` word is zero, + // so every balance read runs the token's `balanceOf` in the EVM. + // --------------------------------------------------------------------------------- + + /// Storage slot of the `balances` mapping that [`BALANCE_OF_RUNTIME`] reads. + pub(crate) const TOKEN_BALANCES_SLOT: u8 = 7; + + /// A fee token answering every call as ERC20 `balanceOf(address)`: + /// `mstore(0, calldataload(4)) mstore(32, 7) mstore(0, sload(keccak256(0, 64))) + /// return(0, 32)`, i.e. `balances[account]` from the mapping at [`TOKEN_BALANCES_SLOT`]. + pub(crate) const BALANCE_OF_RUNTIME: &[u8] = &[ + 0x60, + 0x04, + 0x35, + 0x5f, + 0x52, // PUSH1 4, CALLDATALOAD, PUSH0, MSTORE + 0x60, + TOKEN_BALANCES_SLOT, + 0x60, + 0x20, + 0x52, // PUSH1 slot, PUSH1 32, MSTORE + 0x60, + 0x40, + 0x5f, + 0x20, + 0x54, // PUSH1 64, PUSH0, KECCAK256, SLOAD + 0x5f, + 0x52, + 0x60, + 0x20, + 0x5f, + 0xf3, // PUSH0, MSTORE, PUSH1 32, PUSH0, RETURN + ]; + + /// `balanceOf` implementations that do not produce a balance: one reverts, one returns + /// a single byte instead of a word, one halts on `INVALID`. + pub(crate) const FAILING_BALANCE_OF: [&[u8]; 3] = [ + &[0x5f, 0x5f, 0xfd], // PUSH0, PUSH0, REVERT + &[0x60, 0x01, 0x5f, 0xf3], // PUSH1 1, PUSH0, RETURN + &[0xfe], // INVALID + ]; + + /// Registry storage registering `token` as active fee token `token_id` in EVM-call mode, + /// with 18 decimals, a scale of 1 and a price ratio of 1. + pub(crate) fn call_mode_registry_storage(token_id: u16, token: Address) -> [(U256, U256); 5] { + let mut token_key = [0u8; 32]; + token_key[30..].copy_from_slice(&token_id.to_be_bytes()); + let base = compute_mapping_slot(U256::from(151), &token_key); + let mut active_with_decimals = [0u8; 32]; + active_with_decimals[30] = 18; + active_with_decimals[31] = 1; + [ + (base, U256::from_be_bytes(token.into_word().0)), + // A zero `balanceSlot` word selects EVM-call mode. + (base + U256::from(1), U256::ZERO), + ( + base + U256::from(2), + U256::from_be_bytes(active_with_decimals), + ), + (base + U256::from(3), U256::from(1)), + ( + compute_mapping_slot(U256::from(153), &token_key), + U256::from(1), + ), + ] + } + + /// Key of `account` in the `balances` mapping that [`BALANCE_OF_RUNTIME`] reads. + pub(crate) fn token_balance_key(account: Address) -> U256 { + compute_mapping_slot_for_address(U256::from(TOKEN_BALANCES_SLOT), account) + } + + /// State registering `token` as call-mode fee token `token_id`, whose `balanceOf` runs + /// `code`, with `balance` recorded for `holder` in its `balances` mapping. + pub(crate) fn call_mode_token_state( + token_id: u16, + token: Address, + code: &'static [u8], + holder: Address, + balance: U256, + ) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + for (slot, value) in call_mode_registry_storage(token_id, token) { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + } + let code = Bytecode::new_raw(Bytes::from_static(code)); + db.insert_account_info( + token, + AccountInfo { + code_hash: code.hash_slow(), + code: Some(code), + ..Default::default() + }, + ); + db.insert_account_storage(token, token_balance_key(holder), balance) + .unwrap(); + db + } + + /// Which read of the fee token [`UnreadableTokenDb`] fails. + #[derive(Debug, Clone, Copy)] + pub(crate) enum TokenRead { + /// Any storage slot of the token. + Storage, + /// The token's code, which the EVM then has to fetch by hash. + Code, + } + + /// Fails one kind of read of `token` with a provider error; everything else reads through. + #[derive(Debug)] + pub(crate) struct UnreadableTokenDb { + pub(crate) inner: CacheDB, + pub(crate) token: Address, + pub(crate) failing: TokenRead, + } + + impl reth_revm::Database for UnreadableTokenDb { + type Error = reth_provider::ProviderError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + let mut info = self.inner.basic(address).unwrap(); + if address == self.token && matches!(self.failing, TokenRead::Code) { + // A real provider returns only the code hash; the code is loaded separately. + if let Some(info) = info.as_mut() { + info.code = None; + } + } + Ok(info) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + let token_code_hash = self.inner.basic(self.token).unwrap().map(|i| i.code_hash); + if matches!(self.failing, TokenRead::Code) && token_code_hash == Some(code_hash) { + return Err(reth_provider::ProviderError::BestBlockNotFound); + } + Ok(self.inner.code_by_hash(code_hash).unwrap()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + if address == self.token && matches!(self.failing, TokenRead::Storage) { + return Err(reth_provider::ProviderError::BestBlockNotFound); + } + Ok(self.inner.storage(address, index).unwrap()) + } + + fn block_hash(&mut self, number: u64) -> Result { + Ok(self.inner.block_hash(number).unwrap()) + } + } #[test] fn test_morph_tx_validation_input_construction() { @@ -204,6 +399,7 @@ mod tests { eth_balance: U256::from(1_000_000_000_000_000_000u128), // 1 ETH l1_data_fee: U256::from(100_000), hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; assert_eq!(input.sender, sender); @@ -229,6 +425,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( @@ -242,6 +439,7 @@ mod tests { eth_balance: U256::from(1_000_000_000_000_000_000u128), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); @@ -249,9 +447,9 @@ mod tests { assert_eq!( err, - MorphTxError::InvalidFormat { + MorphTxValidationError::Invalid(MorphTxError::InvalidFormat { reason: "version 1 MorphTx cannot have FeeLimit when FeeTokenID is 0".to_string(), - } + }) ); } @@ -283,11 +481,15 @@ mod tests { eth_balance: U256::from(1_000_000_000_000_000_000u128), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; let mut db = EmptyDB::default(); let err = validate_morph_tx(&mut db, &input).unwrap_err(); - assert_eq!(err, MorphTxError::InvalidTokenId); + assert_eq!( + err, + MorphTxValidationError::Invalid(MorphTxError::InvalidTokenId) + ); } #[test] @@ -307,6 +509,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( @@ -320,11 +523,15 @@ mod tests { eth_balance: U256::from(100u64), // Insufficient ETH l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; let mut db = EmptyDB::default(); let err = validate_morph_tx(&mut db, &input).unwrap_err(); - assert!(matches!(err, MorphTxError::InsufficientEthForValue { .. })); + assert!(matches!( + err, + MorphTxValidationError::Invalid(MorphTxError::InsufficientEthForValue { .. }) + )); } #[test] @@ -345,6 +552,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( @@ -361,6 +569,7 @@ mod tests { eth_balance: U256::from(10u128.pow(18)), // 1 ETH (sufficient) l1_data_fee: U256::from(1000u64), hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); @@ -389,6 +598,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( @@ -403,11 +613,15 @@ mod tests { eth_balance: U256::from(100u64), // Way too low l1_data_fee: U256::from(1000u64), hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); let err = validate_morph_tx(&mut db, &input).unwrap_err(); - assert!(matches!(err, MorphTxError::InsufficientEthForValue { .. })); + assert!(matches!( + err, + MorphTxValidationError::Invalid(MorphTxError::InsufficientEthForValue { .. }) + )); } #[test] @@ -427,6 +641,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( @@ -441,14 +656,225 @@ mod tests { eth_balance: U256::from(10u128.pow(18)), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; let mut db = EmptyDB::default(); // EmptyDB has no token registry state, so token lookup will fail let err = validate_morph_tx(&mut db, &input).unwrap_err(); assert!( - matches!(err, MorphTxError::TokenNotFound { token_id: 42 }), + matches!( + err, + MorphTxValidationError::Invalid(MorphTxError::TokenNotFound { token_id: 42 }) + ), "expected TokenNotFound {{ token_id: 42 }}, got {err:?}" ); } + + 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_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::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), + }; + let mut db = EmptyDB::default(); + + let err = validate_morph_tx(&mut db, &input).unwrap_err(); + assert!(matches!( + err, + MorphTxValidationError::Invalid(MorphTxError::InvalidFormat { ref reason }) + if reason == "MorphTx version 2 is not yet active (celadon fork not reached)" + )); + } + + #[test] + 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::Celadon, + evm_env: &test_evm_env(MorphHardfork::Celadon), + }; + 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 Celadon-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::Celadon, + evm_env: &test_evm_env(MorphHardfork::Celadon), + }; + 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!(matches!( + err, + MorphTxValidationError::Invalid(MorphTxError::InvalidFormat { ref reason }) + if reason == "MorphTx version 2 is not yet active (celadon fork not reached)" + )); + } + + const FEE_TOKEN: Address = address!("5300000000000000000000000000000000000042"); + const TOKEN_PAYER: Address = address!("1000000000000000000000000000000000000001"); + /// `gas_limit * max_fee_per_gas` of [`token_fee_envelope`]; with the fixtures' 1:1 price + /// ratio and no L1 fee this is also its token requirement. + const TOKEN_FEE: u64 = 21_000 * 100; + + fn token_fee_envelope() -> MorphTxEnvelope { + let tx = TxMorph { + chain_id: 2818, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + fee_token_id: 1, + ..Default::default() + }; + MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )) + } + + /// Runs the MorphTx checks for [`token_fee_envelope`] from a sender without ETH. + fn validate_token_fee( + db: &mut DB, + ) -> Result> { + let envelope = token_fee_envelope(); + let evm_env = test_evm_env(MorphHardfork::Jade); + let input = MorphTxValidationInput { + consensus_tx: &envelope, + sender: TOKEN_PAYER, + eth_balance: U256::ZERO, + l1_data_fee: U256::ZERO, + hardfork: MorphHardfork::Jade, + evm_env: &evm_env, + }; + validate_morph_tx(db, &input) + } + + #[test] + fn a_call_mode_token_balance_is_read_through_balance_of() { + let fee = U256::from(TOKEN_FEE); + let mut db = call_mode_token_state(1, FEE_TOKEN, BALANCE_OF_RUNTIME, TOKEN_PAYER, fee); + let result = validate_token_fee(&mut db).unwrap(); + assert!(result.uses_token_fee); + assert_eq!(result.required_token_amount, fee); + assert_eq!(result.token_info.map(|info| info.balance), Some(fee)); + + let short = fee - U256::from(1); + let mut db = call_mode_token_state(1, FEE_TOKEN, BALANCE_OF_RUNTIME, TOKEN_PAYER, short); + assert_eq!( + validate_token_fee(&mut db).unwrap_err(), + MorphTxValidationError::Invalid(MorphTxError::InsufficientTokenBalance { + token_id: 1, + token_address: FEE_TOKEN, + balance: short, + required: fee, + }) + ); + } + + /// A `balanceOf` that reverts, answers with less than a word or halts yields no balance. + /// That is a verdict on the transaction, but not one that blames the relaying peer. + #[test] + fn a_balance_query_without_a_balance_is_an_invalid_transaction() { + use reth_transaction_pool::error::PoolTransactionError; + + for code in FAILING_BALANCE_OF { + let mut db = + call_mode_token_state(1, FEE_TOKEN, code, TOKEN_PAYER, U256::from(TOKEN_FEE)); + assert_eq!( + validate_token_fee(&mut db).unwrap_err(), + MorphTxValidationError::Invalid(MorphTxError::TokenBalanceQueryFailed { + token_id: 1 + }), + "balanceOf code {code:02x?}" + ); + } + assert!(!MorphTxError::TokenBalanceQueryFailed { token_id: 1 }.is_bad_transaction()); + } + + /// A read failure inside the `balanceOf` call says nothing about the transaction. + #[test] + fn an_unreadable_fee_token_is_a_state_error() { + for failing in [TokenRead::Storage, TokenRead::Code] { + let mut db = UnreadableTokenDb { + inner: call_mode_token_state( + 1, + FEE_TOKEN, + BALANCE_OF_RUNTIME, + TOKEN_PAYER, + U256::from(TOKEN_FEE), + ), + token: FEE_TOKEN, + failing, + }; + assert!( + matches!( + validate_token_fee(&mut db), + Err(MorphTxValidationError::State(_)) + ), + "{failing:?}" + ); + } + } } diff --git a/crates/txpool/src/transaction.rs b/crates/txpool/src/transaction.rs index 21411db8..dca2d3ab 100644 --- a/crates/txpool/src/transaction.rs +++ b/crates/txpool/src/transaction.rs @@ -1,7 +1,8 @@ //! Pool transaction type for Morph L2. use alloy_consensus::{ - BlobTransactionValidationError, Typed2718, transaction::Recovered, transaction::TxHashRef, + BlobTransactionValidationError, Transaction as _, Typed2718, transaction::Recovered, + transaction::TxHashRef, }; use alloy_eips::{ eip2930::AccessList, eip7594::BlobTransactionSidecarVariant, eip7702::SignedAuthorization, @@ -25,6 +26,13 @@ pub struct MorphPooledTransaction { #[deref] inner: EthPooledTransaction, + /// Maximum amount of **ETH** this transaction can debit from the sender. + /// + /// Equal to `inner.cost` for every transaction that pays gas in ETH. For a MorphTx + /// (`0x7F`) with `fee_token_id > 0` the gas fee is settled in an ERC20 token, so the + /// only ETH the sender must hold is `value`. See [`PoolTransaction::cost`]. + cost: U256, + /// Cached EIP-2718 encoded bytes of the transaction, lazily computed. encoded_2718: OnceLock, } @@ -32,8 +40,21 @@ pub struct MorphPooledTransaction { impl MorphPooledTransaction { /// Create a new instance of [`MorphPooledTransaction`]. pub fn new(transaction: Recovered, encoded_length: usize) -> Self { + let uses_token_fee = transaction.fee_token_id().is_some_and(|id| id > 0); + let inner = EthPooledTransaction::new(transaction, encoded_length); + // Gas is paid in an ERC20 token, so the ETH-denominated cost is `value` alone. + // Mirrors go-ethereum's `executableTxFilter`, which sets `txCost = nil` for + // `IsMorphTxWithAltFee()` and only requires `costLimit >= value` + // (core/tx_pool.go:1657-1698). + let cost = if uses_token_fee { + inner.transaction().value() + } else { + inner.cost + }; + Self { - inner: EthPooledTransaction::new(transaction, encoded_length), + inner, + cost, encoded_2718: Default::default(), } } @@ -94,8 +115,17 @@ impl PoolTransaction for MorphPooledTransaction { self.inner.transaction.signer_ref() } + /// Maximum amount of ETH this transaction can debit from the sender. + /// + /// The pool compares this against the sender's **ETH** balance to decide whether a + /// transaction is spendable (`ENOUGH_BALANCE` in `TxState`), which in turn decides + /// whether it lands in the pending or the queued sub-pool. Returning the inherited + /// `gas_limit * max_fee_per_gas + value` for a token-fee MorphTx would strand every + /// zero-ETH token payer in the queued sub-pool, where `best_transactions()` never + /// sees them — the exact user morph's ERC20 gas payment exists for. Token + /// affordability is validated separately by `MorphTransactionValidator`. fn cost(&self) -> &U256 { - &self.inner.cost + &self.cost } fn encoded_length(&self) -> usize { @@ -273,6 +303,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Bytes::new(), }; let sig = Signature::test_signature(); @@ -385,4 +416,67 @@ mod tests { // encoded_length is set during construction assert!(tx.encoded_length() > 0); } + + fn create_morph_pooled_tx_with(fee_token_id: u16, value: U256) -> MorphPooledTransaction { + use morph_primitives::TxMorph; + let tx = TxMorph { + chain_id: 1337, + nonce: 0, + gas_limit: 21000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(Address::repeat_byte(0x02)), + value, + access_list: Default::default(), + version: 0, + fee_token_id, + fee_limit: U256::from(1000u64), + reference: None, + memo: None, + authorization_list: Vec::new(), + input: Bytes::new(), + }; + let sig = Signature::test_signature(); + let envelope = MorphTxEnvelope::Morph(Signed::new_unhashed(tx, sig)); + let recovered = Recovered::new_unchecked(envelope, Address::repeat_byte(0xcc)); + let len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, len) + } + + /// A MorphTx paying gas in an ERC20 token must report an ETH cost of `value` only. + /// + /// The pool compares `cost()` against the sender's ETH balance to set the + /// `ENOUGH_BALANCE` state bit, which decides pending vs. queued. Charging the + /// ETH gas budget here would strand every zero-ETH token payer in the queued + /// sub-pool, where the block builder never sees them. + #[test] + fn token_fee_morph_tx_cost_excludes_eth_gas_budget() { + let value = U256::from(7u64); + let tx = create_morph_pooled_tx_with(1, value); + + assert_eq!(*tx.cost(), value); + + // The inherited ETH gas budget would have been orders of magnitude larger. + let eth_gas_budget = U256::from(21_000u64) * U256::from(2_000_000_000u64); + assert!(*tx.cost() < eth_gas_budget); + } + + /// `fee_token_id == 0` is the ETH-fee MorphTx path (reference/memo only), so it keeps + /// the inherited `gas_limit * max_fee_per_gas + value` cost. + #[test] + fn eth_fee_morph_tx_keeps_full_cost() { + let value = U256::from(7u64); + let tx = create_morph_pooled_tx_with(0, value); + + let expected = U256::from(21_000u64) * U256::from(2_000_000_000u64) + value; + assert_eq!(*tx.cost(), expected); + } + + /// Non-MorphTx transactions are untouched by the token-fee carve-out. + #[test] + fn legacy_tx_keeps_full_cost() { + let tx = create_legacy_pooled_tx(); + let expected = U256::from(21_000u64) * U256::from(1_000_000_000u64) + U256::from(100u64); + assert_eq!(*tx.cost(), expected); + } } diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index 1afea643..df1d6999 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -8,29 +8,26 @@ //! - L1 data fee validation //! - MorphTx (0x7F) ERC20 token balance validation -use crate::MorphTxError; -use alloy_consensus::{BlockHeader, Transaction}; +use crate::MorphTxValidationError; +use alloy_consensus::{BlockHeader, Sealable, Transaction}; use alloy_eips::{Encodable2718, Typed2718}; -use alloy_primitives::{Address, U256}; -use morph_chainspec::hardfork::MorphHardforks; +use alloy_primitives::{Address, B256, U256}; +use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; use morph_primitives::MorphTxEnvelope; -use morph_revm::L1BlockInfo; +use morph_revm::{L1BlockInfo, MorphBlockEnv, MorphEvmEnv}; use parking_lot::RwLock; use reth_chainspec::ChainSpecProvider; -use reth_evm::ConfigureEvm; +use reth_evm::{ConfigureEvm, EvmFactory, EvmFactoryFor}; use reth_primitives_traits::{ - Block, BlockTy, GotExpected, SealedBlock, transaction::error::InvalidTransactionError, + Block, BlockTy, GotExpected, HeaderTy, SealedBlock, transaction::error::InvalidTransactionError, }; use reth_revm::database::StateProviderDatabase; -use reth_storage_api::{BlockReaderIdExt, StateProviderFactory}; +use reth_storage_api::{BlockReaderIdExt, StateProviderBox, StateProviderFactory}; use reth_transaction_pool::{ EthPoolTransaction, EthTransactionValidator, PoolTransaction, TransactionOrigin, - TransactionValidationOutcome, TransactionValidator, -}; -use std::sync::{ - Arc, - atomic::{AtomicU64, Ordering}, + TransactionValidationOutcome, TransactionValidator, error::InvalidPoolTransactionError, }; +use std::sync::Arc; /// EIP-3860 max initcode size (`2 * MAX_CODE_SIZE = 2 * 24 576 = 49 152` bytes). /// @@ -41,59 +38,135 @@ use std::sync::{ /// `validate_one_with_state` for why we can't rely on reth's Shanghai-gated check. const MAX_INITCODE_SIZE: usize = reth_revm::revm::primitives::eip3860::MAX_INITCODE_SIZE; -/// Tracks L1 block info for the current chain head. +/// A complete set of fee-validation inputs for one block. +#[derive(Debug)] +pub(crate) struct MorphValidationHead { + hash: B256, + number: u64, + timestamp: u64, + base_fee_per_gas: Option, + pub(crate) l1_block_info: L1BlockInfo, + pub(crate) evm_env: MorphEvmEnv, +} + +/// Tracks L1 fee parameters and the matching block environment. /// -/// This is used to cache L1 fee parameters and update them when the chain head changes. +/// A complete head is published atomically. Readers retain an immutable snapshot while +/// later canonical updates prepare and publish a replacement. #[derive(Debug, Default)] pub struct MorphL1BlockInfo { - /// The current L1 block info. - l1_block_info: RwLock, - /// Current block base fee per gas. - base_fee_per_gas: RwLock>, - /// Current block timestamp. - timestamp: AtomicU64, - /// Current block number. - number: AtomicU64, + head: RwLock>>, } impl MorphL1BlockInfo { - /// Creates a new instance with default values. + /// Creates an uninitialized tracker; the validator publishes each canonical head into it. pub fn new() -> Self { Self::default() } - /// Returns the current L1 block info. + /// Returns the current L1 block info, or its default before initialization. pub fn l1_block_info(&self) -> L1BlockInfo { - *self.l1_block_info.read() + self.head + .read() + .as_ref() + .map(|head| head.l1_block_info) + .unwrap_or_default() } - /// Updates the L1 block info. - pub fn update( + /// Publishes fee parameters and the environment for the supplied header together. + /// + /// `info` must be read from this header's post-state and `evm_env` must be built + /// for the same header. Partial updates are not supported. + pub fn update( &self, info: L1BlockInfo, - timestamp: u64, - number: u64, - base_fee_per_gas: Option, + header: &H, + evm_env: MorphEvmEnv, ) { - *self.l1_block_info.write() = info; - *self.base_fee_per_gas.write() = base_fee_per_gas; - self.timestamp.store(timestamp, Ordering::Relaxed); - self.number.store(number, Ordering::Relaxed); + *self.head.write() = Some(Arc::new(MorphValidationHead { + hash: header.hash_slow(), + number: header.number(), + timestamp: header.timestamp(), + base_fee_per_gas: header.base_fee_per_gas(), + l1_block_info: info, + evm_env, + })); } - /// Returns the current block timestamp. + /// Returns the current block timestamp, or zero before initialization. pub fn timestamp(&self) -> u64 { - self.timestamp.load(Ordering::Relaxed) + self.head + .read() + .as_ref() + .map(|head| head.timestamp) + .unwrap_or_default() } - /// Returns the current block number. + /// Returns the current block number, or zero before initialization. pub fn number(&self) -> u64 { - self.number.load(Ordering::Relaxed) + self.head + .read() + .as_ref() + .map(|head| head.number) + .unwrap_or_default() } /// Returns the current block base fee per gas. pub fn base_fee_per_gas(&self) -> Option { - *self.base_fee_per_gas.read() + self.head + .read() + .as_ref() + .and_then(|head| head.base_fee_per_gas) + } +} + +/// State and fee-validation inputs pinned to one block for a transaction batch. +/// +/// Created lazily by [`MorphTransactionValidator::validate_one_with_state`]. Reusing it +/// keeps account reads, token reads and the EVM environment on the same block, even if +/// the canonical head advances. Start with `None` to validate a new batch at the new head. +pub struct MorphValidationState { + pub(crate) head: Arc, + pub(crate) provider: StateProviderBox, +} + +/// Opens state, EVM environment and L1 parameters for exactly one header. +pub(crate) fn validation_state_for_header( + client: &Client, + evm_config: &Evm, + header: &HeaderTy, +) -> Result> +where + Client: StateProviderFactory, + Evm: ConfigureEvm, + EvmFactoryFor: EvmFactory, +{ + let evm_env = evm_config + .evm_env(header) + .map_err(|err| std::io::Error::other(err.to_string()))?; + let provider = client.state_by_block_hash(header.hash_slow())?; + let l1_block_info = L1BlockInfo::try_fetch( + &mut StateProviderDatabase::new(&provider), + *evm_env.cfg_env.spec(), + )?; + Ok(MorphValidationState { + head: Arc::new(MorphValidationHead { + hash: header.hash_slow(), + number: header.number(), + timestamp: header.timestamp(), + base_fee_per_gas: header.base_fee_per_gas(), + l1_block_info, + evm_env, + }), + provider, + }) +} + +impl std::fmt::Debug for MorphValidationState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MorphValidationState") + .field("head", &self.head) + .finish_non_exhaustive() } } @@ -140,16 +213,6 @@ impl MorphTransactionValidator { self.inner.client() } - /// Returns the current block timestamp. - fn block_timestamp(&self) -> u64 { - self.block_info.timestamp() - } - - /// Returns the current block number. - fn block_number(&self) -> u64 { - self.block_info.number() - } - /// Returns a reference to the block info tracker. pub fn block_info(&self) -> &Arc { &self.block_info @@ -176,9 +239,14 @@ fn insufficient_funds_outcome( impl MorphTransactionValidator where - Client: ChainSpecProvider + StateProviderFactory + BlockReaderIdExt, + Client: ChainSpecProvider + + StateProviderFactory + + BlockReaderIdExt
>, Tx: EthPoolTransaction, Evm: ConfigureEvm, + // Pins the cached environment to Morph's, so the fee-token balance query runs in + // exactly what the execution layer would use. + EvmFactoryFor: EvmFactory, { /// Create a new [`MorphTransactionValidator`]. pub fn new(inner: EthTransactionValidator) -> Self { @@ -206,42 +274,32 @@ where } /// Update the L1 block info for the given header. - pub fn update_l1_block_info(&self, header: &H) - where - H: BlockHeader, - { - self.block_info - .timestamp - .store(header.timestamp(), Ordering::Relaxed); - self.block_info - .number - .store(header.number(), Ordering::Relaxed); - *self.block_info.base_fee_per_gas.write() = header.base_fee_per_gas(); - - let provider = match self - .client() - .state_by_block_number_or_tag(header.number().into()) - { - Ok(provider) => provider, + pub fn update_l1_block_info(&self, header: &HeaderTy) { + match validation_state_for_header(self.client(), self.inner.evm_config(), header) { + Ok(state) => *self.block_info.head.write() = Some(state.head), Err(err) => { - tracing::warn!(target: "morph::txpool", %err, "Failed to get state provider for L1 block info update"); - return; + tracing::warn!(target: "morph::txpool", %err, "Failed to refresh fee-validation head") } - }; - - let mut db = StateProviderDatabase::new(provider); - let hardfork = self - .chain_spec() - .morph_hardfork_at(header.number(), header.timestamp()); + } + } - match L1BlockInfo::try_fetch(&mut db, hardfork) { - Ok(l1_block_info) => { - *self.block_info.l1_block_info.write() = l1_block_info; - } - Err(err) => { - tracing::warn!(target: "morph::txpool", ?err, "Failed to fetch L1 block info"); - } + /// Opens the state of the head last published through `on_new_head_block`. + /// + /// Until a head is published, falls back to the provider's latest header without + /// publishing it, so it can never overwrite a newer canonical update. + fn current_validation_state( + &self, + ) -> Result> { + let head = self.block_info.head.read().clone(); + if let Some(head) = head { + let provider = self.client().state_by_block_hash(head.hash)?; + return Ok(MorphValidationState { head, provider }); } + let header = self + .client() + .latest_header()? + .ok_or_else(|| std::io::Error::other("latest validation header is unavailable"))?; + validation_state_for_header(self.client(), self.inner.evm_config(), header.header()) } /// Validates a single transaction. @@ -261,18 +319,16 @@ where self.validate_one_with_state(origin, transaction, &mut None) } - /// Validates a single transaction, reusing an optional state provider. + /// Validates a single transaction, reusing a state and head snapshot. /// - /// When `state` is `None`, a fresh provider is fetched from the database on - /// first use and stored back into `state` for reuse by subsequent calls. - /// This avoids creating a new [`StateProvider`] for every transaction in a - /// batch, which is the main source of txpool validation slowdown as the - /// state trie grows. + /// When `state` is `None`, validation opens the state of the head last published by + /// `on_new_head_block`. Both are reused for subsequent transactions in the batch. + /// Reset `state` to `None` to pick up a newer head. pub fn validate_one_with_state( &self, origin: TransactionOrigin, transaction: Tx, - state: &mut Option>, + state: &mut Option, ) -> TransactionValidationOutcome { // Reject EIP-4844 blob transactions - not supported on L2 if transaction.is_eip4844() { @@ -290,11 +346,20 @@ where ); } + if state.is_none() { + match self.current_validation_state() { + Ok(snapshot) => *state = Some(snapshot), + Err(err) => return TransactionValidationOutcome::Error(*transaction.hash(), err), + } + } + let state = state.as_ref().expect("validation state initialized above"); + let head = &state.head; + // Reject EIP-7702 transactions before Viridian hardfork (PRAGUE) if transaction.is_eip7702() && !self .chain_spec() - .is_viridian_active_at_timestamp(self.block_timestamp()) + .is_viridian_active_at_timestamp(head.timestamp) { return TransactionValidationOutcome::Invalid( transaction, @@ -310,7 +375,7 @@ where if is_morph_tx && !self .chain_spec() - .is_emerald_active_at_timestamp(self.block_timestamp()) + .is_emerald_active_at_timestamp(head.timestamp) { return TransactionValidationOutcome::Invalid( transaction, @@ -341,9 +406,35 @@ where return TransactionValidationOutcome::Invalid(transaction, err); } + // Token-fee MorphTx reports only its ETH value through cost(), so reth's + // cost() - value() fee-cap check sees zero. Preserve the configured local + // fee cap using the gas budget, independently of the pool's ETH budget. + if is_morph_tx + && self + .inner + .local_transactions_config() + .is_local(origin, transaction.sender_ref()) + && let Some(tx_fee_cap_wei) = self.inner.tx_fee_cap().filter(|cap| *cap != 0) + { + let max_tx_fee_wei = U256::from(transaction.gas_limit()) + .saturating_mul(U256::from(transaction.max_fee_per_gas())); + if max_tx_fee_wei > U256::from(tx_fee_cap_wei) { + return TransactionValidationOutcome::Invalid( + transaction, + InvalidPoolTransactionError::ExceedsFeeCap { + max_tx_fee_wei: max_tx_fee_wei.saturating_to(), + tx_fee_cap_wei, + }, + ); + } + } + + if let Err(err) = self.inner.validate_stateless(origin, &transaction) { + return TransactionValidationOutcome::Invalid(transaction, err); + } let outcome = self .inner - .validate_one_with_state(origin, transaction, state); + .validate_stateful(origin, transaction, &state.provider); if outcome.is_invalid() || outcome.is_error() { tracing::trace!(target: "morph::txpool", ?outcome, "tx pool validation failed"); return outcome; @@ -359,10 +450,8 @@ where authorities, } = outcome { - let l1_block_info = *self.block_info.l1_block_info.read(); - let hardfork = self - .chain_spec() - .morph_hardfork_at(self.block_number(), self.block_timestamp()); + let l1_block_info = head.l1_block_info; + let hardfork = *head.evm_env.cfg_env.spec(); // Calculate L1 data fee (always calculated for all transactions). // Clone consensus tx once — reused for both L1 fee encoding and MorphTx validation. @@ -381,12 +470,9 @@ where sender, balance, l1_data_fee, - hardfork, + state, ) { - return TransactionValidationOutcome::Invalid( - valid_tx.into_transaction(), - err.into(), - ); + return morph_tx_validation_outcome(valid_tx.into_transaction(), err); } } else { // Regular transaction: validate ETH balance covers cost + L1 fee @@ -426,26 +512,17 @@ where sender: Address, eth_balance: U256, l1_data_fee: U256, - hardfork: morph_chainspec::hardfork::MorphHardfork, - ) -> Result { - // Get state provider for token info lookup - let provider = self - .client() - .state_by_block_number_or_tag(self.block_number().into()) - .map_err(|err| MorphTxError::TokenInfoFetchFailed { - token_id: 0, // token_id not yet extracted - message: err.to_string(), - })?; - - let mut db = StateProviderDatabase::new(provider); - - // Use shared validation logic with unified API (includes ETH balance check) + state: &MorphValidationState, + ) -> Result> + { + let mut db = StateProviderDatabase::new(&state.provider); let input = crate::MorphTxValidationInput { consensus_tx, sender, eth_balance, l1_data_fee, - hardfork, + hardfork: *state.head.evm_env.cfg_env.spec(), + evm_env: &state.head.evm_env, }; let result = crate::validate_morph_tx(&mut db, &input)?; @@ -490,9 +567,14 @@ where impl TransactionValidator for MorphTransactionValidator where - Client: ChainSpecProvider + StateProviderFactory + BlockReaderIdExt, + Client: ChainSpecProvider + + StateProviderFactory + + BlockReaderIdExt
>, Tx: EthPoolTransaction, Evm: ConfigureEvm, + // Pins the cached environment to Morph's, so the fee-token balance query runs in + // exactly what the execution layer would use. + EvmFactoryFor: EvmFactory, { type Transaction = Tx; type Block = BlockTy; @@ -519,6 +601,31 @@ where } } +/// Maps a [`MorphTxValidationError`] onto the right validation outcome. +/// +/// [`TransactionValidationOutcome::Invalid`] is a verdict on the transaction: the pool +/// can record it as known-bad. Peer penalties are decided separately by the error type. +/// A failed state read is not such a verdict — the transaction may be perfectly valid and +/// simply could not be checked — so it is reported as +/// [`TransactionValidationOutcome::Error`], which discards this attempt without blaming +/// anyone and leaves the sender free to try again. +fn morph_tx_validation_outcome< + Tx: EthPoolTransaction, + E: std::error::Error + Send + Sync + 'static, +>( + transaction: Tx, + err: MorphTxValidationError, +) -> TransactionValidationOutcome { + match err { + MorphTxValidationError::State(err) => { + TransactionValidationOutcome::Error(*transaction.hash(), Box::new(err)) + } + MorphTxValidationError::Invalid(err) => { + TransactionValidationOutcome::Invalid(transaction, err.into()) + } + } +} + /// Helper function to check if a transaction is an L1 message. fn is_l1_message(tx: &impl Typed2718) -> bool { tx.ty() == morph_primitives::L1_TX_TYPE_ID @@ -532,18 +639,21 @@ fn is_morph_tx(tx: &impl Typed2718) -> bool { #[cfg(test)] mod tests { use super::*; - use alloy_consensus::{Signed, TxEip1559, TxLegacy}; + use crate::MorphTxError; + use crate::morph_tx_validation::tests::{ + BALANCE_OF_RUNTIME, FAILING_BALANCE_OF, call_mode_registry_storage, token_balance_key, + }; + use alloy_consensus::{Sealable, Signed, TxEip1559, TxLegacy}; use alloy_eips::eip2718::Encodable2718; - use alloy_primitives::{B256, Signature, TxKind, address}; + use alloy_primitives::{B256, Bytes, Signature, TxKind, address}; use morph_chainspec::{MORPH_MAINNET, MorphChainSpec}; use morph_evm::MorphEvmConfig; use morph_primitives::{MorphPrimitives, TxL1Msg, TxMorph}; - use morph_revm::{ - L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot, compute_mapping_slot_for_address, - }; + use morph_revm::L2_TOKEN_REGISTRY_ADDRESS; use reth_primitives_traits::Recovered; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; use reth_transaction_pool::{ + CoinbaseTipOrdering, LocalTransactionConfig, Pool, TransactionPool, blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, }; @@ -557,53 +667,392 @@ mod tests { B256::from(slot.to_be_bytes::<32>()) } - fn token_id_key(token_id: u16) -> [u8; 32] { - let mut key = [0u8; 32]; - key[30..32].copy_from_slice(&token_id.to_be_bytes()); - key + /// The registry account with `token` registered as call-mode fee token `token_id`. + fn token_registry_account(token_id: u16, token: alloy_primitives::Address) -> ExtendedAccount { + ExtendedAccount::new(0, U256::ZERO).extend_storage( + call_mode_registry_storage(token_id, token) + .map(|(slot, value)| (storage_key(slot), value)), + ) } - fn token_registry_account( - token_id: u16, - token_address: alloy_primitives::Address, - balance_slot: U256, - token_balance: U256, + /// A fee token whose `balanceOf` runs `code`, with `balance` recorded for `holder`. + fn fee_token_account( + code: &'static [u8], + holder: alloy_primitives::Address, + balance: U256, ) -> ExtendedAccount { - let token_registry_slot = U256::from(151); - let price_ratio_slot = U256::from(153); - let token_key = token_id_key(token_id); - let base = compute_mapping_slot(token_registry_slot, &token_key); + ExtendedAccount::new(0, U256::ZERO) + .with_bytecode(Bytes::from_static(code)) + .extend_storage([(storage_key(token_balance_key(holder)), balance)]) + } - let mut slot_2 = [0u8; 32]; - slot_2[30] = 18; - slot_2[31] = 1; + type TokenFeeValidator = MorphTransactionValidator< + MockEthProvider, + crate::MorphPooledTransaction, + MorphEvmConfig, + >; - ExtendedAccount::new(0, U256::ZERO).extend_storage([ - ( - storage_key(base), - U256::from_be_bytes(token_address.into_word().0), - ), - ( - storage_key(base + U256::from(1)), - balance_slot + U256::from(1), + /// Registered call-mode token with a 1:1 price ratio at an Emerald-active head. + fn token_fee_validator( + eth_balance: U256, + token_balance: U256, + fee_cap: u128, + local_config: LocalTransactionConfig, + ) -> TokenFeeValidator { + let client = new_mock_provider(); + let signer = address!("0000000000000000000000000000000000000001"); + let token = address!("5300000000000000000000000000000000000042"); + let header = morph_primitives::MorphHeader::from(alloy_consensus::Header { + number: 1, + timestamp: 1_767_765_600, + gas_limit: 30_000_000, + base_fee_per_gas: Some(10), + ..Default::default() + }); + client.add_block( + header.hash_slow(), + morph_primitives::Block { + header, + body: Default::default(), + }, + ); + client.add_account(signer, ExtendedAccount::new(0, eth_balance)); + client.add_account(L2_TOKEN_REGISTRY_ADDRESS, token_registry_account(1, token)); + client.add_account( + token, + fee_token_account(BALANCE_OF_RUNTIME, signer, token_balance), + ); + let inner = EthTransactionValidatorBuilder::new( + client, + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .set_tx_fee_cap(fee_cap) + .with_local_transactions_config(local_config) + .build::(InMemoryBlobStore::default()); + MorphTransactionValidator::new(inner) + } + + /// Maximum gas fee is 2,100,000 wei; value remains denominated in ETH. + fn token_fee_transaction(tx_nonce: u64, value: U256) -> crate::MorphPooledTransaction { + let tx = TxMorph { + chain_id: 2818, + nonce: tx_nonce, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value, + fee_token_id: 1, + fee_limit: U256::ZERO, + ..Default::default() + }; + let recovered = Recovered::new_unchecked( + MorphTxEnvelope::Morph(Signed::new_unhashed(tx, Signature::test_signature())), + address!("0000000000000000000000000000000000000001"), + ); + let len = recovered.encode_2718_len(); + crate::MorphPooledTransaction::new(recovered, len) + } + + fn timestamp_sensitive_validator() -> TokenFeeValidator { + let validator = + token_fee_validator(U256::ZERO, U256::from(10_000_000), 0, Default::default()); + let client = validator.client(); + let token = address!("5300000000000000000000000000000000000042"); + // Return 10,000,000 only at the old head's timestamp. The token state stays + // unchanged so both reads of the cached provider must use the old environment. + let old_timestamp = 1_767_765_600u32; + let mut code = vec![0x42, 0x63]; // TIMESTAMP PUSH4 + code.extend_from_slice(&old_timestamp.to_be_bytes()); + code.extend_from_slice(&[ + 0x14, 0x62, 0x98, 0x96, 0x80, 0x02, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3, + ]); + client.add_account( + token, + ExtendedAccount::new(0, U256::ZERO).with_bytecode(code.into()), + ); + + validator + } + + fn replacement_header() -> morph_primitives::MorphHeader { + morph_primitives::MorphHeader::from(alloy_consensus::Header { + number: 1, + timestamp: 1_767_765_601, + gas_limit: 30_000_000, + base_fee_per_gas: Some(10), + ..Default::default() + }) + } + + #[test] + fn a_head_published_during_validation_does_not_change_the_balance_query() { + let mut validator = timestamp_sensitive_validator(); + let block_info = validator.block_info().clone(); + let client = validator.client().clone(); + let replacement = replacement_header(); + let env = MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()) + .evm_env(&replacement) + .unwrap(); + // This extension runs after the account read and before Morph fee validation, + // forcing the interleaving of a concurrent canonical-head publication. + validator + .inner + .set_additional_stateful_validation(move |_, _, _| { + client + .blocks + .lock() + .retain(|_, block| block.header.number() != replacement.number()); + client + .headers + .lock() + .retain(|_, header| header.number() != replacement.number()); + client.add_block( + replacement.hash_slow(), + morph_primitives::Block { + header: replacement.clone(), + body: Default::default(), + }, + ); + block_info.update(L1BlockInfo::default(), &replacement, env.clone()); + Ok(()) + }); + let tx = token_fee_transaction(0, U256::ZERO); + let in_flight = validator.validate_one(TransactionOrigin::Local, tx.clone()); + assert!( + matches!(in_flight, TransactionValidationOutcome::Valid { .. }), + "an in-flight validation must retain its original head: {in_flight:?}" + ); + let fresh = validator.validate_one(TransactionOrigin::Local, tx); + assert!( + matches!(fresh, TransactionValidationOutcome::Invalid(..)), + "a fresh validation must see the published head: {fresh:?}" + ); + } + + /// A `balanceOf` that yields no balance rejects the transaction without blaming the + /// peer that relayed it: the fault lies with the token, not the sender. + #[test] + fn a_failing_balance_query_is_rejected_without_penalizing_the_peer() { + for code in FAILING_BALANCE_OF { + let validator = + token_fee_validator(U256::ZERO, U256::from(10_000_000), 0, Default::default()); + validator.client().add_account( + address!("5300000000000000000000000000000000000042"), + fee_token_account( + code, + address!("0000000000000000000000000000000000000001"), + U256::from(10_000_000), + ), + ); + let outcome = validator.validate_one( + TransactionOrigin::External, + token_fee_transaction(0, U256::ZERO), + ); + let err = match outcome { + TransactionValidationOutcome::Invalid(_, err) => err, + other => panic!("balanceOf code {code:02x?}: {other:?}"), + }; + assert_eq!( + err.downcast_other_ref::(), + Some(&MorphTxError::TokenBalanceQueryFailed { token_id: 1 }), + "balanceOf code {code:02x?}" + ); + assert_eq!( + err.as_other().map(|other| other.is_bad_transaction()), + Some(false), + "balanceOf code {code:02x?}" + ); + } + } + + #[test] + fn token_fee_transaction_above_local_fee_cap_is_rejected() { + // The effective gas price is 20, so only the maximum fee budget exceeds this cap. + let validator = token_fee_validator( + U256::ZERO, + U256::from(10_000_000), + 500_000, + Default::default(), + ); + let outcome = validator.validate_one( + TransactionOrigin::Local, + token_fee_transaction(0, U256::ZERO), + ); + assert!( + matches!( + outcome, + TransactionValidationOutcome::Invalid( + _, + InvalidPoolTransactionError::ExceedsFeeCap { + max_tx_fee_wei: 2_100_000, + tx_fee_cap_wei: 500_000, + } + ) ), + "{outcome:?}" + ); + } + + #[test] + fn token_fee_cap_accepts_zero_or_sufficient_cap_without_counting_value() { + for cap in [0, 2_100_000, 2_100_001] { + let validator = token_fee_validator( + U256::from(7), + U256::from(10_000_000), + cap, + Default::default(), + ); + let outcome = validator.validate_one( + TransactionOrigin::Local, + token_fee_transaction(0, U256::from(7)), + ); + assert!( + matches!(outcome, TransactionValidationOutcome::Valid { .. }), + "cap={cap}: {outcome:?}" + ); + } + } + + #[test] + fn token_fee_cap_respects_local_transaction_configuration() { + let local_sender = LocalTransactionConfig { + local_addresses: [address!("0000000000000000000000000000000000000001")] + .into_iter() + .collect(), + ..Default::default() + }; + for (origin, config, should_reject) in [ ( - storage_key(base + U256::from(2)), - U256::from_be_bytes(slot_2), + TransactionOrigin::External, + LocalTransactionConfig::default(), + false, ), - (storage_key(base + U256::from(3)), U256::from(1)), + (TransactionOrigin::External, local_sender.clone(), true), ( - storage_key(compute_mapping_slot(price_ratio_slot, &token_key)), - U256::from(1), + TransactionOrigin::Local, + LocalTransactionConfig { + no_exemptions: true, + ..Default::default() + }, + false, ), ( - storage_key(compute_mapping_slot_for_address( - balance_slot, - address!("0000000000000000000000000000000000000001"), - )), - token_balance, + TransactionOrigin::External, + LocalTransactionConfig { + no_exemptions: true, + ..local_sender + }, + false, ), - ]) + ] { + let validator = token_fee_validator(U256::ZERO, U256::from(10_000_000), 100, config); + let outcome = validator.validate_one(origin, token_fee_transaction(0, U256::ZERO)); + if should_reject { + assert!( + matches!( + outcome, + TransactionValidationOutcome::Invalid( + _, + InvalidPoolTransactionError::ExceedsFeeCap { .. } + ) + ), + "{outcome:?}" + ); + } else { + assert!( + matches!(outcome, TransactionValidationOutcome::Valid { .. }), + "{outcome:?}" + ); + } + } + } + + #[test] + fn token_fee_transaction_with_zero_eth_is_pending_and_selectable() { + let validator = token_fee_validator( + U256::ZERO, + U256::from(10_000_000), + 2_100_000, + Default::default(), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + let added = futures::executor::block_on(pool.add_transaction( + TransactionOrigin::Local, + token_fee_transaction(0, U256::ZERO), + )) + .unwrap(); + let all = pool.all_transactions(); + assert_eq!(all.pending.len(), 1); + assert!(all.queued.is_empty()); + let best: Vec<_> = pool.best_transactions().map(|tx| *tx.hash()).collect(); + assert_eq!(best, [added.hash]); + } + + #[test] + fn token_fee_transactions_still_reserve_cumulative_eth_value() { + let validator = token_fee_validator( + U256::from(10), + U256::from(10_000_000), + 2_100_000, + Default::default(), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + for nonce in [0, 1] { + futures::executor::block_on(pool.add_transaction( + TransactionOrigin::Local, + token_fee_transaction(nonce, U256::from(7)), + )) + .unwrap(); + } + let all = pool.all_transactions(); + assert_eq!(all.pending.len(), 1); + assert_eq!(all.pending[0].nonce(), 0); + assert_eq!(all.queued.len(), 1); + assert_eq!(all.queued[0].nonce(), 1); + let best: Vec<_> = pool.best_transactions().map(|tx| tx.nonce()).collect(); + assert_eq!(best, [0]); + } + + #[test] + fn an_unreadable_fee_token_state_is_an_error_not_an_invalid_transaction() { + let tx = token_fee_transaction(0, U256::ZERO); + let hash = *tx.hash(); + + let outcome = morph_tx_validation_outcome( + tx, + MorphTxValidationError::State(std::io::Error::other("provider unavailable")), + ); + assert!( + matches!(outcome, TransactionValidationOutcome::Error(reported, _) if reported == hash), + "a failed state read must not mark the transaction known-bad: {outcome:?}" + ); + } + + #[test] + fn a_real_fee_token_failure_is_still_an_invalid_transaction() { + let outcome = morph_tx_validation_outcome( + token_fee_transaction(0, U256::ZERO), + MorphTxValidationError::::Invalid(MorphTxError::TokenNotActive { + token_id: 1, + }), + ); + assert!( + matches!(outcome, TransactionValidationOutcome::Invalid(..)), + "{outcome:?}" + ); } #[test] @@ -617,7 +1066,16 @@ mod tests { fn test_morph_l1_block_info_update() { let info = MorphL1BlockInfo::new(); let l1_info = L1BlockInfo::default(); - info.update(l1_info, 1234, 100, Some(42)); + let header = morph_primitives::MorphHeader::from(alloy_consensus::Header { + timestamp: 1234, + number: 100, + base_fee_per_gas: Some(42), + ..Default::default() + }); + let evm_env = MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()) + .evm_env(&header) + .unwrap(); + info.update(l1_info, &header, evm_env); assert_eq!(info.timestamp(), 1234); assert_eq!(info.number(), 100); @@ -885,42 +1343,9 @@ mod tests { #[test] fn validate_morph_tx_uses_max_fee_for_token_fee_admission() { - let client = new_mock_provider(); + let validator = token_fee_validator(U256::ZERO, U256::from(300_000), 0, Default::default()); let signer = address!("0000000000000000000000000000000000000001"); - let token = address!("5300000000000000000000000000000000000042"); - let balance_slot = U256::from(7); - - client.add_account(signer, ExtendedAccount::new(0, U256::ZERO)); - client.add_account( - L2_TOKEN_REGISTRY_ADDRESS, - token_registry_account(1, token, balance_slot, U256::from(300_000u64)), - ); - client.add_account( - token, - ExtendedAccount::new(0, U256::ZERO).extend_storage([( - storage_key(compute_mapping_slot_for_address(balance_slot, signer)), - U256::from(300_000u64), - )]), - ); - - let morph_evm_config = MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()); - let eth_validator: EthTransactionValidator< - _, - crate::MorphPooledTransaction, - MorphEvmConfig, - > = EthTransactionValidatorBuilder::new(client, morph_evm_config) - .no_shanghai() - .no_cancun() - .disable_balance_check() - .build::(InMemoryBlobStore::default()); - let validator = MorphTransactionValidator::new(eth_validator); - // Simulate an active chain head with base_fee_per_gas = 10. Execution - // would use effective gas price = min(100, 10 + 1) = 11, but txpool - // admission follows geth's conservative max_fee_per_gas budget. - validator - .block_info - .update(L1BlockInfo::default(), 0, 0, Some(10)); - + // Effective execution price is 11, but admission must reserve the maximum 100. let tx = TxMorph { chain_id: 2818, nonce: 0, @@ -935,6 +1360,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( @@ -943,23 +1369,14 @@ mod tests { B256::ZERO, )); let recovered = Recovered::new_unchecked(envelope, signer); - let err = validator - .validate_morph_tx_balance( - &recovered, - signer, - U256::ZERO, - U256::ZERO, - morph_chainspec::hardfork::MorphHardfork::Viridian, - ) - .expect_err("MorphTx should require the max-fee token budget in txpool admission"); - - assert!(matches!( - err, - crate::MorphTxError::InsufficientTokenBalance { - required, - balance, - .. - } if required == U256::from(2_100_000u64) && balance == U256::from(300_000u64) + let len = recovered.encode_2718_len(); + let outcome = validator.validate_one( + TransactionOrigin::Local, + crate::MorphPooledTransaction::new(recovered, len), + ); + assert!(matches!(outcome, + TransactionValidationOutcome::Invalid(_, InvalidPoolTransactionError::Overdraft { cost, balance }) + if cost == U256::from(2_100_000) && balance == U256::from(300_000) )); } }