diff --git a/Cargo.lock b/Cargo.lock index 94036ec6..0e96a416 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", @@ -10064,9 +10057,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 +10143,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/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..6070ce3d 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; // ============================================================================= @@ -781,3 +784,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/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..445035c2 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -199,15 +199,30 @@ impl TokenFeeInfo { entry.load_storage_only(db, caller).map(Some) } - /// Calculate the token amount required for a given ETH amount. + /// Calculate the token amount required for a given ETH amount, rounding up. /// /// Uses the price ratio and scale to convert ETH value to token amount. #[inline] pub fn eth_to_token_amount(&self, eth_amount: U256) -> U256 { + self.eth_to_token_amount_with_credit(eth_amount).0 + } + + /// Same as [`Self::eth_to_token_amount`], and also returns the numerator the + /// rounding-up overcharged. + /// + /// The credit is `price_ratio - remainder` (zero when the division is exact): + /// the part of one whole token unit the caller paid for but did not use. From + /// Celadon on, [`Self::eth_to_token_amount_floor`] hands it back on the refund + /// so that the caller is charged `ceil` of the *net* fee rather than + /// `ceil(prepaid) - ceil(refund)`, which under-collects. + /// + /// Mirrors go-ethereum's `types.EthToAlt`. + #[inline] + pub fn eth_to_token_amount_with_credit(&self, eth_amount: U256) -> (U256, U256) { // If price_ratio or scale is zero (misconfigured token), return MAX to prevent // free-ride transactions. The caller's balance check will reject the tx. if self.price_ratio.is_zero() || self.scale.is_zero() { - return U256::MAX; + return (U256::MAX, U256::ZERO); } // token_amount = eth_amount * scale / price_ratio @@ -215,11 +230,37 @@ impl TokenFeeInfo { .saturating_mul(self.scale) .div_rem(self.price_ratio); // If there's a remainder, round up by adding 1 - if !remainder.is_zero() { - token_amount.saturating_add(U256::from(1)) + if remainder.is_zero() { + (token_amount, U256::ZERO) } else { - token_amount + ( + token_amount.saturating_add(U256::from(1)), + self.price_ratio - remainder, + ) + } + } + + /// Convert an ETH amount plus the prepaid rounding credit into token units, + /// rounding down. + /// + /// `rounding_credit` comes from the matching + /// [`Self::eth_to_token_amount_with_credit`] call made when the fee was + /// deducted. Mirrors go-ethereum's `types.EthToAltFloor`. + /// + /// A misconfigured token refunds nothing. That is the conservative direction + /// (the ceiling path returns `U256::MAX` for the same input to make the + /// deduction fail), and it is unreachable in practice: such a transaction + /// never gets past the balance check at deduction time. + #[inline] + pub fn eth_to_token_amount_floor(&self, eth_amount: U256, rounding_credit: U256) -> U256 { + if self.price_ratio.is_zero() || self.scale.is_zero() { + return U256::ZERO; } + + eth_amount + .saturating_mul(self.scale) + .saturating_add(rounding_credit) + / self.price_ratio } } @@ -589,6 +630,124 @@ pub(crate) mod tests { assert_eq!(token_amount, U256::MAX); } + /// Rounding up the prepaid fee leaves `price_ratio - remainder` of a token + /// unit paid for but unused, which is exactly what the refund gets back. + #[test] + fn eth_to_token_amount_reports_the_rounding_credit() { + let info = TokenFeeInfo { + price_ratio: U256::from(3u64), + scale: U256::from(1u64), + ..Default::default() + }; + + // 10 / 3 = 3 remainder 1 → charge 4, one third of a unit unused → 3 - 1 = 2. + let (amount, credit) = info.eth_to_token_amount_with_credit(U256::from(10u64)); + assert_eq!(amount, U256::from(4u64)); + assert_eq!(credit, U256::from(2u64)); + + // An exact division overcharges nothing. + let (amount, credit) = info.eth_to_token_amount_with_credit(U256::from(9u64)); + assert_eq!(amount, U256::from(3u64)); + assert_eq!(credit, U256::ZERO); + + // The plain ceiling accessor stays the first half of the pair. + assert_eq!( + info.eth_to_token_amount(U256::from(10u64)), + U256::from(4u64) + ); + } + + /// The credit is what makes deduct-then-refund add up to `ceil(net fee)`. + /// Without it, both halves round up independently and the chain + /// under-collects — for `remaining = 4` below, by a whole token unit. + #[test] + fn floor_refund_charges_the_ceiling_of_the_net_fee() { + let info = TokenFeeInfo { + price_ratio: U256::from(3u64), + scale: U256::from(1u64), + ..Default::default() + }; + + for prepaid_eth in 0u64..40 { + let (charged, credit) = info.eth_to_token_amount_with_credit(U256::from(prepaid_eth)); + for remaining_eth in 0..=prepaid_eth { + let refunded = info.eth_to_token_amount_floor(U256::from(remaining_eth), credit); + let net_eth = U256::from(prepaid_eth - remaining_eth); + assert_eq!( + charged - refunded, + info.eth_to_token_amount(net_eth), + "prepaid {prepaid_eth}, remaining {remaining_eth}" + ); + } + } + } + + /// The Celadon change is observable, so the fork gate in the handler is not + /// cosmetic: on an exact deduction the credit is zero and the two roundings + /// disagree for every inexact refund. + #[test] + fn floor_and_ceiling_refunds_differ() { + let info = TokenFeeInfo { + price_ratio: U256::from(3u64), + scale: U256::from(1u64), + ..Default::default() + }; + + // Exact deduction (9 / 3) → no credit; refunding 4 ceils to 2, floors to 1. + let (_, credit) = info.eth_to_token_amount_with_credit(U256::from(9u64)); + assert_eq!(credit, U256::ZERO); + assert_eq!(info.eth_to_token_amount(U256::from(4u64)), U256::from(2u64)); + assert_eq!( + info.eth_to_token_amount_floor(U256::from(4u64), credit), + U256::from(1u64) + ); + } + + /// Flooring can reach zero where the ceiling never does. The handler skips + /// the transfer in that case, matching go-ethereum's `TransferAltTokenHybrid`. + #[test] + fn floor_refund_can_be_zero() { + let info = TokenFeeInfo { + price_ratio: U256::from(5u64), + scale: U256::from(1u64), + ..Default::default() + }; + + let (_, credit) = info.eth_to_token_amount_with_credit(U256::from(5u64)); + assert_eq!(credit, U256::ZERO); + assert_eq!(info.eth_to_token_amount(U256::from(1u64)), U256::from(1u64)); + assert_eq!( + info.eth_to_token_amount_floor(U256::from(1u64), credit), + U256::ZERO + ); + } + + /// A misconfigured token refunds nothing rather than the `U256::MAX` the + /// ceiling path returns to make the deduction fail. + #[test] + fn floor_refund_of_a_misconfigured_token_is_zero() { + for info in [ + TokenFeeInfo { + price_ratio: U256::ZERO, + scale: U256::from(1u64), + ..Default::default() + }, + TokenFeeInfo { + price_ratio: U256::from(1u64), + scale: U256::ZERO, + ..Default::default() + }, + ] { + assert_eq!( + info.eth_to_token_amount_floor(U256::from(10u64), U256::from(3u64)), + U256::ZERO + ); + let (amount, credit) = info.eth_to_token_amount_with_credit(U256::from(10u64)); + assert_eq!(amount, U256::MAX); + assert_eq!(credit, U256::ZERO); + } + } + #[test] fn test_encode_balance_of() { let account = address!("1234567890123456789012345678901234567890"); 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/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index b6abf576..dbcacf6d 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -7,7 +7,10 @@ use alloy_evm::Database; use alloy_primitives::{Address, U256}; use morph_chainspec::hardfork::MorphHardfork; -use morph_primitives::{MorphTxEnvelope, transaction::morph_transaction::MORPH_TX_VERSION_1}; +use morph_primitives::{ + MorphTxEnvelope, + transaction::morph_transaction::{MORPH_TX_VERSION_1, MORPH_TX_VERSION_2}, +}; use morph_revm::{MorphEvmEnv, TokenFeeInfo}; use crate::MorphTxError; @@ -67,6 +70,17 @@ pub fn validate_morph_tx( }); } + // 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(), + }); + } + if let Err(reason) = morph_tx.validate() { return Err(MorphTxError::InvalidFormat { reason: reason.to_string(), @@ -229,6 +243,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( @@ -307,6 +322,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( @@ -345,6 +361,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( @@ -389,6 +406,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( @@ -427,6 +445,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( @@ -451,4 +470,105 @@ mod tests { "expected TokenNotFound {{ token_id: 42 }}, got {err:?}" ); } + + fn v2_eth_fee_envelope( + authorization_list: Vec, + ) -> MorphTxEnvelope { + let tx = TxMorph { + chain_id: 2818, + nonce: 0, + gas_limit: 100_000, + max_fee_per_gas: 1_000_000_000, + max_priority_fee_per_gas: 500_000_000, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + access_list: Default::default(), + version: MORPH_TX_VERSION_2, + fee_token_id: 0, + fee_limit: U256::ZERO, + reference: None, + memo: None, + authorization_list, + input: Default::default(), + }; + MorphTxEnvelope::Morph(Signed::new_unchecked( + tx, + Signature::test_signature(), + B256::ZERO, + )) + } + + fn sample_authorization() -> alloy_eips::eip7702::SignedAuthorization { + alloy_eips::eip7702::Authorization { + chain_id: U256::from(2818), + address: address!("0000000000000000000000000000000000000042"), + nonce: 0, + } + .into_signed(Signature::test_signature()) + } + + #[test] + fn test_validate_morph_tx_v2_rejected_before_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, + }; + let mut db = EmptyDB::default(); + + let err = validate_morph_tx(&mut db, &input).unwrap_err(); + assert_eq!( + err, + MorphTxError::InvalidFormat { + reason: "MorphTx version 2 is not yet active (celadon fork not reached)" + .to_string(), + } + ); + } + + #[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, + }; + 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, + }; + let mut db = EmptyDB::default(); + + let result = validate_morph_tx(&mut db, &input).unwrap(); + assert!(!result.uses_token_fee); + + input.hardfork = MorphHardfork::Jade; + let err = validate_morph_tx(&mut db, &input).unwrap_err(); + assert_eq!( + err, + MorphTxError::InvalidFormat { + reason: "MorphTx version 2 is not yet active (celadon fork not reached)" + .to_string(), + } + ); + } } diff --git a/crates/txpool/src/transaction.rs b/crates/txpool/src/transaction.rs index 21411db8..08690a73 100644 --- a/crates/txpool/src/transaction.rs +++ b/crates/txpool/src/transaction.rs @@ -273,6 +273,7 @@ mod tests { fee_limit: U256::from(1000u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Bytes::new(), }; let sig = Signature::test_signature(); diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index 1afea643..a6c1f955 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -935,6 +935,7 @@ mod tests { fee_limit: U256::from(300_000u64), reference: None, memo: None, + authorization_list: Vec::new(), input: Default::default(), }; let envelope = MorphTxEnvelope::Morph(Signed::new_unchecked(