From 970b40e53529997f83e3393294f27b5b7099a83a Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 28 Apr 2026 16:47:29 +0800 Subject: [PATCH 1/5] rex5: gas limit --- crates/mega-evm/src/constants.rs | 10 ++- crates/mega-evm/src/evm/interfaces.rs | 42 +++++++++- crates/mega-evm/src/evm/mod.rs | 37 +++++++++ .../mega-evm/src/system/sequencer_registry.rs | 78 ++++++++++++++++++- 4 files changed, 161 insertions(+), 6 deletions(-) diff --git a/crates/mega-evm/src/constants.rs b/crates/mega-evm/src/constants.rs index cddad8a7..de1eaa7f 100644 --- a/crates/mega-evm/src/constants.rs +++ b/crates/mega-evm/src/constants.rs @@ -131,7 +131,15 @@ pub mod rex4 { } /// Constants for the `REX5` spec. -pub mod rex5 {} +pub mod rex5 { + /// Floor for the gas limit assigned to pre-block system calls. + /// + /// From REX5, `transact_system_call_with_caller` uses + /// `max(block.gas_limit, SYSTEM_CALL_GAS_LIMIT_FLOOR)`. The floor matches the + /// upstream revm default (30M) so that test harnesses or chains running with a + /// very small block gas limit still have at least the historical budget. + pub const SYSTEM_CALL_GAS_LIMIT_FLOOR: u64 = 30_000_000; +} /// Constants for the `REX` spec. pub mod rex { diff --git a/crates/mega-evm/src/evm/interfaces.rs b/crates/mega-evm/src/evm/interfaces.rs index 411fb3e2..42b1c5b1 100644 --- a/crates/mega-evm/src/evm/interfaces.rs +++ b/crates/mega-evm/src/evm/interfaces.rs @@ -73,7 +73,11 @@ where /// # Note /// /// This function copies the logic from `alloy_op_evm::OpEvm::transact_system_call` - /// to maintain compatibility with the Optimism EVM system call interface. + /// to maintain compatibility with the Optimism EVM system call interface. The + /// transaction's gas limit follows revm's upstream-fixed 30M default. Callers that + /// need to use the live block gas budget — e.g. REX5 pre-block helpers whose cost is + /// sensitive to dynamic storage gas — should use + /// [`MegaEvm::transact_system_call_with_gas_limit`] instead. fn transact_system_call( &mut self, caller: Address, @@ -197,3 +201,39 @@ where revm::handler::Handler::run_system_call(&mut h, self) } } + +impl MegaEvm +where + DB: Database, +{ + /// Transact a system call with an explicit gas limit and finalize. + /// + /// Behaves like [`alloy_evm::Evm::transact_system_call`] but lets the caller specify + /// the gas limit instead of relying on revm's upstream-fixed 30M default. This is + /// intended for REX5+ pre-block helpers (e.g. + /// [`crate::system::sequencer_registry::transact_apply_pending_changes`]) whose + /// real cost is sensitive to dynamic storage gas and is no longer guaranteed to + /// fit within 30M on activation blocks. + /// + /// The recommended argument is `block.gas_limit.max(SYSTEM_CALL_GAS_LIMIT_FLOOR)`. + pub fn transact_system_call_with_gas_limit( + &mut self, + caller: Address, + contract: Address, + data: Bytes, + gas_limit: u64, + ) -> Result, EVMError> { + let mut tx = + ::new_system_tx_with_caller(caller, contract, data); + tx.base.gas_limit = gas_limit; + self.ctx().set_tx(tx); + let mut h: MegaHandler< + Self, + EVMError, + EthFrame, + > = MegaHandler::new(); + let result = revm::handler::Handler::run_system_call(&mut h, self)?; + let state = self.inner.ctx.journal_mut().finalize(); + Ok(ResultAndState { result, state }) + } +} diff --git a/crates/mega-evm/src/evm/mod.rs b/crates/mega-evm/src/evm/mod.rs index 2989d80f..c6af2817 100644 --- a/crates/mega-evm/src/evm/mod.rs +++ b/crates/mega-evm/src/evm/mod.rs @@ -536,6 +536,43 @@ mod tests { assert!(system_call.is_success()); } + #[test] + fn test_transact_system_call_with_gas_limit_uses_passed_value() { + let mut db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(1_000_000)) + .account_code(CALLEE, Bytes::new()); + let mut evm = MegaEvm::new(configure_context(&mut db)); + + let result = evm + .transact_system_call_with_gas_limit(CALLER, CALLEE, Bytes::new(), 123_456_789) + .unwrap(); + assert!(result.result.is_success()); + // The custom gas limit must be applied to the underlying tx. + assert_eq!(evm.inner.ctx.tx.base.gas_limit, 123_456_789); + } + + #[test] + fn test_default_system_call_keeps_upstream_30m_gas_limit() { + let mut db = MemoryDatabase::default() + .account_balance(CALLER, U256::from(1_000_000)) + .account_code(CALLEE, Bytes::new()); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX5); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + context.block.gas_limit = 100_000_000; + let mut evm = MegaEvm::new(context); + + // The default system-call entry point must NOT be widened by REX5 — only the + // explicit `transact_system_call_with_gas_limit` path should pick up the live + // block budget. This preserves byte-level behavior of EIP-2935 / EIP-4788 + // pre-block calls across all specs. + SystemCallEvm::transact_system_call_with_caller(&mut evm, CALLER, CALLEE, Bytes::new()) + .unwrap(); + assert_eq!(evm.inner.ctx.tx.base.gas_limit, 30_000_000); + } + #[test] fn test_mega_evm_exposes_state_wrapper_block_hashes() { let mut db = MemoryDatabase::default(); diff --git a/crates/mega-evm/src/system/sequencer_registry.rs b/crates/mega-evm/src/system/sequencer_registry.rs index ced5afda..0533c07f 100644 --- a/crates/mega-evm/src/system/sequencer_registry.rs +++ b/crates/mega-evm/src/system/sequencer_registry.rs @@ -220,14 +220,26 @@ pub(crate) fn is_apply_pending_changes_due( /// change if they are due in the current block. /// Caller should gate this with [`is_apply_pending_changes_due`] to avoid an EVM /// call on every block. -pub(crate) fn transact_apply_pending_changes( - evm: &mut impl alloy_evm::Evm, -) -> Result, BlockExecutionError> { +/// +/// The system call is issued with `max(block.gas_limit, SYSTEM_CALL_GAS_LIMIT_FLOOR)` +/// instead of the upstream-fixed 30M. `applyPendingChanges()` writes role-rotation +/// slots whose actual cost depends on REX dynamic storage gas, so the upstream default +/// is no longer guaranteed to be enough on activation blocks. +pub(crate) fn transact_apply_pending_changes( + evm: &mut crate::MegaEvm, +) -> Result, BlockExecutionError> +where + DB: alloy_evm::Database, + ExtEnvs: crate::ExternalEnvTypes, +{ let calldata = ISequencerRegistry::applyPendingChangesCall {}.abi_encode(); - let result_and_state = match evm.transact_system_call( + let gas_limit = + evm.block_env_ref().gas_limit.max(crate::constants::rex5::SYSTEM_CALL_GAS_LIMIT_FLOOR); + let result_and_state = match evm.transact_system_call_with_gas_limit( alloy_eips::eip4788::SYSTEM_ADDRESS, SEQUENCER_REGISTRY_ADDRESS, Bytes::from(calldata), + gas_limit, ) { Ok(res) => res, Err(e) => { @@ -879,6 +891,64 @@ mod tests { ); } + #[test] + fn test_transact_apply_pending_changes_uses_block_gas_limit() { + // Block gas_limit > 30M must be passed through to the system call so that + // applyPendingChanges() can absorb the variable cost from REX dynamic + // storage gas instead of being capped at the upstream-fixed 30M. + let mut db = InMemoryDB::default(); + db.insert_account_info( + SEQUENCER_REGISTRY_ADDRESS, + AccountInfo { + code_hash: SEQUENCER_REGISTRY_CODE_HASH, + code: Some(Bytecode::new_raw(SEQUENCER_REGISTRY_CODE)), + ..Default::default() + }, + ); + + let block = + BlockEnv { number: U256::from(1000), gas_limit: 250_000_000, ..Default::default() }; + let mut context = crate::MegaContext::new(&mut db, MegaSpecId::REX5).with_block(block); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + let mut evm = crate::MegaEvm::new(context); + + transact_apply_pending_changes(&mut evm).expect("system call should succeed"); + assert_eq!(revm::handler::EvmTr::ctx_ref(&evm).tx.base.gas_limit, 250_000_000); + } + + #[test] + fn test_transact_apply_pending_changes_respects_30m_floor() { + // When the block gas limit is below the 30M floor, the system call must + // still receive at least the historical default budget. + let mut db = InMemoryDB::default(); + db.insert_account_info( + SEQUENCER_REGISTRY_ADDRESS, + AccountInfo { + code_hash: SEQUENCER_REGISTRY_CODE_HASH, + code: Some(Bytecode::new_raw(SEQUENCER_REGISTRY_CODE)), + ..Default::default() + }, + ); + + let block = + BlockEnv { number: U256::from(1000), gas_limit: 1_000_000, ..Default::default() }; + let mut context = crate::MegaContext::new(&mut db, MegaSpecId::REX5).with_block(block); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + let mut evm = crate::MegaEvm::new(context); + + transact_apply_pending_changes(&mut evm).expect("system call should succeed"); + assert_eq!( + revm::handler::EvmTr::ctx_ref(&evm).tx.base.gas_limit, + crate::constants::rex5::SYSTEM_CALL_GAS_LIMIT_FLOOR, + ); + } + #[test] fn test_transact_apply_pending_changes_errors_when_registry_reverts() { let revert_code = Bytecode::new_legacy(Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd])); From 88d1fba185916020d7eeba8e22182655f6965a78 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 28 Apr 2026 17:29:29 +0800 Subject: [PATCH 2/5] chore: docs --- crates/mega-evm/src/constants.rs | 21 +++++++++++++---- crates/mega-evm/src/evm/interfaces.rs | 23 ++++++++----------- .../system-contracts/sequencer-registry.md | 3 +++ docs/spec/upgrades/rex5.md | 1 + 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/crates/mega-evm/src/constants.rs b/crates/mega-evm/src/constants.rs index de1eaa7f..8b88c9dc 100644 --- a/crates/mega-evm/src/constants.rs +++ b/crates/mega-evm/src/constants.rs @@ -132,12 +132,23 @@ pub mod rex4 { /// Constants for the `REX5` spec. pub mod rex5 { - /// Floor for the gas limit assigned to pre-block system calls. + /// Floor for the gas limit assigned to REX5 pre-block system calls that opt + /// into the live block gas budget — currently only + /// `SequencerRegistry.applyPendingChanges()` via + /// [`crate::MegaEvm::transact_system_call_with_gas_limit`]. /// - /// From REX5, `transact_system_call_with_caller` uses - /// `max(block.gas_limit, SYSTEM_CALL_GAS_LIMIT_FLOOR)`. The floor matches the - /// upstream revm default (30M) so that test harnesses or chains running with a - /// very small block gas limit still have at least the historical budget. + /// The value (30M) is fixed to match revm's hardcoded system-call default — + /// see the `gas_limit(30_000_000)` literal in + /// [`revm::handler::SystemCallTx::new_system_tx_with_caller`]'s `TxEnv` impl + /// (`revm-handler/src/system_call.rs`). + /// revm does not export this as a `pub const`, so we mirror the literal here + /// instead of aliasing it. If upstream ever changes the default, update this + /// constant to keep them in sync. + /// + /// Keeping the floor at the historical default ensures test harnesses or + /// chains configured with a sub-30M block gas limit still receive the + /// budget that pre-REX5 / EIP-2935 / EIP-4788 system calls have always + /// gotten. pub const SYSTEM_CALL_GAS_LIMIT_FLOOR: u64 = 30_000_000; } diff --git a/crates/mega-evm/src/evm/interfaces.rs b/crates/mega-evm/src/evm/interfaces.rs index 42b1c5b1..4f46cb05 100644 --- a/crates/mega-evm/src/evm/interfaces.rs +++ b/crates/mega-evm/src/evm/interfaces.rs @@ -210,12 +210,12 @@ where /// /// Behaves like [`alloy_evm::Evm::transact_system_call`] but lets the caller specify /// the gas limit instead of relying on revm's upstream-fixed 30M default. This is - /// intended for REX5+ pre-block helpers (e.g. - /// [`crate::system::sequencer_registry::transact_apply_pending_changes`]) whose - /// real cost is sensitive to dynamic storage gas and is no longer guaranteed to - /// fit within 30M on activation blocks. + /// intended for REX5+ pre-block helpers (e.g. `transact_apply_pending_changes` in + /// `system::sequencer_registry`) whose real cost is sensitive to dynamic storage gas + /// and is no longer guaranteed to fit within 30M on activation blocks. /// - /// The recommended argument is `block.gas_limit.max(SYSTEM_CALL_GAS_LIMIT_FLOOR)`. + /// The recommended argument is + /// `block.gas_limit.max(crate::constants::rex5::SYSTEM_CALL_GAS_LIMIT_FLOOR)`. pub fn transact_system_call_with_gas_limit( &mut self, caller: Address, @@ -227,13 +227,10 @@ where ::new_system_tx_with_caller(caller, contract, data); tx.base.gas_limit = gas_limit; self.ctx().set_tx(tx); - let mut h: MegaHandler< - Self, - EVMError, - EthFrame, - > = MegaHandler::new(); - let result = revm::handler::Handler::run_system_call(&mut h, self)?; - let state = self.inner.ctx.journal_mut().finalize(); - Ok(ResultAndState { result, state }) + let mut h = MegaHandler::<_, _, EthFrame>::new(); + revm::handler::Handler::run_system_call(&mut h, self).map(|result| { + let state = self.inner.ctx.journal_mut().finalize(); + ResultAndState { result, state } + }) } } diff --git a/docs/spec/system-contracts/sequencer-registry.md b/docs/spec/system-contracts/sequencer-registry.md index c5a9fd28..2538e1eb 100644 --- a/docs/spec/system-contracts/sequencer-registry.md +++ b/docs/spec/system-contracts/sequencer-registry.md @@ -103,6 +103,9 @@ To cancel, pass `activationBlock = type(uint256).max` and `newAddress = address( It is called by the execution layer as a pre-block system call when a Rust-side pre-check confirms any role change is due. For each role, if pending and due, it updates the current address, appends to the change history, and clears pending state. +The system call is issued with `gas_limit = max(block.gas_limit, 30_000_000)` instead of the upstream-fixed 30M used for EIP-2935 / EIP-4788. +This is required because the slot-rotation cost scales with REX dynamic storage gas (SALT bucket capacity), and a fixed 30M is no longer guaranteed to be sufficient on activation blocks. + ### Interception `SequencerRegistry` does NOT use call interception. diff --git a/docs/spec/upgrades/rex5.md b/docs/spec/upgrades/rex5.md index 7de3aaed..6abb409d 100644 --- a/docs/spec/upgrades/rex5.md +++ b/docs/spec/upgrades/rex5.md @@ -54,6 +54,7 @@ All other Oracle functionality (`sendHint`, `multiCall`, `getSlot`, `setSlot`, e Pending role changes are applied during `pre_execution_changes` via a single pre-block EVM system call to `SequencerRegistry.applyPendingChanges()`. This follows the same pattern as EIP-2935 and EIP-4788. The system call is only issued when a Rust-side pre-check confirms any role change is due. +Unlike EIP-2935 / EIP-4788 — which carry the upstream-fixed 30M `gas_limit` — this system call is issued with `max(block.gas_limit, 30_000_000)`, since the slot-rotation cost depends on REX dynamic storage gas and is no longer guaranteed to fit within 30M on activation blocks. ## Developer Impact From fa8ab2e939439c2abb98bbefabde3de21759bb30 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 28 Apr 2026 17:58:55 +0800 Subject: [PATCH 3/5] chore: docs --- crates/mega-evm/src/constants.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/mega-evm/src/constants.rs b/crates/mega-evm/src/constants.rs index 8b88c9dc..910eba16 100644 --- a/crates/mega-evm/src/constants.rs +++ b/crates/mega-evm/src/constants.rs @@ -137,13 +137,17 @@ pub mod rex5 { /// `SequencerRegistry.applyPendingChanges()` via /// [`crate::MegaEvm::transact_system_call_with_gas_limit`]. /// - /// The value (30M) is fixed to match revm's hardcoded system-call default — - /// see the `gas_limit(30_000_000)` literal in + /// The value (30M) matches the historical revm system-call default at the + /// time REX5 was specified — see the `gas_limit(30_000_000)` literal in /// [`revm::handler::SystemCallTx::new_system_tx_with_caller`]'s `TxEnv` impl /// (`revm-handler/src/system_call.rs`). /// revm does not export this as a `pub const`, so we mirror the literal here - /// instead of aliasing it. If upstream ever changes the default, update this - /// constant to keep them in sync. + /// instead of aliasing it. Do NOT raise this value to follow upstream + /// changes — the floor must remain at the historical 30M to preserve + /// backward compatibility for REX5 chains whose block gas limit is smaller + /// than any new upstream default. Lifting it would silently increase the + /// minimum guaranteed budget and become observable via the `GAS` opcode + /// inside `applyPendingChanges()`. /// /// Keeping the floor at the historical default ensures test harnesses or /// chains configured with a sub-30M block gas limit still receive the From 156f31e9c07c130fa48c0610301f5356b90a8efa Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 28 Apr 2026 18:12:24 +0800 Subject: [PATCH 4/5] chore: docs --- crates/mega-evm/src/evm/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/mega-evm/src/evm/mod.rs b/crates/mega-evm/src/evm/mod.rs index c6af2817..5ddf94f2 100644 --- a/crates/mega-evm/src/evm/mod.rs +++ b/crates/mega-evm/src/evm/mod.rs @@ -570,6 +570,9 @@ mod tests { // pre-block calls across all specs. SystemCallEvm::transact_system_call_with_caller(&mut evm, CALLER, CALLEE, Bytes::new()) .unwrap(); + // Literal, not `SYSTEM_CALL_GAS_LIMIT_FLOOR`: this assertion verifies revm's + // upstream hardcoded default. If upstream ever drifts from our floor, this + // test should fail loudly rather than be auto-aligned by our constant. assert_eq!(evm.inner.ctx.tx.base.gas_limit, 30_000_000); } From ebf11db6a9060320f1a17bb8d3badb180a77ae64 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Fri, 1 May 2026 13:34:14 +0800 Subject: [PATCH 5/5] test: update test case --- .../rex5/apply_pending_changes_gas_budget.rs | 326 ++++++++++++++++++ crates/mega-evm/tests/rex5/main.rs | 1 + docs/spec/upgrades/rex5.md | 3 +- 3 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 crates/mega-evm/tests/rex5/apply_pending_changes_gas_budget.rs diff --git a/crates/mega-evm/tests/rex5/apply_pending_changes_gas_budget.rs b/crates/mega-evm/tests/rex5/apply_pending_changes_gas_budget.rs new file mode 100644 index 00000000..b2759e2f --- /dev/null +++ b/crates/mega-evm/tests/rex5/apply_pending_changes_gas_budget.rs @@ -0,0 +1,326 @@ +//! End-to-end regression tests for the REX5 `applyPendingChanges()` system +//! call gas budget. +//! +//! Pre-fix: the pre-block `applyPendingChanges()` call ran with revm's +//! hard-coded 30M default. On a REX5 activation block where SALT bucket +//! capacity inflates the role-rotation `SSTORE`s past 30M, the call would OOG +//! and the rotation would never apply. +//! +//! Post-fix: `transact_apply_pending_changes` issues the call with +//! `max(block.gas_limit, SYSTEM_CALL_GAS_LIMIT_FLOOR)`, so any block whose +//! live budget covers the role-rotation cost succeeds. +//! +//! The apply-pending-change tests pin both halves of the invariant under one shared +//! heavy-SALT scenario: +//! +//! - [`test_rex5_apply_pending_changes_succeeds_under_heavy_storage_gas`] exercises the production +//! path end-to-end via the block executor and asserts the rotation commits. A regression that +//! routes the call back through the upstream `transact_system_call` (30M cap) fails here even +//! though the unit tests in `system/sequencer_registry.rs` still pass. +//! - [`test_rex5_apply_pending_changes_oogs_under_upstream_30m_cap`] pins the *baseline* — that the +//! upstream 30M cap really is insufficient under this scenario — by invoking the system call +//! directly with the upstream default. If that test ever starts to pass (e.g. because role +//! rotation becomes cheap), the fix above no longer protects anything and the scenario itself +//! needs to be re-tuned. +//! +//! The EIP-2935 and EIP-4788 tests pin the isolation invariant: REX5 widens only +//! `SequencerRegistry.applyPendingChanges()`. The default `transact_system_call` +//! path used by the upstream EIP pre-block helpers must keep the historical 30M +//! budget, even when the live block gas limit is higher. + +use std::convert::Infallible; + +use alloy_evm::{block::BlockExecutor, Evm, EvmEnv}; +use alloy_hardforks::ForkCondition; +use alloy_op_evm::block::receipt_builder::OpAlloyReceiptBuilder; +use alloy_primitives::{address, Address, Bytes, B256, U256}; +use alloy_sol_types::SolCall; +use mega_evm::{ + test_utils::MemoryDatabase, BlockLimits, BucketHasher, BucketId, EthHaltReason, + MegaBlockExecutionCtx, MegaBlockExecutorFactory, MegaContext, MegaEvm, MegaEvmFactory, + MegaHaltReason, MegaHardfork, MegaHardforkConfig, MegaSpecId, OpHaltReason, + SequencerRegistryConfig, TestExternalEnvs, MEGA_SYSTEM_ADDRESS, SEQUENCER_REGISTRY_ADDRESS, + SEQUENCER_REGISTRY_CODE, SEQUENCER_REGISTRY_CODE_HASH, +}; +use mega_system_contracts::sequencer_registry::{ + storage_slots::{ + CURRENT_SYSTEM_ADDRESS, PENDING_SYSTEM_ADDRESS, SYSTEM_ADDRESS_ACTIVATION_BLOCK, + }, + ISequencerRegistry, +}; +use revm::{ + context::{result::ExecutionResult, BlockEnv}, + database::{Database as _, State}, + handler::EvmTr, + state::{AccountInfo, Bytecode}, +}; + +const NEW_SYSTEM_ADDRESS: Address = address!("3000000000000000000000000000000000000003"); +const BOOTSTRAP_SEQUENCER: Address = address!("4000000000000000000000000000000000000004"); +const BOOTSTRAP_ADMIN: Address = address!("5000000000000000000000000000000000000005"); + +const ACTIVATION_BLOCK: u64 = 1000; + +/// Bucket that every SALT lookup is routed to under [`SingleBucketHasher`]. +const HEAVY_BUCKET_ID: BucketId = 100_000; +/// Capacity = 2000 × `MIN_BUCKET_SIZE` (256). Yields a 2000× multiplier, so each +/// zero→nonzero `SSTORE` charges ≈ `20_000 × 1999` ≈ 40M of dynamic storage gas +/// (REX `SSTORE_SET_STORAGE_GAS_BASE × (multiplier - 1)`). `applyPendingChanges()` +/// triggers two such writes (history-length slot and the first history element), +/// totaling ≈ 80M — well above the upstream 30M cap and well below the 250M +/// block budget below. +const HEAVY_BUCKET_CAPACITY: u64 = 512_000; +/// Block gas limit chosen high enough to cover the inflated rotation cost. +const BLOCK_GAS_LIMIT: u64 = 250_000_000; + +/// Routes every account/slot to a single bucket so that one +/// `with_bucket_capacity()` call deterministically inflates every `SSTORE` +/// the system call performs, regardless of the slot keys involved. +#[derive(Debug, Clone, Copy)] +struct SingleBucketHasher; + +impl BucketHasher for SingleBucketHasher { + fn bucket_id(_key: &[u8]) -> BucketId { + HEAVY_BUCKET_ID + } +} + +fn sequencer_registry_config() -> SequencerRegistryConfig { + SequencerRegistryConfig { + initial_system_address: MEGA_SYSTEM_ADDRESS, + initial_sequencer: BOOTSTRAP_SEQUENCER, + initial_admin: BOOTSTRAP_ADMIN, + } +} + +fn create_evm_env(gas_limit: u64) -> EvmEnv { + let mut cfg_env = revm::context::CfgEnv::default(); + cfg_env.spec = MegaSpecId::REX5; + let block_env = BlockEnv { + number: U256::from(ACTIVATION_BLOCK), + timestamp: U256::from(1_800_000_000), + gas_limit, + ..Default::default() + }; + EvmEnv::new(cfg_env, block_env) +} + +/// Pre-deploys `SequencerRegistry` with a pending system address change due +/// at [`ACTIVATION_BLOCK`] and funds both system addresses. Deploy is skipped +/// during pre-execution because the account already has the registry code, so +/// the heavy SALT setup only bites the SSTOREs from `applyPendingChanges()`. +fn seed_db_with_pending_change() -> MemoryDatabase { + let mut db = MemoryDatabase::default(); + db.set_account_balance(MEGA_SYSTEM_ADDRESS, U256::from(1_000_000_000_000_000u64)); + db.set_account_balance(NEW_SYSTEM_ADDRESS, U256::from(1_000_000_000_000_000u64)); + + db.insert_account_info( + SEQUENCER_REGISTRY_ADDRESS, + AccountInfo { + code_hash: SEQUENCER_REGISTRY_CODE_HASH, + code: Some(Bytecode::new_raw(SEQUENCER_REGISTRY_CODE)), + ..Default::default() + }, + ); + db.insert_account_storage( + SEQUENCER_REGISTRY_ADDRESS, + CURRENT_SYSTEM_ADDRESS, + MEGA_SYSTEM_ADDRESS.into_word().into(), + ) + .unwrap(); + db.insert_account_storage( + SEQUENCER_REGISTRY_ADDRESS, + PENDING_SYSTEM_ADDRESS, + NEW_SYSTEM_ADDRESS.into_word().into(), + ) + .unwrap(); + db.insert_account_storage( + SEQUENCER_REGISTRY_ADDRESS, + SYSTEM_ADDRESS_ACTIVATION_BLOCK, + U256::from(ACTIVATION_BLOCK), + ) + .unwrap(); + + db +} + +/// Heavy SALT environment: the single bucket is configured with a 2000× +/// multiplier, so any zero→nonzero `SSTORE` performed by +/// `applyPendingChanges()` costs ≈ 40M dynamic storage gas. +fn heavy_external_envs() -> TestExternalEnvs { + TestExternalEnvs::::new() + .with_bucket_capacity(HEAVY_BUCKET_ID, HEAVY_BUCKET_CAPACITY) +} + +fn assert_30m_system_call_oog(result: &ExecutionResult, system_call_name: &str) { + assert!( + matches!( + result, + ExecutionResult::Halt { + reason: MegaHaltReason::Base(OpHaltReason::Base(EthHaltReason::OutOfGas(_))), + .. + } + ), + "{system_call_name} must halt with OOG under the upstream 30M cap with heavy SALT. \ + Result: {result:?}", + ); +} + +/// Without the fix, `applyPendingChanges()` would OOG on the first +/// zero→nonzero `SSTORE` (≈ 40M storage gas alone, > the upstream 30M cap) +/// and `apply_pre_execution_changes()` would error. With the fix, the live +/// 250M block budget is used and the rotation commits. +#[test] +fn test_rex5_apply_pending_changes_succeeds_under_heavy_storage_gas() { + let mut db = seed_db_with_pending_change(); + let mut state = State::builder().with_database(&mut db).build(); + + let evm_factory = MegaEvmFactory::new().with_external_env_factory(heavy_external_envs()); + let chain_spec = MegaHardforkConfig::default() + .with(MegaHardfork::Rex5, ForkCondition::Timestamp(0)) + .with_params(sequencer_registry_config()); + let receipt_builder = OpAlloyReceiptBuilder::default(); + let block_executor_factory = + MegaBlockExecutorFactory::new(chain_spec, evm_factory, receipt_builder); + + let block_ctx = MegaBlockExecutionCtx::new( + B256::ZERO, + Some(B256::ZERO), + Bytes::new(), + BlockLimits::no_limits(), + ); + + let mut executor = block_executor_factory.create_executor( + &mut state, + block_ctx, + create_evm_env(BLOCK_GAS_LIMIT), + ); + executor.apply_pre_execution_changes().expect( + "applyPendingChanges() must succeed under the live block gas budget — \ + a regression here means the system call was capped at the upstream 30M default", + ); + + // The system address rotation only takes effect if `applyPendingChanges()` ran + // to completion. This proves the inflated SSTOREs were actually charged and + // committed, not that the call simply early-returned. + let resolved = executor.evm().ctx_ref().system_address(); + assert_eq!(resolved, NEW_SYSTEM_ADDRESS, "Pending system address change must be applied"); + + assert_eq!( + executor + .evm_mut() + .db_mut() + .storage(SEQUENCER_REGISTRY_ADDRESS, PENDING_SYSTEM_ADDRESS) + .unwrap(), + U256::ZERO, + "Pending system address slot must be cleared after applyPendingChanges() commits", + ); + assert_eq!( + executor + .evm_mut() + .db_mut() + .storage(SEQUENCER_REGISTRY_ADDRESS, SYSTEM_ADDRESS_ACTIVATION_BLOCK) + .unwrap(), + U256::ZERO, + "System address activation slot must be cleared after applyPendingChanges() commits", + ); +} + +/// Pins the regression *baseline*: under the same heavy-SALT scenario, a +/// system call issued with the upstream-fixed 30M cap MUST fail. If this test +/// ever starts to pass, role rotation has become cheap enough for the +/// upstream default and the success test above no longer proves the fix is +/// load-bearing — the SALT setup needs to be re-tuned (or the fix retired). +/// +/// The literal `30_000_000` here is intentional: it mirrors revm's +/// hard-coded `SystemCallTx::new_system_tx_with_caller` gas limit and the +/// constant in [`mega_evm::constants::rex5::SYSTEM_CALL_GAS_LIMIT_FLOOR`]. +/// We don't import the constant so that this assertion is a structural +/// check on the upstream literal, not a circular check on our floor. +#[test] +fn test_rex5_apply_pending_changes_oogs_under_upstream_30m_cap() { + let mut db = seed_db_with_pending_change(); + + let context = MegaContext::new(&mut db, MegaSpecId::REX5) + .with_external_envs(heavy_external_envs().into()) + .with_block(BlockEnv { + number: U256::from(ACTIVATION_BLOCK), + timestamp: U256::from(1_800_000_000), + gas_limit: BLOCK_GAS_LIMIT, + ..Default::default() + }); + let mut evm = MegaEvm::new(context); + + let calldata = ISequencerRegistry::applyPendingChangesCall {}.abi_encode(); + let result = evm + .transact_system_call_with_gas_limit( + alloy_eips::eip4788::SYSTEM_ADDRESS, + SEQUENCER_REGISTRY_ADDRESS, + Bytes::from(calldata), + 30_000_000, // revm upstream's hard-coded system-call gas limit. + ) + .expect("system call should not surface an EVMError"); + + assert_30m_system_call_oog(&result.result, "applyPendingChanges()"); +} + +/// REX5 only widens `SequencerRegistry.applyPendingChanges()`. The EIP-2935 +/// default system-call path must keep using revm's 30M budget, even when the +/// live block gas limit is larger. +#[test] +fn test_rex5_eip2935_blockhashes_keeps_upstream_30m_cap_under_heavy_storage_gas() { + let mut db = MemoryDatabase::default(); + let bytecode = Bytecode::new_raw(alloy_eips::eip2935::HISTORY_STORAGE_CODE.clone()); + db.insert_account_info( + alloy_eips::eip2935::HISTORY_STORAGE_ADDRESS, + AccountInfo { code_hash: bytecode.hash_slow(), code: Some(bytecode), ..Default::default() }, + ); + let context = MegaContext::new(&mut db, MegaSpecId::REX5) + .with_external_envs(heavy_external_envs().into()) + .with_block(BlockEnv { + number: U256::from(ACTIVATION_BLOCK), + timestamp: U256::from(1_800_000_000), + gas_limit: BLOCK_GAS_LIMIT, + ..Default::default() + }); + let mut evm = MegaEvm::new(context); + let result = evm + .transact_system_call( + alloy_eips::eip4788::SYSTEM_ADDRESS, + alloy_eips::eip2935::HISTORY_STORAGE_ADDRESS, + B256::from([0x29; 32]).0.into(), + ) + .expect("EIP-2935 system call should not surface an EVMError"); + + assert_30m_system_call_oog(&result.result, "EIP-2935 blockhashes system call"); +} + +/// Same invariant for EIP-4788: REX5's widened budget must not leak into the +/// default beacon-roots system-call path. +#[test] +fn test_rex5_eip4788_beacon_root_keeps_upstream_30m_cap_under_heavy_storage_gas() { + let mut db = MemoryDatabase::default(); + let bytecode = Bytecode::new_raw(alloy_eips::eip4788::BEACON_ROOTS_CODE.clone()); + db.insert_account_info( + alloy_eips::eip4788::BEACON_ROOTS_ADDRESS, + AccountInfo { code_hash: bytecode.hash_slow(), code: Some(bytecode), ..Default::default() }, + ); + let context = MegaContext::new(&mut db, MegaSpecId::REX5) + .with_external_envs(heavy_external_envs().into()) + .with_block(BlockEnv { + number: U256::from(ACTIVATION_BLOCK), + timestamp: U256::from(1_800_000_000), + gas_limit: BLOCK_GAS_LIMIT, + ..Default::default() + }); + let mut evm = MegaEvm::new(context); + let result = evm + .transact_system_call( + alloy_eips::eip4788::SYSTEM_ADDRESS, + alloy_eips::eip4788::BEACON_ROOTS_ADDRESS, + B256::from([0x47; 32]).0.into(), + ) + .expect("EIP-4788 system call should not surface an EVMError"); + + assert_30m_system_call_oog(&result.result, "EIP-4788 beacon-roots system call"); +} diff --git a/crates/mega-evm/tests/rex5/main.rs b/crates/mega-evm/tests/rex5/main.rs index 60e46733..721385ab 100644 --- a/crates/mega-evm/tests/rex5/main.rs +++ b/crates/mega-evm/tests/rex5/main.rs @@ -1,3 +1,4 @@ //! Tests for `Rex5` hardfork features. +mod apply_pending_changes_gas_budget; mod frame_target_updated_dedup; diff --git a/docs/spec/upgrades/rex5.md b/docs/spec/upgrades/rex5.md index 8d426bb5..140aaadd 100644 --- a/docs/spec/upgrades/rex5.md +++ b/docs/spec/upgrades/rex5.md @@ -59,7 +59,8 @@ This differs from pre-Rex5 upgrades, which cleared existing Oracle storage. Pending role changes are applied during `pre_execution_changes` via a single pre-block EVM system call to `SequencerRegistry.applyPendingChanges()`. This follows the same pattern as EIP-2935 and EIP-4788. The system call is only issued when a Rust-side pre-check confirms any role change is due. -Unlike EIP-2935 / EIP-4788 — which carry the upstream-fixed 30M `gas_limit` — this system call is issued with `max(block.gas_limit, 30_000_000)`, since the slot-rotation cost depends on REX dynamic storage gas and is no longer guaranteed to fit within 30M on activation blocks. +Unlike EIP-2935 / EIP-4788, which carry the upstream-fixed 30M `gas_limit`, this system call is issued with `max(block.gas_limit, 30_000_000)`. +This is required because the role-rotation slot writes are charged by REX dynamic storage gas, so their cost is no longer guaranteed to fit within a fixed 30M budget on activation blocks. ### 5. Caller-Account Update Deduplication (Data Size and KV Updates)