diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cd1be31e..055cb26e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: cache-on-failure: true - name: Install nextest - uses: taiki-e/install-action@v2.87.5 + uses: taiki-e/install-action@v2.87.11 with: tool: cargo-nextest @@ -86,7 +86,7 @@ jobs: cache-on-failure: true - name: Install nextest - uses: taiki-e/install-action@v2.87.5 + uses: taiki-e/install-action@v2.87.11 with: tool: cargo-nextest 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/runner.rs b/bin/morph-statetest/src/runner.rs index a3c650b9..1fed0dc6 100644 --- a/bin/morph-statetest/src/runner.rs +++ b/bin/morph-statetest/src/runner.rs @@ -246,6 +246,15 @@ fn validation_error( where E: std::fmt::Display, { + // `expectException` is checked for presence, deliberately not for its text. + // go-ethereum's own statetest harness does the same -- `tests/state_test.go` + // returns early on `len(ExpectException) > 0` under a standing + // "TODO check error string" -- so matching on the text here would make this + // runner stricter than the client the fixtures are generated from, and a + // fixture imported from go-ethereum could fail on wording alone. The string + // stays in the JSON as documentation of which failure the case is meant to + // provoke; the assertion is that the transaction is *rejected*, which is what + // both clients agree on. match (&test.expect_exception, exec_result) { (Some(_), Err(_)) => return None, (Some(expected), Ok(_)) => { diff --git a/bin/morph-statetest/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/fee_token_internal_calls.rs b/bin/morph-statetest/tests/fee_token_internal_calls.rs new file mode 100644 index 00000000..16958bad --- /dev/null +++ b/bin/morph-statetest/tests/fee_token_internal_calls.rs @@ -0,0 +1,76 @@ +//! Golden roots/logs generated by morph-geth (Emerald and Jade): the original twelve +//! cases on 5744b8f66, the three later ones on 4012f174b, which differs only in the +//! transaction-size limit in `core/tx_pool.go` and reproduces all twelve unchanged. +//! The gas constants are transaction totals; geth's statetest tool subtracts +//! intrinsic gas when the total is at least the intrinsic cost. +use morph_statetest::runner::run_suite_str; + +#[test] +fn fee_token_calls_match_geth() { + let outcomes = run_suite_str(include_str!("fixtures/fee_token_internal_calls.json")).unwrap(); + assert_eq!(outcomes.len(), 30); + for outcome in outcomes { + assert!( + outcome.pass, + "{} / {}: {}", + outcome.test, outcome.fork, outcome.error_msg + ); + let gas = match outcome.test.as_str() { + "deduct_clear" => 16_800, + "main_revert" => 16_804, + "main_oog" => 95_200, + "main_restores_cleared_slot" => 23_291, + "balance_writes" => 0, + "zero_fee_reads_balance" => 23_574, + // The transaction's own frame reads MSIZE and MLOAD(0) into storage before + // writing either. go-ethereum allocates a fresh `Memory` per interpreter run, + // so it sees zeros and neither SSTORE changes state; a client whose fee frames + // leave their bytes on the transaction's memory writes two non-zero slots and + // misses this root by two SSTORE_SETs. + "main_reads_uninitialized_memory" => 25_417, + // The registry's direct-slot path. `slot_deduct_clear` is the same transaction + // as `deduct_clear`, which costs 16_800: clearing the payer's balance through a + // real `transfer` books a `+4800` SSTORE refund that reaches the transaction, + // while `SetState` books nothing. That 4_200 is the only way the two modes bill + // differently. + "slot_deduct_keep" | "slot_deduct_clear" => 21_000, + "deduct_keep" + | "origin_guard" + | "gasprice_guard" + | "refund_false_keeps_transfer" + | "zero_fee" + | "refund_revert_rolls_back_transfer" => 21_000, + name => panic!("missing gas expectation for {name}"), + }; + assert_eq!(outcome.gas_used, gas, "{} / {}", outcome.test, outcome.fork); + } +} + +/// Replays mainnet transaction `0x9ebfdac9040d7c2a8739ffdaae8baf5e7aa22fdb48585f80592de4b4cf39ed44` +/// (block 26836567, Jade): a MorphTx V0 that pays its fee in token 1, which the registry still +/// resolves through the direct-slot path, and whose call transfers that same token. The fixtures +/// above are synthetic; this is slot mode as mainnet actually ran it, which every node must keep +/// replaying identically after the registry moves off slot mode. +/// +/// go-ethereum's state-test runner signs with `secretKey` and fixes the chain id to 1, so the +/// sender is the harness account `0xa94f…6ebf0b`, carrying the real sender's nonce, EIP-7702 +/// delegation code and token balance under its own balance slot, and the fee vault's balance sits +/// under the harness vault. The prestate tracer does not see the slots the fee logic reads straight +/// from state, so the token balances, the registry entry and the L1 gas price oracle come from the +/// parent block, which is exact here because the transaction is alone in its block. Roots are from +/// morph-geth 4012f174b. The logs root equals the on-chain receipt's logs with the sender topic +/// substituted, and the gas matches the on-chain receipt: the substitution changes the L1 data fee, +/// and with it the token amount charged, but not the gas burned. +#[test] +fn mainnet_slot_mode_fee_token_transfer_matches_geth() { + let outcomes = + run_suite_str(include_str!("fixtures/mainnet_slot_mode_fee_token.json")).unwrap(); + assert_eq!(outcomes.len(), 1); + let outcome = &outcomes[0]; + assert!( + outcome.pass, + "{} / {}: {}", + outcome.test, outcome.fork, outcome.error_msg + ); + assert_eq!(outcome.gas_used, 51_257); +} diff --git a/bin/morph-statetest/tests/fixtures/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/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json b/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json new file mode 100644 index 00000000..c8ca1348 --- /dev/null +++ b/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json @@ -0,0 +1,1324 @@ +{ + "balance_writes": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x60015f55620f42405f5260205ff3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x7c1c360fabaec3c980927e916a02d97a6ef79b93d601e0a1616e4a8014ce5669", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "expectException": "TokenBalanceQueryFailed" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x7c1c360fabaec3c980927e916a02d97a6ef79b93d601e0a1616e4a8014ce5669", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "expectException": "TokenBalanceQueryFailed" + } + ] + } + }, + "deduct_clear": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xfb0ac627f1b92b10d0f1c60ef87667dc42cd3eb7a919ceb84e9427c63f9b6125", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xfb0ac627f1b92b10d0f1c60ef87667dc42cd3eb7a919ceb84e9427c63f9b6125", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "deduct_keep": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xe1a1a7e57eaf4aee439d65814e0a688a2775ee81fbdc2c4d77f06534d7088eac", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xe1a1a7e57eaf4aee439d65814e0a688a2775ee81fbdc2c4d77f06534d7088eac", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "gasprice_guard": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x3a600a14600a575f5ffd5b36604414601e576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0d1c7d12041d949d73f15b8d08dbdcacecdf844675d40d41632a0bfb463ef36d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0d1c7d12041d949d73f15b8d08dbdcacecdf844675d40d41632a0bfb463ef36d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_oog": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0xfe", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x1b9c3313759258951e68ab4e7754f0859f0b049431eb3d1e7a2ff460ceafe82b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x1b9c3313759258951e68ab4e7754f0859f0b049431eb3d1e7a2ff460ceafe82b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_reads_uninitialized_memory": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x5960005560005160015500", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xb05c0ccb41d7ad9b13f5e8ac1b93f42dc8c8b8bbd312e0af5f91fa5525f47a49", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xb05c0ccb41d7ad9b13f5e8ac1b93f42dc8c8b8bbd312e0af5f91fa5525f47a49", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_restores_cleared_slot": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x3660041460385736604414601a576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f35b6001325500", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x3000000000000000000000000000000000000003", + "value": [ + "0x0" + ], + "data": [ + "0xdeadbeef" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdda0e3d5c58b32c62b9168cf468233be793eb5d1d0ab886dacdaf490b84a72e2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdda0e3d5c58b32c62b9168cf468233be793eb5d1d0ab886dacdaf490b84a72e2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_revert": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x5f5ffd", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x4bfefc48ad519124f544514a1d413329e38fce98ee522a763a79fdd96b68fdc2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x4bfefc48ad519124f544514a1d413329e38fce98ee522a763a79fdd96b68fdc2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "origin_guard": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x3273a94f5374fce5edbc8e2a8697c15331677e6ebf0b14601d575f5ffd5b366044146031576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x3ed6676fa11fc0e4294ed8a446546a0c3c43f5b664bfa28f4104489d6390102a", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x3ed6676fa11fc0e4294ed8a446546a0c3c43f5b664bfa28f4104489d6390102a", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "refund_false_keeps_transfer": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b602435803354033355600435805482019055505f5fa03373a94f5374fce5edbc8e2a8697c15331677e6ebf0b145f5260205ff3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x02e23c9ed829c4eb474097e4babff09ad0214f7d1f4affe7f661f4927ecba46c", + "logs": "0x773911b8633886efdf9360b2936f4f0ee4ae34b8cd1c0ffd0c06861d1c751aeb" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x02e23c9ed829c4eb474097e4babff09ad0214f7d1f4affe7f661f4927ecba46c", + "logs": "0x773911b8633886efdf9360b2936f4f0ee4ae34b8cd1c0ffd0c06861d1c751aeb" + } + ] + } + }, + "refund_revert_rolls_back_transfer": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b602435803354033355600435805482019055505f5fa03373a94f5374fce5edbc8e2a8697c15331677e6ebf0b146047575f5ffd5b600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0b438cf7a8bddc48e5097828159eed83ccdf052406ad296ec69cb8e194de0737", + "logs": "0x16ecc5d734ad798966d0ece89ce9fc365a53ed550661fb7607afee69c831daab" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0b438cf7a8bddc48e5097828159eed83ccdf052406ad296ec69cb8e194de0737", + "logs": "0x16ecc5d734ad798966d0ece89ce9fc365a53ed550661fb7607afee69c831daab" + } + ] + } + }, + "slot_deduct_clear": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x9734b052146069605dcf2a05300c1dd5cd5852a2844e5491b2eb25d6daa909bc": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x84e1019fa81970b8488a94f2651174f4b8d901e6d71c51a3f2d43bc1b2d2736f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x84e1019fa81970b8488a94f2651174f4b8d901e6d71c51a3f2d43bc1b2d2736f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "slot_deduct_keep": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x9734b052146069605dcf2a05300c1dd5cd5852a2844e5491b2eb25d6daa909bc": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xd4d266ff3372ea95e9fd510e2e3be04679fdb72b83671acf29222402bdb4e0e8", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xd4d266ff3372ea95e9fd510e2e3be04679fdb72b83671acf29222402bdb4e0e8", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "zero_fee": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x0", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b602435803354033355600435805482019055505f5fa0600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0x0", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x316b400ab83009d30ab6a987f74a061de29c2f3116918ca244b7267a69b75be2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x316b400ab83009d30ab6a987f74a061de29c2f3116918ca244b7267a69b75be2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "zero_fee_reads_balance": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x0", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0x0", + "gasLimit": [ + "0x186a0" + ], + "to": "0x3000000000000000000000000000000000000003", + "value": [ + "0x0" + ], + "data": [ + "0x70a08231000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xeb101e868f55079346e3fc2d78473e1ebf0b446070185d3a266f8f0f81f534a0", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xeb101e868f55079346e3fc2d78473e1ebf0b446070185d3a266f8f0f81f534a0", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + } +} diff --git a/bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json b/bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json new file mode 100644 index 00000000..30868ac0 --- /dev/null +++ b/bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json @@ -0,0 +1,96 @@ +{ + "mainnet_26836567_slot_mode_fee_token_transfer": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x2aea540", + "currentNumber": "0x1997e57", + "currentTimestamp": "0x6aa9b017", + "currentBaseFee": "0xf4240", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0x0", + "nonce": "0x1", + "code": "0xef0100a845c74344fc9405b1fcf712f04668979573c1bf", + "storage": {} + }, + "0xc7d67a9cbb121b3b0b9c053dd9f469523243379a": { + "balance": "0x0", + "nonce": "0x1", + "code": "0x363d3d373d3d3d363d73530000000000000000000000000000000000000d5af43d82803e903d91602b57fd5bf3", + "storage": { + "0x079b50c9ea8b3523e07d83295bfee8283b220c82526d7e046695778db0093d05": "0x00000000000000000000000000000000000000000000000000000000002d4c65", + "0x660c1bc2d47d7d811c3cf3324a889eebebd8edca5e38a70b3ba2905b38e251d3": "0x000000000000000000000000000000000000000000000000000000000011c927" + } + }, + "0x530000000000000000000000000000000000000d": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x608060405234801561000f575f80fd5b506004361061016e575f3560e01c806370a08231116100d25780639dc29fac11610088578063c820f14611610063578063c820f14614610354578063d505accf14610367578063dd62ed3e1461037a575f80fd5b80639dc29fac1461031b578063a457c2d71461032e578063a9059cbb14610341575f80fd5b80637ecebe00116100b85780637ecebe00146102e557806384b0196e146102f857806395d89b4114610313575f80fd5b806370a0823114610290578063797594b0146102c5575f80fd5b8063313ce56711610127578063395093511161010d57806339509351146102555780634000aea01461026857806340c10f191461027b575f80fd5b8063313ce5671461021d5780633644e5151461024d575f80fd5b8063116191b611610157578063116191b6146101b357806318160ddd146101f857806323b872dd1461020a575f80fd5b806306fdde0314610172578063095ea7b314610190575b5f80fd5b61017a6103bf565b60405161018791906119ce565b60405180910390f35b6101a361019e366004611a0f565b61044f565b6040519015158152602001610187565b60cc546101d39073ffffffffffffffffffffffffffffffffffffffff1681565b60405173ffffffffffffffffffffffffffffffffffffffff9091168152602001610187565b6035545b604051908152602001610187565b6101a3610218366004611a37565b610468565b60cd5474010000000000000000000000000000000000000000900460ff1660405160ff9091168152602001610187565b6101fc61048b565b6101a3610263366004611a0f565b610499565b6101a3610276366004611a70565b6104e4565b61028e610289366004611a0f565b61054d565b005b6101fc61029e366004611af0565b73ffffffffffffffffffffffffffffffffffffffff165f9081526033602052604090205490565b60cd546101d39073ffffffffffffffffffffffffffffffffffffffff1681565b6101fc6102f3366004611af0565b6105c7565b6103006105f1565b6040516101879796959493929190611b09565b61017a6106ae565b61028e610329366004611a0f565b6106bd565b6101a361033c366004611a0f565b61072e565b6101a361034f366004611a0f565b6107e4565b61028e610362366004611cad565b6107f1565b61028e610375366004611d3d565b610a08565b6101fc610388366004611da2565b73ffffffffffffffffffffffffffffffffffffffff9182165f90815260346020908152604080832093909416825291909152205490565b6060603680546103ce90611dd3565b80601f01602080910402602001604051908101604052809291908181526020018280546103fa90611dd3565b80156104455780601f1061041c57610100808354040283529160200191610445565b820191905f5260205f20905b81548152906001019060200180831161042857829003601f168201915b5050505050905090565b5f3361045c818585610b90565b60019150505b92915050565b5f33610475858285610d0f565b610480858585610dcb565b506001949350505050565b5f610494610ff1565b905090565b335f81815260346020908152604080832073ffffffffffffffffffffffffffffffffffffffff8716845290915281205490919061045c90829086906104df908790611e1e565b610b90565b5f6104ef85856107e4565b5073ffffffffffffffffffffffffffffffffffffffff85163b1561048057610480858585858080601f0160208091040260200160405190810160405280939291908181526020018383808284375f92019190915250610ffa92505050565b60cc5473ffffffffffffffffffffffffffffffffffffffff1633146105b95760405162461bcd60e51b815260206004820152600c60248201527f4f6e6c792047617465776179000000000000000000000000000000000000000060448201526064015b60405180910390fd5b6105c38282611085565b5050565b73ffffffffffffffffffffffffffffffffffffffff81165f90815260996020526040812054610462565b5f6060805f805f60606065545f801b14801561060d5750606654155b6106595760405162461bcd60e51b815260206004820152601560248201527f4549503731323a20556e696e697469616c697a6564000000000000000000000060448201526064016105b0565b61066161115e565b61066961116d565b604080515f808252602082019092527f0f000000000000000000000000000000000000000000000000000000000000009b939a50919850469750309650945092509050565b6060603780546103ce90611dd3565b60cc5473ffffffffffffffffffffffffffffffffffffffff1633146107245760405162461bcd60e51b815260206004820152600c60248201527f4f6e6c792047617465776179000000000000000000000000000000000000000060448201526064016105b0565b6105c3828261117c565b335f81815260346020908152604080832073ffffffffffffffffffffffffffffffffffffffff87168452909152812054909190838110156107d75760405162461bcd60e51b815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f7760448201527f207a65726f00000000000000000000000000000000000000000000000000000060648201526084016105b0565b6104808286868403610b90565b5f3361045c818585610dcb565b5f54610100900460ff161580801561080f57505f54600160ff909116105b806108285750303b15801561082857505f5460ff166001145b61089a5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201527f647920696e697469616c697a656400000000000000000000000000000000000060648201526084016105b0565b5f80547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0016600117905580156108f6575f80547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff166101001790555b6108ff86611309565b61090986866113c7565b60cd805460cc805473ffffffffffffffffffffffffffffffffffffffff8088167fffffffffffffffffffffffff00000000000000000000000000000000000000009283161790925590851660ff88167401000000000000000000000000000000000000000002919091167fffffffffffffffffffffff000000000000000000000000000000000000000000909216919091171790558015610a00575f80547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b505050505050565b83421115610a585760405162461bcd60e51b815260206004820152601d60248201527f45524332305065726d69743a206578706972656420646561646c696e6500000060448201526064016105b0565b5f7f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9888888610a868c61144d565b60408051602081019690965273ffffffffffffffffffffffffffffffffffffffff94851690860152929091166060840152608083015260a082015260c0810186905260e0016040516020818303038152906040528051906020012090505f610aed82611481565b90505f610afc828787876114c8565b90508973ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614610b795760405162461bcd60e51b815260206004820152601e60248201527f45524332305065726d69743a20696e76616c6964207369676e6174757265000060448201526064016105b0565b610b848a8a8a610b90565b50505050505050505050565b73ffffffffffffffffffffffffffffffffffffffff8316610c185760405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f2061646460448201527f726573730000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8216610ca15760405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f20616464726560448201527f737300000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8381165f8181526034602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92591015b60405180910390a3505050565b73ffffffffffffffffffffffffffffffffffffffff8381165f908152603460209081526040808320938616835292905220547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8114610dc55781811015610db85760405162461bcd60e51b815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e636500000060448201526064016105b0565b610dc58484848403610b90565b50505050565b73ffffffffffffffffffffffffffffffffffffffff8316610e545760405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f20616460448201527f647265737300000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8216610edd5760405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201527f657373000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff83165f9081526033602052604090205481811015610f785760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e742065786365656473206260448201527f616c616e6365000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8085165f8181526033602052604080822086860390559286168082529083902080548601905591517fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef90610fe49086815260200190565b60405180910390a3610dc5565b5f6104946114ee565b6040517fa4c0ed36000000000000000000000000000000000000000000000000000000008152839073ffffffffffffffffffffffffffffffffffffffff82169063a4c0ed369061105290339087908790600401611e56565b5f604051808303815f87803b158015611069575f80fd5b505af115801561107b573d5f803e3d5ffd5b5050505050505050565b73ffffffffffffffffffffffffffffffffffffffff82166110e85760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f20616464726573730060448201526064016105b0565b8060355f8282546110f99190611e1e565b909155505073ffffffffffffffffffffffffffffffffffffffff82165f818152603360209081526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b6060606780546103ce90611dd3565b6060606880546103ce90611dd3565b73ffffffffffffffffffffffffffffffffffffffff82166112055760405162461bcd60e51b815260206004820152602160248201527f45524332303a206275726e2066726f6d20746865207a65726f2061646472657360448201527f730000000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff82165f90815260336020526040902054818110156112a05760405162461bcd60e51b815260206004820152602260248201527f45524332303a206275726e20616d6f756e7420657863656564732062616c616e60448201527f636500000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff83165f8181526033602090815260408083208686039055603580548790039055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9101610d02565b505050565b5f54610100900460ff166113855760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b6113c4816040518060400160405280600181526020017f3100000000000000000000000000000000000000000000000000000000000000815250611561565b50565b5f54610100900460ff166114435760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b6105c38282611604565b73ffffffffffffffffffffffffffffffffffffffff81165f9081526099602052604090208054600181018255905b50919050565b5f61046261148d610ff1565b836040517f19010000000000000000000000000000000000000000000000000000000000008152600281019290925260228201526042902090565b5f805f6114d787878787611699565b915091506114e481611781565b5095945050505050565b5f7f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f6115186118e5565b61152061193d565b60408051602081019490945283019190915260608201524660808201523060a082015260c00160405160208183030381529060405280519060200120905090565b5f54610100900460ff166115dd5760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b60676115e98382611ede565b5060686115f68282611ede565b50505f606581905560665550565b5f54610100900460ff166116805760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b603661168c8382611ede565b5060376113048282611ede565b5f807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a08311156116ce57505f90506003611778565b604080515f8082526020820180845289905260ff881692820192909252606081018690526080810185905260019060a0016020604051602081039080840390855afa15801561171f573d5f803e3d5ffd5b50506040517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0015191505073ffffffffffffffffffffffffffffffffffffffff8116611772575f60019250925050611778565b91505f90505b94509492505050565b5f81600481111561179457611794611ff6565b0361179c5750565b60018160048111156117b0576117b0611ff6565b036117fd5760405162461bcd60e51b815260206004820152601860248201527f45434453413a20696e76616c6964207369676e6174757265000000000000000060448201526064016105b0565b600281600481111561181157611811611ff6565b0361185e5760405162461bcd60e51b815260206004820152601f60248201527f45434453413a20696e76616c6964207369676e6174757265206c656e6774680060448201526064016105b0565b600381600481111561187257611872611ff6565b036113c45760405162461bcd60e51b815260206004820152602260248201527f45434453413a20696e76616c6964207369676e6174757265202773272076616c60448201527f756500000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b5f806118ef61115e565b805190915015611906578051602090910120919050565b60655480156119155792915050565b7fc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4709250505090565b5f8061194761116d565b80519091501561195e578051602090910120919050565b60665480156119155792915050565b5f81518084525f5b8181101561199157602081850181015186830182015201611975565b505f6020828601015260207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f83011685010191505092915050565b602081525f6119e0602083018461196d565b9392505050565b803573ffffffffffffffffffffffffffffffffffffffff81168114611a0a575f80fd5b919050565b5f8060408385031215611a20575f80fd5b611a29836119e7565b946020939093013593505050565b5f805f60608486031215611a49575f80fd5b611a52846119e7565b9250611a60602085016119e7565b9150604084013590509250925092565b5f805f8060608587031215611a83575f80fd5b611a8c856119e7565b935060208501359250604085013567ffffffffffffffff80821115611aaf575f80fd5b818701915087601f830112611ac2575f80fd5b813581811115611ad0575f80fd5b886020828501011115611ae1575f80fd5b95989497505060200194505050565b5f60208284031215611b00575f80fd5b6119e0826119e7565b7fff00000000000000000000000000000000000000000000000000000000000000881681525f602060e06020840152611b4560e084018a61196d565b8381036040850152611b57818a61196d565b6060850189905273ffffffffffffffffffffffffffffffffffffffff8816608086015260a0850187905284810360c0860152855180825260208088019350909101905f5b81811015611bb757835183529284019291840191600101611b9b565b50909c9b505050505050505050505050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b5f82601f830112611c05575f80fd5b813567ffffffffffffffff80821115611c2057611c20611bc9565b604051601f83017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f01168101908282118183101715611c6657611c66611bc9565b81604052838152866020858801011115611c7e575f80fd5b836020870160208301375f602085830101528094505050505092915050565b803560ff81168114611a0a575f80fd5b5f805f805f60a08688031215611cc1575f80fd5b853567ffffffffffffffff80821115611cd8575f80fd5b611ce489838a01611bf6565b96506020880135915080821115611cf9575f80fd5b50611d0688828901611bf6565b945050611d1560408701611c9d565b9250611d23606087016119e7565b9150611d31608087016119e7565b90509295509295909350565b5f805f805f805f60e0888a031215611d53575f80fd5b611d5c886119e7565b9650611d6a602089016119e7565b95506040880135945060608801359350611d8660808901611c9d565b925060a0880135915060c0880135905092959891949750929550565b5f8060408385031215611db3575f80fd5b611dbc836119e7565b9150611dca602084016119e7565b90509250929050565b600181811c90821680611de757607f821691505b60208210810361147b577f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b80820180821115610462577f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b73ffffffffffffffffffffffffffffffffffffffff84168152826020820152606060408201525f611e8a606083018461196d565b95945050505050565b601f82111561130457805f5260205f20601f840160051c81016020851015611eb85750805b601f840160051c820191505b81811015611ed7575f8155600101611ec4565b5050505050565b815167ffffffffffffffff811115611ef857611ef8611bc9565b611f0c81611f068454611dd3565b84611e93565b602080601f831160018114611f5e575f8415611f285750858301515b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600386901b1c1916600185901b178555610a00565b5f858152602081207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08616915b82811015611faa57888601518255948401946001909101908401611f8b565b5085821015611fe657878501517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600388901b60f8161c191681555b5050505050600190811b01905550565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602160045260245ffdfea164736f6c6343000818000a", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x000000000000000000000000c7d67a9cbb121b3b0b9c053dd9f469523243379a", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000034", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000000601", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x00000000000000000000000000000000000000000000000000000000000f4240", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x00000000000000000000000000000000000000000000000000017969711b4fae" + } + }, + "0x530000000000000000000000000000000000000f": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000000": "0x000000000000000000000000f4a82c4ab398771c6c7a0c0dedf0b34fc0161192", + "0x0000000000000000000000000000000000000000000000000000000000000001": "0x000000000000000000000000000000000000000000000000000000000c669869", + "0x0000000000000000000000000000000000000000000000000000000000000002": "0x00000000000000000000000000000000000000000000000000000000000009c4", + "0x0000000000000000000000000000000000000000000000000000000000000003": "0x000000000000000000000000000000000000000000000000000000003b9aca00", + "0x0000000000000000000000000000000000000000000000000000000000000004": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000006": "0x00000000000000000000000000000000000000000000000000000000005cc6c6", + "0x0000000000000000000000000000000000000000000000000000000000000007": "0x000000000000000000000000000000000000000000000000000001e6a77be7ac", + "0x0000000000000000000000000000000000000000000000000000000000000008": "0x0000000000000000000000000000000000000000000000000000000034faa1da", + "0x0000000000000000000000000000000000000000000000000000000000000009": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x1", + "gasPrice": "0xf4240", + "gasLimit": [ + "0x181b1" + ], + "to": "0xc7d67a9cbb121b3b0b9c053dd9f469523243379a", + "value": [ + "0x0" + ], + "data": [ + "0xa9059cbb0000000000000000000000007ef77f9a8cbf84d1becf98b7768d49c7b4fef6ed00000000000000000000000000000000000000000000000000000000002d2a80" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x78bee0ec231c3fc7b87ebd7ec48c3d3c61a35338749c8727315fd2c8507daaf1", + "logs": "0x735e75fcb5f5511a4bf02e5f79e7b123630ef4b1b7208a71bf2eaf451221bd59" + } + ] + } + } +} diff --git a/crates/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/factory.rs b/crates/evm/src/block/factory.rs index 0ba66897..a1219465 100644 --- a/crates/evm/src/block/factory.rs +++ b/crates/evm/src/block/factory.rs @@ -74,6 +74,6 @@ impl BlockExecutorFactory for MorphBlockExecutorFactory { DB: StateDB, I: Inspector>, { - MorphBlockExecutor::new(evm, self.spec.clone(), self.receipt_builder) + MorphBlockExecutor::new(evm, self.receipt_builder) } } diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index bb032056..fbdecf02 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -23,7 +23,6 @@ use alloy_evm::{ }, }; use alloy_primitives::{Address, Log, U256}; -use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; use morph_primitives::{MorphReceipt, MorphTxEnvelope}; use morph_revm::{L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, TokenFeeInfo, evm::MorphContext}; use reth_primitives_traits::Recovered; @@ -84,8 +83,6 @@ impl TxResult for MorphTxResult { pub struct MorphBlockExecutor { /// The EVM used by executor (owned, not a reference) evm: MorphEvm, - /// Chain specification - spec: std::sync::Arc, /// Receipt builder receipt_builder: DefaultMorphReceiptBuilder, /// Receipts of executed transactions @@ -97,9 +94,6 @@ pub struct MorphBlockExecutor { /// Unlike receipt gas, L1 messages reserve their full gas limit because Morph geth does not /// return their unused gas to the block gas pool. gas_pool_used: u64, - /// Cached hardfork for this block (constant across all transactions). - /// Set in `apply_pre_execution_changes`, reused in `commit_transaction`. - hardfork: MorphHardfork, } impl MorphBlockExecutor @@ -111,21 +105,14 @@ where /// /// # Arguments /// * `evm` - The EVM instance configured for Morph execution - /// * `spec` - Chain specification containing hardfork information /// * `receipt_builder` - Builder for constructing transaction receipts - pub(crate) fn new( - evm: MorphEvm, - spec: std::sync::Arc, - receipt_builder: DefaultMorphReceiptBuilder, - ) -> Self { + pub(crate) fn new(evm: MorphEvm, receipt_builder: DefaultMorphReceiptBuilder) -> Self { Self { evm, - spec, receipt_builder, receipts: Vec::new(), gas_used: 0, gas_pool_used: 0, - hardfork: MorphHardfork::default(), } } @@ -138,7 +125,6 @@ where &mut self, tx: &MorphTxEnvelope, sender: Address, - hardfork: MorphHardfork, ) -> Result, BlockExecutionError> { if !tx.is_morph_tx() { return Ok(None); @@ -169,12 +155,13 @@ where let token_info = match self.evm.cached_token_fee_info() { Some(info) => Some(info), - None => { - TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork) - .map_err(|e| { - BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) - })? - } + // Only `price_ratio` and `scale` are read below, and both come straight from + // registry storage. `load_storage_only` reads exactly that and never builds a + // temporary EVM to resolve a balance this receipt has no use for. + None => TokenFeeInfo::load_storage_only(self.evm.db_mut(), fee_token_id, sender) + .map_err(|e| { + BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) + })?, }; Ok(token_info.map(|info| MorphReceiptTxFields { @@ -218,12 +205,6 @@ where .basic(L1_GAS_PRICE_ORACLE_ADDRESS) .map_err(BlockExecutionError::other)?; - let block_number: u64 = self.evm.block().number.to(); - let hardfork = self - .spec - .morph_hardfork_at(block_number, self.evm.block().timestamp.to::()); - self.hardfork = hardfork; - Ok(()) } @@ -300,7 +281,7 @@ where // are tracing-only — the trait API no longer permits us to surface errors // from `commit_transaction`. let (tx, signer) = recovered.into_parts(); - let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer, self.hardfork) { + let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer) { Ok(fields) => fields, Err(err) => { tracing::error!( diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 347c8265..6a9d415b 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -161,8 +161,12 @@ impl MorphReceiptBuilder for DefaultMorphReceiptBuilder { // Assemble logs in chronological order matching go-ethereum: // [deduct Transfer] + [main tx logs] + [refund Transfer] - // Fee logs are cached separately from the journal so they survive - // main tx revert (revm's ExecutionResult::Revert carries no logs). + // The fee logs cannot come from `result`. The call-mode deduction runs a + // mid-transaction `finalize()` that clears the journal's logs, so the handler + // moves them out first, and it drains the refund's logs the same way. `result` + // carries only the main frame's logs, which a revert has already discarded, + // while the fee logs survive it as they do in go-ethereum, whose `StateDB.logs` + // sit outside the snapshot/revert mechanism. let is_success = result.is_success(); let main_logs = result.into_logs(); let mut logs = @@ -340,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/evm/src/evm.rs b/crates/evm/src/evm.rs index e2454e4d..368f605d 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -13,7 +13,6 @@ use alloy_evm::{ use alloy_primitives::{Address, Bytes}; use morph_chainspec::hardfork::MorphHardfork; use morph_revm::{MorphHaltReason, MorphInvalidTransaction, MorphTxEnv, evm::MorphContext}; -use reth_revm::MainContext; use std::ops::{Deref, DerefMut}; use crate::MorphBlockEnv; @@ -66,17 +65,7 @@ pub struct MorphEvm { impl MorphEvm { /// Create a new [`MorphEvm`] instance. pub fn new(db: DB, input: EvmEnv) -> Self { - let ctx = Context::mainnet() - .with_db(db) - .with_block(input.block_env) - .with_cfg(input.cfg_env) - .with_tx(Default::default()) - .with_chain(morph_revm::l1block::L1BlockInfo::default()); - - // Build the inner MorphEvm which creates precompiles once. - // Derive the PrecompilesMap from the inner's precompiles to avoid - // a second MorphPrecompiles::new_with_spec call. - let inner = morph_revm::MorphEvm::new(ctx, NoOpInspector {}); + let inner = morph_revm::MorphEvm::from_env(db, input, NoOpInspector {}); let precompiles_map = PrecompilesMap::from_static(inner.precompiles.precompiles()); Self { diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs index b61b5809..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), @@ -828,12 +853,33 @@ impl L1MessageBuilder { /// - token_address = `TEST_TOKEN_ADDRESS` /// - price_ratio = 1e18 (1:1 with ETH) /// - decimals = 18, isActive = true +/// - balanceSlot = 0, i.e. EVM-call mode +/// +/// Call mode is what mainnet runs: every registered fee token there has its +/// `balanceSlot` cleared, so the fee is moved by real `balanceOf` / `transfer` +/// calls into the token contract and the receipt carries their `Transfer` events. pub const TEST_TOKEN_ID: u16 = 1; /// Address of the test ERC20 token deployed in the test genesis. /// /// Pre-funded with 1000 tokens (1e21 wei) for test accounts 0 and 1. /// Address: `0x5300000000000000000000000000000000000022` +/// +/// The genesis gives it the optimized runtime of: +/// +/// ```solidity +/// contract Slot1Token { +/// uint256 private dummy; +/// mapping(address => uint256) public balanceOf; // slot 1 +/// event Transfer(address indexed from, address indexed to, uint256 value); +/// function transfer(address to, uint256 amount) external returns (bool) { ... } +/// } +/// ``` +/// +/// Real code is what makes the registry's EVM-call mode usable: the fee path calls +/// `balanceOf` and `transfer` on this contract rather than writing its storage +/// directly. Keeping `balanceOf` at slot 1 also lets [`test_token_balance_slot`] +/// derive the same slot independently as a test oracle. pub const TEST_TOKEN_ADDRESS: Address = Address::new([ 0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x22, @@ -849,9 +895,9 @@ pub const TEST_FEE_VAULT_ADDRESS: Address = Address::new([ /// Base slot of the test token's `balances` mapping. /// -/// The registry stores this one-based so that zero means "unknown", and -/// `morph_revm`'s token-fee reader subtracts one. The test genesis registers `2` -/// for `TEST_TOKEN_ID`, so the effective base slot is `1`. +/// The registry's own `balanceSlot` is zero (call mode), so this is not read from +/// the registry — it mirrors the layout of the token contract's bytecode so tests +/// can check balances without going through the fee-token code under test. const TEST_TOKEN_BALANCE_BASE_SLOT: u64 = 1; /// Storage slot holding `account`'s balance of the test ERC20 token. @@ -881,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()?; /// ``` @@ -889,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()?; /// ``` @@ -909,6 +955,7 @@ pub struct MorphTxBuilder { access_list: alloy_eips::eip2930::AccessList, reference: Option, memo: Option, + authorization_list: Vec, } impl MorphTxBuilder { @@ -916,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, @@ -933,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) @@ -990,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; @@ -1052,6 +1138,7 @@ impl MorphTxBuilder { fee_limit: self.fee_limit, reference: self.reference, memo: self.memo, + authorization_list: self.authorization_list, input: self.input, }; @@ -1064,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 09cc751b..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" } @@ -67,7 +68,7 @@ "code": "0x00", "storage": { "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000005300000000000000000000000000000000000022", - "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" @@ -75,7 +76,7 @@ }, "0x5300000000000000000000000000000000000022": { "balance": "0x0", - "code": "0x00", + "code": "0x608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806370a0823114610038578063a9059cbb1461006a575b5f5ffd5b61005761004636600461015e565b60016020525f908152604090205481565b6040519081526020015b60405180910390f35b61007d61007836600461017e565b61008d565b6040519015158152602001610061565b335f90815260016020526040812054828110156100da5760405162461bcd60e51b815260206004820152600760248201526662616c616e636560c81b604482015260640160405180910390fd5b335f81815260016020908152604080832087860390556001600160a01b03881680845292819020805488019055518681529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b80356001600160a01b0381168114610159575f5ffd5b919050565b5f6020828403121561016e575f5ffd5b61017782610143565b9392505050565b5f5f6040838503121561018f575f5ffd5b61019883610143565b94602093909301359350505056", "storage": { "0xa3c1274aadd82e4d12c8004c33fb244ca686dad4fcc8957fc5668588c11d9502": "0x00000000000000000000000000000000000000000000003635c9adc5dea00000", "0x3c8e904cdb19937d60d41c8d984b1a8803ad6e0891b4f9e032dcec2a22c2c7f5": "0x00000000000000000000000000000000000000000000003635c9adc5dea00000" 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 97cef34d..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; // ============================================================================= @@ -376,21 +379,6 @@ fn address_topic(address: Address) -> B256 { B256::from(topic) } -/// Optimized runtime for: -/// -/// ```solidity -/// contract Slot1Token { -/// uint256 private dummy; -/// mapping(address => uint256) public balanceOf; // slot 1 -/// event Transfer(address indexed from, address indexed to, uint256 value); -/// function transfer(address to, uint256 amount) external returns (bool) { ... } -/// } -/// ``` -/// -/// Keeping `balanceOf` at slot 1 lets the test token use the same storage layout -/// as `tests/assets/test-genesis.json` and the token registry's direct-slot path. -const SLOT1_ERC20_RUNTIME_CODE: &str = "0x608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806370a0823114610038578063a9059cbb1461006a575b5f5ffd5b61005761004636600461015e565b60016020525f908152604090205481565b6040519081526020015b60405180910390f35b61007d61007836600461017e565b61008d565b6040519015158152602001610061565b335f90815260016020526040812054828110156100da5760405162461bcd60e51b815260206004820152600760248201526662616c616e636560c81b604482015260640160405180910390fd5b335f81815260016020908152604080832087860390556001600160a01b03881680845292819020805488019055518681529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b80356001600160a01b0381168114610159575f5ffd5b919050565b5f6020828403121561016e575f5ffd5b61017782610143565b9392505050565b5f5f6040838503121561018f575f5ffd5b61019883610143565b94602093909301359350505056"; - /// After a successful MorphTx v0 with ERC20 fee, the sender's token balance /// must decrease (fee was charged from tokens, not ETH). #[tokio::test(flavor = "multi_thread")] @@ -492,7 +480,6 @@ async fn token_fee_transfer_gas_regression( let token_addr = morph_node::test_utils::TEST_TOKEN_ADDRESS; let (mut nodes, wallet) = TestNodeBuilder::new() .with_schedule(schedule) - .with_account_code(token_addr, SLOT1_ERC20_RUNTIME_CODE) .build() .await?; let mut node = nodes.pop().unwrap(); @@ -560,17 +547,24 @@ async fn token_fee_transfer_gas_regression( .iter() .filter(|log| log.address == token_addr && log.topics().first() == Some(&transfer_topic)) .collect(); + // In call mode the fee is moved by real ERC20 calls, so the transaction's own + // transfer arrives bracketed by them, in go-ethereum's order: deduction, main, + // reimbursement. assert_eq!( transfer_logs.len(), - 1, - "the main ERC20 transfer should execute against the fee token contract" + 3, + "receipt should carry the fee deduction, the main transfer and the fee refund" ); assert_eq!(transfer_logs[0].topics()[1], address_topic(sender)); - assert_eq!(transfer_logs[0].topics()[2], address_topic(recipient)); + assert_eq!(transfer_logs[0].topics()[2], address_topic(fee_vault)); + assert_eq!(transfer_logs[1].topics()[1], address_topic(sender)); + assert_eq!(transfer_logs[1].topics()[2], address_topic(recipient)); assert_eq!( - transfer_logs[0].data.data.as_ref(), + transfer_logs[1].data.data.as_ref(), amount.to_be_bytes::<32>() ); + assert_eq!(transfer_logs[2].topics()[1], address_topic(fee_vault)); + assert_eq!(transfer_logs[2].topics()[2], address_topic(sender)); let state_after = node.inner.provider.latest()?; let sender_after = state_after @@ -640,10 +634,21 @@ const RUNTIME_REVERT_INIT: &[u8] = &[ /// 1. Block 1: Deploy a contract whose runtime always reverts (EIP-1559 tx) /// 2. Block 2: Call that contract with MorphTx v0 (ERC20 fee) /// 3. Verify: receipt.status = false, but token balance decreased +/// 4. Verify: the receipt still carries both fee `Transfer` events /// /// This exercises the handler's `validate_and_deduct_token_fee` (charges fee /// upfront) and `reimburse_caller_token_fee` (partial refund for unused gas) /// paths when the main transaction execution reverts. +/// +/// The log assertion is the point of running the fee path on a *reverting* main +/// frame. go-ethereum keeps `StateDB.logs` outside the state snapshot/revert +/// mechanism, so the deduction's `Transfer` survives a main-frame revert; that +/// is the entire reason morph-reth caches fee logs in `pre_fee_logs` / +/// `post_fee_logs` instead of leaving them in the journal (`crates/evm/src/block/receipt.rs`). +/// A regression there -- the fee logs dropped, or restored into the reverted +/// frame -- changes the receipt's logs and therefore the block's receipts root, +/// and no state assertion in this test would notice. This is the only test that +/// runs the production receipt builder against a reverting main frame. #[tokio::test(flavor = "multi_thread")] async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { reth_tracing::init_test_tracing(); @@ -720,6 +725,40 @@ async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { before={bal_before}, after={bal_after}" ); + // Both fee transfers must survive the main frame's revert: go-ethereum keeps + // `StateDB.logs` outside the state snapshot/revert mechanism, so the deduction's + // `Transfer` is still in the receipt while the reverted main frame contributes + // none. Order is go-ethereum's: deduction, (empty) main frame, reimbursement. + let fee_vault = morph_node::test_utils::TEST_FEE_VAULT_ADDRESS; + let transfer_topic = erc20_transfer_topic(); + let transfer_logs: Vec<_> = receipt + .logs() + .iter() + .filter(|log| log.address == token_addr && log.topics().first() == Some(&transfer_topic)) + .collect(); + assert_eq!( + transfer_logs.len(), + 2, + "receipt must carry the fee deduction and the fee reimbursement even though \ + the main frame reverted; dropping the deduction's log changes the receipts root. \ + got {transfer_logs:?}" + ); + assert_eq!( + (transfer_logs[0].topics()[1], transfer_logs[0].topics()[2]), + (address_topic(sender), address_topic(fee_vault)), + "first log must be the fee deduction (sender -> fee vault)" + ); + assert_ne!( + transfer_logs[0].data.data.as_ref(), + [0u8; 32], + "the deduction must move a non-zero fee" + ); + assert_eq!( + (transfer_logs[1].topics()[1], transfer_logs[1].topics()[2]), + (address_topic(fee_vault), address_topic(sender)), + "second log must be the fee reimbursement (fee vault -> sender)" + ); + // The receipt should carry MorphTx-specific fee fields match &receipt { morph_primitives::MorphReceipt::Morph(morph_receipt) => { @@ -745,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 4515a2ae..7f9190ca 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -27,6 +27,11 @@ pub enum MorphInvalidTransaction { #[error("Token with ID {0} has invalid fee configuration")] InvalidTokenConfig(u16), + /// The token balance call reverted, violated static execution, or returned malformed data. + #[error("Token balance query failed")] + TokenBalanceQueryFailed, + + /// The transfer failed or its return value/balance delta was invalid. #[error("Token transfer failed: {reason}")] TokenTransferFailed { /// Token transfer failure reason. @@ -43,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 a0969696..e8034f29 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -95,11 +95,21 @@ 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 /// `MorphTxEnv.rlp_bytes`). pub(crate) cached_l1_data_fee: U256, + /// Signed refund counter from a successful fee deduction call. + /// Applied before the final refund cap; refund-transfer refunds are excluded. + pub(crate) pre_fee_refund: i64, /// Transfer event logs from token fee deduction (pre-execution phase). /// /// In go-ethereum, `buyAltTokenGas()` emits Transfer events into `StateDB.logs` @@ -113,6 +123,14 @@ pub struct MorphEvm { } impl MorphEvm { + /// Constructs an EVM from the full environment used by both execution and pool queries. + pub fn from_env(db: DB, env: crate::MorphEvmEnv, inspector: I) -> Self { + let ctx = MorphContext::new(db, *env.cfg_env.spec()) + .with_cfg(env.cfg_env) + .with_block(env.block_env); + Self::new(ctx, inspector) + } + /// Create a new Morph EVM. /// /// The precompiles are automatically selected based on the hardfork spec @@ -172,7 +190,9 @@ 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(), post_fee_logs: Vec::new(), } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index a0d9f2eb..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,14 +15,18 @@ 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::{ - MorphEvm, MorphInvalidTransaction, MorphTxEnv, + MorphEvm, MorphInvalidTransaction, error::MorphHaltReason, evm::MorphContext, l1block::L1BlockInfo, - token_fee::{TokenRegistryEntry, compute_mapping_slot_for_address, encode_balance_of_calldata}, + token_fee::{ + TokenFeeInfo, TokenRegistryEntry, compute_mapping_slot_for_address, + encode_balance_of_calldata, read_balance_from_storage, + }, tx::MorphTxExt, }; @@ -84,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] @@ -101,7 +143,9 @@ where ) -> Result<(), Self::Error> { // Reset per-transaction caches from the previous iteration. evm.cached_l1_data_fee = U256::ZERO; + evm.pre_fee_refund = 0; evm.cached_token_fee_info = None; + evm.cached_alt_fee_rounding_credit = U256::ZERO; evm.pre_fee_logs.clear(); evm.post_fee_logs.clear(); @@ -178,6 +222,7 @@ where exec_result.gas_mut().set_refund(0); return Ok(()); } + exec_result.gas_mut().record_refund(evm.pre_fee_refund); post_execution::refund( evm.ctx().cfg().gas_params(), exec_result.gas_mut(), @@ -251,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(()) } @@ -344,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( @@ -408,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( @@ -422,14 +519,31 @@ where // This ensures the same price_ratio/scale is used for both deduction and reimbursement. // The cache is kept populated (not taken) so the block executor's receipt builder // can also read it without re-querying the DB. - let token_fee_info = - evm.cached_token_fee_info - .ok_or(MorphInvalidTransaction::TokenTransferFailed { - reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), - })?; + let token_fee_info = evm.cached_token_fee_info.ok_or_else(|| { + MorphInvalidTransaction::TokenTransferFailed { + reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), + } + })?; + + // 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) + }; - // Calculate token amount required for total fee - let token_amount_required = 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 @@ -464,10 +578,16 @@ where .drain(log_count_before..) .collect(); evm.post_fee_logs = refund_logs; - result + result.map(|_| ()) }; if let Err(err) = refund_result { + // A contract may reject a refund, but unavailable state is not a verdict + // about the contract. Internal calls have already taken the context error, + // so it must reach the executor here rather than disappearing at finalization. + if matches!(err, EVMError::Database(_)) { + return Err(err); + } tracing::error!( target: "morph::evm", token_id = ?evm.ctx_ref().tx().fee_token_id, @@ -555,11 +675,7 @@ where let hardfork = *evm.ctx_ref().cfg().spec(); - let token_fee_info = token_registry_entry.load_for_caller( - evm.ctx_mut().journal_mut().db_mut(), - caller_addr, - hardfork, - )?; + let token_fee_info = load_token_fee_info(evm, token_registry_entry, caller_addr)?; let beneficiary = evm.ctx_ref().block().beneficiary(); let rlp_bytes = evm.ctx_ref().tx().rlp_bytes.clone().unwrap_or_default(); @@ -581,14 +697,13 @@ 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); - // Determine fee limit - let mut fee_limit = fee_limit_from_tx; - if fee_limit.is_zero() || fee_limit > token_fee_info.balance { - fee_limit = token_fee_info.balance - } + let fee_limit = token_fee_info.effective_fee_limit(fee_limit_from_tx); // Check if caller has sufficient token balance if fee_limit < token_amount_required { @@ -599,13 +714,12 @@ where .into()); } - if let Some(balance_slot) = token_fee_info.balance_slot { + if token_amount_required.is_zero() { + // Geth skips both transfer modes for a zero fee. Nonce and caches below + // still need their normal per-transaction updates. + } else if let Some(balance_slot) = token_fee_info.balance_slot { // Transfer with token slot. - // Ensure token account is loaded into the journal state, because `sload`/`sstore` - // assume the account is present. let journal = evm.ctx_mut().journal_mut(); - let _ = journal.load_account_mut(token_fee_info.token_address)?; - journal.touch(token_fee_info.token_address); let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( journal, caller_addr, @@ -627,7 +741,7 @@ where } } else { // Transfer with evm call (from=caller, balance known from token registry). - transfer_erc20_with_evm( + evm.pre_fee_refund = transfer_erc20_with_evm( evm, caller_addr, beneficiary, @@ -635,17 +749,60 @@ where token_amount_required, Some(token_fee_info.balance), )?; + } + if token_fee_info.balance_slot.is_none() { + // balanceOf runs even for a zero fee. Geth Prepare clears its access + // list/transient storage before the main transaction in that case too. // Cache fee Transfer logs separately from the journal. // // go-ethereum's StateDB.logs is independent of the state snapshot/revert - // mechanism — fee logs survive regardless of main tx result. revm's - // ExecutionResult::Revert has no logs field, so we keep fee logs out of - // the handler pipeline entirely and merge them in the receipt builder. + // mechanism — fee logs survive regardless of main tx result. In revm they + // would not: the `finalize()` below clears the journal's logs, and whatever + // survived would still be dropped when `execution_result` commits the + // transaction. So the fee logs are kept out of the handler pipeline entirely + // and merged back in the receipt builder. evm.pre_fee_logs = std::mem::take(&mut evm.ctx_mut().journal_mut().logs); // State changes should be marked cold to avoid warm access in the main tx execution. - // finalize() clears journal state (including logs, which we already took above). + // Fee deduction ran a real EVM frame, so its state writes must survive while the + // frame's metadata must not: go-ethereum's `StateDB.Prepare` rebuilds the access + // list and resets transient storage before the main transaction + // (core/state/statedb.go:1066). `finalize()` is the nearest revm equivalent — it + // commits the deduction's state and drops the journal, undo history, logs and + // transient storage. + // + // `mark_cold` below only has to *drop* the warmth this frame's own CALL created; it + // does not restore what `Prepare` would have left warm, and must not try to. That + // warmth arrives later from upstream, which is why the two cannot be swapped: + // `run_without_catch_error` runs this deduction inside `validate()`, then + // `pre_execution()` → `pre_execution::load_accounts` re-warms the coinbase + // (EIP-3651) and the transaction's access list, and the nonce bump just below + // re-loads the caller. If a future change reorders those phases, a main frame that + // reads `COINBASE` would be charged 2600 instead of go-ethereum's 100; nothing here + // would catch it, because no fixture's main frame touches the coinbase. + // + // The `transaction_id` handling inside `finalize()` is load-bearing, not incidental. + // Warming a slot goes through `EvmStorageSlot::mark_warm_with_transaction_id`, which + // re-baselines the EIP-2200 `original_value` to the present value whenever the slot's + // transaction id differs from the journal's (revm-state/src/lib.rs). That must not + // happen to the slot the deduction just cleared: re-baselining it to zero would make + // the main frame's SSTORE a *create* (SSTORE_SET, 20000) rather than a *recreate* + // (100), and would drop the `SubRefund` that cancels the deduction frame's `+4800`. + // Measured on `main_restores_cleared_slot`: 23_291 gas becomes 38_391 (+19_900 + // -4_800), and the state root moves with the fee it implies. + // + // It does not happen because ids stay equal throughout execution. revm advances the + // id only when a transaction finishes — `commit_tx()` from `execution_result`, or + // `discard_tx()` on the error path — both after the main frame is done; + // `ExecuteEvm::finalize` then resets it to ZERO before the next transaction. So + // across this deduction and the main frame the journal's id is 0 — and this + // `finalize()` keeps it at 0 rather than advancing it. Swapping in `commit_tx()` here + // would leave the deduction-warmed slots holding 0 while the journal held 1, and the + // main frame's first touch of them would re-baseline `original_value`; the call-path + // fixtures under `bin/morph-statetest` catch exactly that. An explicit `mark_cold` + // carries no such risk: it drives only the warm/cold gas decision, never the + // re-baseline. let mut state = evm.finalize(); state.iter_mut().for_each(|(_, acc)| { acc.mark_cold(); @@ -669,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(()) @@ -697,57 +855,13 @@ where } } -/// Execute `f` within a journal checkpoint, saving and restoring `evm.tx`. -/// -/// On `Ok` the checkpoint is committed; on `Err` it is reverted. -/// `evm.tx` is always restored to its original value regardless of the outcome, -/// so callers of [`evm_call`] inside `f` do not need to manage `evm.tx` themselves. -#[inline] -fn with_evm_checkpoint( - evm: &mut MorphEvm, - f: impl FnOnce(&mut MorphEvm) -> Result>, -) -> Result> -where - DB: alloy_evm::Database, -{ - let tx_origin = std::mem::take(&mut evm.tx); - let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); - let result = f(evm); - evm.tx = tx_origin; - match result { - Ok(val) => { - evm.ctx_mut().journal_mut().checkpoint_commit(); - Ok(val) - } - Err(err) => { - evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); - Err(err) - } - } -} - -/// Execute `f` within a journal snapshot that always reverts, saving and restoring `evm.tx`. -/// -/// This gives `f` read-only (StaticCall-like) semantics: any state changes made by -/// [`evm_call`] inside `f` are discarded when `f` returns. -#[inline] -fn with_evm_snapshot( - evm: &mut MorphEvm, - f: impl FnOnce(&mut MorphEvm) -> T, -) -> T -where - DB: alloy_evm::Database, -{ - let tx_origin = std::mem::take(&mut evm.tx); - let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); - let result = f(evm); - evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); - evm.tx = tx_origin; - result -} - /// Performs an ERC20 balance transfer by directly `sload`/`sstore`-ing the token contract storage /// using the known `balance` mapping base slot, returning the computed storage slots for `from`/`to`. +/// +/// The token account is loaded and touched here, ahead of the checkpoint, rather than by the +/// callers. The journal's `sload`/`sstore` panic instead of erroring when the account is absent +/// from `journal.state`, and touching keeps the token among the transaction's state changes even +/// for a self-transfer that writes no slot, as go-ethereum's `SetState` still marks it dirty. #[inline] fn transfer_erc20_with_slot( journal: &mut revm::Journal, @@ -760,6 +874,8 @@ fn transfer_erc20_with_slot( where DB: alloy_evm::Database, { + let _ = journal.load_account_mut(token)?; + journal.touch(token); with_journal_checkpoint(journal, |journal| { // Sub amount (checked: reject if insufficient, matching go-ethereum's // changeAltTokenBalanceByState which returns an error on underflow) @@ -796,69 +912,179 @@ where /// Gas limit for internal EVM calls (ERC20 transfer, balanceOf). const EVM_CALL_GAS_LIMIT: u64 = 200_000; -/// Execute an internal EVM call, matching go-ethereum's `evm.Call()` semantics. +/// Loads internal-call code without changing the account's access-list temperature. +/// Geth's direct Call/StaticCall resolve code without executing a CALL opcode. /// -/// Unlike `system_call_one_with_caller`, this only runs the handler's `execution()` -/// phase — NOT `execution_result()`. This means: -/// - Logs emitted during the call (e.g., ERC20 Transfer events) remain in the journal -/// - State changes remain in the journal -/// -/// **Caller is responsible for saving/restoring `evm.tx` if needed.** +/// This is also what puts `address` into `journal.state`, which [`evm_call`] depends on: +/// a `CallValue::Transfer` frame runs `Journal::transfer_loaded`, and its zero-value path +/// is `self.state.get_mut(&to).unwrap()` — a panic, not an error. In an ordinary CALL the +/// account is there because the opcode's `load_acc_and_calc_gas` put it there; an internal +/// call has no opcode, so this is the only load. Resolving the bytecode some other way +/// (caching it, hoisting it, short-circuiting on a known code hash) must keep the load. +fn internal_call_code( + journal: &mut revm::Journal, + address: Address, +) -> Result<(alloy_primitives::B256, revm::state::Bytecode), DB::Error> { + let account = journal.load_account_with_code(address)?; + let was_cold = account.is_cold; + let code = ( + account.info.code_hash(), + account.info.code.clone().unwrap_or_default(), + ); + if was_cold { + journal + .state + .get_mut(&address) + .expect("account was loaded") + .mark_cold(); + } + Ok(code) +} + +/// Executes a fee-token frame while retaining the outer transaction's ORIGIN/GASPRICE. +/// The frame owns its VM checkpoint; a successful call is not rolled back merely +/// because the token's return value or balance delta fails a later business check. fn evm_call( evm: &mut MorphEvm, caller: Address, target: Address, calldata: Bytes, + is_static: bool, ) -> Result> where DB: alloy_evm::Database, { - evm.tx = MorphTxEnv { - inner: revm::context::TxEnv { - caller, - kind: target.into(), - data: calldata, + use revm::context_interface::LocalContextTr; + use revm::interpreter::interpreter_action::FrameInit; + use revm::interpreter::{ + CallInput, CallInputs, CallScheme, CallValue, FrameInput, SharedMemory, + }; + + // Frame execution reports database failures through ctx.error. Check both before + // and after so a refund cannot overwrite an error from the main transaction. + take_context_error(evm)?; + let mut known_bytecode = internal_call_code(evm.ctx_mut().journal_mut(), target)?; + if let Some(delegate) = known_bytecode.1.eip7702_address() { + known_bytecode = internal_call_code(evm.ctx_mut().journal_mut(), delegate)?; + } + // Fee frames are top-level frames that run in the middle of a transaction, so + // neither of revm's truncation points covers them: `free_child_context` only + // releases a *child* frame's region, and `LocalContext::clear` only runs once the + // whole transaction is done. Carve this frame's memory out above whatever the + // shared buffer already holds and release it on the way out, so the main + // transaction frame still starts on zeroed memory the way go-ethereum's + // per-run `NewMemory()` guarantees. The frame keeps using the context's buffer + // rather than one of its own because a nested call hands its callee a + // `CallInput::SharedBuffer` range, and a precompile callee resolves that range + // against the context's buffer (`CallInput::as_bytes`) rather than against the + // calling frame's memory. + let mut fee_frame_memory = + SharedMemory::new_with_buffer(evm.ctx_ref().local().shared_memory_buffer().clone()); + let mut memory = fee_frame_memory.new_child_context(); + memory.set_memory_limit(evm.ctx_ref().cfg().memory_limit()); + let frame = FrameInit { + depth: 0, + memory, + frame_input: FrameInput::Call(Box::new(CallInputs { + input: CallInput::Bytes(calldata), + return_memory_offset: 0..0, gas_limit: EVM_CALL_GAS_LIMIT, - ..Default::default() - }, - ..Default::default() + reservoir: 0, + bytecode_address: target, + known_bytecode, + target_address: target, + caller, + // A zero transfer also performs geth StaticCall's legacy account touch. + value: CallValue::Transfer(U256::ZERO), + scheme: if is_static { + CallScheme::StaticCall + } else { + CallScheme::Call + }, + is_static, + charged_new_account_state_gas: false, + })), }; - let mut h = MorphEvmHandler::::new(); - let init_and_floor_gas = InitialAndFloorGas::new(0, 0); - let mut gas = h.tx_gas(evm, &init_and_floor_gas); - // `execution` owns this checkpoint: it commits once the runtime gas phase is done, or - // unwinds to it when that phase runs out of gas. The `None` arm is only reachable - // under EIP-2780 (AMSTERDAM), which Morph never enables, so it is unreachable today; - // it is kept faithful to upstream so a future hardfork mapping cannot silently skip it. - let checkpoint = evm.ctx().journal_mut().checkpoint(); - match h.execution(evm, checkpoint, &mut gas)? { - Some(res) => Ok(res), - None => h.runtime_oog_result(evm, &init_and_floor_gas, &mut gas), + let result = MorphEvmHandler::::new().run_exec_loop(evm, frame); + fee_frame_memory.free_child_context(); + let result = result?; + take_context_error(evm)?; + Ok(result) +} + +/// Moves a database failure recorded on the context into the return path. +#[inline] +fn take_context_error( + evm: &mut MorphEvm, +) -> Result<(), EVMError> +where + DB: alloy_evm::Database, +{ + revm::context_interface::context::take_error::< + EVMError, + DB::Error, + >(&mut evm.ctx_mut().error) +} + +/// Queries the token using a genuine static frame, as geth's StaticCall does. +/// Successful reads retain access-list warming; writes and malformed results fail. +pub(crate) fn evm_call_balance_of( + evm: &mut MorphEvm, + token: Address, + account: Address, +) -> Result> +where + DB: alloy_evm::Database, +{ + let result = evm_call( + evm, + account, + token, + encode_balance_of_calldata(account), + true, + )?; + let output = &result.interpreter_result().output; + if !result.instruction_result().is_ok() || output.len() < 32 { + return Err(MorphInvalidTransaction::TokenBalanceQueryFailed.into()); } + Ok(U256::from_be_slice(&output[..32])) } -/// Query ERC20 `balanceOf(address)` via an internal EVM call. +/// Resolves the caller's fee-token balance against the **executing** EVM. /// -/// Uses [`with_evm_snapshot`] to match go-ethereum's StaticCall semantics: -/// all state changes and `evm.tx` mutations are reverted after the call. -fn evm_call_balance_of(evm: &mut MorphEvm, token: Address, account: Address) -> U256 +/// go-ethereum reads it through `st.evm` (`GetAltTokenBalanceHybrid`, core/token_gas.go:43), +/// so the `balanceOf` call sees the real block context, the real chain config and the user as +/// `msg.sender`. Building a throwaway EVM here instead — as this path used to, through +/// `system_call_one` — answers under `BlockEnv::default()` and `CfgEnv::default()`: block 0, +/// timestamp 1, chain id 1, zero coinbase and base fee, with `SYSTEM_ADDRESS` as the sender. +/// For any token whose `balanceOf` reads that context the two clients would charge different +/// fees for the same transaction. The gas budget was never the problem: this crate's +/// `system_call_one` set the limit to its own `SYSTEM_CALL_GAS_LIMIT` — `exec.rs`, 200_000, +/// which deliberately shadows revm's `SYSTEM_CALL_GAS_LIMIT` of 30_000_000 at the +/// `SystemCallEvm` impl — and that 200k is go-ethereum's `maxGas`. [`EVM_CALL_GAS_LIMIT`] +/// carries the same number forward. +fn load_token_fee_info( + evm: &mut MorphEvm, + entry: TokenRegistryEntry, + caller: Address, +) -> Result> where DB: alloy_evm::Database, { - with_evm_snapshot(evm, |evm| { - let calldata = encode_balance_of_calldata(account); - match evm_call(evm, Address::ZERO, token, calldata) { - Ok(ref result) if result.instruction_result().is_ok() => { - let output = &result.interpreter_result().output; - if output.len() >= 32 { - U256::from_be_slice(&output[..32]) - } else { - U256::ZERO - } - } - _ => U256::ZERO, - } - }) + let balance = match entry.balance_slot() { + // Slot mode is a plain storage read with no environment to get wrong. It goes + // through the database rather than the journal deliberately: the journal is empty + // at this point in the transaction, and an `sload` here would warm a slot that the + // fee deduction below is careful to leave cold. + Some(slot) => read_balance_from_storage( + evm.ctx_mut().journal_mut().db_mut(), + entry.token_address(), + caller, + slot, + )?, + None => evm_call_balance_of(evm, entry.token_address(), caller)?, + }; + Ok(entry.into_fee_info(caller, balance)) } /// Matches go-ethereum's `transferAltTokenByEVM` validation: @@ -872,6 +1098,8 @@ where /// /// `from_balance_before` is the sender's balance before the transfer. If `None`, /// the balance is queried via EVM call (matching go-eth's nil `userBalanceBefore`). +/// Returns the signed refund counter for successful transfers; the deduction phase +/// carries it into transaction gas accounting, while reimbursement ignores it. fn transfer_erc20_with_evm( evm: &mut MorphEvm, from: Address, @@ -879,70 +1107,73 @@ fn transfer_erc20_with_evm( token_address: Address, token_amount: U256, from_balance_before: Option, -) -> Result<(), EVMError> +) -> Result> where DB: alloy_evm::Database, { + if token_amount.is_zero() { + return Ok(0); + } // Read sender balance before transfer if not provided. - // This uses with_evm_snapshot internally, so evm.tx is safe. let from_balance_before = match from_balance_before { Some(b) => b, - None => evm_call_balance_of(evm, token_address, from), + None => evm_call_balance_of(evm, token_address, from)?, }; - with_evm_checkpoint(evm, |evm| { - let calldata = build_transfer_calldata(to, token_amount); - let frame_result = evm_call(evm, from, token_address, calldata).map_err(|e| { - EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { + // Geth checks affordability before executing the token contract. + let expected_balance = from_balance_before + .checked_sub(token_amount) + .ok_or_else(|| MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance {from_balance_before} less than token amount {token_amount}" + ), + })?; + + let calldata = build_transfer_calldata(to, token_amount); + let frame_result = + evm_call(evm, from, token_address, calldata, false).map_err(|e| match e { + EVMError::Database(_) => e, + _ => EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { reason: format!("Error: {e:?}"), - }) + }), })?; - if !frame_result.instruction_result().is_ok() { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!("{:?}", frame_result.interpreter_result()), - } - .into()); + if !frame_result.instruction_result().is_ok() { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("{:?}", frame_result.interpreter_result()), } + .into()); + } - // Validate ABI bool return value, matching go-ethereum behavior: - // - No return data: accepted (old tokens that don't return bool) - // - 32+ bytes with last byte == 1: accepted (standard ERC20) - // - Otherwise: rejected - let output = &frame_result.interpreter_result().output; - if !output.is_empty() && (output.len() < 32 || output[31] != 1) { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: "alt token transfer returned failure".to_string(), - } - .into()); + // Validate ABI bool return value, matching go-ethereum behavior: + // - No return data: accepted (old tokens that don't return bool) + // - 32+ bytes with last byte == 1: accepted (standard ERC20) + // - Otherwise: rejected + let output = &frame_result.interpreter_result().output; + if !output.is_empty() && (output.len() < 32 || output[31] != 1) { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: "alt token transfer returned failure".to_string(), } + .into()); + } - // Verify sender balance changed by the expected amount, matching go-ethereum. - // evm_call_balance_of uses with_evm_snapshot, so evm.tx is safe here too. - let from_balance_after = evm_call_balance_of(evm, token_address, from); - - // Verify sender balance decreased by exactly the transfer amount. - // Matches go-ethereum's transferAltTokenByEVM which always checks this, - // even for self-transfers (from == to), where it would fail because the - // net balance change is zero but the expected decrease is `token_amount`. - let expected_balance = from_balance_before.checked_sub(token_amount).ok_or( - MorphInvalidTransaction::TokenTransferFailed { - reason: format!( - "sender balance {from_balance_before} less than token amount {token_amount}" - ), - }, - )?; - if from_balance_after != expected_balance { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!( - "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" - ), - } - .into()); + // Verify sender balance changed by the expected amount, matching go-ethereum. + let from_balance_after = evm_call_balance_of(evm, token_address, from)?; + + // Verify sender balance decreased by exactly the transfer amount. + // Matches go-ethereum's transferAltTokenByEVM which always checks this, + // even for self-transfers (from == to), where it would fail because the + // net balance change is zero but the expected decrease is `token_amount`. + if from_balance_after != expected_balance { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" + ), } + .into()); + } - Ok(()) - }) + Ok(frame_result.gas().refunded()) } /// Build the calldata for ERC20 `transfer(address,uint256)` call. @@ -1032,6 +1263,8 @@ fn calculate_caller_fee_with_l1_cost( #[cfg(test)] mod tests { use super::*; + use crate::MorphTxEnv; + use crate::token_fee::tests::{TokenReadFailure, UnreadableTokenDb}; use crate::{ MorphBlockEnv, token_fee::{L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot}, @@ -1051,6 +1284,197 @@ mod tests { atomic::{AtomicBool, Ordering}, }; + fn finish_transaction_with_refund( + code: Bytes, + ) -> Result, EVMError> + { + let caller = address!("1000000000000000000000000000000000000001"); + let beneficiary = address!("2000000000000000000000000000000000000002"); + let token = address!("3000000000000000000000000000000000000003"); + let target = address!("4000000000000000000000000000000000000004"); + let mut inner = CacheDB::new(EmptyDB::default()); + inner.insert_account_info( + token, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + let mut evm = MorphEvm::new( + MorphContext::new(UnreadableTokenDb { inner, token }, MorphHardfork::Emerald), + NoOpInspector, + ); + evm.block.inner.beneficiary = beneficiary; + // Produce a real successful main frame. The remainder of this probe enters the + // normal reimbursement and result-finalization phases with an unused gas budget. + let frame = evm_call(&mut evm, caller, target, Bytes::new(), false).unwrap(); + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller, + gas_price: 1, + gas_limit: 30_000, + kind: TxKind::Call(target), + ..Default::default() + }, + fee_token_id: Some(1), + ..Default::default() + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: token, + is_active: true, + price_ratio: U256::from(1), + scale: U256::from(1), + caller, + balance: U256::from(100_000), + balance_slot: None, + ..Default::default() + }); + let mut handler = MorphEvmHandler::<_, NoOpInspector>::default(); + handler + .reimburse_caller_token_fee(&mut evm, &Gas::new(1_000)) + .and_then(|_| handler.execution_result(&mut evm, frame, ResultGas::default())) + } + + /// A readable token under [`UnreadableTokenDb`], so any failure reported below comes + /// from the context, not from a read. + fn readable_token_evm() -> MorphEvm { + let token = address!("3000000000000000000000000000000000000003"); + let mut inner = CacheDB::new(EmptyDB::default()); + insert_contract( + &mut inner, + token, + alloy_primitives::bytes!("6000545f5260205ff3"), + ); + inner + .insert_account_storage(token, U256::ZERO, U256::from(42)) + .unwrap(); + MorphEvm::new( + MorphContext::new( + UnreadableTokenDb { + inner, + // Nothing reads this address, so every storage read succeeds. + token: address!("9000000000000000000000000000000000000009"), + }, + MorphHardfork::Emerald, + ), + NoOpInspector, + ) + } + + #[test] + fn a_nested_call_does_not_run_in_a_context_the_main_frame_already_poisoned() { + let token = address!("3000000000000000000000000000000000000003"); + let account = address!("1000000000000000000000000000000000000001"); + let mut evm = readable_token_evm(); + assert_eq!( + evm_call_balance_of(&mut evm, token, account).unwrap(), + U256::from(42), + "sanity: the token is readable" + ); + + // The main frame halted on a failed read; post-execution then reaches this call. + evm.ctx_mut().error = Err(revm::context_interface::context::ContextError::Db( + TokenReadFailure, + )); + let result = evm_call_balance_of(&mut evm, token, account); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "the main frame's failure must be reported, not overwritten by a nested call: {result:?}" + ); + assert!( + evm.ctx_ref().error.is_ok(), + "the failure has been moved into the return path" + ); + } + + #[test] + fn a_refund_after_a_poisoned_main_frame_reports_the_main_frame_failure() { + let caller = address!("1000000000000000000000000000000000000001"); + let token = address!("3000000000000000000000000000000000000003"); + let mut evm = readable_token_evm(); + evm.block.inner.beneficiary = address!("2000000000000000000000000000000000000002"); + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller, + gas_price: 1, + gas_limit: 30_000, + ..Default::default() + }, + fee_token_id: Some(1), + ..Default::default() + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: token, + is_active: true, + price_ratio: U256::from(1), + scale: U256::from(1), + caller, + balance: U256::from(100_000), + balance_slot: None, + ..Default::default() + }); + evm.ctx_mut().error = Err(revm::context_interface::context::ContextError::Db( + TokenReadFailure, + )); + + let result = MorphEvmHandler::<_, NoOpInspector>::default() + .reimburse_caller_token_fee(&mut evm, &Gas::new(1_000)); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "{result:?}" + ); + } + + #[test] + fn refund_database_failure_aborts_final_execution_result() { + let result = finish_transaction_with_refund(alloy_primitives::bytes!("6000545f5260205ff3")); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "refund I/O must abort execution, not finalize success without a refund: {result:?}" + ); + } + + #[test] + fn refund_contract_revert_still_allows_transaction_to_finish() { + let result = finish_transaction_with_refund(alloy_primitives::bytes!("5f5ffd")); + assert!( + matches!(result, Ok(ExecutionResult::Success { .. })), + "{result:?}" + ); + } + + #[test] + fn token_transfer_database_failure_is_not_transaction_invalidity() { + let token = address!("3000000000000000000000000000000000000003"); + let from = address!("1000000000000000000000000000000000000001"); + let to = address!("2000000000000000000000000000000000000002"); + let mut inner = CacheDB::new(EmptyDB::default()); + insert_contract( + &mut inner, + token, + alloy_primitives::bytes!("6000545f5260205ff3"), + ); + let mut evm = MorphEvm::new( + MorphContext::new(UnreadableTokenDb { inner, token }, MorphHardfork::Emerald), + NoOpInspector, + ); + let result = transfer_erc20_with_evm( + &mut evm, + from, + to, + token, + U256::from(1), + Some(U256::from(10)), + ); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "{result:?}" + ); + } + fn mutating_return_code(write_value: u8, return_value: u8) -> Bytes { Bytes::from(vec![ 0x60, @@ -1104,28 +1528,99 @@ mod tests { } } - fn insert_test_fee_token( - db: &mut CacheDB, - token_id: u16, - token: Address, - is_active: bool, - ) { - insert_test_fee_token_config(db, token_id, token, is_active, U256::from(1), U256::from(1)); + /// ` PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN` — a `balanceOf` that reports one piece + /// of its environment instead of a balance, so a call made under the wrong environment + /// shows up in the value that comes back. + fn code_returning(opcode: u8) -> Bytes { + Bytes::from(vec![opcode, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3]) } - fn insert_test_fee_token_config( - db: &mut CacheDB, - token_id: u16, - token: Address, - is_active: bool, - price_ratio: U256, - scale: U256, - ) { - let mut token_id_bytes = [0u8; 32]; - token_id_bytes[30..32].copy_from_slice(&token_id.to_be_bytes()); - let base = compute_mapping_slot(U256::from(151), &token_id_bytes); - - db.insert_account_storage( + fn insert_contract(db: &mut CacheDB, address: Address, code: Bytes) { + db.insert_account_info( + address, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + } + + /// Loads token 1's registry entry and resolves `caller`'s balance against `evm`. + fn probe_fee_token_balance(db: CacheDB, block: BlockEnv, caller: Address) -> U256 { + let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Emerald), NoOpInspector); + evm.block = MorphBlockEnv { inner: block }; + + let entry = TokenRegistryEntry::load(evm.ctx_mut().journal_mut().db_mut(), 1) + .unwrap() + .unwrap(); + load_token_fee_info(&mut evm, entry, caller) + .unwrap() + .balance + } + + #[test] + fn fee_token_balance_is_read_under_the_executing_block_environment() { + const TIMESTAMP: u64 = 1_767_765_600; + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("1000000000000000000000000000000000000001"); + + let mut db = CacheDB::new(EmptyDB::default()); + insert_test_fee_token(&mut db, 1, token, true); + insert_contract(&mut db, token, code_returning(0x42)); // TIMESTAMP + + let balance = probe_fee_token_balance( + db, + BlockEnv { + timestamp: U256::from(TIMESTAMP), + ..Default::default() + }, + caller, + ); + + // `BlockEnv::default()` reports timestamp 1, which is what a throwaway EVM would + // have answered with regardless of the block being executed. + assert_eq!(balance, U256::from(TIMESTAMP)); + } + + #[test] + fn fee_token_balance_query_names_the_queried_account_as_the_caller() { + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("1000000000000000000000000000000000000001"); + + let mut db = CacheDB::new(EmptyDB::default()); + insert_test_fee_token(&mut db, 1, token, true); + insert_contract(&mut db, token, code_returning(0x33)); // CALLER + + let balance = probe_fee_token_balance(db, BlockEnv::default(), caller); + + // go-ethereum queries as the account being asked about, not as the zero address and + // not as `SYSTEM_ADDRESS`. + assert_eq!(balance, U256::from_be_bytes(caller.into_word().0)); + } + + fn insert_test_fee_token( + db: &mut CacheDB, + token_id: u16, + token: Address, + is_active: bool, + ) { + insert_test_fee_token_config(db, token_id, token, is_active, U256::from(1), U256::from(1)); + } + + fn insert_test_fee_token_config( + db: &mut CacheDB, + token_id: u16, + token: Address, + is_active: bool, + price_ratio: U256, + scale: U256, + ) { + let mut token_id_bytes = [0u8; 32]; + token_id_bytes[30..32].copy_from_slice(&token_id.to_be_bytes()); + let base = compute_mapping_slot(U256::from(151), &token_id_bytes); + + db.insert_account_storage( L2_TOKEN_REGISTRY_ADDRESS, base, U256::from_be_bytes(token.into_word().0), @@ -1215,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( @@ -1251,7 +2305,7 @@ mod tests { } #[test] - fn transfer_erc20_with_evm_reverts_state_on_validation_failure() { + fn transfer_erc20_with_evm_keeps_state_on_post_call_validation_failure() { let from = address!("1000000000000000000000000000000000000001"); let to = address!("2000000000000000000000000000000000000002"); let token = address!("3000000000000000000000000000000000000003"); @@ -1306,7 +2360,7 @@ mod tests { .get(&token) .and_then(|account| account.storage.get(&U256::ZERO)) .unwrap(); - assert_eq!(slot_state.present_value, original_balance); + assert_eq!(slot_state.present_value, U256::from(1)); } #[test] @@ -1352,14 +2406,7 @@ mod tests { err, EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { .. }) )); - let slot_state = evm - .ctx_ref() - .journal() - .state - .get(&token) - .and_then(|account| account.storage.get(&U256::ZERO)) - .unwrap(); - assert_eq!(slot_state.present_value, original_balance); + assert!(evm.ctx_ref().journal().state.is_empty()); } #[test] @@ -1386,10 +2433,8 @@ mod tests { inner: BlockEnv::default(), }; + // Nothing loads the token first: the helper has to put it in `journal.state` itself. let journal = evm.ctx_mut().journal_mut(); - let _ = journal.load_account_mut(token).unwrap(); - journal.touch(token); - let err = transfer_erc20_with_slot(journal, from, to, token, U256::from(1), balance_slot) .unwrap_err(); @@ -1432,17 +2477,12 @@ mod tests { inner: BlockEnv::default(), }; - let balance = evm_call_balance_of(&mut evm, token, account); - - assert_eq!(balance, U256::from(42)); - let slot_state = evm - .ctx_ref() - .journal() - .state - .get(&token) - .and_then(|acct| acct.storage.get(&U256::ZERO)) - .unwrap(); - assert_eq!(slot_state.present_value, original_balance); + assert!(evm_call_balance_of(&mut evm, token, account).is_err()); + assert_eq!( + revm::Database::storage(evm.ctx_mut().journal_mut().db_mut(), token, U256::ZERO) + .unwrap(), + original_balance + ); } /// `disable_fee_charge` must leave the caller balance untouched. @@ -1588,4 +2628,819 @@ mod tests { "simulation must not query the fee-token contract or its balance storage" ); } + const FEE_REFUND_TOKEN_ID: u16 = 1; + const FEE_REFUND_GAS_LIMIT: u64 = 100_000; + const FEE_REFUND_GAS_PRICE: u128 = 10; + /// `gas_limit * effective_gas_price` (the L1 data fee is zero with an empty + /// gas-price oracle), converted at scale 1 / price_ratio 1. + const FEE_REFUND_TOKEN_FEE: u64 = 1_000_000; + const FEE_REFUND_CALLER: Address = address!("1000000000000000000000000000000000000001"); + const FEE_REFUND_TOKEN: Address = address!("3000000000000000000000000000000000000003"); + const FEE_REFUND_BENEFICIARY: Address = address!("530000000000000000000000000000000000000a"); + /// Plain EOA target: the main frame does nothing beyond intrinsic gas. + const FEE_REFUND_TARGET: Address = address!("4200000000000000000000000000000000000042"); + + /// Minimal call-mode ERC20: `balance[addr]` lives at slot `uint256(addr)` (no + /// keccak), so `CALLER`, `calldataload(4)` and `balanceOf`'s argument all name the + /// same slot. Dispatch is on `CALLDATASIZE`: + /// - 68 bytes => `transfer(address,uint256)`: SSTORE(caller, SLOAD(caller) - amount), + /// SSTORE(to, SLOAD(to) + amount), return `true`. + /// - anything else => `balanceOf(address)`: return SLOAD(calldataload(4)). + fn fee_refund_slotless_erc20_code() -> Bytes { + Bytes::from(vec![ + 0x36, // CALLDATASIZE + 0x60, 0x44, // PUSH1 68 + 0x14, // EQ + 0x60, 0x13, // PUSH1 19 (transfer JUMPDEST) + 0x57, // JUMPI + // balanceOf(address) + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD + 0x54, // SLOAD + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xf3, // RETURN + // transfer(address,uint256) + 0x5b, // JUMPDEST (pc 19) + 0x60, 0x24, // PUSH1 36 + 0x35, // CALLDATALOAD -> amount + 0x80, // DUP1 -> amount amount + 0x33, // CALLER -> caller amount amount + 0x54, // SLOAD -> bal_from amount amount + 0x03, // SUB -> bal_from-amount amount + 0x33, // CALLER -> caller new_from amount + 0x55, // SSTORE -> amount + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD -> to amount + 0x80, // DUP1 -> to to amount + 0x54, // SLOAD -> bal_to to amount + 0x82, // DUP3 -> amount bal_to to amount + 0x01, // ADD -> new_to to amount + 0x90, // SWAP1 -> to new_to amount + 0x55, // SSTORE -> amount + 0x50, // POP + 0x60, 0x01, // PUSH1 1 + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xf3, // RETURN + ]) + } + + fn fee_refund_balance_slot(account: Address) -> U256 { + U256::from_be_bytes(account.into_word().0) + } + + fn fee_refund_evm(payer_token_balance: U256) -> MorphEvm, NoOpInspector> { + let code = fee_refund_slotless_erc20_code(); + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(FEE_REFUND_CALLER, AccountInfo::default()); + db.insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + db.insert_account_storage( + FEE_REFUND_TOKEN, + fee_refund_balance_slot(FEE_REFUND_CALLER), + payer_token_balance, + ) + .unwrap(); + // `balanceSlot` word left at zero => call mode (`balance_slot == None`). + insert_test_fee_token(&mut db, FEE_REFUND_TOKEN_ID, FEE_REFUND_TOKEN, true); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.block = MorphBlockEnv { + inner: BlockEnv { + basefee: 1, + beneficiary: FEE_REFUND_BENEFICIARY, + gas_limit: 30_000_000, + ..Default::default() + }, + }; + // Production Morph configuration disables the Ethereum calldata gas floor. + evm.cfg.disable_eip7623 = true; + evm + } + + fn fee_refund_present_value( + evm: &MorphEvm, NoOpInspector>, + account: Address, + ) -> U256 { + evm.ctx_ref() + .journal() + .state + .get(&FEE_REFUND_TOKEN) + .and_then(|acct| acct.storage.get(&fee_refund_balance_slot(account))) + .map(|slot| slot.present_value) + .expect("fee-token slot must be in the journal") + } + + /// Runs one call-mode token-fee MorphTx (plain call to an EOA) and returns + /// `(gas_used, final refund applied to the main frame, payer token balance after + /// reimbursement)`. + fn fee_refund_run_token_fee_tx(payer_token_balance: U256) -> (u64, u64, U256) { + let mut evm = fee_refund_evm(payer_token_balance); + let tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: FEE_REFUND_CALLER, + gas_limit: FEE_REFUND_GAS_LIMIT, + gas_price: FEE_REFUND_GAS_PRICE, + kind: TxKind::Call(FEE_REFUND_TARGET), + ..Default::default() + }, + fee_token_id: Some(FEE_REFUND_TOKEN_ID), + ..Default::default() + }; + + let result = evm + .transact_one(tx) + .expect("token-fee MorphTx must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + let gas_used = result.tx_gas_used(); + let gas_refunded = result.gas().final_refunded(); + + // Sanity: the fee was charged in call mode and equals exactly FEE_REFUND_TOKEN_FEE. + let info = evm + .cached_token_fee_info() + .expect("token fee info is cached"); + assert_eq!( + info.balance_slot, None, + "token must be registered in call mode" + ); + assert_eq!( + info.balance, payer_token_balance, + "balanceOf must see the seeded balance" + ); + assert_eq!( + info.eth_to_token_amount(U256::from( + FEE_REFUND_GAS_LIMIT as u128 * FEE_REFUND_GAS_PRICE + )), + U256::from(FEE_REFUND_TOKEN_FEE) + ); + + ( + gas_used, + gas_refunded, + fee_refund_present_value(&evm, FEE_REFUND_CALLER), + ) + } + + #[test] + fn deduction_sstore_refund_reaches_transaction_gas() { + let fee = U256::from(FEE_REFUND_TOKEN_FEE); + let (gas, refund, balance) = fee_refund_run_token_fee_tx(fee); + assert_eq!((gas, refund), (16_800, 4_200)); + assert_eq!(balance, fee - U256::from(168_000)); + let (gas, refund, balance) = fee_refund_run_token_fee_tx(fee + U256::from(1)); + assert_eq!((gas, refund), (21_000, 0)); + assert_eq!(balance, fee + U256::from(1) - U256::from(210_000)); + } + + #[test] + fn balance_queries_reject_state_writes() { + let mut evm = fee_refund_evm(U256::from(FEE_REFUND_TOKEN_FEE)); + let code = mutating_return_code(1, 1); + evm.ctx_mut().journal_mut().db_mut().insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(&code), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + assert!(evm_call_balance_of(&mut evm, FEE_REFUND_TOKEN, FEE_REFUND_CALLER).is_err()); + } + + #[test] + fn internal_calls_preserve_origin_and_effective_gas_price() { + for (opcode, expected) in [ + (0x32, U256::from_be_slice(FEE_REFUND_CALLER.as_slice())), + (0x3a, U256::from(3)), + ] { + let mut evm = fee_refund_evm(U256::from(FEE_REFUND_TOKEN_FEE)); + evm.tx.inner.caller = FEE_REFUND_CALLER; + evm.tx.inner.tx_type = MORPH_TX_TYPE_ID; + evm.tx.inner.gas_price = 10; + evm.tx.inner.gas_priority_fee = Some(2); + let code = Bytes::from(vec![opcode, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3]); + evm.ctx_mut().journal_mut().db_mut().insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(&code), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + assert_eq!( + evm_call_balance_of(&mut evm, FEE_REFUND_TOKEN, FEE_REFUND_BENEFICIARY).unwrap(), + expected + ); + assert_eq!(evm.tx.inner.caller, FEE_REFUND_CALLER); + } + } + + #[test] + fn zero_token_transfer_does_not_call_the_contract() { + let mut evm = fee_refund_evm(U256::ZERO); + let code = mutating_return_code(1, 0); + evm.ctx_mut().journal_mut().db_mut().insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(&code), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + transfer_erc20_with_evm( + &mut evm, + FEE_REFUND_CALLER, + FEE_REFUND_BENEFICIARY, + FEE_REFUND_TOKEN, + U256::ZERO, + Some(U256::ZERO), + ) + .unwrap(); + assert!(evm.ctx_ref().journal().state.is_empty()); + } + + /// Runs one transaction against `code` deployed at [`FEE_REFUND_TARGET`] and + /// returns what the main frame returned. `fee_token_id` selects the call-mode + /// token-fee path or the ordinary ETH-fee path. + fn fee_refund_run_probe(code: Bytes, fee_token_id: Option) -> Bytes { + let mut evm = fee_refund_evm(U256::from(FEE_REFUND_TOKEN_FEE)); + let db = evm.ctx_mut().journal_mut().db_mut(); + insert_contract(db, FEE_REFUND_TARGET, code); + db.insert_account_info( + FEE_REFUND_CALLER, + AccountInfo { + balance: U256::from(FEE_REFUND_GAS_LIMIT as u128 * FEE_REFUND_GAS_PRICE), + ..Default::default() + }, + ); + let tx = MorphTxEnv { + inner: TxEnv { + tx_type: if fee_token_id.is_some() { + MORPH_TX_TYPE_ID + } else { + 0 + }, + caller: FEE_REFUND_CALLER, + gas_limit: FEE_REFUND_GAS_LIMIT, + gas_price: FEE_REFUND_GAS_PRICE, + kind: TxKind::Call(FEE_REFUND_TARGET), + ..Default::default() + }, + fee_token_id, + ..Default::default() + }; + let result = evm.transact_one(tx).expect("probe must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + result.output().cloned().unwrap_or_default() + } + + /// Minimal proxy: copies its calldata into memory and `DELEGATECALL`s + /// `implementation`, the way mainnet's call-mode fee tokens reach theirs. + fn delegating_proxy_code(implementation: Address) -> Bytes { + let mut code = vec![ + 0x36, // CALLDATASIZE (size) + 0x5f, // PUSH0 (offset) + 0x5f, // PUSH0 (destOffset) + 0x37, // CALLDATACOPY + 0x5f, // PUSH0 (retSize) + 0x5f, // PUSH0 (retOffset) + 0x36, // CALLDATASIZE (argsSize) + 0x5f, // PUSH0 (argsOffset) + 0x73, // PUSH20 implementation + ]; + code.extend_from_slice(implementation.as_slice()); + code.extend_from_slice(&[ + 0x5a, // GAS + 0xf4, // DELEGATECALL + 0x3d, // RETURNDATASIZE (size) + 0x5f, // PUSH0 (offset) + 0x5f, // PUSH0 (destOffset) + 0x3e, // RETURNDATACOPY + 0x50, // POP (DELEGATECALL success flag) + 0x3d, // RETURNDATASIZE (size) + 0x5f, // PUSH0 (offset) + 0xf3, // RETURN + ]); + Bytes::from(code) + } + + /// Same ERC20, except `balanceOf` returns its result through the identity + /// precompile, reading the precompile's arguments out of memory. + fn fee_refund_precompile_erc20_code() -> Bytes { + let mut code = vec![ + 0x36, // CALLDATASIZE + 0x60, 0x44, // PUSH1 68 + 0x14, // EQ + 0x60, 0x1e, // PUSH1 30 (transfer JUMPDEST) + 0x57, // JUMPI + // balanceOf(address), answered by identity(mem[0..32]) + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD + 0x54, // SLOAD + 0x5f, // PUSH0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 (retSize) + 0x60, 0x20, // PUSH1 32 (retOffset) + 0x60, 0x20, // PUSH1 32 (argsSize) + 0x5f, // PUSH0 (argsOffset) + 0x60, 0x04, // PUSH1 4 (identity precompile) + 0x5a, // GAS + 0xfa, // STATICCALL + 0x50, // POP + 0x60, 0x20, // PUSH1 32 (size) + 0x60, 0x20, // PUSH1 32 (offset) + 0xf3, // RETURN + ]; + assert_eq!(code.len(), 30, "the transfer JUMPDEST moved"); + code.extend_from_slice(&fee_refund_slotless_erc20_code()[19..]); + Bytes::from(code) + } + + /// A precompile called from inside a fee frame resolves its arguments against + /// the context's shared buffer, so the fee frames have to keep writing into that + /// buffer rather than into one of their own. + #[test] + fn fee_token_frames_reach_a_precompile_through_memory() { + let fee = U256::from(FEE_REFUND_TOKEN_FEE); + let mut evm = fee_refund_evm(fee); + let db = evm.ctx_mut().journal_mut().db_mut(); + insert_contract(db, FEE_REFUND_TOKEN, fee_refund_precompile_erc20_code()); + assert_eq!( + fee_refund_run_fee_tx(&mut evm), + fee_refund_run_token_fee_tx(fee) + ); + } + + /// Mainnet's call-mode fee tokens are proxies, so a fee frame's nested + /// `DELEGATECALL` reads its calldata back out of the frame's memory. + #[test] + fn fee_token_frames_reach_a_delegating_proxy_implementation() { + const IMPLEMENTATION: Address = address!("3000000000000000000000000000000000000004"); + let fee = U256::from(FEE_REFUND_TOKEN_FEE); + let mut evm = fee_refund_evm(fee); + let db = evm.ctx_mut().journal_mut().db_mut(); + insert_contract(db, IMPLEMENTATION, fee_refund_slotless_erc20_code()); + insert_contract(db, FEE_REFUND_TOKEN, delegating_proxy_code(IMPLEMENTATION)); + + // Indistinguishable from the same transaction against an unproxied token: + // the implementation saw exactly the calldata the fee frame wrote. + assert_eq!( + fee_refund_run_fee_tx(&mut evm), + fee_refund_run_token_fee_tx(fee) + ); + } + + /// Runs the standard call-mode token-fee MorphTx on an already-built `evm` and + /// reports it the way [`fee_refund_run_token_fee_tx`] does. + fn fee_refund_run_fee_tx( + evm: &mut MorphEvm, NoOpInspector>, + ) -> (u64, u64, U256) { + let result = evm + .transact_one(MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: FEE_REFUND_CALLER, + gas_limit: FEE_REFUND_GAS_LIMIT, + gas_price: FEE_REFUND_GAS_PRICE, + kind: TxKind::Call(FEE_REFUND_TARGET), + ..Default::default() + }, + fee_token_id: Some(FEE_REFUND_TOKEN_ID), + ..Default::default() + }) + .expect("token-fee MorphTx must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + ( + result.tx_gas_used(), + result.gas().final_refunded(), + fee_refund_present_value(evm, FEE_REFUND_CALLER), + ) + } + + /// go-ethereum allocates a fresh `Memory` for every interpreter run + /// (`core/vm/interpreter.go`), so a transaction's frame always starts on zeroed + /// memory. The fee-token frames run on the transaction's shared memory buffer, + /// so they have to hand it back the length they found it at. + #[test] + fn fee_token_frames_do_not_leak_memory_into_the_main_frame() { + // `MSIZE` and `MLOAD(0)`, each returned as the frame's 32-byte output. + for probe in [ + vec![0x59, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3], + vec![0x5f, 0x51, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3], + ] { + let code = Bytes::from(probe); + assert_eq!( + U256::from_be_slice(&fee_refund_run_probe(code.clone(), None)), + U256::ZERO, + "ETH-fee control" + ); + assert_eq!( + U256::from_be_slice(&fee_refund_run_probe(code, Some(FEE_REFUND_TOKEN_ID))), + U256::ZERO, + "a token-fee main frame must start on zeroed memory too" + ); + } + } +} + +/// Alt-token refund rounding, gated on Celadon. +/// +/// Uses the registry's direct-slot path so the arithmetic is the only variable; +/// the call path routes the same `token_amount_required` through an ERC20 +/// `transfer`, which [`tests`] already covers. +#[cfg(test)] +mod refund_rounding_tests { + use super::*; + use crate::{L2_TOKEN_REGISTRY_ADDRESS, MorphTxEnv, TokenFeeInfo, compute_mapping_slot}; + use alloy_primitives::{Address, TxKind, U256, address}; + use morph_chainspec::hardfork::MorphHardfork; + use morph_primitives::MORPH_TX_TYPE_ID; + use revm::{ + context::TxEnv, + database::{CacheDB, EmptyDB}, + inspector::NoOpInspector, + state::AccountInfo, + }; + + const CALLER: Address = address!("1000000000000000000000000000000000000001"); + const BENEFICIARY: Address = address!("2000000000000000000000000000000000000002"); + const TOKEN: Address = address!("3000000000000000000000000000000000000003"); + const BALANCE_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); + /// A `price_ratio` of 3 against a `scale` of 1 makes two of every three wei + /// an inexact conversion, which is what the two roundings disagree about. + const PRICE_RATIO: u64 = 3; + const FEE_TOKEN_ID: u16 = 1; + const VAULT_BALANCE: u64 = 1_000_000; + const STARTING_BALANCE: u64 = 1_000_000; + + fn slot_of(account: Address) -> U256 { + compute_mapping_slot_for_address(BALANCE_SLOT, account) + } + + fn token_balance( + evm: &mut MorphEvm, NoOpInspector>, + account: Address, + ) -> U256 { + let journal = evm.ctx.journal_mut(); + journal.load_account_mut(TOKEN).expect("load token"); + *journal.sload(TOKEN, slot_of(account)).expect("sload") + } + + fn ceil_to_token(eth_amount: u64) -> U256 { + TokenFeeInfo { + price_ratio: U256::from(PRICE_RATIO), + scale: U256::from(1u64), + ..Default::default() + } + .eth_to_token_amount(U256::from(eth_amount)) + } + + // ------------------------------------------------------------------------- + // Whole-transaction coverage: deduction, call, refund + // ------------------------------------------------------------------------- + + /// Registers the fee token in the L2TokenRegistry on the direct-slot path. + /// + /// `balanceSlot` is stored one-based, so zero there means "call mode". + fn register_slot_mode_token(db: &mut CacheDB) { + let mut token_id_bytes = [0u8; 32]; + token_id_bytes[30..32].copy_from_slice(&FEE_TOKEN_ID.to_be_bytes()); + let base = compute_mapping_slot(U256::from(151), &token_id_bytes); + + let mut put = |slot: U256, value: U256| { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + }; + put(base, U256::from_be_bytes(TOKEN.into_word().0)); + put(base + U256::from(1), BALANCE_SLOT + U256::from(1)); + let mut status = [0u8; 32]; + status[30] = 18; // decimals + status[31] = 1; // isActive + put(base + U256::from(2), U256::from_be_bytes(status)); + put(base + U256::from(3), U256::from(1u64)); // scale + put( + compute_mapping_slot(U256::from(153), &token_id_bytes), + U256::from(PRICE_RATIO), + ); + } + + /// A funded caller with the fee token registered on the direct-slot path. + fn slot_mode_evm(spec: MorphHardfork) -> MorphEvm, NoOpInspector> { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(TOKEN, AccountInfo::default()); + db.insert_account_info(CALLER, AccountInfo::default()); + register_slot_mode_token(&mut db); + db.insert_account_storage(TOKEN, slot_of(CALLER), U256::from(STARTING_BALANCE)) + .unwrap(); + db.insert_account_storage(TOKEN, slot_of(BENEFICIARY), U256::ZERO) + .unwrap(); + + let mut evm = MorphEvm::new(MorphContext::new(db, spec), NoOpInspector); + evm.block.inner.beneficiary = BENEFICIARY; + evm.block.inner.basefee = 0; + evm.block.inner.gas_limit = 30_000_000; + evm + } + + /// `calldata_len` bytes of non-zero calldata shift the transaction's gas + /// cost, which is what moves `gas_used` through its remainders modulo + /// `PRICE_RATIO`. + fn fee_tx(gas_limit: u64, calldata_len: usize) -> MorphTxEnv { + MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: CALLER, + kind: TxKind::Call(Address::repeat_byte(0x0e)), + gas_limit, + gas_price: 1, + data: alloy_primitives::Bytes::from(vec![0x01; calldata_len]), + ..Default::default() + }, + fee_token_id: Some(FEE_TOKEN_ID), + ..Default::default() + } + } + + /// Runs a whole token-fee transaction and reports + /// `(net token spend, gas used)`. + fn net_token_spend(spec: MorphHardfork, gas_limit: u64, calldata_len: usize) -> (U256, u64) { + let mut evm = slot_mode_evm(spec); + let result = evm + .transact_one(fee_tx(gas_limit, calldata_len)) + .expect("token-fee MorphTx must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + let gas_used = result.tx_gas_used(); + + ( + U256::from(STARTING_BALANCE) - token_balance(&mut evm, CALLER), + gas_used, + ) + } + + /// The deduction must hand the refund the numerator its rounding-up + /// overcharged. + /// + /// Asserted directly, because whether the credit changes the refunded amount + /// depends on how prepaid and remaining gas land modulo `PRICE_RATIO` — an + /// end-to-end assertion alone passes with the credit stuck at zero. + #[test] + fn deduction_records_the_rounding_credit_for_the_refund() { + for (gas_limit, want_credit) in [(99_999u64, 0u64), (100_000, 2), (100_001, 1)] { + let mut evm = slot_mode_evm(MorphHardfork::Celadon); + evm.tx = fee_tx(gas_limit, 0); + MorphEvmHandler::default() + .validate_against_state_and_deduct_caller( + &mut evm, + &mut InitialAndFloorGas::default(), + ) + .expect("deduction must succeed"); + + // The prepaid fee is `gas_limit` at price 1, so the credit is what + // rounding `gas_limit / PRICE_RATIO` up left unused. + assert_eq!( + evm.cached_alt_fee_rounding_credit, + U256::from(want_credit), + "gas_limit {gas_limit}" + ); + } + } + + /// From Celadon on, deduction and refund together charge exactly + /// `ceil(net ETH fee)` in token units, whatever the prepaid amount rounded to. + /// + /// The sweep covers every remainder `gas_used` and the prepaid amount can + /// have modulo `PRICE_RATIO`; the credit only changes the refund for some of + /// those combinations, and the final assertion holds the sweep to covering + /// them. + /// The credit belongs to one transaction. It is only read next to + /// `cached_token_fee_info`, which the same deduction writes, so a stale value + /// cannot reach a refund today. Clearing it with the other per-transaction + /// caches keeps that true without depending on where the reads happen. + #[test] + fn the_rounding_credit_does_not_outlive_its_transaction() { + let handler = MorphEvmHandler::default(); + let mut evm = slot_mode_evm(MorphHardfork::Celadon); + + evm.tx = fee_tx(100_000, 0); + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut InitialAndFloorGas::default()) + .expect("deduction must succeed"); + assert_eq!(evm.cached_alt_fee_rounding_credit, U256::from(2u64)); + + // The next transaction on the same EVM pays no token fee. An L1 message is + // the shortest such path: it clears the caches and returns. + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: morph_primitives::L1_TX_TYPE_ID, + caller: CALLER, + kind: TxKind::Call(Address::repeat_byte(0x0e)), + gas_limit: 100_000, + ..Default::default() + }, + ..Default::default() + }; + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut InitialAndFloorGas::default()) + .expect("an L1 message needs no fee"); + + assert!(evm.cached_token_fee_info.is_none()); + assert_eq!(evm.cached_alt_fee_rounding_credit, U256::ZERO); + } + + #[test] + fn celadon_charges_the_ceiling_of_the_net_fee_end_to_end() { + let mut seen_remainders = [false; PRICE_RATIO as usize]; + for calldata_len in 0..PRICE_RATIO as usize { + for gas_limit in 100_000u64..100_000 + PRICE_RATIO { + let (spent, gas_used) = + net_token_spend(MorphHardfork::Celadon, gas_limit, calldata_len); + assert_eq!( + spent, + ceil_to_token(gas_used), + "gas_limit {gas_limit}, calldata_len {calldata_len}, gas_used {gas_used}" + ); + seen_remainders[(gas_used % PRICE_RATIO) as usize] = true; + } + } + assert!( + seen_remainders.iter().all(|seen| *seen), + "the sweep must cover every gas_used remainder modulo {PRICE_RATIO}, \ + otherwise it misses the cases where the credit changes the refund" + ); + } + + /// The pre-Celadon rule under-collects, so the fork gate is load-bearing. + /// + /// Rounding both halves up independently cancels out whenever the prepaid + /// amount and the refund leave the same remainder, so the shortfall shows on + /// only some transactions: the assertion is that at least one swept + /// transaction is short, and that none over-collects. + #[test] + fn pre_celadon_under_collects_end_to_end() { + let mut short = 0; + for calldata_len in 0..PRICE_RATIO as usize { + for gas_limit in 100_000u64..100_000 + PRICE_RATIO { + let (spent, gas_used) = + net_token_spend(MorphHardfork::Jade, gas_limit, calldata_len); + let ceiling = ceil_to_token(gas_used); + assert!( + spent <= ceiling, + "pre-Celadon must never collect more than the ceiling of the net fee, \ + gas_limit {gas_limit}, calldata_len {calldata_len}: spent {spent} > {ceiling}" + ); + if spent < ceiling { + short += 1; + } + } + } + assert!( + short > 0, + "pre-Celadon must under-collect on at least one swept transaction, \ + otherwise this test cannot tell the two rules apart" + ); + } + + // ------------------------------------------------------------------------- + // The refund step in isolation + // ------------------------------------------------------------------------- + + /// An EVM parked right before `reimburse_caller_token_fee`, holding the state + /// the deduction phase would have left behind: the fee already in the vault, + /// and `rounding_credit` recorded from the deduction's rounding-up. + fn evm_after_deduction( + spec: MorphHardfork, + rounding_credit: U256, + ) -> MorphEvm, NoOpInspector> { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(TOKEN, AccountInfo::default()); + db.insert_account_storage(TOKEN, slot_of(BENEFICIARY), U256::from(VAULT_BALANCE)) + .unwrap(); + db.insert_account_storage(TOKEN, slot_of(CALLER), U256::ZERO) + .unwrap(); + + let mut evm = MorphEvm::new(MorphContext::new(db, spec), NoOpInspector); + evm.block.inner.beneficiary = BENEFICIARY; + evm.block.inner.basefee = 0; + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: CALLER, + kind: TxKind::Call(Address::ZERO), + gas_limit: 100_000, + // With a zero basefee the effective gas price is 1, so the ETH to + // refund equals the gas left and the arithmetic reads directly. + gas_price: 1, + ..Default::default() + }, + fee_token_id: Some(FEE_TOKEN_ID), + ..Default::default() + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: TOKEN, + is_active: true, + price_ratio: U256::from(PRICE_RATIO), + scale: U256::from(1u64), + caller: CALLER, + balance: U256::ZERO, + balance_slot: Some(BALANCE_SLOT), + ..Default::default() + }); + evm.cached_alt_fee_rounding_credit = rounding_credit; + evm + } + + /// Refunds `gas_left` worth of gas and reports what reached the caller. + fn refund_with(spec: MorphHardfork, gas_left: u64, rounding_credit: U256) -> U256 { + let mut evm = evm_after_deduction(spec, rounding_credit); + MorphEvmHandler::default() + .reimburse_caller_token_fee(&mut evm, &Gas::new(gas_left)) + .expect("refund must not fail"); + token_balance(&mut evm, CALLER) + } + + /// Before Celadon both halves of the fee round up independently. That + /// under-collects, but it is mainnet's history and must not move. + #[test] + fn pre_celadon_refund_rounds_up() { + // ceil(4 / 3) = 2, whatever credit the deduction recorded. + assert_eq!( + refund_with(MorphHardfork::Jade, 4, U256::ZERO), + U256::from(2u64) + ); + assert_eq!( + refund_with(MorphHardfork::Jade, 4, U256::from(2u64)), + U256::from(2u64) + ); + } + + /// From Celadon on the refund adds the prepaid rounding credit and rounds + /// down. + #[test] + fn celadon_refund_rounds_down_with_the_credit() { + // floor((4 + 0) / 3) = 1 — a whole token unit less than the old rule. + assert_eq!( + refund_with(MorphHardfork::Celadon, 4, U256::ZERO), + U256::from(1u64) + ); + // floor((4 + 2) / 3) = 2: the credit can bring the refund back up. + assert_eq!( + refund_with(MorphHardfork::Celadon, 4, U256::from(2u64)), + U256::from(2u64) + ); + } + + /// Flooring can reach zero, where the ceiling always refunded at least one + /// unit. A zero refund must move no tokens and write no slots, matching + /// go-ethereum's `TransferAltTokenHybrid` early return. + #[test] + fn celadon_zero_refund_touches_nothing() { + // floor(2 / 3) = 0 while ceil(2 / 3) = 1. + assert_eq!( + refund_with(MorphHardfork::Celadon, 2, U256::ZERO), + U256::ZERO + ); + assert_eq!( + refund_with(MorphHardfork::Jade, 2, U256::ZERO), + U256::from(1u64) + ); + + let mut evm = evm_after_deduction(MorphHardfork::Celadon, U256::ZERO); + MorphEvmHandler::default() + .reimburse_caller_token_fee(&mut evm, &Gas::new(2)) + .unwrap(); + assert!( + evm.post_fee_logs.is_empty(), + "a zero refund must emit no Transfer log" + ); + // Asserted before any read of our own: `token_balance` would itself pull + // the account and slot into the journal. + assert!( + evm.ctx + .journal_mut() + .state + .get(&TOKEN) + .is_none_or(|token| token.storage.is_empty()), + "a zero refund must not load or write the token's balance slots" + ); + assert_eq!( + token_balance(&mut evm, BENEFICIARY), + U256::from(VAULT_BALANCE), + "a zero refund must leave the vault balance alone" + ); + } } diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 00aea498..a887c914 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -73,7 +73,7 @@ pub use l1block::{ }; pub use precompiles::MorphPrecompiles; pub use token_fee::{ - L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, compute_mapping_slot, - compute_mapping_slot_for_address, encode_balance_of_calldata, query_erc20_balance, + L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, compute_mapping_slot, + compute_mapping_slot_for_address, encode_balance_of_calldata, }; pub use tx::{MorphTxEnv, MorphTxExt}; diff --git a/crates/revm/src/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 c32827cb..445035c2 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -9,12 +9,18 @@ use alloy_evm::Database; use alloy_primitives::{Address, Bytes, U256, address, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::Database as RevmDatabase; -use revm::SystemCallEvm; use revm::{context_interface::result::EVMError, inspector::NoOpInspector}; -use crate::evm::MorphContext; use crate::{MorphEvm, MorphInvalidTransaction}; +/// The environment a fee-token `balanceOf` call is evaluated in. +/// +/// Produced by `ConfigureEvm::evm_env` for the block whose state is being read, so the pool +/// resolves the same balance the execution layer would. go-ethereum builds the equivalent +/// `vm.BlockContext` from the header before querying a call-mode token +/// (`pool.getBalanceFunc`, core/tx_pool.go:330). +pub type MorphEvmEnv = alloy_evm::EvmEnv; + /// L2 Token Registry contract address on Morph L2. /// Reference: pub const L2_TOKEN_REGISTRY_ADDRESS: Address = address!("5300000000000000000000000000000000000021"); @@ -60,6 +66,18 @@ pub(crate) struct TokenRegistryEntry { } impl TokenRegistryEntry { + /// The registered ERC20 contract. + pub(crate) const fn token_address(&self) -> Address { + self.token_address + } + + /// The caller's balance storage slot, when the registry declares one. + /// + /// `None` means call mode: the balance has to be read by calling `balanceOf`. + pub(crate) const fn balance_slot(&self) -> Option { + self.balance_slot + } + /// Load fee-token metadata without reading a caller's token balance. pub(crate) fn load( db: &mut DB, @@ -84,14 +102,14 @@ impl TokenRegistryEntry { self, db: &mut DB, caller: Address, - hardfork: MorphHardfork, - ) -> Result { + env: &MorphEvmEnv, + ) -> Result> { let balance = read_token_balance_with_fallback( db, self.token_address, caller, self.balance_slot, - hardfork, + env, )?; Ok(self.into_fee_info(caller, balance)) } @@ -109,7 +127,7 @@ impl TokenRegistryEntry { Ok(self.into_fee_info(caller, balance)) } - fn into_fee_info(self, caller: Address, balance: U256) -> TokenFeeInfo { + pub(crate) fn into_fee_info(self, caller: Address, balance: U256) -> TokenFeeInfo { TokenFeeInfo { token_address: self.token_address, is_active: self.is_active, @@ -124,6 +142,16 @@ impl TokenRegistryEntry { } impl TokenFeeInfo { + /// Maximum permitted token debit, bounded by the available balance. + /// A zero fee limit means that the whole balance is available. + pub fn effective_fee_limit(&self, fee_limit: U256) -> U256 { + if fee_limit.is_zero() { + self.balance + } else { + self.balance.min(fee_limit) + } + } + /// Load token fee information with EVM call fallback. /// /// Reads token parameters from L2 Token Registry storage. If the token's @@ -133,14 +161,14 @@ impl TokenFeeInfo { db: &mut DB, token_id: u16, caller: Address, - hardfork: MorphHardfork, - ) -> Result, DB::Error> { + env: &MorphEvmEnv, + ) -> Result, EVMError> { let entry = match TokenRegistryEntry::load(db, token_id)? { Some(e) => e, None => return Ok(None), }; - entry.load_for_caller(db, caller, hardfork).map(Some) + entry.load_for_caller(db, caller, env).map(Some) } /// Storage-only variant of [`Self::load_for_caller`]. @@ -171,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 @@ -187,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 } } @@ -292,26 +361,33 @@ fn read_token_balance_with_fallback( token: Address, account: Address, balance_slot: Option, - hardfork: MorphHardfork, -) -> Result { + env: &MorphEvmEnv, +) -> Result> { if let Some(slot) = balance_slot { - return read_balance_from_storage(db, token, account, slot); + return Ok(read_balance_from_storage(db, token, account, slot)?); } - // EVM fallback: construct temporary MorphEvm for balanceOf call + // Call mode: stand the EVM up in the caller's environment rather than a default one, + // and make the same `balanceOf` call the execution layer makes, so both reach the same + // answer for a token whose balance depends on block context or `msg.sender`. let db: &mut dyn Database = db; - let mut evm = MorphEvm::new(MorphContext::new(db, hardfork), NoOpInspector {}); - - match query_balance_via_system_call(&mut evm, token, account) { - Ok(balance) => Ok(balance), - Err(EVMError::Database(e)) => Err(e), - Err(_) => Ok(U256::ZERO), // Non-DB errors → zero (safe fallback) - } + let mut evm = MorphEvm::from_env(db, env.clone(), NoOpInspector {}); + // ORIGIN follows this client's own execution layer, which resolves the same + // `balanceOf` against the transaction's `caller`. go-ethereum's pool instead builds + // its query on an empty `vm.TxContext{}` (core/tx_pool.go:341), leaving ORIGIN at the + // zero address and disagreeing with go-ethereum's own execution layer. Admission + // exists to predict what the builder will be able to include, so it follows execution + // rather than the other client's pool. GASPRICE is the one input this query still + // cannot match: it stays at the `TxEnv` default of zero because the effective price + // depends on the next block's base fee, which admission does not know. go-ethereum's + // pool has the same gap. + evm.tx.inner.caller = account; + crate::handler::evm_call_balance_of(&mut evm, token, account) } /// Read ERC20 balance directly from storage slot. #[inline] -fn read_balance_from_storage( +pub(crate) fn read_balance_from_storage( db: &mut DB, token: Address, account: Address, @@ -322,44 +398,6 @@ fn read_balance_from_storage( read_mapping_value(db, token, balance_slot, &key) } -/// Execute EVM `balanceOf(address)` call. -fn query_balance_via_system_call( - evm: &mut MorphEvm, - token: Address, - account: Address, -) -> Result> -where - DB: Database, -{ - let calldata = encode_balance_of_calldata(account); - match evm.system_call_one(token, calldata) { - Ok(result) if result.is_success() => { - if let Some(output) = result.output() - && output.len() >= 32 - { - return Ok(U256::from_be_slice(&output[..32])); - } - Ok(U256::ZERO) - } - Ok(_) => Ok(U256::ZERO), - Err(_) => Ok(U256::ZERO), - } -} - -/// Query ERC20 balance via EVM call. -/// -/// Use this when you have a `MorphEvm` instance and need to call `balanceOf`. -pub fn query_erc20_balance( - evm: &mut MorphEvm, - token: Address, - account: Address, -) -> Result> -where - DB: Database, -{ - query_balance_via_system_call(evm, token, account) -} - /// Encode ERC20 `balanceOf(address)` calldata. /// /// Function selector: `0x70a08231` @@ -373,9 +411,166 @@ pub fn encode_balance_of_calldata(account: Address) -> Bytes { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + use alloy_primitives::{B256, address, bytes}; + use revm::bytecode::Bytecode; + use revm::database::{CacheDB, EmptyDB}; + use revm::state::AccountInfo; + + /// The storage read failure injected by [`UnreadableTokenDb`], distinguishable from any + /// error a real database would report. Shared with the handler tests. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) struct TokenReadFailure; + + impl core::fmt::Display for TokenReadFailure { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("injected token storage read failure") + } + } + + impl core::error::Error for TokenReadFailure {} + + impl revm::database_interface::DBErrorMarker for TokenReadFailure {} + + /// Fails every storage read of `token` with [`TokenReadFailure`]; everything else reads + /// normally, so a failure a test observes comes from that token's storage. Shared with the + /// handler tests. + #[derive(Debug)] + pub(crate) struct UnreadableTokenDb { + pub(crate) inner: CacheDB, + pub(crate) token: Address, + } + + impl RevmDatabase for UnreadableTokenDb { + type Error = TokenReadFailure; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + Ok(self.inner.basic(address).unwrap()) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + Ok(self.inner.code_by_hash(code_hash).unwrap()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + if address == self.token { + return Err(TokenReadFailure); + } + Ok(self.inner.storage(address, index).unwrap()) + } + + fn block_hash(&mut self, number: u64) -> Result { + Ok(self.inner.block_hash(number).unwrap()) + } + } + + /// Registry state for a call-mode token (no `balanceSlot`) whose `balanceOf` returns + /// storage slot 0, so reading it is a storage read of the token contract. + fn call_mode_token_state(token: Address, balance: u64) -> CacheDB { + call_mode_token_state_with_code(token, balance, bytes!("6000545f5260205ff3")) + } + + /// As [`call_mode_token_state`], with an explicit `balanceOf` implementation. + fn call_mode_token_state_with_code( + token: Address, + balance: u64, + code: Bytes, + ) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + let mut token_id_bytes = [0u8; 32]; + token_id_bytes[31] = 1; + let base = compute_mapping_slot(TOKEN_REGISTRY_SLOT, &token_id_bytes); + + let mut packed = [0u8; 32]; + packed[30] = 18; // decimals + packed[31] = 1; // isActive + for (slot, value) in [ + (base, U256::from_be_bytes(token.into_word().0)), + // Zero means "no known balance slot": the EVM `balanceOf` fallback is used. + (base + U256::from(1), U256::ZERO), + (base + U256::from(2), U256::from_be_bytes(packed)), + (base + U256::from(3), U256::from(1)), // scale + ( + compute_mapping_slot(PRICE_RATIO_SLOT, &token_id_bytes), + U256::from(1), // priceRatio + ), + ] { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + } + + db.insert_account_info( + token, + AccountInfo { + code_hash: alloy_primitives::keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, U256::ZERO, U256::from(balance)) + .unwrap(); + db + } + + #[test] + fn call_mode_balance_is_read_under_the_supplied_block_environment() { + const TIMESTAMP: u64 = 1_767_765_600; + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("0000000000000000000000000000000000000001"); + + // TIMESTAMP PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN — a `balanceOf` that reports the + // block time, so an answer produced under the wrong environment is visible. + let mut db = call_mode_token_state_with_code(token, 0, bytes!("425f5260205ff3")); + + let env = MorphEvmEnv::new( + revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + crate::MorphBlockEnv { + inner: revm::context::BlockEnv { + timestamp: U256::from(TIMESTAMP), + ..Default::default() + }, + }, + ); + + let info = TokenFeeInfo::load_for_caller(&mut db, 1, caller, &env) + .unwrap() + .unwrap(); + + // `BlockEnv::default()` reports timestamp 1, which is what the pool answered with + // regardless of the block it was validating against. + assert_eq!(info.balance, U256::from(TIMESTAMP)); + } + + #[test] + fn balance_of_fallback_reports_a_failed_state_read_instead_of_a_zero_balance() { + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("0000000000000000000000000000000000000001"); + let env = MorphEvmEnv::new( + revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + crate::MorphBlockEnv::default(), + ); + + // Readable state: the fallback reaches the token and reads the balance. + let mut readable = call_mode_token_state(token, 10_000_000); + let info = TokenFeeInfo::load_for_caller(&mut readable, 1, caller, &env) + .unwrap() + .unwrap(); + assert_eq!(info.balance, U256::from(10_000_000)); + + // Same state, but the token's storage cannot be read. Reporting a zero balance here + // would be indistinguishable from an account that genuinely cannot pay. + let mut unreadable = UnreadableTokenDb { + inner: call_mode_token_state(token, 10_000_000), + token, + }; + assert_eq!( + TokenFeeInfo::load_for_caller(&mut unreadable, 1, caller, &env).unwrap_err(), + EVMError::Database(TokenReadFailure) + ); + } + #[test] fn test_token_fee_info_default() { let info = TokenFeeInfo::default(); @@ -435,6 +630,124 @@ 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 e666c295..dbcacf6d 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -7,8 +7,11 @@ use alloy_evm::Database; use alloy_primitives::{Address, U256}; use morph_chainspec::hardfork::MorphHardfork; -use morph_primitives::{MorphTxEnvelope, transaction::morph_transaction::MORPH_TX_VERSION_1}; -use morph_revm::TokenFeeInfo; +use morph_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(), @@ -107,7 +121,15 @@ pub fn validate_morph_tx( }); } - let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, input.hardfork) + // Pool admission has no block environment, so a call-mode token's `balanceOf` + // is evaluated under the hardfork's defaults. That matches the pool's previous + // behaviour; threading the real head environment through admission is txpool + // work and does not belong in this change. + let env = MorphEvmEnv::new( + reth_revm::revm::context::CfgEnv::new_with_spec(input.hardfork), + morph_revm::MorphBlockEnv::default(), + ); + let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, &env) .map_err(|err| MorphTxError::TokenInfoFetchFailed { token_id: fee_token_id, message: format!("{err:?}"), @@ -136,14 +158,9 @@ pub fn validate_morph_tx( let total_token_fee = token_gas_fee.saturating_add(input.l1_data_fee); let required_token_amount = token_info.eth_to_token_amount(total_token_fee); - // Match REVM semantics: - // - fee_limit == 0 => use token balance as effective limit - // - fee_limit > balance => cap by token balance - let effective_limit = if fee_limit.is_zero() || fee_limit > token_info.balance { - token_info.balance - } else { - fee_limit - }; + // Share the execution layer's clamp rather than restating it: a zero `fee_limit` + // means the whole token balance, and a larger one is capped by it. + let effective_limit = token_info.effective_fee_limit(fee_limit); // Check token balance against effective limit. if effective_limit < required_token_amount { @@ -226,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( @@ -304,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( @@ -342,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( @@ -386,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( @@ -424,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( @@ -448,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(