diff --git a/bin/morph-statetest/tests/celadon_alt_token_refund.rs b/bin/morph-statetest/tests/celadon_alt_token_refund.rs new file mode 100644 index 0000000..4762795 --- /dev/null +++ b/bin/morph-statetest/tests/celadon_alt_token_refund.rs @@ -0,0 +1,50 @@ +//! Golden roots from morph-geth 5a0d0d771 (go-ethereum#371), which reads them back +//! from this same fixture. +//! +//! The fee token is registered with `priceRatio = 3` against `scale = 1`, so +//! converting ETH to token units is inexact. Each fork runs three calldata +//! lengths against three consecutive gas limits: +//! +//! - one, two and three non-zero calldata bytes cost 21_016, 21_032 and 21_048 +//! gas, which covers every remainder of the *net* fee modulo the price ratio; +//! - the gas limits 100_001..=100_003 cover every remainder of the *prepaid* fee. +//! +//! Tokens collected, per gas limit: +//! +//! | net gas | Emerald, Jade | Celadon | floor without the credit | +//! |---------|---------------------|---------------------|--------------------------| +//! | 21_016 | 7_005, 7_005, 7_006 | 7_006, 7_006, 7_006 | 7_006, 7_006, 7_006 | +//! | 21_032 | 7_011, 7_010, 7_011 | 7_011, 7_011, 7_011 | 7_011, 7_011, 7_012 | +//! | 21_048 | 7_016, 7_016, 7_016 | 7_016, 7_016, 7_016 | 7_017, 7_016, 7_017 | +//! +//! Celadon collects `ceil(net / 3)` on every gas limit, so it ends on one state +//! root per calldata length. Emerald and Jade round the prepaid fee and the refund +//! up independently and come out a token unit short on three of the nine, so the +//! first two rows end on two roots each. +//! +//! The last column is why one calldata length is not enough. With a net fee of +//! 21_016 the prepaid rounding credit never carries into the refund, so a client +//! that rounds the refund down but drops the credit still lands on every root of +//! that row. The other two rows are the ones that pin the credit itself. +//! +//! The gas figures also pin the transaction's gas: morph does not apply the +//! EIP-7623 calldata floor, which would bill 21_040 for the first row and miss +//! every root here. +use morph_statetest::runner::run_suite_str; + +#[test] +fn celadon_alt_token_refund_matches_geth() { + let outcomes = run_suite_str(include_str!("fixtures/celadon_alt_token_refund.json")).unwrap(); + assert_eq!( + outcomes.len(), + 27, + "3 forks × 3 calldata lengths × 3 gas limits" + ); + for outcome in outcomes { + assert!( + outcome.pass, + "{} / {}: {}", + outcome.test, outcome.fork, outcome.error_msg + ); + } +} diff --git a/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json b/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json new file mode 100644 index 0000000..b4b85e9 --- /dev/null +++ b/bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json @@ -0,0 +1,321 @@ +{ + "celadon_alt_token_refund_rounding": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x9734b052146069605dcf2a05300c1dd5cd5852a2844e5491b2eb25d6daa909bc": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000003" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0x1", + "gasLimit": [ + "0x186a1", + "0x186a2", + "0x186a3" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x01", + "0x0101", + "0x010101" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdfaedbf51f7737495c2597c811b6127d75727bc8e359c7b6261dd5ff6519a14d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 1, + "value": 0 + }, + "hash": "0xdfaedbf51f7737495c2597c811b6127d75727bc8e359c7b6261dd5ff6519a14d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 2, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 0, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 1, + "value": 0 + }, + "hash": "0x8b7ea1313b3109e09cae63d493df2f9a405cad607147f72abcde55d84dee1b5b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 2, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 0, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 1, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 2, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdfaedbf51f7737495c2597c811b6127d75727bc8e359c7b6261dd5ff6519a14d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 1, + "value": 0 + }, + "hash": "0xdfaedbf51f7737495c2597c811b6127d75727bc8e359c7b6261dd5ff6519a14d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 2, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 0, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 1, + "value": 0 + }, + "hash": "0x8b7ea1313b3109e09cae63d493df2f9a405cad607147f72abcde55d84dee1b5b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 2, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 0, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 1, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 2, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Celadon": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 1, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 0, + "gas": 2, + "value": 0 + }, + "hash": "0x29e5e2daaf49bd925026bc3834581a8062ef52ba8faf28bae579ca9b0bc5b43f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 0, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 1, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 1, + "gas": 2, + "value": 0 + }, + "hash": "0x1643801e0afc66f0ba3a58f2b40b3251dce6286128b32e3dea600e0a94dc5150", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 0, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 1, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + }, + { + "indexes": { + "data": 2, + "gas": 2, + "value": 0 + }, + "hash": "0xd0320d67596f1f8a013ab8ed47c1e87a66e707ad72accc3957f33438c4ee8bd6", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + } +} diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index eda44e6..e8034f2 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -95,6 +95,13 @@ pub struct MorphEvm { /// Ensures consistent price_ratio/scale between deduct and reimburse, /// matching go-ethereum's `st.feeRate`/`st.tokenScale` caching pattern. pub(crate) cached_token_fee_info: Option, + /// Token-unit numerator the fee deduction overcharged by rounding up. + /// + /// From Celadon on, the unused-gas refund adds this back before rounding + /// down, so the caller pays `ceil` of the net fee instead of + /// `ceil(prepaid) - ceil(refund)`, which under-collects. Mirrors + /// go-ethereum's `st.altFeeRoundingCredit`. + pub(crate) cached_alt_fee_rounding_credit: U256, /// Cached L1 data fee calculated during handler validation. /// Avoids re-encoding the full transaction RLP in the block executor's /// receipt-building path (the handler already has the encoded bytes via @@ -183,6 +190,7 @@ impl MorphEvm { Self { inner, cached_token_fee_info: None, + cached_alt_fee_rounding_credit: U256::ZERO, cached_l1_data_fee: U256::ZERO, pre_fee_refund: 0, pre_fee_logs: Vec::new(), diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index a1c5ccf..be10eea 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -145,6 +145,7 @@ where evm.cached_l1_data_fee = U256::ZERO; evm.pre_fee_refund = 0; evm.cached_token_fee_info = None; + evm.cached_alt_fee_rounding_credit = U256::ZERO; evm.pre_fee_logs.clear(); evm.post_fee_logs.clear(); @@ -503,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( @@ -523,8 +525,25 @@ where } })?; - // Calculate token amount required for total fee - let token_amount_required = token_fee_info.eth_to_token_amount(reimburse_eth); + // From Celadon on, add back the numerator the deduction's rounding-up + // overcharged and round down, so the caller ends up paying `ceil` of the + // net fee. Before Celadon both halves round up independently, which + // under-collects; that is mainnet's history and must stay bit-identical. + // Matches go-ethereum's `refundGas` gate on `IsCeladon`. + let token_amount_required = if hardfork.is_celadon() { + token_fee_info + .eth_to_token_amount_floor(reimburse_eth, evm.cached_alt_fee_rounding_credit) + } else { + token_fee_info.eth_to_token_amount(reimburse_eth) + }; + + // Rounding down can land on zero, which the ceiling never did for a + // non-zero `reimburse_eth`. go-ethereum's `TransferAltTokenHybrid` + // returns early on a zero amount: no `Transfer(.., 0)` log on the + // call path, and no slot writes on the direct-slot path. + if token_amount_required.is_zero() { + return Ok(()); + } // Attempt token refund. Matches go-ethereum's refundGas() which silently logs // and continues on failure: "Continue execution even if refund fails - refund @@ -678,8 +697,11 @@ where // Total fee in ETH let total_eth_fee = l2_gas_fee.saturating_add(l1_data_fee); - // Calculate token amount required for total fee - let token_amount_required = token_fee_info.eth_to_token_amount(total_eth_fee); + // Calculate token amount required for total fee. The credit is the part of + // one token unit the rounding-up overcharged; from Celadon on the refund + // hands it back (see `reimburse_caller_token_fee`). + let (token_amount_required, alt_fee_rounding_credit) = + token_fee_info.eth_to_token_amount_with_credit(total_eth_fee); let fee_limit = token_fee_info.effective_fee_limit(fee_limit_from_tx); @@ -804,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(()) @@ -3034,3 +3057,390 @@ mod tests { } } } + +/// Alt-token refund rounding, gated on Celadon. +/// +/// Uses the registry's direct-slot path so the arithmetic is the only variable; +/// the call path routes the same `token_amount_required` through an ERC20 +/// `transfer`, which [`tests`] already covers. +#[cfg(test)] +mod refund_rounding_tests { + use super::*; + use crate::{L2_TOKEN_REGISTRY_ADDRESS, MorphTxEnv, TokenFeeInfo, compute_mapping_slot}; + use alloy_primitives::{Address, TxKind, U256, address}; + use morph_chainspec::hardfork::MorphHardfork; + use morph_primitives::MORPH_TX_TYPE_ID; + use revm::{ + context::TxEnv, + database::{CacheDB, EmptyDB}, + inspector::NoOpInspector, + state::AccountInfo, + }; + + const CALLER: Address = address!("1000000000000000000000000000000000000001"); + const BENEFICIARY: Address = address!("2000000000000000000000000000000000000002"); + const TOKEN: Address = address!("3000000000000000000000000000000000000003"); + const BALANCE_SLOT: U256 = U256::from_limbs([7, 0, 0, 0]); + /// A `price_ratio` of 3 against a `scale` of 1 makes two of every three wei + /// an inexact conversion, which is what the two roundings disagree about. + const PRICE_RATIO: u64 = 3; + const FEE_TOKEN_ID: u16 = 1; + const VAULT_BALANCE: u64 = 1_000_000; + const STARTING_BALANCE: u64 = 1_000_000; + + fn slot_of(account: Address) -> U256 { + compute_mapping_slot_for_address(BALANCE_SLOT, account) + } + + fn token_balance( + evm: &mut MorphEvm, NoOpInspector>, + account: Address, + ) -> U256 { + let journal = evm.ctx.journal_mut(); + journal.load_account_mut(TOKEN).expect("load token"); + *journal.sload(TOKEN, slot_of(account)).expect("sload") + } + + fn ceil_to_token(eth_amount: u64) -> U256 { + TokenFeeInfo { + price_ratio: U256::from(PRICE_RATIO), + scale: U256::from(1u64), + ..Default::default() + } + .eth_to_token_amount(U256::from(eth_amount)) + } + + // ------------------------------------------------------------------------- + // Whole-transaction coverage: deduction, call, refund + // ------------------------------------------------------------------------- + + /// Registers the fee token in the L2TokenRegistry on the direct-slot path. + /// + /// `balanceSlot` is stored one-based, so zero there means "call mode". + fn register_slot_mode_token(db: &mut CacheDB) { + let mut token_id_bytes = [0u8; 32]; + token_id_bytes[30..32].copy_from_slice(&FEE_TOKEN_ID.to_be_bytes()); + let base = compute_mapping_slot(U256::from(151), &token_id_bytes); + + let mut put = |slot: U256, value: U256| { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + }; + put(base, U256::from_be_bytes(TOKEN.into_word().0)); + put(base + U256::from(1), BALANCE_SLOT + U256::from(1)); + let mut status = [0u8; 32]; + status[30] = 18; // decimals + status[31] = 1; // isActive + put(base + U256::from(2), U256::from_be_bytes(status)); + put(base + U256::from(3), U256::from(1u64)); // scale + put( + compute_mapping_slot(U256::from(153), &token_id_bytes), + U256::from(PRICE_RATIO), + ); + } + + /// A funded caller with the fee token registered on the direct-slot path. + fn slot_mode_evm(spec: MorphHardfork) -> MorphEvm, NoOpInspector> { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(TOKEN, AccountInfo::default()); + db.insert_account_info(CALLER, AccountInfo::default()); + register_slot_mode_token(&mut db); + db.insert_account_storage(TOKEN, slot_of(CALLER), U256::from(STARTING_BALANCE)) + .unwrap(); + db.insert_account_storage(TOKEN, slot_of(BENEFICIARY), U256::ZERO) + .unwrap(); + + let mut evm = MorphEvm::new(MorphContext::new(db, spec), NoOpInspector); + evm.block.inner.beneficiary = BENEFICIARY; + evm.block.inner.basefee = 0; + evm.block.inner.gas_limit = 30_000_000; + evm + } + + /// `calldata_len` bytes of non-zero calldata shift the transaction's gas + /// cost, which is what moves `gas_used` through its remainders modulo + /// `PRICE_RATIO`. + fn fee_tx(gas_limit: u64, calldata_len: usize) -> MorphTxEnv { + MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: CALLER, + kind: TxKind::Call(Address::repeat_byte(0x0e)), + gas_limit, + gas_price: 1, + data: alloy_primitives::Bytes::from(vec![0x01; calldata_len]), + ..Default::default() + }, + fee_token_id: Some(FEE_TOKEN_ID), + ..Default::default() + } + } + + /// Runs a whole token-fee transaction and reports + /// `(net token spend, gas used)`. + fn net_token_spend(spec: MorphHardfork, gas_limit: u64, calldata_len: usize) -> (U256, u64) { + let mut evm = slot_mode_evm(spec); + let result = evm + .transact_one(fee_tx(gas_limit, calldata_len)) + .expect("token-fee MorphTx must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + let gas_used = result.tx_gas_used(); + + ( + U256::from(STARTING_BALANCE) - token_balance(&mut evm, CALLER), + gas_used, + ) + } + + /// The deduction must hand the refund the numerator its rounding-up + /// overcharged. + /// + /// Asserted directly, because whether the credit changes the refunded amount + /// depends on how prepaid and remaining gas land modulo `PRICE_RATIO` — an + /// end-to-end assertion alone passes with the credit stuck at zero. + #[test] + fn deduction_records_the_rounding_credit_for_the_refund() { + for (gas_limit, want_credit) in [(99_999u64, 0u64), (100_000, 2), (100_001, 1)] { + let mut evm = slot_mode_evm(MorphHardfork::Celadon); + evm.tx = fee_tx(gas_limit, 0); + MorphEvmHandler::default() + .validate_against_state_and_deduct_caller( + &mut evm, + &mut InitialAndFloorGas::default(), + ) + .expect("deduction must succeed"); + + // The prepaid fee is `gas_limit` at price 1, so the credit is what + // rounding `gas_limit / PRICE_RATIO` up left unused. + assert_eq!( + evm.cached_alt_fee_rounding_credit, + U256::from(want_credit), + "gas_limit {gas_limit}" + ); + } + } + + /// From Celadon on, deduction and refund together charge exactly + /// `ceil(net ETH fee)` in token units, whatever the prepaid amount rounded to. + /// + /// The sweep covers every remainder `gas_used` and the prepaid amount can + /// have modulo `PRICE_RATIO`; the credit only changes the refund for some of + /// those combinations, and the final assertion holds the sweep to covering + /// them. + /// The credit belongs to one transaction. It is only read next to + /// `cached_token_fee_info`, which the same deduction writes, so a stale value + /// cannot reach a refund today. Clearing it with the other per-transaction + /// caches keeps that true without depending on where the reads happen. + #[test] + fn the_rounding_credit_does_not_outlive_its_transaction() { + let handler = MorphEvmHandler::default(); + let mut evm = slot_mode_evm(MorphHardfork::Celadon); + + evm.tx = fee_tx(100_000, 0); + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut InitialAndFloorGas::default()) + .expect("deduction must succeed"); + assert_eq!(evm.cached_alt_fee_rounding_credit, U256::from(2u64)); + + // The next transaction on the same EVM pays no token fee. An L1 message is + // the shortest such path: it clears the caches and returns. + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: morph_primitives::L1_TX_TYPE_ID, + caller: CALLER, + kind: TxKind::Call(Address::repeat_byte(0x0e)), + gas_limit: 100_000, + ..Default::default() + }, + ..Default::default() + }; + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut InitialAndFloorGas::default()) + .expect("an L1 message needs no fee"); + + assert!(evm.cached_token_fee_info.is_none()); + assert_eq!(evm.cached_alt_fee_rounding_credit, U256::ZERO); + } + + #[test] + fn celadon_charges_the_ceiling_of_the_net_fee_end_to_end() { + let mut seen_remainders = [false; PRICE_RATIO as usize]; + for calldata_len in 0..PRICE_RATIO as usize { + for gas_limit in 100_000u64..100_000 + PRICE_RATIO { + let (spent, gas_used) = + net_token_spend(MorphHardfork::Celadon, gas_limit, calldata_len); + assert_eq!( + spent, + ceil_to_token(gas_used), + "gas_limit {gas_limit}, calldata_len {calldata_len}, gas_used {gas_used}" + ); + seen_remainders[(gas_used % PRICE_RATIO) as usize] = true; + } + } + assert!( + seen_remainders.iter().all(|seen| *seen), + "the sweep must cover every gas_used remainder modulo {PRICE_RATIO}, \ + otherwise it misses the cases where the credit changes the refund" + ); + } + + /// The pre-Celadon rule under-collects, so the fork gate is load-bearing. + /// + /// Rounding both halves up independently cancels out whenever the prepaid + /// amount and the refund leave the same remainder, so the shortfall shows on + /// only some transactions: the assertion is that at least one swept + /// transaction is short, and that none over-collects. + #[test] + fn pre_celadon_under_collects_end_to_end() { + let mut short = 0; + for calldata_len in 0..PRICE_RATIO as usize { + for gas_limit in 100_000u64..100_000 + PRICE_RATIO { + let (spent, gas_used) = + net_token_spend(MorphHardfork::Jade, gas_limit, calldata_len); + let ceiling = ceil_to_token(gas_used); + assert!( + spent <= ceiling, + "pre-Celadon must never collect more than the ceiling of the net fee, \ + gas_limit {gas_limit}, calldata_len {calldata_len}: spent {spent} > {ceiling}" + ); + if spent < ceiling { + short += 1; + } + } + } + assert!( + short > 0, + "pre-Celadon must under-collect on at least one swept transaction, \ + otherwise this test cannot tell the two rules apart" + ); + } + + // ------------------------------------------------------------------------- + // The refund step in isolation + // ------------------------------------------------------------------------- + + /// An EVM parked right before `reimburse_caller_token_fee`, holding the state + /// the deduction phase would have left behind: the fee already in the vault, + /// and `rounding_credit` recorded from the deduction's rounding-up. + fn evm_after_deduction( + spec: MorphHardfork, + rounding_credit: U256, + ) -> MorphEvm, NoOpInspector> { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(TOKEN, AccountInfo::default()); + db.insert_account_storage(TOKEN, slot_of(BENEFICIARY), U256::from(VAULT_BALANCE)) + .unwrap(); + db.insert_account_storage(TOKEN, slot_of(CALLER), U256::ZERO) + .unwrap(); + + let mut evm = MorphEvm::new(MorphContext::new(db, spec), NoOpInspector); + evm.block.inner.beneficiary = BENEFICIARY; + evm.block.inner.basefee = 0; + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: CALLER, + kind: TxKind::Call(Address::ZERO), + gas_limit: 100_000, + // With a zero basefee the effective gas price is 1, so the ETH to + // refund equals the gas left and the arithmetic reads directly. + gas_price: 1, + ..Default::default() + }, + fee_token_id: Some(FEE_TOKEN_ID), + ..Default::default() + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: TOKEN, + is_active: true, + price_ratio: U256::from(PRICE_RATIO), + scale: U256::from(1u64), + caller: CALLER, + balance: U256::ZERO, + balance_slot: Some(BALANCE_SLOT), + ..Default::default() + }); + evm.cached_alt_fee_rounding_credit = rounding_credit; + evm + } + + /// Refunds `gas_left` worth of gas and reports what reached the caller. + fn refund_with(spec: MorphHardfork, gas_left: u64, rounding_credit: U256) -> U256 { + let mut evm = evm_after_deduction(spec, rounding_credit); + MorphEvmHandler::default() + .reimburse_caller_token_fee(&mut evm, &Gas::new(gas_left)) + .expect("refund must not fail"); + token_balance(&mut evm, CALLER) + } + + /// Before Celadon both halves of the fee round up independently. That + /// under-collects, but it is mainnet's history and must not move. + #[test] + fn pre_celadon_refund_rounds_up() { + // ceil(4 / 3) = 2, whatever credit the deduction recorded. + assert_eq!( + refund_with(MorphHardfork::Jade, 4, U256::ZERO), + U256::from(2u64) + ); + assert_eq!( + refund_with(MorphHardfork::Jade, 4, U256::from(2u64)), + U256::from(2u64) + ); + } + + /// From Celadon on the refund adds the prepaid rounding credit and rounds + /// down. + #[test] + fn celadon_refund_rounds_down_with_the_credit() { + // floor((4 + 0) / 3) = 1 — a whole token unit less than the old rule. + assert_eq!( + refund_with(MorphHardfork::Celadon, 4, U256::ZERO), + U256::from(1u64) + ); + // floor((4 + 2) / 3) = 2: the credit can bring the refund back up. + assert_eq!( + refund_with(MorphHardfork::Celadon, 4, U256::from(2u64)), + U256::from(2u64) + ); + } + + /// Flooring can reach zero, where the ceiling always refunded at least one + /// unit. A zero refund must move no tokens and write no slots, matching + /// go-ethereum's `TransferAltTokenHybrid` early return. + #[test] + fn celadon_zero_refund_touches_nothing() { + // floor(2 / 3) = 0 while ceil(2 / 3) = 1. + assert_eq!( + refund_with(MorphHardfork::Celadon, 2, U256::ZERO), + U256::ZERO + ); + assert_eq!( + refund_with(MorphHardfork::Jade, 2, U256::ZERO), + U256::from(1u64) + ); + + let mut evm = evm_after_deduction(MorphHardfork::Celadon, U256::ZERO); + MorphEvmHandler::default() + .reimburse_caller_token_fee(&mut evm, &Gas::new(2)) + .unwrap(); + assert!( + evm.post_fee_logs.is_empty(), + "a zero refund must emit no Transfer log" + ); + // Asserted before any read of our own: `token_balance` would itself pull + // the account and slot into the journal. + assert!( + evm.ctx + .journal_mut() + .state + .get(&TOKEN) + .is_none_or(|token| token.storage.is_empty()), + "a zero refund must not load or write the token's balance slots" + ); + assert_eq!( + token_balance(&mut evm, BENEFICIARY), + U256::from(VAULT_BALANCE), + "a zero refund must leave the vault balance alone" + ); + } +} diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index 7b1b657..445035c 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -199,15 +199,30 @@ impl TokenFeeInfo { entry.load_storage_only(db, caller).map(Some) } - /// Calculate the token amount required for a given ETH amount. + /// Calculate the token amount required for a given ETH amount, rounding up. /// /// Uses the price ratio and scale to convert ETH value to token amount. #[inline] pub fn eth_to_token_amount(&self, eth_amount: U256) -> U256 { + self.eth_to_token_amount_with_credit(eth_amount).0 + } + + /// Same as [`Self::eth_to_token_amount`], and also returns the numerator the + /// rounding-up overcharged. + /// + /// The credit is `price_ratio - remainder` (zero when the division is exact): + /// the part of one whole token unit the caller paid for but did not use. From + /// Celadon on, [`Self::eth_to_token_amount_floor`] hands it back on the refund + /// so that the caller is charged `ceil` of the *net* fee rather than + /// `ceil(prepaid) - ceil(refund)`, which under-collects. + /// + /// Mirrors go-ethereum's `types.EthToAlt`. + #[inline] + pub fn eth_to_token_amount_with_credit(&self, eth_amount: U256) -> (U256, U256) { // If price_ratio or scale is zero (misconfigured token), return MAX to prevent // free-ride transactions. The caller's balance check will reject the tx. if self.price_ratio.is_zero() || self.scale.is_zero() { - return U256::MAX; + return (U256::MAX, U256::ZERO); } // token_amount = eth_amount * scale / price_ratio @@ -215,11 +230,37 @@ impl TokenFeeInfo { .saturating_mul(self.scale) .div_rem(self.price_ratio); // If there's a remainder, round up by adding 1 - if !remainder.is_zero() { - token_amount.saturating_add(U256::from(1)) + if remainder.is_zero() { + (token_amount, U256::ZERO) } else { - token_amount + ( + token_amount.saturating_add(U256::from(1)), + self.price_ratio - remainder, + ) + } + } + + /// Convert an ETH amount plus the prepaid rounding credit into token units, + /// rounding down. + /// + /// `rounding_credit` comes from the matching + /// [`Self::eth_to_token_amount_with_credit`] call made when the fee was + /// deducted. Mirrors go-ethereum's `types.EthToAltFloor`. + /// + /// A misconfigured token refunds nothing. That is the conservative direction + /// (the ceiling path returns `U256::MAX` for the same input to make the + /// deduction fail), and it is unreachable in practice: such a transaction + /// never gets past the balance check at deduction time. + #[inline] + pub fn eth_to_token_amount_floor(&self, eth_amount: U256, rounding_credit: U256) -> U256 { + if self.price_ratio.is_zero() || self.scale.is_zero() { + return U256::ZERO; } + + eth_amount + .saturating_mul(self.scale) + .saturating_add(rounding_credit) + / self.price_ratio } } @@ -589,6 +630,124 @@ pub(crate) mod tests { assert_eq!(token_amount, U256::MAX); } + /// Rounding up the prepaid fee leaves `price_ratio - remainder` of a token + /// unit paid for but unused, which is exactly what the refund gets back. + #[test] + fn eth_to_token_amount_reports_the_rounding_credit() { + let info = TokenFeeInfo { + price_ratio: U256::from(3u64), + scale: U256::from(1u64), + ..Default::default() + }; + + // 10 / 3 = 3 remainder 1 → charge 4, one third of a unit unused → 3 - 1 = 2. + let (amount, credit) = info.eth_to_token_amount_with_credit(U256::from(10u64)); + assert_eq!(amount, U256::from(4u64)); + assert_eq!(credit, U256::from(2u64)); + + // An exact division overcharges nothing. + let (amount, credit) = info.eth_to_token_amount_with_credit(U256::from(9u64)); + assert_eq!(amount, U256::from(3u64)); + assert_eq!(credit, U256::ZERO); + + // The plain ceiling accessor stays the first half of the pair. + assert_eq!( + info.eth_to_token_amount(U256::from(10u64)), + U256::from(4u64) + ); + } + + /// The credit is what makes deduct-then-refund add up to `ceil(net fee)`. + /// Without it, both halves round up independently and the chain + /// under-collects — for `remaining = 4` below, by a whole token unit. + #[test] + fn floor_refund_charges_the_ceiling_of_the_net_fee() { + let info = TokenFeeInfo { + price_ratio: U256::from(3u64), + scale: U256::from(1u64), + ..Default::default() + }; + + for prepaid_eth in 0u64..40 { + let (charged, credit) = info.eth_to_token_amount_with_credit(U256::from(prepaid_eth)); + for remaining_eth in 0..=prepaid_eth { + let refunded = info.eth_to_token_amount_floor(U256::from(remaining_eth), credit); + let net_eth = U256::from(prepaid_eth - remaining_eth); + assert_eq!( + charged - refunded, + info.eth_to_token_amount(net_eth), + "prepaid {prepaid_eth}, remaining {remaining_eth}" + ); + } + } + } + + /// The Celadon change is observable, so the fork gate in the handler is not + /// cosmetic: on an exact deduction the credit is zero and the two roundings + /// disagree for every inexact refund. + #[test] + fn floor_and_ceiling_refunds_differ() { + let info = TokenFeeInfo { + price_ratio: U256::from(3u64), + scale: U256::from(1u64), + ..Default::default() + }; + + // Exact deduction (9 / 3) → no credit; refunding 4 ceils to 2, floors to 1. + let (_, credit) = info.eth_to_token_amount_with_credit(U256::from(9u64)); + assert_eq!(credit, U256::ZERO); + assert_eq!(info.eth_to_token_amount(U256::from(4u64)), U256::from(2u64)); + assert_eq!( + info.eth_to_token_amount_floor(U256::from(4u64), credit), + U256::from(1u64) + ); + } + + /// Flooring can reach zero where the ceiling never does. The handler skips + /// the transfer in that case, matching go-ethereum's `TransferAltTokenHybrid`. + #[test] + fn floor_refund_can_be_zero() { + let info = TokenFeeInfo { + price_ratio: U256::from(5u64), + scale: U256::from(1u64), + ..Default::default() + }; + + let (_, credit) = info.eth_to_token_amount_with_credit(U256::from(5u64)); + assert_eq!(credit, U256::ZERO); + assert_eq!(info.eth_to_token_amount(U256::from(1u64)), U256::from(1u64)); + assert_eq!( + info.eth_to_token_amount_floor(U256::from(1u64), credit), + U256::ZERO + ); + } + + /// A misconfigured token refunds nothing rather than the `U256::MAX` the + /// ceiling path returns to make the deduction fail. + #[test] + fn floor_refund_of_a_misconfigured_token_is_zero() { + for info in [ + TokenFeeInfo { + price_ratio: U256::ZERO, + scale: U256::from(1u64), + ..Default::default() + }, + TokenFeeInfo { + price_ratio: U256::from(1u64), + scale: U256::ZERO, + ..Default::default() + }, + ] { + assert_eq!( + info.eth_to_token_amount_floor(U256::from(10u64), U256::from(3u64)), + U256::ZERO + ); + let (amount, credit) = info.eth_to_token_amount_with_credit(U256::from(10u64)); + assert_eq!(amount, U256::MAX); + assert_eq!(credit, U256::ZERO); + } + } + #[test] fn test_encode_balance_of() { let account = address!("1234567890123456789012345678901234567890");