From 56cfdc5f8c263ad6467a2746333653ef33fe47be Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 14:54:16 +0800 Subject: [PATCH 01/43] bench: add cheap-opcode interpreter hotloop workload to transact --- crates/mega-evm/benches/transact.rs | 48 ++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/mega-evm/benches/transact.rs b/crates/mega-evm/benches/transact.rs index b32abddb..8c30516d 100644 --- a/crates/mega-evm/benches/transact.rs +++ b/crates/mega-evm/benches/transact.rs @@ -86,10 +86,56 @@ fn bench_weth9_transfer(c: &mut Criterion) { group.finish(); } +/// Builds a tight countdown loop of cheap opcodes: +/// +/// ```text +/// PUSH3 iterations +/// loop: JUMPDEST; PUSH1 1; SWAP1; SUB; DUP1; PUSH1 loop; JUMPI +/// STOP +/// ``` +/// +/// Each iteration executes 7 opcodes for 26 gas (JUMPDEST 1 + PUSH1 3 + SWAP1 3 + +/// SUB 3 + DUP1 3 + PUSH1 3 + JUMPI 10), all from the cheap-opcode family that +/// dominates real interpreter workloads. +fn hotloop_code(iterations: u32) -> Bytes { + let mut code = Vec::with_capacity(14); + // PUSH3 + code.push(0x62); + code.extend_from_slice(&iterations.to_be_bytes()[1..4]); + // loop target is the JUMPDEST right after the initial PUSH3 (offset 4). + let loop_target = code.len() as u8; + code.push(0x5b); // JUMPDEST + code.push(0x60); // PUSH1 + code.push(0x01); + code.push(0x90); // SWAP1 + code.push(0x03); // SUB + code.push(0x80); // DUP1 + code.push(0x60); // PUSH1 + code.push(loop_target); + code.push(0x57); // JUMPI + code.push(0x00); // STOP + Bytes::from(code) +} + +/// Benchmark a cheap-opcode-dense interpreter hot loop (~700k executed opcodes, +/// ~2.6M gas), the workload shape where per-opcode gas-accounting overhead is +/// the dominant tax. +fn bench_interpreter_hotloop(c: &mut Criterion) { + let mut group = c.benchmark_group("interpreter_hotloop"); + // Gas price is zero, so the caller needs no balance. Callee holds the loop body. + let workload = Workload::single( + vec![Account::new(CALLEE).code(hotloop_code(100_000))], + TxSpec::call(CALLER, CALLEE), + ); + register_all(&mut group, &workload); + group.finish(); +} + criterion_group!( benches, bench_empty_transaction, bench_simple_ether_transfer, - bench_weth9_transfer + bench_weth9_transfer, + bench_interpreter_hotloop ); criterion_main!(benches); From c520902943144cdf39491f65850f11ce11518a5b Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 15:26:58 +0800 Subject: [PATCH 02/43] feat(rex7): settle compute gas at checkpoints Plain opcodes in the REX7 instruction table run revm's raw instructions with no per-opcode recording; compute gas settles as an interpreter-gas delta at each checkpoint (storage-gas opcodes, CALL/CREATE family, volatile opcodes, frame entry/resume/exit). Per-transaction totals are unchanged; a limit exceed now surfaces at the next checkpoint. Specs <= REX6 are untouched. --- crates/mega-evm/src/evm/instructions.rs | 369 +++++++++++++++++++++++- crates/mega-evm/src/limit/limit.rs | 131 ++++++++- 2 files changed, 482 insertions(+), 18 deletions(-) diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index db6b23ff..16b2acf7 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -165,6 +165,16 @@ use revm::{ /// usage. CREATE2 is the one real behavior change: REX6+ short-circuits to `create_rex6`, which /// folds the memory-expansion gas into the single post-body recording instead of recording it as /// a separate eager entry as REX5 did. +/// - **REX7** (extends REX6): switches to **checkpoint compute-gas settlement**. The plain opcodes +/// are revm's own instructions with no recording wrapper at all; compute gas settles as an +/// interpreter-gas delta at each checkpoint — the storage-gas opcodes, the CALL / CREATE family, +/// the volatile opcodes, and frame entry / resume / exit. Per-transaction totals are unchanged; a +/// limit exceed surfaces at the next checkpoint rather than at the opcode that crossed it. +/// - Volatile opcodes: `volatile_data_ext::*_checkpoint` (raw instruction + segment settlement + +/// detention cap) in place of the `compute_gas_ext` delegation +/// - Storage-gas, CALL-family, CREATE and SELFDESTRUCT: the REX6 handler chains, settling from +/// the checkpoint baseline internally +/// - Every other opcode: revm's raw instruction /// /// Note: chains terminating at `storage_gas_ext` (rather than `compute_gas_ext`) reflect the /// canonical metering order above — `storage_gas_ext::*` records compute gas internally via @@ -577,22 +587,94 @@ macro_rules! set_halt_action { mod rex7 { use super::*; - /// Returns the instruction table for the `REX7` spec. - /// - /// Changes from Rex6: none yet. + /// Returns the instruction table for the `REX7` spec — **checkpoint compute-gas accounting**. + /// + /// Unlike every earlier custom table, the plain opcodes are revm's own instructions with no + /// per-opcode gas recording at all: the interpreter's gas counter is the accounting source, + /// and compute gas settles as a segment delta at each checkpoint. The checkpoints are exactly + /// the positions that have to stay wrapped anyway: + /// + /// - the storage-gas opcodes (SSTORE, LOG0–LOG4, SELFDESTRUCT) and the CALL / CREATE family — + /// the same handler chains as Rex6, whose [`record_storage_compute_gas!`] settles from the + /// checkpoint baseline instead of a per-opcode capture; + /// - the volatile / detention opcodes — `*_checkpoint` variants that run the raw instruction, + /// settle the segment, then apply the detention cap; + /// - frame entry / resume and frame exit — `AdditionalLimit::before_frame_run` opens the window + /// and `after_frame_run_instructions` settles the tail segment. + /// + /// Per-transaction totals telescope to the same sums as per-opcode recording. What differs is + /// where a limit-exceeding transaction halts: the exceed surfaces at the next checkpoint + /// rather than at the opcode that crossed the limit. + /// + /// The Rex6 behavior differences (canonical metering order, `create_rex6` dispatch, + /// SELFDESTRUCT existing-target accounting, CALL-family EIP-7702 delegate resolution on the + /// disabled path) live as internal `spec.is_enabled(MegaSpecId::REX6)` dispatch inside the + /// shared handlers reused here, so they carry over unchanged. + /// + /// `H` is `Sized` here — unlike the earlier tables — because the base table comes from revm's + /// own [`instructions::instruction_table`], whose bound it is. The only caller instantiates it + /// with [`MegaContext`], so nothing is lost. pub(super) const fn instruction_table< WIRE: InterpreterTypes, - H: HostExt + ContextTr + JournalInspectTr + ?Sized, + H: HostExt + ContextTr + JournalInspectTr, >() -> [Instruction; 256] where WIRE::Stack: StackInspectTr, { - rex6::instruction_table::() + use revm::bytecode::opcode::*; + let mut table = instructions::instruction_table::(); + + // revm's table wires these four ahead of the fork that activates them; every `MegaSpecId` + // maps to a pre-activation Ethereum spec, and no `MegaETH` table has ever dispatched them. + // Restore the unknown-opcode handler so the checkpoint table's opcode set is the same one + // Rex6 exposes. + table[DUPN as usize] = Instruction::new(control::unknown); + table[SWAPN as usize] = Instruction::new(control::unknown); + table[EXCHANGE as usize] = Instruction::new(control::unknown); + table[SLOTNUM as usize] = Instruction::new(control::unknown); + + // Volatile / detention checkpoints: raw instruction, segment settlement, detention cap. + table[BALANCE as usize] = Instruction::new(volatile_data_ext::balance_checkpoint); + table[EXTCODESIZE as usize] = Instruction::new(volatile_data_ext::extcodesize_checkpoint); + table[EXTCODECOPY as usize] = Instruction::new(volatile_data_ext::extcodecopy_checkpoint); + table[EXTCODEHASH as usize] = Instruction::new(volatile_data_ext::extcodehash_checkpoint); + table[BLOCKHASH as usize] = Instruction::new(volatile_data_ext::blockhash_checkpoint); + table[COINBASE as usize] = Instruction::new(volatile_data_ext::coinbase_checkpoint); + table[TIMESTAMP as usize] = Instruction::new(volatile_data_ext::timestamp_checkpoint); + table[NUMBER as usize] = Instruction::new(volatile_data_ext::block_number_checkpoint); + table[DIFFICULTY as usize] = Instruction::new(volatile_data_ext::difficulty_checkpoint); + table[GASLIMIT as usize] = Instruction::new(volatile_data_ext::gas_limit_opcode_checkpoint); + table[BASEFEE as usize] = Instruction::new(volatile_data_ext::basefee_checkpoint); + table[BLOBBASEFEE as usize] = Instruction::new(volatile_data_ext::blobbasefee_checkpoint); + table[BLOBHASH as usize] = Instruction::new(volatile_data_ext::blobhash_checkpoint); + table[SELFBALANCE as usize] = Instruction::new(volatile_data_ext::selfbalance_checkpoint); + table[SLOAD as usize] = Instruction::new(volatile_data_ext::sload_checkpoint); + + // Storage-gas and frame-spawning checkpoints: the Rex6 handler chains unchanged. Under + // Rex7 they settle from the checkpoint baseline internally. + table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore); + table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, _, _>); + table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, _, _>); + table[LOG2 as usize] = Instruction::new(additional_limit_ext::log::<2, _, _>); + table[LOG3 as usize] = Instruction::new(additional_limit_ext::log::<3, _, _>); + table[LOG4 as usize] = Instruction::new(additional_limit_ext::log::<4, _, _>); + table[CREATE as usize] = Instruction::new(forward_gas_ext::create); + table[CREATE2 as usize] = Instruction::new(forward_gas_ext::create2); + table[CALL as usize] = Instruction::new(volatile_data_ext::call); + table[CALLCODE as usize] = Instruction::new(volatile_data_ext::call_code); + table[DELEGATECALL as usize] = Instruction::new(volatile_data_ext::delegate_call); + table[STATICCALL as usize] = Instruction::new(volatile_data_ext::static_call); + table[SELFDESTRUCT as usize] = + Instruction::new(volatile_data_ext::selfdestruct_with_beneficiary_guard); + + table } /// Returns the static gas table for the `REX7` spec. /// - /// The instruction table is unchanged from Rex6, so the zeroed set is too. + /// The volatile-guarded set is unchanged from Rex6 — the checkpoint handlers guard and charge + /// exactly the opcodes their per-opcode counterparts did — so the zeroed set is too. The plain + /// opcodes keep revm's entries: their pre-charge is what the segment delta measures. pub(super) const fn gas_table(table: GasTable) -> GasTable { rex6::gas_table(table) } @@ -758,6 +840,10 @@ macro_rules! run_inner_instruction_or_abort { /// CREATE2 differs only by folding its memory-expansion gas into this single window instead of /// recording it separately. /// +/// Under REX7 checkpoint accounting the window instead opens at the previous checkpoint, so the +/// same recording also settles the unwrapped plain opcodes that ran since; see the macro body for +/// why the exclusions stay exact and why the static gas is no longer added back. +/// /// On exceeding the compute-gas limit, halts the interpreter and returns from the enclosing /// instruction handler. The early return mirrors [`compute_gas!`] so a trailing statement after /// this macro (e.g. the pre-REX5 `resize_gas` late-record in `storage_gas_ext::create`) is only @@ -765,9 +851,24 @@ macro_rules! run_inner_instruction_or_abort { /// add gas to the tracker after the OOG was already set. macro_rules! record_storage_compute_gas { ($context:expr, $gas_before:expr, $storage_charged:expr, $opcode:expr) => {{ + let spec = $context.host.spec_id(); + let is_rex6 = spec.is_enabled(MegaSpecId::REX6); + let is_checkpoint_accounting = spec.is_enabled(MegaSpecId::REX7); let gas_after = $context.interpreter.gas.remaining(); - let mut gas_used = (const { static_gas($opcode) } + $gas_before.saturating_sub(gas_after)) - .saturating_sub($storage_charged); + // Under checkpoint accounting the window opens at the last checkpoint — frame entry / + // resume, or the previous checkpoint opcode — instead of at this handler's own + // `$gas_before` capture, so the plain opcodes that ran since settle here in the same + // recording. Only plain opcodes can run inside that extra span, so no storage gas and no + // forwarded child gas hides in it and the exclusions below stay exact. The window then + // also contains the interpreter's static-gas pre-charge for this opcode, which the + // per-opcode form has to add back because its capture sits after it. + let mut gas_used = if is_checkpoint_accounting { + let baseline = $context.host.additional_limit().borrow().checkpoint_baseline(); + baseline.saturating_sub(gas_after).saturating_sub($storage_charged) + } else { + (const { static_gas($opcode) } + $gas_before.saturating_sub(gas_after)) + .saturating_sub($storage_charged) + }; // Exclude gas forwarded to a child frame. REX5+ excludes the revm-side `CALL_STIPEND` // (added by value-transferring CALL/CALLCODE without deducting from the parent) so the // parent's compute gas is not under-counted; pre-REX5 subtracts the full child gas limit @@ -776,7 +877,7 @@ macro_rules! record_storage_compute_gas { let mut forwarded_child_gas: u64 = 0; match $context.interpreter.bytecode.action() { Some(InterpreterAction::NewFrame(FrameInput::Call(call_inputs))) => { - let stipend_from_revm = if $context.host.spec_id().is_enabled(MegaSpecId::REX5) && + let stipend_from_revm = if spec.is_enabled(MegaSpecId::REX5) && matches!(call_inputs.scheme, CallScheme::Call | CallScheme::CallCode) && call_inputs.transfers_value() { @@ -797,9 +898,13 @@ macro_rules! record_storage_compute_gas { // On a compute-limit halt the pending child `NewFrame` is discarded (the child never runs), // but revm already deducted the forwarded gas and the outer `forward_gas_ext` erase is // skipped on this abort path. REX6+: return that gas to the parent before halting. - let is_rex6 = $context.host.spec_id().is_enabled(MegaSpecId::REX6); let exceeding_result = { let mut additional_limit = $context.host.additional_limit().borrow_mut(); + // Re-open the settlement window at this opcode's exit before recording, so neither a + // halt here nor the frame-final settlement can bill this segment twice. + if is_checkpoint_accounting { + additional_limit.sync_checkpoint_baseline(gas_after); + } if additional_limit.record_compute_gas(gas_used) { None } else { @@ -1803,6 +1908,223 @@ pub mod volatile_data_ext { wrap_call_volatile_check!(static_call, STATICCALL, forward_gas_ext::static_call); wrap_call_volatile_check!(delegate_call, DELEGATECALL, forward_gas_ext::delegate_call); wrap_call_volatile_check!(call_code, CALLCODE, forward_gas_ext::call_code); + + /* Checkpoint variants of the volatile handlers (REX7+). + + Under checkpoint accounting the volatile opcodes stay wrapped — they are checkpoints — but + they run revm's raw instruction and settle the whole open segment, measured on the + interpreter's own gas counter, in one recording, instead of delegating to a per-opcode + `compute_gas_ext` wrapper. Wherever the opcode's static gas is charged it lands inside that + segment, so the settlement adds nothing back; each handler keeps charging it at the position + its per-opcode counterpart does, because that position decides what an underfunded frame has + already done when it halts. + + The settlement runs before `apply_compute_gas_limit!`, so a REX4+ relative detention cap is + still derived from fully settled usage at the access point. + + The frozen detention-window tripwire the per-opcode conditional wrapper carries is not + repeated here: it watches for historical transactions whose replay would diverge across a revm + bump, and no such transaction can exist for a spec with no activation history. */ + + /// Settles the open checkpoint segment at the interpreter's current gas, re-opens the window, + /// and halts — returning from the enclosing handler — when a limit surfaces. + /// + /// A frame-local exceed reports as a revert, which the enclosing handler's tail would have + /// treated as a normal (non-halting) outcome and still followed with the detention cap, so the + /// cap is applied here before returning. A TX-level exceed reports as an out-of-gas halt, which + /// that tail short-circuits, so the cap is not applied on that path. + macro_rules! settle_checkpoint_compute_gas { + ($context:expr) => { + let exceeding_result = { + let gas_after = $context.interpreter.gas.remaining(); + let mut additional_limit = $context.host.additional_limit().borrow_mut(); + if additional_limit.settle_checkpoint(gas_after) { + None + } else { + Some(additional_limit.exceeding_instruction_result()) + } + }; + if let Some(result) = exceeding_result { + set_halt_action!($context.interpreter, result); + if !result.is_halt() { + apply_compute_gas_limit!($context); + } + return Err(result); + } + }; + } + + /// Checkpoint form of [`wrap_op_detain_gas_unconditional`]: disabled guard, static gas ahead + /// of the raw instruction (the position these opcodes' revm bodies charge from), segment + /// settlement, detention cap. + macro_rules! wrap_checkpoint_detain_gas_unconditional { + ($fn_name:ident, $opcode:ident, $original_fn:path, $access_type:expr) => { + #[doc = concat!("`", stringify!($opcode), "` opcode as a checkpoint: raw instruction, segment settlement, gas detention.")] + #[inline] + pub fn $fn_name( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + if context.host.volatile_access_disabled() { + revert_volatile_access_disabled!(context, $opcode, $access_type); + } + charge_static_gas!(context, $opcode); + + run_inner_instruction_or_abort!($original_fn, context, inner_outcome); + settle_checkpoint_compute_gas!(context); + apply_compute_gas_limit!(context); + inner_outcome + } + }; + } + + /// Checkpoint form of [`wrap_op_detain_gas_conditional`]: beneficiary peek, raw instruction, + /// static gas after it (the position these opcodes' revm bodies charge from, so an underfunded + /// frame has already popped its operands and marked its access), segment settlement, detention + /// cap. + macro_rules! wrap_checkpoint_detain_gas_conditional { + ($fn_name:ident, $opcode:ident, $original_fn:path) => { + #[doc = concat!("`", stringify!($opcode), "` opcode as a checkpoint: raw instruction, segment settlement, gas detention.")] + #[inline] + pub fn $fn_name, H: HostExt + ?Sized>( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + if let Some(addr_word) = context.interpreter.stack.inspect::<0>() { + let target: Address = addr_word.into_address(); + let beneficiary = context.host.beneficiary_address(); + if target == beneficiary && context.host.volatile_access_disabled() { + revert_volatile_access_disabled!( + context, + $opcode, + VolatileDataAccessType::Beneficiary + ); + } + } + + run_inner_instruction_or_abort!($original_fn, context, inner_outcome); + charge_static_gas!(context, $opcode); + settle_checkpoint_compute_gas!(context); + apply_compute_gas_limit!(context); + inner_outcome + } + }; + } + + wrap_checkpoint_detain_gas_unconditional!( + timestamp_checkpoint, + TIMESTAMP, + instructions::block_info::timestamp, + VolatileDataAccessType::Timestamp + ); + wrap_checkpoint_detain_gas_unconditional!( + block_number_checkpoint, + NUMBER, + instructions::block_info::block_number, + VolatileDataAccessType::BlockNumber + ); + wrap_checkpoint_detain_gas_unconditional!( + difficulty_checkpoint, + DIFFICULTY, + instructions::block_info::difficulty, + VolatileDataAccessType::Difficulty + ); + wrap_checkpoint_detain_gas_unconditional!( + gas_limit_opcode_checkpoint, + GASLIMIT, + instructions::block_info::gaslimit, + VolatileDataAccessType::GasLimit + ); + wrap_checkpoint_detain_gas_unconditional!( + basefee_checkpoint, + BASEFEE, + instructions::block_info::basefee, + VolatileDataAccessType::BaseFee + ); + wrap_checkpoint_detain_gas_unconditional!( + coinbase_checkpoint, + COINBASE, + instructions::block_info::coinbase, + VolatileDataAccessType::Coinbase + ); + wrap_checkpoint_detain_gas_unconditional!( + blockhash_checkpoint, + BLOCKHASH, + instructions::host::blockhash, + VolatileDataAccessType::BlockHash + ); + wrap_checkpoint_detain_gas_unconditional!( + blobbasefee_checkpoint, + BLOBBASEFEE, + instructions::block_info::blob_basefee, + VolatileDataAccessType::BlobBaseFee + ); + wrap_checkpoint_detain_gas_unconditional!( + blobhash_checkpoint, + BLOBHASH, + instructions::tx_info::blob_hash, + VolatileDataAccessType::BlobHash + ); + + wrap_checkpoint_detain_gas_conditional!( + balance_checkpoint, + BALANCE, + instructions::host::balance + ); + wrap_checkpoint_detain_gas_conditional!( + extcodesize_checkpoint, + EXTCODESIZE, + instructions::host::extcodesize + ); + wrap_checkpoint_detain_gas_conditional!( + extcodecopy_checkpoint, + EXTCODECOPY, + instructions::host::extcodecopy + ); + wrap_checkpoint_detain_gas_conditional!( + extcodehash_checkpoint, + EXTCODEHASH, + instructions::host::extcodehash + ); + + /// `SLOAD` as a checkpoint. Same oracle-volatile handling as [`sload`], but the raw revm + /// instruction runs unwrapped and the open segment settles here. + #[inline] + pub fn sload_checkpoint( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + let target = context.interpreter.input.target_address(); + if target == ORACLE_CONTRACT_ADDRESS && context.host.volatile_access_disabled() { + revert_volatile_access_disabled!(context, SLOAD, VolatileDataAccessType::Oracle); + } + + run_inner_instruction_or_abort!(instructions::host::sload, context, inner_outcome); + charge_static_gas!(context, SLOAD); + settle_checkpoint_compute_gas!(context); + apply_compute_gas_limit!(context); + inner_outcome + } + + /// `SELFBALANCE` as a checkpoint. Same beneficiary-volatile handling as [`selfbalance`], but + /// the raw revm instruction runs unwrapped and the open segment settles here. + #[inline] + pub fn selfbalance_checkpoint( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + let target = context.interpreter.input.target_address(); + let beneficiary = context.host.beneficiary_address(); + if target == beneficiary && context.host.volatile_access_disabled() { + revert_volatile_access_disabled!( + context, + SELFBALANCE, + VolatileDataAccessType::Beneficiary + ); + } + charge_static_gas!(context, SELFBALANCE); + + run_inner_instruction_or_abort!(instructions::host::selfbalance, context, inner_outcome); + settle_checkpoint_compute_gas!(context); + apply_compute_gas_limit!(context); + inner_outcome + } } /// Extends opcodes with additional limit (kv update limit, data limit, etc.) enforcement. @@ -2607,7 +2929,19 @@ pub mod storage_gas_ext { }; let drained = context.host.additional_limit().borrow_mut().try_consume_storage_stipend(cost); - gas!(context.interpreter, cost - drained); + let storage_charged = cost - drained; + gas!(context.interpreter, storage_charged); + + // Under checkpoint accounting this storage debit sits inside the window that the + // trailing compute recording in `compute_gas_ext::selfdestruct_self_charged` closes, + // and that recording has no storage term of its own — exclude the debit by lowering + // the open baseline here. + { + let mut additional_limit = context.host.additional_limit().borrow_mut(); + if additional_limit.checkpoint_accounting() { + additional_limit.deduct_checkpoint_baseline(storage_charged); + } + } // Record resource usage for new beneficiary account context.host.additional_limit().borrow_mut().on_selfdestruct_new_account(); @@ -2958,8 +3292,19 @@ pub mod compute_gas_ext { } let pre_charged = if SELF_CHARGES_STATIC_GAS { 0 } else { const { static_gas(opcode::SELFDESTRUCT) } }; - let gas_used = pre_charged + gas_before.saturating_sub(context.interpreter.gas.remaining()); + let gas_after = context.interpreter.gas.remaining(); let mut additional_limit = context.host.additional_limit().borrow_mut(); + // Under checkpoint accounting the window opens at the previous checkpoint, so this + // recording also settles the unwrapped plain opcodes that ran since. Wherever the opcode's + // static gas was charged it lands inside that window, so nothing is added back; the + // beneficiary-creation storage charge already lowered the baseline by its own amount. + let gas_used = if additional_limit.checkpoint_accounting() { + let used = additional_limit.checkpoint_baseline().saturating_sub(gas_after); + additional_limit.sync_checkpoint_baseline(gas_after); + used + } else { + pre_charged + gas_before.saturating_sub(gas_after) + }; if !additional_limit.record_compute_gas_all_dims(gas_used) { // A successful inner SELFDESTRUCT has already set its return action, which the halt // replaces; the `Err` is what stops the interpreter loop. diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index 98251938..7be501e5 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -107,6 +107,22 @@ pub struct AdditionalLimit { /// A tracker for the `STORAGE_CALL_STIPEND` granted to value-transferring calls (REX4+). pub(crate) storage_call_stipend: storage_call_stipend::StorageCallStipendTracker, + + /// REX7+: whether compute gas settles at checkpoints rather than per opcode. + /// + /// When set, plain opcodes run unwrapped and record nothing; the interpreter's own gas + /// counter is read at each checkpoint and the whole segment since the previous one is + /// recorded in a single call. + checkpoint_accounting: bool, + + /// Interpreter gas remaining at the start of the current unsettled segment — the previous + /// checkpoint, or the frame entry / resume that opened the window. Only meaningful while a + /// frame is running and only when [`checkpoint_accounting`](Self::checkpoint_accounting) is + /// active. Re-synced at every [`before_frame_run`](Self::before_frame_run) (which covers both + /// frame entry and every resume after a child frame's outcome is merged back) and at every + /// checkpoint settlement, and lowered by storage-gas charge sites that debit interpreter gas + /// inside an open window. + checkpoint_baseline: u64, } /// The usage of the additional limits. @@ -134,6 +150,8 @@ impl AdditionalLimit { kv_update: kv_update::KVUpdateTracker::new(spec, limits.tx_kv_updates_limit), compute_gas: compute_gas::ComputeGasTracker::new(spec, limits.tx_compute_gas_limit), storage_call_stipend: storage_call_stipend::StorageCallStipendTracker::new(spec), + checkpoint_accounting: spec.is_enabled(MegaSpecId::REX7), + checkpoint_baseline: 0, } } } @@ -175,6 +193,53 @@ impl AdditionalLimit { self.data_size.reset(); self.kv_update.reset(); self.storage_call_stipend.reset(); + self.checkpoint_baseline = 0; + } + + /// Whether compute gas settles at checkpoints (REX7+) rather than per opcode. + #[inline] + pub(crate) fn checkpoint_accounting(&self) -> bool { + self.checkpoint_accounting + } + + /// Interpreter gas remaining at the start of the current unsettled segment. + /// + /// Settlement sites that need to subtract their own storage gas or forwarded child gas read + /// this instead of a per-opcode `gas_before` capture, so the measured delta covers every + /// unwrapped plain opcode executed since the previous checkpoint. + #[inline] + pub(crate) fn checkpoint_baseline(&self) -> u64 { + self.checkpoint_baseline + } + + /// Re-opens the settlement window at `remaining`, without recording anything. + /// + /// Used by settlement sites that compute their own segment amount; every such site must + /// call this once it has recorded, so a later settlement cannot bill the segment twice. + #[inline] + pub(crate) fn sync_checkpoint_baseline(&mut self, remaining: u64) { + self.checkpoint_baseline = remaining; + } + + /// Lowers the open window's baseline by `amount`, excluding a storage-gas debit from the + /// segment that the next settlement will measure. + /// + /// Charge sites that debit storage gas to interpreter gas while a window is open, and whose + /// settlement site does not receive the charged amount directly, use this instead: the + /// settlement then takes `baseline − remaining` with no storage term of its own. + #[inline] + pub(crate) fn deduct_checkpoint_baseline(&mut self, amount: u64) { + self.checkpoint_baseline = self.checkpoint_baseline.saturating_sub(amount); + } + + /// Settles the open segment against `gas_remaining`, re-opens the window there, and returns + /// `false` when a limit — including a non-compute exceed latched since the previous + /// checkpoint — surfaces. + #[inline] + pub(crate) fn settle_checkpoint(&mut self, gas_remaining: u64) -> bool { + let gas_used = self.checkpoint_baseline.saturating_sub(gas_remaining); + self.checkpoint_baseline = gas_remaining; + self.record_compute_gas(gas_used) } /// Test-only setter for [`has_exceeded_limit`](Self::has_exceeded_limit). Bypasses every @@ -418,8 +483,33 @@ impl AdditionalLimit { /// This runs on every metered opcode, so it is the hottest hook in the whole tracker. /// `#[inline]` lets the record + within-limit check fold directly into the per-opcode /// wrapper, removing a call across the `RefMut` boundary. + /// + /// Surfacing an exceed here is what turns a latched non-compute overflow into a halt, so + /// the call sites are also the positions a halt can land on: every metered opcode under + /// per-opcode accounting, and every checkpoint under checkpoint accounting. #[inline] pub(crate) fn record_compute_gas(&mut self, compute_gas_used: u64) -> bool { + self.record_compute_gas_impl::(compute_gas_used) + } + + /// Records the compute gas used without the latch-protocol guard. + /// + /// The guard in [`record_compute_gas_impl`](Self::record_compute_gas_impl) asserts that no + /// non-compute dimension is over limit without having latched, which holds at every position + /// an opcode can record from. It does not hold at a frame's final settlement: a pre-inner + /// recorder whose opcode then failed (SELFDESTRUCT's beneficiary accounting) deliberately + /// leaves its usage unlatched, and the frame is about to pop and discard it. Recording it + /// through the guarded entry point would trip the assert on that path. + #[inline] + pub(crate) fn record_compute_gas_unguarded(&mut self, compute_gas_used: u64) -> bool { + self.record_compute_gas_impl::(compute_gas_used) + } + + #[inline] + fn record_compute_gas_impl( + &mut self, + compute_gas_used: u64, + ) -> bool { // Record unconditionally, even when another dimension has already latched an exceed: // the compute work was performed, and the recorded total feeds the transaction outcome // and block-level compute accounting. Skipping the record would under-report compute @@ -437,13 +527,15 @@ impl AdditionalLimit { // only if every non-compute mutation site already latched its own exceed. If a // non-compute dimension is over limit but not yet latched, some mutation site is missing // its `check_limit()` — catch it here in tests, not in production. The sub-tracker - // `check_limit()` calls are non-mutating, so this compiles out of release builds. (The - // one pre-inner recorder, SELFDESTRUCT, routes through `record_compute_gas_all_dims`, not - // this method, so it never trips this.) + // `check_limit()` calls are non-mutating, so this compiles out of release builds. The one + // pre-inner recorder, SELFDESTRUCT, routes through `record_compute_gas_all_dims`, not this + // method, so it never trips this; the frame-final settlement, which can observe that same + // recorder's usage after its opcode failed, opts out via `GUARD_LATCH_PROTOCOL`. debug_assert!( - !self.data_size.check_limit().exceeded_limit() && - !self.kv_update.check_limit().exceeded_limit() && - !self.state_growth.check_limit().exceeded_limit(), + !GUARD_LATCH_PROTOCOL || + (!self.data_size.check_limit().exceeded_limit() && + !self.kv_update.check_limit().exceeded_limit() && + !self.state_growth.check_limit().exceeded_limit()), "non-compute limit exceeded without latching: a mutation site is missing check_limit()", ); // Recording compute gas can only change the compute-gas dimension, so check just that one @@ -670,6 +762,15 @@ impl AdditionalLimit { &mut self, frame: &EthFrame, ) -> Option { + // Checkpoint accounting: open the settlement window at the frame's current gas. This hook + // runs both at frame entry and at every resume after a child frame's outcome — including + // the gas it returned — has been merged back into this frame's interpreter, so the window + // always starts at an instruction boundary with the interpreter's counter in its real, + // post-merge state. + if self.checkpoint_accounting { + self.checkpoint_baseline = frame.interpreter.gas.remaining(); + } + self.state_growth.before_frame_run(frame); self.data_size.before_frame_run(frame); self.kv_update.before_frame_run(frame); @@ -719,6 +820,24 @@ impl AdditionalLimit { frame: &'a EthFrame, action: &'a mut InterpreterAction, ) { + // Checkpoint accounting: the frame has produced its final action, so settle the tail + // segment — everything since the last checkpoint — against the interpreter's gas counter. + // `frame.interpreter.gas` still holds the loop-exit value here (the code-deposit storage + // charge applied by the execution-layer hook mutates only the action's gas copy), so the + // delta telescopes over exactly the unwrapped plain opcodes that ran since. A checkpoint + // that already settled and halted leaves `baseline == remaining` (delta 0), and a CALL + // abort path's forwarded-gas `erase_cost` can only raise `remaining` above the baseline, + // which the saturation turns into 0. Any exceed recorded here is latched, and the frame + // result marking below / in `before_frame_return_result` surfaces it. + if self.checkpoint_accounting { + if let InterpreterAction::Return(_) = action { + let remaining = frame.interpreter.gas.remaining(); + let gas_used = self.checkpoint_baseline.saturating_sub(remaining); + self.checkpoint_baseline = remaining; + let _ = self.record_compute_gas_unguarded(gas_used); + } + } + self.state_growth.after_frame_run(frame, action); self.data_size.after_frame_run(frame, action); self.kv_update.after_frame_run(frame, action); From 035426bd32f5b65c750d796ecb18f171966549d5 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 15:26:58 +0800 Subject: [PATCH 03/43] test(rex7): pin REX6/REX7 checkpoint settlement parity Covers plain segments, SSTORE/LOG, SLOAD, the CALL family (success, revert, nested), CREATE/CREATE2, SELFDESTRUCT, volatile detention below the cap and the GAS reading, each with minimum and scaled SALT buckets. Also pins the two places the models differ: checkpoint-coarsened halts and out-of-gas frames. --- .../tests/rex7/checkpoint_settlement.rs | 542 ++++++++++++++++++ crates/mega-evm/tests/rex7/common.rs | 134 +++++ crates/mega-evm/tests/rex7/main.rs | 5 + 3 files changed, 681 insertions(+) create mode 100644 crates/mega-evm/tests/rex7/checkpoint_settlement.rs create mode 100644 crates/mega-evm/tests/rex7/common.rs diff --git a/crates/mega-evm/tests/rex7/checkpoint_settlement.rs b/crates/mega-evm/tests/rex7/checkpoint_settlement.rs new file mode 100644 index 00000000..41deb3ca --- /dev/null +++ b/crates/mega-evm/tests/rex7/checkpoint_settlement.rs @@ -0,0 +1,542 @@ +//! REX7 checkpoint compute-gas settlement. +//! +//! Under checkpoint accounting the plain opcodes run revm's raw instructions with no per-opcode +//! recording; compute gas settles as an interpreter-gas delta at each checkpoint — the storage-gas +//! opcodes, the CALL / CREATE family, the volatile opcodes, and frame entry / resume / exit. +//! +//! The property these tests pin is the **precision invariant**: for a transaction that stays +//! inside every per-tx limit, the settled totals are bit-identical to per-opcode recording, so +//! REX6 and REX7 produce the same compute gas, the same four-dimension usage, the same receipt +//! `gas_used`, and the same execution result. The interpreter's gas counter meters every opcode +//! anyway, so summing it by segment reproduces the per-opcode sum exactly. +//! +//! The two places where the models are *not* identical are pinned at the bottom of this file: +//! a limit crossing inside a plain-opcode segment surfaces at the next checkpoint rather than at +//! the crossing opcode, and a frame that halts out of gas settles its burned remainder as compute +//! gas. + +use crate::common::{ + transact, transact_default, transact_with_bucket_capacity, Outcome, CALLEE, CALLER, CONTRACT, + EMPTY_TARGET, ONE_ETH, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaSpecId, +}; +use revm::bytecode::opcode::{ + ADD, BALANCE, CALL, CALLCODE, CREATE, CREATE2, DELEGATECALL, DUP1, EXTCODEHASH, EXTCODESIZE, + GAS, JUMPDEST, JUMPI, LOG1, MUL, POP, SELFDESTRUCT, SLOAD, SSTORE, STATICCALL, STOP, SUB, + SWAP1, TIMESTAMP, +}; + +/// A third contract, so a CALL chain can reach depth 2. +const INNER: Address = address!("0000000000000000000000000000000000300004"); + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) +} + +/// A SALT bucket capacity four times the minimum, so every SALT-scaled storage-gas charge +/// (`SSTORE` set, new account, contract creation) is non-zero and the settlement sites that have +/// to exclude those charges from the compute window are actually exercised. +const SCALED_BUCKET_CAPACITY: u64 = 4 * mega_evm::MIN_BUCKET_SIZE as u64; + +/// Asserts that `build_db()` executes identically under REX6 (per-opcode recording) and REX7 +/// (checkpoint settlement): same success, same result, same four-dimension usage, same `gas_used`. +/// +/// Every case is run twice — once with minimum SALT buckets and once with +/// [`SCALED_BUCKET_CAPACITY`] — so the storage-gas exclusions are checked with a non-zero charge +/// as well. The returned outcomes are the minimum-bucket ones. +fn assert_settlement_parity( + label: &str, + expect_success: bool, + build_db: impl Fn() -> MemoryDatabase, +) -> (Outcome, Outcome) { + assert_settlement_parity_with_limits( + label, + expect_success, + build_db, + EvmTxRuntimeLimits::from_spec, + ) +} + +/// [`assert_settlement_parity`] with per-spec runtime limits. +fn assert_settlement_parity_with_limits( + label: &str, + expect_success: bool, + build_db: impl Fn() -> MemoryDatabase, + limits: impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + assert_outcomes_match(label, expect_success, &r6, &r7); + + let scaled_label = alloc_scaled_label(label); + let s6 = transact_with_bucket_capacity( + MegaSpecId::REX6, + build_db(), + limits(MegaSpecId::REX6), + SCALED_BUCKET_CAPACITY, + ); + let s7 = transact_with_bucket_capacity( + MegaSpecId::REX7, + build_db(), + limits(MegaSpecId::REX7), + SCALED_BUCKET_CAPACITY, + ); + assert_outcomes_match(&scaled_label, expect_success, &s6, &s7); + + (r6, r7) +} + +fn alloc_scaled_label(label: &str) -> String { + format!("{label} (scaled SALT buckets)") +} + +fn assert_outcomes_match(label: &str, expect_success: bool, r6: &Outcome, r7: &Outcome) { + assert_eq!( + r6.is_success(), + expect_success, + "{label}: REX6 success expectation mismatch; got {:?}", + r6.result + ); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "{label}: execution result must be identical; REX6={:?} REX7={:?}", + r6.result, + r7.result + ); + assert_eq!( + r6.compute_gas, r7.compute_gas, + "{label}: checkpoint settlement must telescope to the per-opcode compute-gas sum; \ + REX6={} REX7={}", + r6.compute_gas, r7.compute_gas + ); + assert_eq!( + r6.gas_used, r7.gas_used, + "{label}: receipt gas_used must be unchanged; REX6={} REX7={}", + r6.gas_used, r7.gas_used + ); + assert_eq!( + (r6.data_size, r6.kv_updates, r6.state_growth), + (r7.data_size, r7.kv_updates, r7.state_growth), + "{label}: the non-compute dimensions must be unchanged", + ); +} + +/// A countdown loop of cheap opcodes with no checkpoint anywhere inside the loop body: +/// +/// ```text +/// PUSH2 iterations; loop: JUMPDEST; PUSH1 1; SWAP1; SUB; DUP1; PUSH1 loop; JUMPI; STOP +/// ``` +/// +/// `prefix` is prepended verbatim and participates in the jump-target offset. +fn countdown_loop_code(prefix: &[u8], iterations: u16) -> Bytes { + let mut code = prefix.to_vec(); + code.push(0x61); // PUSH2 + code.extend_from_slice(&iterations.to_be_bytes()); + let loop_target = u8::try_from(code.len()).expect("loop target must fit in a PUSH1"); + code.push(JUMPDEST); + code.extend_from_slice(&[0x60, 0x01]); // PUSH1 1 + code.push(SWAP1); + code.push(SUB); + code.push(DUP1); + code.extend_from_slice(&[0x60, loop_target]); // PUSH1 loop + code.push(JUMPI); + code.push(STOP); + Bytes::from(code) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that record nothing of their own under checkpoint +/// accounting. +fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// A pure arithmetic loop settles only at the frame-exit checkpoint, and that single settlement +/// must equal the sum the per-opcode wrappers would have recorded opcode by opcode. +#[test] +fn test_plain_arithmetic_loop_settles_to_the_per_opcode_sum() { + let code = countdown_loop_code(&[], 500); + let (r6, _) = assert_settlement_parity("plain loop", true, || base_db(code.clone())); + assert!(r6.compute_gas > 10_000, "the loop must be substantial; compute={}", r6.compute_gas); +} + +/// A segment that runs plain opcodes, hits a mid-code checkpoint, then runs more plain opcodes +/// before the frame exits: the two settlements must partition the frame's gas exactly. +#[test] +fn test_plain_segments_around_a_mid_code_checkpoint() { + let code = plain_filler(BytecodeBuilder::default(), 40) + .push_u256(U256::from(99u64)) + .push_u256(U256::from(7u64)) + .append(SSTORE); + let code = plain_filler(code, 40).append(STOP).build(); + assert_settlement_parity("plain | SSTORE | plain", true, || base_db(code.clone())); +} + +/// SSTORE and LOG both charge storage gas inside their compute window and subtract it back out. +/// Interleaving them with plain opcodes checks that the subtraction stays exact once the window +/// also spans the plain opcodes before them. +#[test] +fn test_sstore_and_log_mixed_with_plain_opcodes() { + let code = plain_filler(BytecodeBuilder::default(), 20) + .sstore(U256::from(1), U256::from(0x11)) + .mstore(0, [0x22u8; 32]) + .push_number(0xabcu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(LOG1); + let code = plain_filler(code, 20).sstore(U256::from(2), U256::from(0x33)).append(STOP).build(); + let (r6, _) = assert_settlement_parity("SSTORE + LOG mix", true, || base_db(code.clone())); + assert!(r6.data_size > 0, "the log and stores must register data size"); + assert!(r6.kv_updates > 0, "the stores must register KV updates"); +} + +/// A cold then warm SLOAD, each a checkpoint, with plain opcodes between them. +#[test] +fn test_sload_checkpoints_with_plain_opcodes_between() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_u256(U256::from(3)) + .append(SLOAD) + .append(POP); + let code = plain_filler(code, 10) + .push_u256(U256::from(3)) + .append(SLOAD) + .append(POP) + .append(STOP) + .build(); + assert_settlement_parity("SLOAD checkpoints", true, || base_db(code.clone())); +} + +/// Bytecode for a CALL to `target` forwarding `gas_limit` and `value`. +fn call_code(target: Address, value: u64, gas_limit: u64) -> BytecodeBuilder { + BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(value) + .push_address(target) + .push_number(gas_limit) + .append(CALL) + .append(POP) +} + +/// A CALL sub-frame that succeeds: the caller's segment settles at the CALL checkpoint (before +/// `frame_init`), the callee settles its own segments, and the caller's window re-opens at the +/// resume with the callee's returned gas already merged back. +#[test] +fn test_call_subframe_success() { + let callee = plain_filler(BytecodeBuilder::default(), 15) + .sstore(U256::from(5), U256::from(0x77)) + .append(STOP) + .build(); + let code = plain_filler(call_code(CALLEE, 0, 1_000_000), 15).append(STOP).build(); + assert_settlement_parity("CALL success", true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); +} + +/// A CALL sub-frame that reverts: the callee's segments still settle (compute gas is persistent +/// even when the frame's state changes are dropped), and the returned gas re-opens the caller's +/// window at the resume. +#[test] +fn test_call_subframe_revert() { + let callee = plain_filler(BytecodeBuilder::default(), 15) + .sstore(U256::from(5), U256::from(0x77)) + .revert() + .build(); + let code = plain_filler(call_code(CALLEE, 0, 1_000_000), 15).append(STOP).build(); + let (r6, _) = assert_settlement_parity("CALL revert", true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); + assert!(r6.compute_gas > 0); +} + +/// A value-transferring CALL to an empty account: the new-account storage gas is charged inside +/// the CALL's compute window and subtracted back out, with the window now also spanning the plain +/// opcodes ahead of it. +#[test] +fn test_call_value_transfer_to_empty_account() { + let code = plain_filler(call_code(EMPTY_TARGET, 1, 1_000_000), 10).append(STOP).build(); + assert_settlement_parity("CALL value transfer", true, || base_db(code.clone())); +} + +/// Two nested CALL frames, so the resume path runs at two depths. +#[test] +fn test_nested_call_frames() { + let inner = plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(9), U256::from(0x99)) + .append(STOP) + .build(); + let callee = plain_filler(call_code(INNER, 0, 500_000), 10).append(STOP).build(); + let code = plain_filler(call_code(CALLEE, 0, 2_000_000), 10).append(STOP).build(); + assert_settlement_parity("nested CALL", true, || { + base_db(code.clone()) + .account_code(CALLEE, callee.clone()) + .account_code(INNER, inner.clone()) + }); +} + +/// DELEGATECALL, STATICCALL and CALLCODE all reach the same checkpoint chain as CALL. +#[test] +fn test_delegatecall_staticcall_callcode_frames() { + let callee = plain_filler(BytecodeBuilder::default(), 10).append(STOP).build(); + for (label, opcode, value_operand) in [ + ("DELEGATECALL", DELEGATECALL, false), + ("STATICCALL", STATICCALL, false), + ("CALLCODE", CALLCODE, true), + ] { + let mut builder = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64); // argsOffset + if value_operand { + builder = builder.push_number(0u64); + } + let code = plain_filler( + builder.push_address(CALLEE).push_number(500_000u64).append(opcode).append(POP), + 10, + ) + .append(STOP) + .build(); + assert_settlement_parity(label, true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); + } +} + +/// Initcode that deploys `runtime` as the created contract's code. +fn deploying_initcode(runtime: &[u8]) -> Vec { + BytecodeBuilder::default().return_with_data(runtime).build_vec() +} + +/// A CREATE whose child frame really runs initcode: the create frame gets its own window at entry +/// and its own tail settlement at exit, and the code-deposit accounting runs on top of both. +#[test] +fn test_create_child_frame() { + let runtime = plain_filler(BytecodeBuilder::default(), 4).append(STOP).build_vec(); + let initcode = deploying_initcode(&runtime); + let len = initcode.len() as u64; + let code = plain_filler(BytecodeBuilder::default(), 10) + .mstore(0, &initcode) + .push_number(len) // length + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP); + let code = plain_filler(code, 10).append(STOP).build(); + assert_settlement_parity("CREATE", true, || base_db(code.clone())); +} + +/// CREATE2 folds its memory-expansion gas into the same single window, which under checkpoint +/// accounting also spans the plain opcodes before it. +#[test] +fn test_create2_child_frame() { + let runtime = plain_filler(BytecodeBuilder::default(), 4).append(STOP).build_vec(); + let initcode = deploying_initcode(&runtime); + let len = initcode.len() as u64; + let code = plain_filler(BytecodeBuilder::default(), 10) + .mstore(0, &initcode) + .push_number(0x5a5au64) // salt + .push_number(len) // length + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE2) + .append(POP); + let code = plain_filler(code, 10).append(STOP).build(); + assert_settlement_parity("CREATE2", true, || base_db(code.clone())); +} + +/// SELFDESTRUCT to an empty beneficiary charges new-account storage gas from a site that is not +/// the settlement site, so the open window's baseline has to be lowered by exactly that charge. +#[test] +fn test_selfdestruct_new_beneficiary_excludes_its_storage_gas() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_address(EMPTY_TARGET) + .append(SELFDESTRUCT) + .build(); + assert_settlement_parity("SELFDESTRUCT new beneficiary", true, || base_db(code.clone())); +} + +/// SELFDESTRUCT to an existing beneficiary takes the other arm (no storage-gas charge, REX6+ +/// account-write accounting only). +#[test] +fn test_selfdestruct_existing_beneficiary() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_address(CALLEE) + .append(SELFDESTRUCT) + .build(); + let callee = BytecodeBuilder::default().append(STOP).build(); + assert_settlement_parity("SELFDESTRUCT existing beneficiary", true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); +} + +/// TIMESTAMP marks block-environment access and lowers the compute-gas limit to +/// `usage_at_access + cap`. With the cap comfortably above what the rest of the transaction +/// spends, detention is engaged but never binding — and the detention cap must be derived from +/// fully settled usage, so REX6 and REX7 must still agree bit for bit. +#[test] +fn test_block_env_detention_below_the_cap() { + let code = countdown_loop_code(&[TIMESTAMP, POP], 200); + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = 1_000_000; + limits + }; + assert_settlement_parity_with_limits( + "TIMESTAMP detention", + true, + || base_db(code.clone()), + limits, + ); +} + +/// The beneficiary-conditional volatile checkpoints (BALANCE / EXTCODESIZE / EXTCODEHASH) charge +/// their static gas after the raw instruction, which under checkpoint accounting lands inside the +/// settled segment rather than being added back. +#[test] +fn test_conditional_volatile_checkpoints() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_address(CALLEE) + .append(BALANCE) + .append(POP) + .push_address(CALLEE) + .append(EXTCODESIZE) + .append(POP) + .push_address(CALLEE) + .append(EXTCODEHASH) + .append(POP); + let code = plain_filler(code, 10).append(STOP).build(); + let callee = BytecodeBuilder::default().append(STOP).build(); + assert_settlement_parity("conditional volatile", true, || { + base_db(code.clone()).account_code(CALLEE, callee.clone()) + }); +} + +/// `GAS` is not a checkpoint: it runs raw and reads the interpreter's own counter. The settlement +/// must not perturb that counter, so a contract that stores its `GAS` reading must store the same +/// value under both specs. +#[test] +fn test_gas_opcode_reads_the_same_remaining_gas() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .append(GAS) + .push_u256(U256::from(4)) + .append(SSTORE) + .append(STOP) + .build(); + let (r6, r7) = assert_settlement_parity("GAS reading", true, || base_db(code.clone())); + let slot = U256::from(4); + assert_eq!( + r6.storage_value(CONTRACT, slot), + r7.storage_value(CONTRACT, slot), + "GAS must observe the same interpreter gas under both accounting models", + ); + assert!(!r6.storage_value(CONTRACT, slot).is_zero(), "the GAS reading must be non-zero"); +} + +/// A long straight run of arithmetic with no checkpoint at all, so the whole frame is one segment +/// settled once at the frame-exit checkpoint. +#[test] +fn test_single_segment_frame() { + let mut builder = BytecodeBuilder::default().push_number(7u64); + for _ in 0..200 { + builder = builder.push_number(3u64).append(ADD).push_number(2u64).append(MUL); + } + let code = builder.append(POP).append(STOP).build(); + assert_settlement_parity("single segment", true, || base_db(code.clone())); +} + +/// Pushes the operands of an `SSTORE(slot=7, value=99)` and stops before the SSTORE byte, so a run +/// measures the compute gas accumulated up to the opcode under test. +fn plain_run_then_sstore_code(pairs: usize, include_sstore: bool) -> Bytes { + let builder = plain_filler(BytecodeBuilder::default(), pairs) + .push_u256(U256::from(99u64)) + .push_u256(U256::from(7u64)); + let builder = if include_sstore { builder.append(SSTORE) } else { builder }; + builder.append(STOP).build() +} + +/// The one enforcement difference this ticket's model has: a compute-gas crossing inside a +/// plain-opcode segment is not caught at the crossing opcode — nothing is metered there — but at +/// the next checkpoint. Both specs halt; REX7 records the whole segment up to that checkpoint, +/// which is exactly what an unconstrained run records at the same point. +#[test] +fn test_compute_limit_crossing_surfaces_at_the_next_checkpoint() { + let code = plain_run_then_sstore_code(200, true); + let usage_before_sstore = + transact_default(MegaSpecId::REX7, base_db(plain_run_then_sstore_code(200, false))) + .compute_gas; + // Trip the limit partway through the plain run, well before the SSTORE checkpoint. + let intrinsic = + transact_default(MegaSpecId::REX7, base_db(plain_run_then_sstore_code(0, false))) + .compute_gas; + let compute_limit = intrinsic + (usage_before_sstore - intrinsic) / 2; + let limits = + |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(compute_limit); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code.clone()), limits(MegaSpecId::REX7)); + let r7_unconstrained = transact_default(MegaSpecId::REX7, base_db(code)); + + assert!(!r6.is_success(), "REX6 must halt on the tight compute limit; got {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must halt on the tight compute limit; got {:?}", r7.result); + assert!( + r6.compute_gas <= compute_limit + 12, + "REX6 halts at the crossing opcode; compute={} limit={compute_limit}", + r6.compute_gas + ); + assert_eq!( + r7.compute_gas, r7_unconstrained.compute_gas, + "REX7 settles the whole segment through the SSTORE checkpoint", + ); + assert!( + r7.compute_gas > r6.compute_gas, + "checkpoint enforcement overshoots per-opcode enforcement; REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); +} + +/// The second difference: a frame that halts out of EVM gas has its remaining budget zeroed by the +/// interpreter before the frame-exit settlement reads the counter, so the burned remainder settles +/// as compute gas. Per-opcode recording attributes nothing to the failing opcode and nothing to +/// the burn, so REX7 reports strictly more compute gas for such a frame. +/// +/// The direction is the safe one — compute usage is over-reported, never under-reported — and the +/// halt itself is identical. +#[test] +fn test_out_of_gas_frame_settles_its_burned_gas_as_compute() { + // A callee that runs out of the gas its caller forwarded. + let callee = countdown_loop_code(&[], 10_000); + let code = call_code(CALLEE, 0, 5_000).append(STOP).build(); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let r6 = transact_default(MegaSpecId::REX6, build_db()); + let r7 = transact_default(MegaSpecId::REX7, build_db()); + + assert!(r6.is_success(), "the outer transaction survives the callee's OOG: {:?}", r6.result); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the halt itself is unchanged", + ); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas is unchanged"); + assert!( + r7.compute_gas > r6.compute_gas, + "the burned remainder settles as compute gas under REX7; REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); +} diff --git a/crates/mega-evm/tests/rex7/common.rs b/crates/mega-evm/tests/rex7/common.rs new file mode 100644 index 00000000..f66e1a10 --- /dev/null +++ b/crates/mega-evm/tests/rex7/common.rs @@ -0,0 +1,134 @@ +//! Shared helpers for the REX7 test suite. + +use alloy_primitives::{address, Address, Bytes, U256}; +use mega_evm::{ + test_utils::MemoryDatabase, EvmTxRuntimeLimits, MegaContext, MegaEvm, MegaHaltReason, + MegaSpecId, MegaTransaction, MegaTransactionNew as _, TestExternalEnvs, +}; +use revm::{ + context::{result::ExecutionResult, tx::TxEnvBuilder}, + handler::EvmTr, + state::EvmState, +}; + +/// Transaction sender. +pub(crate) const CALLER: Address = address!("0000000000000000000000000000000000300000"); +/// Contract invoked by the transaction; its code exercises the opcodes under test. +pub(crate) const CONTRACT: Address = address!("0000000000000000000000000000000000300001"); +/// A second contract, used as the target of internal CALL-family frames. +pub(crate) const CALLEE: Address = address!("0000000000000000000000000000000000300002"); +/// A spare empty address used as a value-transfer / SELFDESTRUCT target. +pub(crate) const EMPTY_TARGET: Address = address!("0000000000000000000000000000000000300003"); + +/// One ether, in wei. +pub(crate) const ONE_ETH: u128 = 1_000_000_000_000_000_000; + +/// The post-transaction readings compared across specs. +pub(crate) struct Outcome { + pub(crate) result: ExecutionResult, + /// Post-tx compute-gas tracker reading (`get_usage().compute_gas`). + pub(crate) compute_gas: u64, + /// Post-tx data-size tracker reading (`get_usage().data_size`). + pub(crate) data_size: u64, + /// Post-tx KV-update tracker reading (`get_usage().kv_updates`). + pub(crate) kv_updates: u64, + /// Post-tx state-growth tracker reading (`get_usage().state_growth`). + pub(crate) state_growth: u64, + /// Receipt `gas_used` (combined compute + storage EVM gas). + pub(crate) gas_used: u64, + /// The state the transaction produced. + pub(crate) state: EvmState, +} + +impl Outcome { + pub(crate) fn is_success(&self) -> bool { + self.result.is_success() + } + + /// Reads a storage slot out of the produced state, defaulting to zero when the transaction + /// never touched it. + pub(crate) fn storage_value(&self, address: Address, slot: U256) -> U256 { + self.state + .get(&address) + .and_then(|account| account.storage.get(&slot)) + .map(|value| value.present_value()) + .unwrap_or_default() + } +} + +/// Runs a single transaction that calls [`CONTRACT`] under `spec` with the given DB and runtime +/// limits, returning the execution result plus the post-tx tracker readings and `gas_used`. +pub(crate) fn transact( + spec: MegaSpecId, + mut db: MemoryDatabase, + limits: EvmTxRuntimeLimits, +) -> Outcome { + let mut context = MegaContext::new(&mut db, spec).with_tx_runtime_limits(limits); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let tx = + TxEnvBuilder::default().caller(CALLER).call(CONTRACT).gas_limit(100_000_000).build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + let mut evm = MegaEvm::new(context); + let result = + alloy_evm::Evm::transact_raw(&mut evm, tx).expect("tx should not surface EVMError"); + let usage = evm.ctx_ref().additional_limit.borrow().get_usage(); + let gas_used = result.result.tx_gas_used(); + Outcome { + result: result.result, + compute_gas: usage.compute_gas, + data_size: usage.data_size, + kv_updates: usage.kv_updates, + state_growth: usage.state_growth, + gas_used, + state: result.state, + } +} + +/// Runs [`transact`] with the spec's default runtime limits. +pub(crate) fn transact_default(spec: MegaSpecId, db: MemoryDatabase) -> Outcome { + transact(spec, db, EvmTxRuntimeLimits::from_spec(spec)) +} + +/// [`transact`] with every SALT bucket reporting `bucket_capacity`. +/// +/// The SALT-scaled storage-gas charges (`SSTORE` set, new account, contract creation) are +/// `base × (capacity / MIN_BUCKET_SIZE − 1)`, so only a capacity above +/// [`mega_evm::MIN_BUCKET_SIZE`] makes them non-zero and exercises the paths that have to +/// exclude them from the compute-gas window. +pub(crate) fn transact_with_bucket_capacity( + spec: MegaSpecId, + mut db: MemoryDatabase, + limits: EvmTxRuntimeLimits, + bucket_capacity: u64, +) -> Outcome { + let envs = TestExternalEnvs::default().with_default_bucket_capacity(bucket_capacity); + let mut context = MegaContext::new(&mut db, spec) + .with_external_envs(envs.into()) + .with_tx_runtime_limits(limits); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let tx = + TxEnvBuilder::default().caller(CALLER).call(CONTRACT).gas_limit(100_000_000).build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + let mut evm = MegaEvm::new(context); + let result = + alloy_evm::Evm::transact_raw(&mut evm, tx).expect("tx should not surface EVMError"); + let usage = evm.ctx_ref().additional_limit.borrow().get_usage(); + let gas_used = result.result.tx_gas_used(); + Outcome { + result: result.result, + compute_gas: usage.compute_gas, + data_size: usage.data_size, + kv_updates: usage.kv_updates, + state_growth: usage.state_growth, + gas_used, + state: result.state, + } +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index da0c0c50..fa0bd395 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -1,3 +1,8 @@ //! Tests for the `REX7` spec. +//! +//! - `checkpoint_settlement` — checkpoint compute-gas settlement: per-transaction totals stay +//! bit-identical to per-opcode recording, and the two places where the models diverge. +mod checkpoint_settlement; +mod common; mod modexp_gas; From 9d7d2a1aa90d5ea2f670612798c63169409ddbb6 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 16:25:32 +0800 Subject: [PATCH 04/43] feat(rex7): enforce compute limits with the V0 gas clamp At every checkpoint and frame entry/resume the interpreter's visible gas is clamped to the compute headroom -- the tighter of the frame-local budget and the TX-level detained limit -- and the hidden remainder is recorded together with the constraint that bound it. revm's own per-opcode gas check then stops a crossing opcode at the clamp boundary before it executes, so a plain-opcode segment is bounded with no per-opcode accounting at all. Checkpoint handlers gain a prologue (settle the open segment, restore the clamp so CALL forwarding, GAS and storage charges observe the true counter) and an epilogue (re-clamp against the possibly detained headroom). GAS joins the checkpoint set so the clamp stays unobservable. The frame's final result restores the hidden gas and reclassifies a clamp-induced out-of-gas as the compute exceed it stands for: frame-local binding reverts to the parent, TX-level binding halts with the gas rescued, and detention keeps its VolatileDataAccessOutOfGas attribution. Transactions that stay inside every limit remain bit-identical to per-opcode accounting; a crossing now halts one opcode earlier, with that opcode's cost excluded from the recorded usage. Specs <= REX6 are unchanged. --- crates/mega-evm/src/evm/execution.rs | 7 +- crates/mega-evm/src/evm/instructions.rs | 298 ++++++++++++++++------- crates/mega-evm/src/limit/compute_gas.rs | 25 ++ crates/mega-evm/src/limit/limit.rs | 187 +++++++++++--- 4 files changed, 389 insertions(+), 128 deletions(-) diff --git a/crates/mega-evm/src/evm/execution.rs b/crates/mega-evm/src/evm/execution.rs index a275b949..ee3718c2 100644 --- a/crates/mega-evm/src/evm/execution.rs +++ b/crates/mega-evm/src/evm/execution.rs @@ -419,7 +419,7 @@ impl MegaEvm { #[inline] fn before_frame_run( ctx: &MegaContext, - frame: &EthFrame, + frame: &mut EthFrame, ) -> Result, ContextDbError>> { // Check if the additional limit is already exceeded, if so, we should immediately stop // and synthesize an interpreter action. @@ -456,6 +456,11 @@ impl MegaEvm { let is_rex5 = ctx.spec.is_enabled(MegaSpecId::REX5); if let InterpreterAction::Return(interpreter_result) = action { + // REX7 V0 clamp: hand any clamp-hidden gas back to the result — and latch a + // clamp-induced out-of-gas as the compute exceed it stands for — before the + // code-deposit charge below observes the result's gas. + ctx.additional_limit.borrow_mut().restore_clamp_into_result(interpreter_result); + // Charge storage gas cost for the number of bytes if frame.data.is_create() && interpreter_result.is_ok() { let code_deposit_storage_gas = constants::mini_rex::CODEDEPOSIT_STORAGE_GAS * diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index 16b2acf7..0c5fa5ee 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -650,8 +650,12 @@ mod rex7 { table[SELFBALANCE as usize] = Instruction::new(volatile_data_ext::selfbalance_checkpoint); table[SLOAD as usize] = Instruction::new(volatile_data_ext::sload_checkpoint); + // V0 gas-clamp enforcement: `GAS` has to be a checkpoint so the clamp is restored before + // the counter is observed. + table[GAS as usize] = Instruction::new(compute_gas_ext::gas_checkpoint); + // Storage-gas and frame-spawning checkpoints: the Rex6 handler chains unchanged. Under - // Rex7 they settle from the checkpoint baseline internally. + // Rex7 they open with a checkpoint prologue and close with an epilogue. table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore); table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, _, _>); table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, _, _>); @@ -818,6 +822,88 @@ macro_rules! run_inner_instruction_or_abort { }; } +/// REX7 checkpoint prologue. Runs at the top of every checkpoint handler, before any gas capture or +/// gas-consuming work: +/// +/// 1. Settles the open plain-opcode segment — `baseline − remaining`, both readings on the clamped +/// counter, telescoping over exactly the unwrapped opcodes since the last checkpoint. +/// 2. Restores the clamp-hidden gas, so the checkpoint's body runs on the **true** counter: the +/// CALL-family forwarding math, the `GAS` opcode's pushed value and the storage-gas charges all +/// observe real gas, which is what keeps the clamp unobservable to a transaction that never +/// exceeds a limit. +/// 3. Re-opens the settlement window at the restored counter. +/// +/// Halts — returning from the enclosing handler — when the settlement surfaces a limit exceed, +/// including one latched earlier by a non-compute mutation site. The restore has already happened +/// on that path, so the frame result carries true gas. No-op before REX7. +macro_rules! checkpoint_prologue { + ($context:expr) => { + if $context.host.spec_id().is_enabled(MegaSpecId::REX7) { + let exceeding_result = { + let mut additional_limit = $context.host.additional_limit().borrow_mut(); + let remaining = $context.interpreter.gas.remaining(); + let segment = additional_limit.checkpoint_baseline().saturating_sub(remaining); + let hidden = additional_limit.checkpoint_restore_hidden(); + $context.interpreter.gas.erase_cost(hidden); + additional_limit.sync_checkpoint_baseline($context.interpreter.gas.remaining()); + if additional_limit.record_compute_gas(segment) { + None + } else { + Some(additional_limit.exceeding_instruction_result()) + } + }; + if let Some(result) = exceeding_result { + set_halt_action!($context.interpreter, result); + return Err(result); + } + } + }; +} + +/// REX7 checkpoint epilogue: re-applies the V0 gas clamp from the freshly settled usage — including +/// any detention cap the checkpoint just installed — and re-opens the settlement window on the +/// clamped counter. +/// +/// Only applies when the frame keeps executing. A checkpoint that published an action has either +/// suspended into a child frame (the resume clamps in `AdditionalLimit::before_frame_run`) or ended +/// the frame (the frame's final result restores instead), and clamping either would strand hidden +/// gas across the boundary. No-op before REX7. +macro_rules! checkpoint_epilogue { + ($context:expr) => { + if $context.host.spec_id().is_enabled(MegaSpecId::REX7) && + $context.interpreter.bytecode.action().is_none() + { + let mut additional_limit = $context.host.additional_limit().borrow_mut(); + let hide = + additional_limit.checkpoint_clamp_amount($context.interpreter.gas.remaining()); + if hide > 0 { + let clamped = $context.interpreter.gas.record_regular_cost(hide); + debug_assert!(clamped, "clamp amount exceeds remaining gas"); + } + additional_limit.sync_checkpoint_baseline($context.interpreter.gas.remaining()); + } + }; +} + +/// Records a checkpoint opcode's own body gas (`$gas_before − remaining`) and re-opens the +/// settlement window, enforcing the compute-gas limit exactly as the per-opcode wrappers do. +/// +/// Used by the REX7 checkpoint handlers whose bodies can never spawn a child frame (the volatile +/// opcodes, `SLOAD`, `SELFBALANCE`, `GAS`). The CALL / CREATE and storage-gas bodies use +/// [`record_storage_compute_gas!`] instead, which additionally excludes storage charges and +/// forwarded child gas. +macro_rules! record_checkpoint_body_compute_gas { + ($context:expr, $gas_before:expr) => { + let gas_after = $context.interpreter.gas.remaining(); + let gas_used = $gas_before.saturating_sub(gas_after); + { + let mut additional_limit = $context.host.additional_limit().borrow_mut(); + additional_limit.sync_checkpoint_baseline(gas_after); + compute_gas!($context.interpreter, additional_limit, gas_used); + } + }; +} + /// Records an opcode's compute gas in a single measurement window and enforces the compute-gas /// limit. The REX6 storage-affecting handlers invoke it directly with the storage gas they /// charged; plain opcodes use the leaner inline recording in @@ -840,9 +926,8 @@ macro_rules! run_inner_instruction_or_abort { /// CREATE2 differs only by folding its memory-expansion gas into this single window instead of /// recording it separately. /// -/// Under REX7 checkpoint accounting the window instead opens at the previous checkpoint, so the -/// same recording also settles the unwrapped plain opcodes that ran since; see the macro body for -/// why the exclusions stay exact and why the static gas is no longer added back. +/// Under REX7 checkpoint accounting the window is the same one — [`checkpoint_prologue!`] runs +/// ahead of the `$gas_before` capture — but the static gas is not added back: see the macro body. /// /// On exceeding the compute-gas limit, halts the interpreter and returns from the enclosing /// instruction handler. The early return mirrors [`compute_gas!`] so a trailing statement after @@ -855,16 +940,18 @@ macro_rules! record_storage_compute_gas { let is_rex6 = spec.is_enabled(MegaSpecId::REX6); let is_checkpoint_accounting = spec.is_enabled(MegaSpecId::REX7); let gas_after = $context.interpreter.gas.remaining(); - // Under checkpoint accounting the window opens at the last checkpoint — frame entry / - // resume, or the previous checkpoint opcode — instead of at this handler's own - // `$gas_before` capture, so the plain opcodes that ran since settle here in the same - // recording. Only plain opcodes can run inside that extra span, so no storage gas and no - // forwarded child gas hides in it and the exclusions below stay exact. The window then - // also contains the interpreter's static-gas pre-charge for this opcode, which the - // per-opcode form has to add back because its capture sits after it. + // The per-opcode `$gas_before` window applies on every spec: under checkpoint accounting + // the plain segment ahead of this opcode was already settled by + // [`checkpoint_prologue!`], which also restored the gas clamp, so `$gas_before` + // (captured after the prologue) lives on the true counter and measures the same + // span it measures everywhere else. + // + // What the two differ on is the opcode's static gas. Whoever charges it — the interpreter + // before dispatch, or an outer volatile wrapper — does so ahead of the prologue, so under + // checkpoint accounting it is already inside the settled segment and adding it back here + // would bill it twice. let mut gas_used = if is_checkpoint_accounting { - let baseline = $context.host.additional_limit().borrow().checkpoint_baseline(); - baseline.saturating_sub(gas_after).saturating_sub($storage_charged) + $gas_before.saturating_sub(gas_after).saturating_sub($storage_charged) } else { (const { static_gas($opcode) } + $gas_before.saturating_sub(gas_after)) .saturating_sub($storage_charged) @@ -1216,8 +1303,19 @@ pub mod forward_gas_ext { /// - `$wrapped_fn`: Path to the wrapped instruction implementation /// - `$has_transfer_logic`: Expression to determine if value is being transferred (e.g., /// `has_transfer` or `false`) + /// + /// The `@checkpoint_tail` variant additionally re-applies the REX7 gas clamp on the way out. It + /// is used by `CREATE` / `CREATE2`, whose table entries dispatch straight here; the CALL family + /// is wrapped once more by `volatile_data_ext::wrap_call_volatile_check`, which owns the + /// epilogue so that it lands after the detention cap that wrapper installs. macro_rules! wrap_gas_cap { ($fn_name:ident, $opcode_name:expr, $wrapped_fn:path, $has_transfer_logic:expr) => { + wrap_gas_cap!(@inner $fn_name, $opcode_name, $wrapped_fn, $has_transfer_logic, false); + }; + (@checkpoint_tail $fn_name:ident, $opcode_name:expr, $wrapped_fn:path, $has_transfer_logic:expr) => { + wrap_gas_cap!(@inner $fn_name, $opcode_name, $wrapped_fn, $has_transfer_logic, true); + }; + (@inner $fn_name:ident, $opcode_name:expr, $wrapped_fn:path, $has_transfer_logic:expr, $checkpoint_tail:literal) => { #[doc = concat!("`", $opcode_name, "` opcode with 98/100 gas forwarding rule.")] #[inline] pub fn $fn_name< @@ -1301,6 +1399,9 @@ pub mod forward_gas_ext { } _ => {} } + if $checkpoint_tail { + checkpoint_epilogue!(context); + } inner_outcome } }; @@ -1333,8 +1434,12 @@ pub mod forward_gas_ext { wrap_gas_cap!(call_code, "CALLCODE", storage_gas_ext::call_code, check_call_has_transfer); wrap_gas_cap!(delegate_call, "DELEGATECALL", storage_gas_ext::delegate_call, no_transfer); wrap_gas_cap!(static_call, "STATICCALL", storage_gas_ext::static_call, no_transfer); - wrap_gas_cap!(create, "CREATE", storage_gas_ext::create::, no_transfer); - wrap_gas_cap!(create2, "CREATE2", storage_gas_ext::create::, no_transfer); + wrap_gas_cap!( + @checkpoint_tail create, "CREATE", storage_gas_ext::create::, no_transfer + ); + wrap_gas_cap!( + @checkpoint_tail create2, "CREATE2", storage_gas_ext::create::, no_transfer + ); } /** Volatile data access opcode handlers with compute gas limit enforcement. @@ -1897,6 +2002,11 @@ pub mod volatile_data_ext { // not interpreter state, so it is safe in any interpreter state (including // `NewFrame` after a successful CALL). apply_compute_gas_limit!(context); + // REX7: re-clamp for a CALL that never published a child frame (an insufficient balance + // or depth rejection pushes 0 and lets the frame keep running). The epilogue is what + // keeps the following plain segment bounded, and it sits after the cap above so a CALL + // that just marked beneficiary access clamps against the detained headroom. + checkpoint_epilogue!(context); inner_outcome } }; @@ -1911,55 +2021,26 @@ pub mod volatile_data_ext { /* Checkpoint variants of the volatile handlers (REX7+). - Under checkpoint accounting the volatile opcodes stay wrapped — they are checkpoints — but - they run revm's raw instruction and settle the whole open segment, measured on the - interpreter's own gas counter, in one recording, instead of delegating to a per-opcode - `compute_gas_ext` wrapper. Wherever the opcode's static gas is charged it lands inside that - segment, so the settlement adds nothing back; each handler keeps charging it at the position - its per-opcode counterpart does, because that position decides what an underfunded frame has - already done when it halts. + Under checkpoint accounting the volatile opcodes stay wrapped — they are checkpoints. The + prologue settles the open plain segment and restores the gas clamp, revm's raw instruction runs + on the true counter, the body's own gas is recorded per opcode, the detention cap is applied + from the fully settled usage exactly as the per-opcode order applies it, and the epilogue + re-clamps against the possibly-lowered headroom. - The settlement runs before `apply_compute_gas_limit!`, so a REX4+ relative detention cap is - still derived from fully settled usage at the access point. + Each handler keeps charging the opcode's static gas at the position its per-opcode counterpart + charges it, because that position decides what an underfunded frame has already done when it + halts. The frozen detention-window tripwire the per-opcode conditional wrapper carries is not repeated here: it watches for historical transactions whose replay would diverge across a revm bump, and no such transaction can exist for a spec with no activation history. */ - /// Settles the open checkpoint segment at the interpreter's current gas, re-opens the window, - /// and halts — returning from the enclosing handler — when a limit surfaces. - /// - /// A frame-local exceed reports as a revert, which the enclosing handler's tail would have - /// treated as a normal (non-halting) outcome and still followed with the detention cap, so the - /// cap is applied here before returning. A TX-level exceed reports as an out-of-gas halt, which - /// that tail short-circuits, so the cap is not applied on that path. - macro_rules! settle_checkpoint_compute_gas { - ($context:expr) => { - let exceeding_result = { - let gas_after = $context.interpreter.gas.remaining(); - let mut additional_limit = $context.host.additional_limit().borrow_mut(); - if additional_limit.settle_checkpoint(gas_after) { - None - } else { - Some(additional_limit.exceeding_instruction_result()) - } - }; - if let Some(result) = exceeding_result { - set_halt_action!($context.interpreter, result); - if !result.is_halt() { - apply_compute_gas_limit!($context); - } - return Err(result); - } - }; - } - - /// Checkpoint form of [`wrap_op_detain_gas_unconditional`]: disabled guard, static gas ahead - /// of the raw instruction (the position these opcodes' revm bodies charge from), segment - /// settlement, detention cap. + /// Checkpoint form of [`wrap_op_detain_gas_unconditional`]: disabled guard, prologue, static + /// gas ahead of the raw instruction (the position these opcodes' revm bodies charge from), + /// body recording, detention cap, epilogue. macro_rules! wrap_checkpoint_detain_gas_unconditional { ($fn_name:ident, $opcode:ident, $original_fn:path, $access_type:expr) => { - #[doc = concat!("`", stringify!($opcode), "` opcode as a checkpoint: raw instruction, segment settlement, gas detention.")] + #[doc = concat!("`", stringify!($opcode), "` opcode as a checkpoint: segment settlement, raw instruction, gas detention, re-clamp.")] #[inline] pub fn $fn_name( context: InstructionContext<'_, H, WIRE>, @@ -1967,23 +2048,26 @@ pub mod volatile_data_ext { if context.host.volatile_access_disabled() { revert_volatile_access_disabled!(context, $opcode, $access_type); } + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); charge_static_gas!(context, $opcode); run_inner_instruction_or_abort!($original_fn, context, inner_outcome); - settle_checkpoint_compute_gas!(context); + record_checkpoint_body_compute_gas!(context, gas_before); apply_compute_gas_limit!(context); + checkpoint_epilogue!(context); inner_outcome } }; } - /// Checkpoint form of [`wrap_op_detain_gas_conditional`]: beneficiary peek, raw instruction, - /// static gas after it (the position these opcodes' revm bodies charge from, so an underfunded - /// frame has already popped its operands and marked its access), segment settlement, detention - /// cap. + /// Checkpoint form of [`wrap_op_detain_gas_conditional`]: beneficiary peek, prologue, raw + /// instruction, static gas after it (the position these opcodes' revm bodies charge from, so an + /// underfunded frame has already popped its operands and marked its access), body recording, + /// detention cap, epilogue. macro_rules! wrap_checkpoint_detain_gas_conditional { ($fn_name:ident, $opcode:ident, $original_fn:path) => { - #[doc = concat!("`", stringify!($opcode), "` opcode as a checkpoint: raw instruction, segment settlement, gas detention.")] + #[doc = concat!("`", stringify!($opcode), "` opcode as a checkpoint: segment settlement, raw instruction, gas detention, re-clamp.")] #[inline] pub fn $fn_name, H: HostExt + ?Sized>( context: InstructionContext<'_, H, WIRE>, @@ -1999,11 +2083,14 @@ pub mod volatile_data_ext { ); } } + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); run_inner_instruction_or_abort!($original_fn, context, inner_outcome); charge_static_gas!(context, $opcode); - settle_checkpoint_compute_gas!(context); + record_checkpoint_body_compute_gas!(context, gas_before); apply_compute_gas_limit!(context); + checkpoint_epilogue!(context); inner_outcome } }; @@ -2086,7 +2173,7 @@ pub mod volatile_data_ext { ); /// `SLOAD` as a checkpoint. Same oracle-volatile handling as [`sload`], but the raw revm - /// instruction runs unwrapped and the open segment settles here. + /// instruction runs unwrapped and the open segment settles in the prologue. #[inline] pub fn sload_checkpoint( context: InstructionContext<'_, H, WIRE>, @@ -2095,16 +2182,19 @@ pub mod volatile_data_ext { if target == ORACLE_CONTRACT_ADDRESS && context.host.volatile_access_disabled() { revert_volatile_access_disabled!(context, SLOAD, VolatileDataAccessType::Oracle); } + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); run_inner_instruction_or_abort!(instructions::host::sload, context, inner_outcome); charge_static_gas!(context, SLOAD); - settle_checkpoint_compute_gas!(context); + record_checkpoint_body_compute_gas!(context, gas_before); apply_compute_gas_limit!(context); + checkpoint_epilogue!(context); inner_outcome } /// `SELFBALANCE` as a checkpoint. Same beneficiary-volatile handling as [`selfbalance`], but - /// the raw revm instruction runs unwrapped and the open segment settles here. + /// the raw revm instruction runs unwrapped and the open segment settles in the prologue. #[inline] pub fn selfbalance_checkpoint( context: InstructionContext<'_, H, WIRE>, @@ -2118,11 +2208,14 @@ pub mod volatile_data_ext { VolatileDataAccessType::Beneficiary ); } + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); charge_static_gas!(context, SELFBALANCE); run_inner_instruction_or_abort!(instructions::host::selfbalance, context, inner_outcome); - settle_checkpoint_compute_gas!(context); + record_checkpoint_body_compute_gas!(context, gas_before); apply_compute_gas_limit!(context); + checkpoint_epilogue!(context); inner_outcome } } @@ -2183,6 +2276,9 @@ pub mod additional_limit_ext { set_halt_action!(context.interpreter, result); return Err(result); } + drop(additional_limit); + // REX7: re-clamp once every dimension this opcode touches has been recorded. + checkpoint_epilogue!(context); inner_outcome } @@ -2220,6 +2316,9 @@ pub mod additional_limit_ext { set_halt_action!(context.interpreter, result); return Err(result); } + drop(additional_limit); + // REX7: re-clamp once every dimension this opcode touches has been recorded. + checkpoint_epilogue!(context); inner_outcome } } @@ -2310,6 +2409,9 @@ pub mod storage_gas_ext { >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { + // REX7: settle the open segment and restore the clamp before any gas observation, + // so the storage charge and the body's 63/64 forwarding math see the true counter. + checkpoint_prologue!(context); // Captured at the very top so the single compute window covers all of the // opcode's compute work. let gas_before = context.interpreter.gas.remaining(); @@ -2664,6 +2766,10 @@ pub mod storage_gas_ext { return Err(InstructionResult::StateChangeDuringStaticCall); } + // REX7: settle the open segment and restore the clamp before any gas observation, so the + // memory expansion, the storage charge and the body's forwarding math see the true counter. + checkpoint_prologue!(context); + // Captured before any gas movement so the single compute window covers the wrapper-side // CREATE2 memory expansion as well as the inner opcode. let gas_before = context.interpreter.gas.remaining(); @@ -2745,6 +2851,8 @@ pub mod storage_gas_ext { >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { + // REX7: settle the open segment and restore the clamp before any gas observation. + checkpoint_prologue!(context); // Captured at the very top so the single compute window covers the inner opcode. let gas_before = context.interpreter.gas.remaining(); let Some(len) = context.interpreter.stack.inspect::<1>() else { @@ -2805,6 +2913,8 @@ pub mod storage_gas_ext { >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { + // REX7: settle the open segment and restore the clamp before any gas observation. + checkpoint_prologue!(context); // Captured at the very top so the single compute window covers the inner opcode. let gas_before = context.interpreter.gas.remaining(); // The address to the underlying execution contract state @@ -2881,6 +2991,11 @@ pub mod storage_gas_ext { >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { + // REX7: settle the open segment and restore the clamp before any gas observation — the + // beneficiary-creation storage charge below and the inner opcode both run on the true + // counter, which is what keeps the storage charge outside every compute window. + checkpoint_prologue!(context); + // Inside a static frame, revm's inner SELFDESTRUCT halts on the // static-context check without changing state. Skip the mega host work below // (two account inspections, SALT account-creation pricing, the storage-gas @@ -2929,19 +3044,7 @@ pub mod storage_gas_ext { }; let drained = context.host.additional_limit().borrow_mut().try_consume_storage_stipend(cost); - let storage_charged = cost - drained; - gas!(context.interpreter, storage_charged); - - // Under checkpoint accounting this storage debit sits inside the window that the - // trailing compute recording in `compute_gas_ext::selfdestruct_self_charged` closes, - // and that recording has no storage term of its own — exclude the debit by lowering - // the open baseline here. - { - let mut additional_limit = context.host.additional_limit().borrow_mut(); - if additional_limit.checkpoint_accounting() { - additional_limit.deduct_checkpoint_baseline(storage_charged); - } - } + gas!(context.interpreter, cost - drained); // Record resource usage for new beneficiary account context.host.additional_limit().borrow_mut().on_selfdestruct_new_account(); @@ -3294,17 +3397,14 @@ pub mod compute_gas_ext { if SELF_CHARGES_STATIC_GAS { 0 } else { const { static_gas(opcode::SELFDESTRUCT) } }; let gas_after = context.interpreter.gas.remaining(); let mut additional_limit = context.host.additional_limit().borrow_mut(); - // Under checkpoint accounting the window opens at the previous checkpoint, so this - // recording also settles the unwrapped plain opcodes that ran since. Wherever the opcode's - // static gas was charged it lands inside that window, so nothing is added back; the - // beneficiary-creation storage charge already lowered the baseline by its own amount. - let gas_used = if additional_limit.checkpoint_accounting() { - let used = additional_limit.checkpoint_baseline().saturating_sub(gas_after); + // The per-opcode `gas_before` window applies on every spec. Under checkpoint accounting the + // plain segment ahead of this opcode was already settled by the `checkpoint_prologue!` in + // `storage_gas_ext::selfdestruct`, which also restored the clamp; the window is re-opened + // here so the frame's final settlement cannot bill this body a second time. + let gas_used = pre_charged + gas_before.saturating_sub(gas_after); + if additional_limit.checkpoint_accounting() { additional_limit.sync_checkpoint_baseline(gas_after); - used - } else { - pre_charged + gas_before.saturating_sub(gas_after) - }; + } if !additional_limit.record_compute_gas_all_dims(gas_used) { // A successful inner SELFDESTRUCT has already set its return action, which the halt // replaces; the `Err` is what stops the interpreter loop. @@ -3314,6 +3414,24 @@ pub mod compute_gas_ext { } inner_outcome } + + /// `GAS` as a REX7 checkpoint. + /// + /// `GAS` has to be a checkpoint under V0 clamp enforcement even though it charges nothing but + /// its static gas: the prologue hands the clamp-hidden gas back before the raw instruction + /// reads the counter, so the value pushed on the stack is the true remaining and the clamp + /// stays invisible to any transaction that never exceeds a limit. + #[inline] + pub fn gas_checkpoint( + context: InstructionContext<'_, H, WIRE>, + ) -> InstructionExecResult { + checkpoint_prologue!(context); + let gas_before = context.interpreter.gas.remaining(); + run_inner_instruction_or_abort!(instructions::system::gas, context, inner_outcome); + record_checkpoint_body_compute_gas!(context, gas_before); + checkpoint_epilogue!(context); + inner_outcome + } } /// Trait to inspect the stack elements. diff --git a/crates/mega-evm/src/limit/compute_gas.rs b/crates/mega-evm/src/limit/compute_gas.rs index d37e27ca..90f42953 100644 --- a/crates/mega-evm/src/limit/compute_gas.rs +++ b/crates/mega-evm/src/limit/compute_gas.rs @@ -110,6 +110,31 @@ impl ComputeGasTracker { self.detained_limit } + /// Returns the base (undetained) TX compute gas limit. + pub(crate) fn base_tx_limit(&self) -> u64 { + self.frame_tracker.tx_limit() + } + + /// Returns the compute gas headroom the V0 gas clamp may leave visible to the interpreter, + /// and whether the binding constraint is the frame-local budget (`true`) or the TX-level + /// (possibly detained) limit (`false`). + /// + /// The headroom is the tighter of the current frame's remaining compute budget (Rex4+) and + /// the TX-level remaining under the effective (possibly detained) limit — the same pair + /// [`check_limit`](TxRuntimeLimit::check_limit) enforces. Gas hidden beyond this headroom is + /// therefore reachable only by a transaction that would exceed one of those two limits. + #[inline] + pub(crate) fn clamp_headroom(&self) -> (u64, bool) { + let tx_remaining = self.tx_limit().saturating_sub(self.tx_usage()); + if self.rex4_enabled { + let frame_remaining = self.frame_tracker.current_frame_remaining(); + if frame_remaining < tx_remaining { + return (frame_remaining, true); + } + } + (tx_remaining, false) + } + /// Returns `true` when gas detention is the binding TX-level constraint, i.e., the detained /// limit is tighter than the base TX limit AND actual usage exceeds it. pub(crate) fn is_detained_exceed(&self) -> bool { diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index 7be501e5..d85e39e6 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -120,9 +120,35 @@ pub struct AdditionalLimit { /// frame is running and only when [`checkpoint_accounting`](Self::checkpoint_accounting) is /// active. Re-synced at every [`before_frame_run`](Self::before_frame_run) (which covers both /// frame entry and every resume after a child frame's outcome is merged back) and at every - /// checkpoint settlement, and lowered by storage-gas charge sites that debit interpreter gas - /// inside an open window. + /// checkpoint prologue and body recording. checkpoint_baseline: u64, + + /// V0 gas-clamp enforcement (REX7+): the part of the executing frame's interpreter gas hidden + /// from the interpreter, so that revm's own per-opcode gas checks enforce the compute headroom + /// inside plain-opcode segments at no per-opcode cost. + /// + /// Non-zero only while the current frame is inside a plain segment: every checkpoint restores + /// it before running its body — so CALL forwarding, `GAS` and storage charges observe the true + /// counter — and re-applies it on the way out, and the frame's final result restores it via + /// [`restore_clamp_into_result`](Self::restore_clamp_into_result). + clamp_hidden: u64, + + /// Whether the headroom that bound the last clamp was the frame-local compute budget (`true`) + /// or the TX-level (possibly detained) limit (`false`). + /// + /// This decides how a clamp-induced out-of-gas is reclassified: a frame-local exceed reverts + /// to the parent, a TX-level exceed halts the transaction. + clamp_frame_local: bool, + + /// Whether a clamp-induced out-of-gas was latched while gas detention was the binding TX-level + /// constraint. + /// + /// [`ComputeGasTracker::is_detained_exceed`] requires `used > detained_limit`, which a + /// clamp-stopped transaction never reaches — the crossing opcode is stopped before it + /// executes, so usage stays at or below the limit. The halt-reason attribution consults + /// this flag instead, keeping the reported reason `VolatileDataAccessOutOfGas` exactly as + /// per-opcode enforcement reports it. + clamp_latched_detained: bool, } /// The usage of the additional limits. @@ -152,6 +178,9 @@ impl AdditionalLimit { storage_call_stipend: storage_call_stipend::StorageCallStipendTracker::new(spec), checkpoint_accounting: spec.is_enabled(MegaSpecId::REX7), checkpoint_baseline: 0, + clamp_hidden: 0, + clamp_frame_local: false, + clamp_latched_detained: false, } } } @@ -194,6 +223,9 @@ impl AdditionalLimit { self.kv_update.reset(); self.storage_call_stipend.reset(); self.checkpoint_baseline = 0; + self.clamp_hidden = 0; + self.clamp_frame_local = false; + self.clamp_latched_detained = false; } /// Whether compute gas settles at checkpoints (REX7+) rather than per opcode. @@ -221,25 +253,90 @@ impl AdditionalLimit { self.checkpoint_baseline = remaining; } - /// Lowers the open window's baseline by `amount`, excluding a storage-gas debit from the - /// segment that the next settlement will measure. + /// Takes the outstanding clamp-hidden gas so the caller can hand it back to the interpreter. + /// + /// Every checkpoint prologue calls this before running its body, and the frame's final result + /// calls it before the result propagates, so the clamp is never observable outside a plain + /// segment. + #[inline] + pub(crate) fn checkpoint_restore_hidden(&mut self) -> u64 { + core::mem::take(&mut self.clamp_hidden) + } + + /// Computes how much interpreter gas to hide so the visible remaining equals the compute + /// headroom, records it as outstanding, and returns it for the caller to debit from the + /// interpreter's counter. /// - /// Charge sites that debit storage gas to interpreter gas while a window is open, and whose - /// settlement site does not receive the charged amount directly, use this instead: the - /// settlement then takes `baseline − remaining` with no storage term of its own. + /// Returns 0 when clamping does not apply: the transaction is exempt from per-tx metering, or + /// a limit has already been latched (the enclosing site halts on it instead). #[inline] - pub(crate) fn deduct_checkpoint_baseline(&mut self, amount: u64) { - self.checkpoint_baseline = self.checkpoint_baseline.saturating_sub(amount); + pub(crate) fn checkpoint_clamp_amount(&mut self, remaining: u64) -> u64 { + debug_assert_eq!(self.clamp_hidden, 0, "clamp applied while a clamp is outstanding"); + if !self.has_exceeded_limit.within_limit() { + return 0; + } + let (headroom, frame_local) = self.compute_gas.clamp_headroom(); + let hide = remaining.saturating_sub(headroom); + self.clamp_hidden = hide; + self.clamp_frame_local = frame_local; + hide } - /// Settles the open segment against `gas_remaining`, re-opens the window there, and returns - /// `false` when a limit — including a non-compute exceed latched since the previous - /// checkpoint — surfaces. + /// Latches a clamp-induced out-of-gas as a compute gas limit exceed. + /// + /// The crossing opcode never executed — revm's own gas check stopped it at the clamp boundary — + /// so its cost is not in the recorded usage and an ordinary [`check_limit`](Self::check_limit) + /// pass sees usage at or below the limit. The latch is therefore stamped directly, with + /// `frame_local` taken from the constraint that bound the clamp, so the existing frame-result + /// machinery (frame-local absorb to revert; TX-level mark plus gas rescue) produces the halt + /// shape it produces for every other compute exceed. #[inline] - pub(crate) fn settle_checkpoint(&mut self, gas_remaining: u64) -> bool { - let gas_used = self.checkpoint_baseline.saturating_sub(gas_remaining); - self.checkpoint_baseline = gas_remaining; - self.record_compute_gas(gas_used) + fn latch_clamp_exceed(&mut self) { + if !self.has_exceeded_limit.within_limit() { + return; + } + self.has_exceeded_limit = LimitCheck::ExceedsLimit { + kind: super::LimitKind::ComputeGas, + frame_local: self.clamp_frame_local, + limit: self.compute_gas.tx_limit(), + used: self.compute_gas.tx_usage(), + }; + // Preserve the volatile-detention attribution: when the binding TX-level constraint at + // clamp time was the detained limit, the halt must classify as `VolatileDataAccessOutOfGas` + // exactly as per-opcode enforcement classifies it. + self.clamp_latched_detained = !self.clamp_frame_local && + self.compute_gas.detained_limit() < self.compute_gas.base_tx_limit(); + } + + /// Restores any outstanding V0 clamp into the frame's final interpreter result, and latches a + /// clamp-induced out-of-gas as a compute exceed. + /// + /// Must run before anything reads or charges the result's gas — in particular before the + /// execution-layer code-deposit storage charge, which would otherwise observe the clamped copy + /// and mis-fire an out-of-gas on a CREATE frame that is nowhere near its limits. + /// + /// A clamp can only be outstanding when the frame ended inside a plain-opcode segment, because + /// every checkpoint prologue restores it before its body. An out-of-gas exit from such a + /// segment is a clamp artifact: the true counter held `hidden` more gas than the + /// interpreter could see, and the crossing opcode was stopped at the clamp boundary *before + /// executing* — exactly the V0 enforcement point. When the crossing opcode would have + /// exceeded the true remaining as well, the compute classification still wins: the two are + /// indistinguishable here, and attributing the halt to the resource limit keeps the + /// sender's remaining gas refundable. + pub(crate) fn restore_clamp_into_result(&mut self, result: &mut InterpreterResult) { + if !self.checkpoint_accounting { + return; + } + let hidden = self.checkpoint_restore_hidden(); + if hidden == 0 { + return; + } + result.gas.erase_cost(hidden); + // `MemoryOOG` is the same gas shortage reported from the memory-expansion path; every other + // result either is unrelated to gas or cannot arise from a plain opcode. + if matches!(result.result, InstructionResult::OutOfGas | InstructionResult::MemoryOOG) { + self.latch_clamp_exceed(); + } } /// Test-only setter for [`has_exceeded_limit`](Self::has_exceeded_limit). Bypasses every @@ -383,10 +480,15 @@ impl AdditionalLimit { &self, access_type: VolatileDataAccess, ) -> Option { - self.compute_gas.is_detained_exceed().then(|| MegaHaltReason::VolatileDataAccessOutOfGas { - access_type, - limit: self.compute_gas.detained_limit(), - actual: self.compute_gas.tx_usage(), + // `is_detained_exceed` covers per-opcode enforcement, where usage crossed the detained + // limit. `clamp_latched_detained` covers V0 clamp enforcement, where the crossing opcode + // was stopped before executing and usage therefore stays at or below the limit. + (self.compute_gas.is_detained_exceed() || self.clamp_latched_detained).then(|| { + MegaHaltReason::VolatileDataAccessOutOfGas { + access_type, + limit: self.compute_gas.detained_limit(), + actual: self.compute_gas.tx_usage(), + } }) } @@ -760,17 +862,8 @@ impl AdditionalLimit { /// indicating that the limit is exceeded. pub(crate) fn before_frame_run( &mut self, - frame: &EthFrame, + frame: &mut EthFrame, ) -> Option { - // Checkpoint accounting: open the settlement window at the frame's current gas. This hook - // runs both at frame entry and at every resume after a child frame's outcome — including - // the gas it returned — has been merged back into this frame's interpreter, so the window - // always starts at an instruction boundary with the interpreter's counter in its real, - // post-merge state. - if self.checkpoint_accounting { - self.checkpoint_baseline = frame.interpreter.gas.remaining(); - } - self.state_growth.before_frame_run(frame); self.data_size.before_frame_run(frame); self.kv_update.before_frame_run(frame); @@ -784,6 +877,23 @@ impl AdditionalLimit { output, )); } + + // Checkpoint accounting: apply the V0 gas clamp and open the settlement window at the + // frame's clamped gas. This hook runs both at frame entry and at every resume after a child + // frame's outcome — including the gas it returned — has been merged back into this frame's + // interpreter, so the window always starts at an instruction boundary with the + // interpreter's counter in its real, post-merge state. No clamp can be outstanding + // here: every suspension point (the CALL / CREATE checkpoint prologue) and every + // frame end restores it first. + if self.checkpoint_accounting { + debug_assert_eq!(self.clamp_hidden, 0, "frame resumed with a clamp outstanding"); + let hide = self.checkpoint_clamp_amount(frame.interpreter.gas.remaining()); + if hide > 0 { + let clamped = frame.interpreter.gas.record_regular_cost(hide); + debug_assert!(clamped, "clamp amount exceeds remaining gas"); + } + self.checkpoint_baseline = frame.interpreter.gas.remaining(); + } None } @@ -822,13 +932,16 @@ impl AdditionalLimit { ) { // Checkpoint accounting: the frame has produced its final action, so settle the tail // segment — everything since the last checkpoint — against the interpreter's gas counter. - // `frame.interpreter.gas` still holds the loop-exit value here (the code-deposit storage - // charge applied by the execution-layer hook mutates only the action's gas copy), so the - // delta telescopes over exactly the unwrapped plain opcodes that ran since. A checkpoint - // that already settled and halted leaves `baseline == remaining` (delta 0), and a CALL - // abort path's forwarded-gas `erase_cost` can only raise `remaining` above the baseline, - // which the saturation turns into 0. Any exceed recorded here is latched, and the frame - // result marking below / in `before_frame_return_result` surfaces it. + // `frame.interpreter.gas` still holds the loop-exit value here (the clamp restore and the + // code-deposit storage charge both mutate only the action's gas copy), and both it and the + // baseline live in the same clamped domain, so the delta telescopes over exactly the + // unwrapped plain opcodes that ran since. A checkpoint that already settled and halted + // leaves `baseline == remaining` (delta 0), and a CALL abort path's forwarded-gas + // `erase_cost` can only raise `remaining` above the baseline, which the saturation turns + // into 0. Any exceed recorded here is latched, and the frame result marking below / in + // `before_frame_return_result` surfaces it. The clamp restore itself already happened, in + // `restore_clamp_into_result`, before the execution-layer hook charged code-deposit storage + // gas against the action's gas. if self.checkpoint_accounting { if let InterpreterAction::Return(_) = action { let remaining = frame.interpreter.gas.remaining(); From dc541a58af03383ab773c03e5bfe26da8dd67524 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 16:25:40 +0800 Subject: [PATCH 05/43] test(rex7): cover V0 gas-clamp enforcement Pins that the clamp is unobservable through GAS, that a crossing opcode is stopped before it executes with its cost excluded from usage, that a detention cap is enforced inside a checkpoint-free loop, and that a clamp-induced out-of-gas is reclassified by whichever constraint bound the clamp (frame-local revert, TX-level halt with rescue, volatile-detention attribution) including the double-exceed corner where the compute classification wins. The checkpoint-settlement suite's enforcement case is updated from the checkpoint-deferred halt to the V0 halt position. --- .../tests/rex7/checkpoint_settlement.rs | 46 +- crates/mega-evm/tests/rex7/common.rs | 25 +- crates/mega-evm/tests/rex7/main.rs | 3 + crates/mega-evm/tests/rex7/v0_clamp.rs | 444 ++++++++++++++++++ 4 files changed, 498 insertions(+), 20 deletions(-) create mode 100644 crates/mega-evm/tests/rex7/v0_clamp.rs diff --git a/crates/mega-evm/tests/rex7/checkpoint_settlement.rs b/crates/mega-evm/tests/rex7/checkpoint_settlement.rs index 41deb3ca..2dde394f 100644 --- a/crates/mega-evm/tests/rex7/checkpoint_settlement.rs +++ b/crates/mega-evm/tests/rex7/checkpoint_settlement.rs @@ -11,9 +11,9 @@ //! anyway, so summing it by segment reproduces the per-opcode sum exactly. //! //! The two places where the models are *not* identical are pinned at the bottom of this file: -//! a limit crossing inside a plain-opcode segment surfaces at the next checkpoint rather than at -//! the crossing opcode, and a frame that halts out of gas settles its burned remainder as compute -//! gas. +//! a limit crossing inside a plain-opcode segment halts *before* the crossing opcode rather than +//! after it, and a frame that halts out of gas settles its burned remainder as compute gas. The +//! enforcement mechanism behind the first — the V0 gas clamp — has its own suite in `v0_clamp`. use crate::common::{ transact, transact_default, transact_with_bucket_capacity, Outcome, CALLEE, CALLER, CONTRACT, @@ -425,9 +425,9 @@ fn test_conditional_volatile_checkpoints() { }); } -/// `GAS` is not a checkpoint: it runs raw and reads the interpreter's own counter. The settlement -/// must not perturb that counter, so a contract that stores its `GAS` reading must store the same -/// value under both specs. +/// `GAS` reads the interpreter's own counter, so neither the settlement nor the gas clamp may +/// perturb what it observes: a contract that stores its `GAS` reading must store the same value +/// under both specs. #[test] fn test_gas_opcode_reads_the_same_remaining_gas() { let code = plain_filler(BytecodeBuilder::default(), 10) @@ -468,12 +468,14 @@ fn plain_run_then_sstore_code(pairs: usize, include_sstore: bool) -> Bytes { builder.append(STOP).build() } -/// The one enforcement difference this ticket's model has: a compute-gas crossing inside a -/// plain-opcode segment is not caught at the crossing opcode — nothing is metered there — but at -/// the next checkpoint. Both specs halt; REX7 records the whole segment up to that checkpoint, -/// which is exactly what an unconstrained run records at the same point. +/// The one enforcement difference this model has: a compute-gas crossing inside a plain-opcode +/// segment is not caught *at* the crossing opcode — nothing is metered there — but *before* it, by +/// the V0 gas clamp, which leaves the interpreter only as much visible gas as the compute headroom +/// allows. Both specs halt, and both halt in the middle of the plain run without ever reaching the +/// SSTORE checkpoint downstream; REX6 executes the crossing opcode and records it, so its usage +/// ends up over the limit, while REX7 stops one opcode earlier and its usage stays at the limit. #[test] -fn test_compute_limit_crossing_surfaces_at_the_next_checkpoint() { +fn test_compute_limit_crossing_halts_before_the_crossing_opcode() { let code = plain_run_then_sstore_code(200, true); let usage_before_sstore = transact_default(MegaSpecId::REX7, base_db(plain_run_then_sstore_code(200, false))) @@ -487,26 +489,32 @@ fn test_compute_limit_crossing_surfaces_at_the_next_checkpoint() { |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(compute_limit); let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); - let r7 = transact(MegaSpecId::REX7, base_db(code.clone()), limits(MegaSpecId::REX7)); - let r7_unconstrained = transact_default(MegaSpecId::REX7, base_db(code)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); assert!(!r6.is_success(), "REX6 must halt on the tight compute limit; got {:?}", r6.result); assert!(!r7.is_success(), "REX7 must halt on the tight compute limit; got {:?}", r7.result); assert!( - r6.compute_gas <= compute_limit + 12, - "REX6 halts at the crossing opcode; compute={} limit={compute_limit}", + r6.compute_gas > compute_limit, + "REX6 records the crossing opcode it just executed; compute={} limit={compute_limit}", r6.compute_gas ); assert_eq!( - r7.compute_gas, r7_unconstrained.compute_gas, - "REX7 settles the whole segment through the SSTORE checkpoint", + r7.compute_gas, compute_limit, + "REX7 stops at the clamp boundary, with the crossing opcode's cost excluded", ); assert!( - r7.compute_gas > r6.compute_gas, - "checkpoint enforcement overshoots per-opcode enforcement; REX6={} REX7={}", + r7.compute_gas < r6.compute_gas, + "clamp enforcement must be at least as tight as per-opcode enforcement; REX6={} REX7={}", r6.compute_gas, r7.compute_gas ); + let slot = U256::from(7u64); + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.storage_value(CONTRACT, slot).is_zero(), + "{label}: the halt lands inside the plain run, so the SSTORE downstream never executes", + ); + } } /// The second difference: a frame that halts out of EVM gas has its remaining budget zeroed by the diff --git a/crates/mega-evm/tests/rex7/common.rs b/crates/mega-evm/tests/rex7/common.rs index f66e1a10..9b0d5cb6 100644 --- a/crates/mega-evm/tests/rex7/common.rs +++ b/crates/mega-evm/tests/rex7/common.rs @@ -45,6 +45,14 @@ impl Outcome { self.result.is_success() } + /// The halt reason, or a panic with `label` when the transaction did not halt. + pub(crate) fn halt_reason(&self, label: &str) -> &MegaHaltReason { + match &self.result { + ExecutionResult::Halt { reason, .. } => reason, + other => panic!("{label}: expected a halt, got {other:?}"), + } + } + /// Reads a storage slot out of the produced state, defaulting to zero when the transaction /// never touched it. pub(crate) fn storage_value(&self, address: Address, slot: U256) -> U256 { @@ -56,12 +64,27 @@ impl Outcome { } } +/// The transaction gas limit [`transact`] runs with — high enough that EVM gas is never the +/// binding constraint. +pub(crate) const DEFAULT_TX_GAS_LIMIT: u64 = 100_000_000; + /// Runs a single transaction that calls [`CONTRACT`] under `spec` with the given DB and runtime /// limits, returning the execution result plus the post-tx tracker readings and `gas_used`. pub(crate) fn transact( + spec: MegaSpecId, + db: MemoryDatabase, + limits: EvmTxRuntimeLimits, +) -> Outcome { + transact_with_gas_limit(spec, db, limits, DEFAULT_TX_GAS_LIMIT) +} + +/// [`transact`] with an explicit transaction gas limit, for cases that need EVM gas itself to run +/// out. +pub(crate) fn transact_with_gas_limit( spec: MegaSpecId, mut db: MemoryDatabase, limits: EvmTxRuntimeLimits, + gas_limit: u64, ) -> Outcome { let mut context = MegaContext::new(&mut db, spec).with_tx_runtime_limits(limits); context.modify_chain(|chain| { @@ -69,7 +92,7 @@ pub(crate) fn transact( chain.operator_fee_constant = Some(U256::from(0)); }); let tx = - TxEnvBuilder::default().caller(CALLER).call(CONTRACT).gas_limit(100_000_000).build_fill(); + TxEnvBuilder::default().caller(CALLER).call(CONTRACT).gas_limit(gas_limit).build_fill(); let mut tx = MegaTransaction::new(tx); tx.enveloped_tx = Some(Bytes::new()); let mut evm = MegaEvm::new(context); diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index fa0bd395..edd2739c 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -2,7 +2,10 @@ //! //! - `checkpoint_settlement` — checkpoint compute-gas settlement: per-transaction totals stay //! bit-identical to per-opcode recording, and the two places where the models diverge. +//! - `v0_clamp` — V0 gas-clamp enforcement: a crossing opcode is stopped before it executes, and +//! the resulting out-of-gas is restored and reclassified by the constraint that bound the clamp. mod checkpoint_settlement; mod common; mod modexp_gas; +mod v0_clamp; diff --git a/crates/mega-evm/tests/rex7/v0_clamp.rs b/crates/mega-evm/tests/rex7/v0_clamp.rs new file mode 100644 index 00000000..29000935 --- /dev/null +++ b/crates/mega-evm/tests/rex7/v0_clamp.rs @@ -0,0 +1,444 @@ +//! REX7 V0 gas-clamp enforcement. +//! +//! Plain opcodes under checkpoint accounting record nothing, so nothing checks a limit while a +//! plain segment runs. Enforcement instead comes from the interpreter itself: at every checkpoint +//! and frame entry / resume the visible remaining gas is clamped down to the compute headroom — the +//! tighter of the frame-local budget and the TX-level (possibly detained) limit — and the hidden +//! remainder is remembered along with the constraint that bound it. revm's own per-opcode gas check +//! then stops a crossing opcode at the clamp boundary *before it executes*, and the frame's final +//! result restores the hidden gas and reclassifies the out-of-gas as the limit exceed it stands +//! for. +//! +//! These tests pin the three sides of that mechanism: +//! +//! - **Unobservable while within limits**: `GAS` reads the true counter even under an active clamp, +//! so a transaction that never exceeds a limit is bit-identical to per-opcode accounting. +//! - **Exact enforcement**: the crossing opcode never runs, its cost never enters the recorded +//! usage, and usage therefore stops at or below the limit — including inside a checkpoint-free +//! arithmetic loop, where deferring to the next checkpoint would overshoot by the whole loop. +//! - **Faithful reclassification**: frame-local exceeds revert to the parent, TX-level exceeds halt +//! the transaction with the remaining gas rescued, and a detention exceed keeps reporting +//! `VolatileDataAccessOutOfGas`. + +use crate::common::{ + transact, transact_default, transact_with_gas_limit, Outcome, CALLEE, CONTRACT, ONE_ETH, +}; +use alloy_primitives::{Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaHaltReason, MegaSpecId, +}; +use revm::bytecode::opcode::{ + CALL, DUP1, GAS, JUMPDEST, JUMPI, MSTORE, POP, RETURN, SSTORE, STOP, SUB, SWAP1, TIMESTAMP, +}; + +/// Slot the outer contract stores the CALL success flag into. +const CALL_RESULT_SLOT: u64 = 0x10; +/// Slot a callee writes to, so a reverted sub-frame can be told from a committed one. +const CALLEE_SLOT: u64 = 0x11; + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(crate::common::CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) +} + +/// A countdown loop of cheap opcodes with no checkpoint anywhere inside the loop body: +/// +/// ```text +/// [prefix] PUSH2 iterations; loop: JUMPDEST; PUSH1 1; SWAP1; SUB; DUP1; PUSH1 loop; JUMPI; STOP +/// ``` +/// +/// Each iteration runs seven plain opcodes for 26 gas. `prefix` is prepended verbatim and +/// participates in the jump-target offset. +fn countdown_loop_code(prefix: &[u8], iterations: u16) -> Bytes { + let mut code = prefix.to_vec(); + code.push(0x61); // PUSH2 + code.extend_from_slice(&iterations.to_be_bytes()); + let loop_target = u8::try_from(code.len()).expect("loop target must fit in a PUSH1"); + code.push(JUMPDEST); + code.extend_from_slice(&[0x60, 0x01]); // PUSH1 1 + code.push(SWAP1); + code.push(SUB); + code.push(DUP1); + code.extend_from_slice(&[0x60, loop_target]); // PUSH1 loop + code.push(JUMPI); + code.push(STOP); + Bytes::from(code) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that record nothing of their own. +fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// Per-spec runtime limits with the TX compute gas limit replaced. +fn compute_limit(limit: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit) +} + +/// Per-spec runtime limits with the block-environment detention cap replaced. +fn detention_cap(cap: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = cap; + limits + } +} + +/// The compute gas a transaction running `code` uses when nothing constrains it. +fn unconstrained_compute_gas(code: Bytes) -> u64 { + transact_default(MegaSpecId::REX7, base_db(code)).compute_gas +} + +/// `GAS` must observe the true remaining gas even while a clamp is outstanding — the checkpoint +/// prologue restores the hidden gas before the raw instruction reads the counter. +/// +/// A tight detention cap keeps the clamp active for the whole post-access run while the transaction +/// itself stays far inside every limit, so the stored reading, the compute total and the receipt +/// must all match per-opcode REX6, where no clamp exists at all. +#[test] +fn test_clamp_is_unobservable_via_the_gas_opcode() { + let code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .append(GAS) + .push_u256(U256::from(CALL_RESULT_SLOT)) + .append(SSTORE) + .append(STOP) + .build(); + // A cap two orders of magnitude below the frame's remaining EVM gas, so the clamp is active at + // the GAS opcode, but well above what the rest of this transaction spends, so nothing is ever + // exceeded. + let limits = detention_cap(1_000_000); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + + let slot = U256::from(CALL_RESULT_SLOT); + let r7_reading = r7.storage_value(CONTRACT, slot); + assert!(!r7_reading.is_zero(), "the GAS reading must be non-zero"); + assert_eq!( + r6.storage_value(CONTRACT, slot), + r7_reading, + "GAS must push the true remaining gas, not the clamped value", + ); + assert_eq!(r6.compute_gas, r7.compute_gas, "compute totals must be identical"); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas must be identical"); +} + +/// The crossing opcode is stopped before it executes, so its cost never enters the recorded usage. +/// +/// The limit is placed partway through a straight plain-opcode run. REX6 executes the crossing +/// opcode and only then records it, so its usage ends up strictly over the limit; REX7 clamps the +/// visible gas to the headroom, so revm rejects the crossing opcode at the boundary and usage stops +/// exactly at the limit. +#[test] +fn test_crossing_opcode_is_stopped_before_it_executes() { + let code = plain_filler(BytecodeBuilder::default(), 200).append(STOP).build(); + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + let full_run = unconstrained_compute_gas(code.clone()); + // Trip the limit halfway through the plain run. + let limit = intrinsic + (full_run - intrinsic) / 2; + let limits = compute_limit(limit); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + + assert!(!r6.is_success(), "REX6 must stop on the tight compute limit: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must stop on the tight compute limit: {:?}", r7.result); + + assert!( + r6.compute_gas > limit, + "REX6 records the crossing opcode before halting; compute={} limit={limit}", + r6.compute_gas + ); + assert_eq!( + r7.compute_gas, limit, + "REX7 must stop exactly at the limit: the crossing opcode never runs, and the headroom it \ + could not pay for is what the frame burns", + ); +} + +/// A TX-level crossing halts the transaction, and the gas the clamp was hiding is rescued for the +/// sender rather than burned. +/// +/// The top-level frame's compute budget equals the TX-level remaining, so the TX limit is what +/// binds and the halt must propagate. +#[test] +fn test_tx_level_clamp_exceed_halts_with_the_hidden_gas_rescued() { + // ~260k gas of plain opcodes with no checkpoint inside the loop at all. + let code = countdown_loop_code(&[], 10_000); + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + let limit = intrinsic + 5_000; + + let r7 = transact(MegaSpecId::REX7, base_db(code), compute_limit(limit)(MegaSpecId::REX7)); + + assert!(!r7.is_success(), "the tight compute limit must halt the transaction: {:?}", r7.result); + assert!( + matches!(r7.halt_reason("REX7"), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "a TX-level clamp exceed must report the compute-gas limit; got {:?}", + r7.halt_reason("REX7"), + ); + assert_eq!(r7.compute_gas, limit, "usage must stop at the limit, not past it"); + assert!( + r7.gas_used < 200_000, + "the clamp-hidden gas must be rescued, not burned; gas_used={}", + r7.gas_used + ); +} + +/// A frame-local crossing reverts the sub-frame and lets the caller continue. +/// +/// A nested frame's compute budget is 98/100 of its parent's remaining budget, so it is always +/// tighter than the TX-level remaining — the clamp binds frame-locally, and the clamp-induced +/// out-of-gas must be reclassified into the ordinary frame-local revert rather than a TX halt. +#[test] +fn test_frame_local_clamp_exceed_reverts_to_the_parent() { + // The callee writes a slot and then burns far more compute than its frame budget allows. The + // write is passed as the loop's prefix so the loop's jump target accounts for it. + let prologue = + BytecodeBuilder::default().sstore(U256::from(CALLEE_SLOT), U256::from(0x77)).build_vec(); + let callee = countdown_loop_code(&prologue, 10_000); + + // The caller returns the CALL's success flag. A nested frame may consume up to 98/100 of its + // parent's compute budget, so the caller's own tail has to be cheap enough to fit in the + // remainder — a storage write would push the caller over its budget too. + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(50_000_000u64) // gas + .append(CALL) + .push_number(0u64) // memory offset + .append(MSTORE) + .push_number(32u64) // length + .push_number(0u64) // offset + .append(RETURN) + .build(); + + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + // Enough headroom for the caller's own work and the callee's SSTORE, far short of its loop. + let limits = compute_limit(intrinsic + 100_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.is_success(), + "{label}: the outer transaction survives a frame-local exceed: {:?}", + r.result + ); + assert_eq!( + r.result.output().map(|o| U256::from_be_slice(o)), + Some(U256::ZERO), + "{label}: the CALL must report failure", + ); + assert!( + r.storage_value(CALLEE, U256::from(CALLEE_SLOT)).is_zero(), + "{label}: the reverted sub-frame's storage write must be discarded", + ); + } + assert!( + r7.compute_gas < r6.compute_gas, + "REX7 stops the callee before the crossing opcode, so it records less than REX6; \ + REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); +} + +/// A detention crossing keeps its `VolatileDataAccessOutOfGas` attribution. +/// +/// Detention lowers the TX-level limit to `usage_at_access + cap`, so it is the TX-level constraint +/// that binds. The usual detained-exceed predicate needs usage to have crossed the detained limit, +/// which clamp enforcement never lets happen — the attribution has to survive on the clamp's own +/// record of what bound it. +#[test] +fn test_detention_clamp_exceed_keeps_the_volatile_attribution() { + let code = countdown_loop_code(&[TIMESTAMP, POP], 10_000); + let limits = detention_cap(1_000); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + + assert!(!r6.is_success(), "REX6 must halt on the detention cap: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must halt on the detention cap: {:?}", r7.result); + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + matches!(r.halt_reason(label), MegaHaltReason::VolatileDataAccessOutOfGas { .. }), + "{label}: the halt must be attributed to volatile detention; got {:?}", + r.halt_reason(label), + ); + } +} + +/// The clamp bounds a detention cap inside a checkpoint-free arithmetic loop — the shape that makes +/// checkpoint-deferred enforcement unbounded, since the loop body contains no checkpoint at all and +/// the whole ~260k-gas loop would otherwise run to completion before anything checked. +#[test] +fn test_clamp_bounds_detention_inside_a_checkpoint_free_loop() { + let cap = 1_000; + let code = countdown_loop_code(&[TIMESTAMP, POP], 10_000); + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + let limits = detention_cap(cap); + + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code.clone()), limits(MegaSpecId::REX7)); + let unconstrained = unconstrained_compute_gas(code); + + // The detained limit is `usage_at_access + cap`; the access happens two opcodes in, so + // `intrinsic + TIMESTAMP + cap` bounds it from above. + let detained_upper = intrinsic + 2 + cap; + assert!( + unconstrained > 250_000, + "the loop must be far larger than the cap to make the test meaningful; loop={unconstrained}" + ); + assert!( + r7.compute_gas <= detained_upper, + "REX7 must not overshoot the detention cap; compute={} cap≈{detained_upper}", + r7.compute_gas + ); + // Per-opcode enforcement stops within one opcode of the cap; the clamp must not stop earlier. + assert!( + r7.compute_gas + 32 >= r6.compute_gas, + "REX7 must stop at the clamp boundary, not before it; REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); +} + +/// The adjudicated double-exceed corner: when the crossing opcode outruns both the true EVM +/// remaining and the compute headroom, the compute classification wins. +/// +/// A memory expansion far larger than the transaction's whole gas limit is unaffordable either way. +/// REX6 reports revm's memory out-of-gas and burns the frame; REX7 reports the compute-gas limit +/// and rescues the clamp-hidden remainder for the sender. The two are indistinguishable at the +/// frame boundary — an out-of-gas carries no opcode cost — and this direction favours the sender +/// without opening anything new: a caller that wants to avoid the burn can already REVERT. +#[test] +fn test_double_exceed_prefers_the_compute_classification() { + // A ~7.5 MB memory offset: the expansion costs on the order of 10^8 gas, well past the + // transaction's gas limit below. + let code = plain_filler(BytecodeBuilder::default(), 5) + .push_number(0u64) // value + .push_number(7_500_000u64) // offset + .append(MSTORE) + .append(STOP) + .build(); + let gas_limit = 1_000_000; + let intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + // Headroom well below the frame's true remaining, so the clamp is outstanding at the MSTORE. + let limits = compute_limit(intrinsic + 1_000); + + let r6 = transact_with_gas_limit( + MegaSpecId::REX6, + base_db(code.clone()), + limits(MegaSpecId::REX6), + gas_limit, + ); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(code), + limits(MegaSpecId::REX7), + gas_limit, + ); + + assert!(!r6.is_success(), "REX6 must fail on the unaffordable expansion: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must fail on the unaffordable expansion: {:?}", r7.result); + assert!( + matches!(r6.halt_reason("REX6"), MegaHaltReason::Base(_)), + "REX6 reports revm's own out-of-gas; got {:?}", + r6.halt_reason("REX6"), + ); + assert!( + matches!(r7.halt_reason("REX7"), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "REX7 must classify the double exceed as a compute exceed; got {:?}", + r7.halt_reason("REX7"), + ); + assert_eq!(r6.gas_used, gas_limit, "REX6 burns the whole gas limit"); + assert!( + r7.gas_used < gas_limit, + "REX7 must rescue the clamp-hidden gas; gas_used={} limit={gas_limit}", + r7.gas_used + ); +} + +/// Every checkpoint kind has to restore the clamp before its body runs and re-apply it afterwards, +/// or the segments around it would either observe clamped gas or run unbounded. Exercising them in +/// one transaction that stays inside every limit pins the round trip: any asymmetry between the +/// restore and the re-clamp shows up as a compute-gas or receipt difference against REX6. +#[test] +fn test_clamp_round_trips_through_every_checkpoint_kind() { + let callee = plain_filler(BytecodeBuilder::default(), 5).append(STOP).build(); + let code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .append(GAS) + .append(POP) + .sstore(U256::from(1), U256::from(0x22)) + .push_u256(U256::from(1)) + .append(revm::bytecode::opcode::SLOAD) + .append(POP) + .push_address(CALLEE) + .append(revm::bytecode::opcode::BALANCE) + .append(POP); + let code = plain_filler(code, 5) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(500_000u64) // gas + .append(CALL) + .append(POP); + let code = plain_filler(code, 5) + .mstore(0, [0x33u8; 32]) + .push_number(0xabcu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(revm::bytecode::opcode::LOG1) + .append(STOP) + .build(); + + // A detention cap that engages at the TIMESTAMP but is never binding, so the clamp is + // outstanding across every later checkpoint. + let limits = detention_cap(1_000_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + assert_outcomes_identical(&r6, &r7); +} + +fn assert_outcomes_identical(r6: &Outcome, r7: &Outcome) { + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "execution result must be identical", + ); + assert_eq!(r6.compute_gas, r7.compute_gas, "compute gas must be identical"); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas_used must be identical"); + assert_eq!( + (r6.data_size, r6.kv_updates, r6.state_growth), + (r7.data_size, r7.kv_updates, r7.state_growth), + "the non-compute dimensions must be identical", + ); +} From 603af8477ae4cbbbe3460f84687f92c5c9ab1e6c Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 16:47:55 +0800 Subject: [PATCH 06/43] fix(rex7): apply the detention cap on a frame-local checkpoint exceed A frame-local compute exceed reports as a revert, which the per-opcode layering carries past the detention tail rather than returning on, so the cap is installed even though the frame is about to unwind; a TX-level exceed reports as an out-of-gas halt, which that layering short-circuits on. The volatile checkpoint handlers now reproduce both arms when recording their own body, instead of returning on either. Adds a REX6/REX7 parity test for a volatile checkpoint whose own body crosses the compute limit, covering the halt, the recorded usage and the resulting detained limit together. --- crates/mega-evm/src/evm/instructions.rs | 32 +++++++++-- crates/mega-evm/tests/rex7/common.rs | 15 +++++- crates/mega-evm/tests/rex7/v0_clamp.rs | 72 ++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 8 deletions(-) diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index 0c5fa5ee..f40f01e9 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -902,6 +902,30 @@ macro_rules! record_checkpoint_body_compute_gas { compute_gas!($context.interpreter, additional_limit, gas_used); } }; + // Variant for the volatile checkpoints, whose tail installs the detention cap. A frame-local + // exceed reports as a revert, which the per-opcode layering carries past the cap application + // rather than returning on, so the cap is applied here before returning. A TX-level exceed + // reports as an out-of-gas halt, which that layering does short-circuit — no cap on that path. + ($context:expr, $gas_before:expr, detention_tail) => { + let gas_after = $context.interpreter.gas.remaining(); + let gas_used = $gas_before.saturating_sub(gas_after); + let exceeding_result = { + let mut additional_limit = $context.host.additional_limit().borrow_mut(); + additional_limit.sync_checkpoint_baseline(gas_after); + if additional_limit.record_compute_gas(gas_used) { + None + } else { + Some(additional_limit.exceeding_instruction_result()) + } + }; + if let Some(result) = exceeding_result { + set_halt_action!($context.interpreter, result); + if !result.is_halt() { + apply_compute_gas_limit!($context); + } + return Err(result); + } + }; } /// Records an opcode's compute gas in a single measurement window and enforces the compute-gas @@ -2053,7 +2077,7 @@ pub mod volatile_data_ext { charge_static_gas!(context, $opcode); run_inner_instruction_or_abort!($original_fn, context, inner_outcome); - record_checkpoint_body_compute_gas!(context, gas_before); + record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); checkpoint_epilogue!(context); inner_outcome @@ -2088,7 +2112,7 @@ pub mod volatile_data_ext { run_inner_instruction_or_abort!($original_fn, context, inner_outcome); charge_static_gas!(context, $opcode); - record_checkpoint_body_compute_gas!(context, gas_before); + record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); checkpoint_epilogue!(context); inner_outcome @@ -2187,7 +2211,7 @@ pub mod volatile_data_ext { run_inner_instruction_or_abort!(instructions::host::sload, context, inner_outcome); charge_static_gas!(context, SLOAD); - record_checkpoint_body_compute_gas!(context, gas_before); + record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); checkpoint_epilogue!(context); inner_outcome @@ -2213,7 +2237,7 @@ pub mod volatile_data_ext { charge_static_gas!(context, SELFBALANCE); run_inner_instruction_or_abort!(instructions::host::selfbalance, context, inner_outcome); - record_checkpoint_body_compute_gas!(context, gas_before); + record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); checkpoint_epilogue!(context); inner_outcome diff --git a/crates/mega-evm/tests/rex7/common.rs b/crates/mega-evm/tests/rex7/common.rs index 9b0d5cb6..5e2392df 100644 --- a/crates/mega-evm/tests/rex7/common.rs +++ b/crates/mega-evm/tests/rex7/common.rs @@ -36,6 +36,9 @@ pub(crate) struct Outcome { pub(crate) state_growth: u64, /// Receipt `gas_used` (combined compute + storage EVM gas). pub(crate) gas_used: u64, + /// Post-tx detained compute gas limit — equal to the configured TX limit unless volatile + /// access lowered it. + pub(crate) detained_compute_gas_limit: u64, /// The state the transaction produced. pub(crate) state: EvmState, } @@ -98,7 +101,10 @@ pub(crate) fn transact_with_gas_limit( let mut evm = MegaEvm::new(context); let result = alloy_evm::Evm::transact_raw(&mut evm, tx).expect("tx should not surface EVMError"); - let usage = evm.ctx_ref().additional_limit.borrow().get_usage(); + let (usage, detained_compute_gas_limit) = { + let additional_limit = evm.ctx_ref().additional_limit.borrow(); + (additional_limit.get_usage(), additional_limit.detained_compute_gas_limit()) + }; let gas_used = result.result.tx_gas_used(); Outcome { result: result.result, @@ -107,6 +113,7 @@ pub(crate) fn transact_with_gas_limit( kv_updates: usage.kv_updates, state_growth: usage.state_growth, gas_used, + detained_compute_gas_limit, state: result.state, } } @@ -143,7 +150,10 @@ pub(crate) fn transact_with_bucket_capacity( let mut evm = MegaEvm::new(context); let result = alloy_evm::Evm::transact_raw(&mut evm, tx).expect("tx should not surface EVMError"); - let usage = evm.ctx_ref().additional_limit.borrow().get_usage(); + let (usage, detained_compute_gas_limit) = { + let additional_limit = evm.ctx_ref().additional_limit.borrow(); + (additional_limit.get_usage(), additional_limit.detained_compute_gas_limit()) + }; let gas_used = result.result.tx_gas_used(); Outcome { result: result.result, @@ -152,6 +162,7 @@ pub(crate) fn transact_with_bucket_capacity( kv_updates: usage.kv_updates, state_growth: usage.state_growth, gas_used, + detained_compute_gas_limit, state: result.state, } } diff --git a/crates/mega-evm/tests/rex7/v0_clamp.rs b/crates/mega-evm/tests/rex7/v0_clamp.rs index 29000935..2e8c2343 100644 --- a/crates/mega-evm/tests/rex7/v0_clamp.rs +++ b/crates/mega-evm/tests/rex7/v0_clamp.rs @@ -23,13 +23,14 @@ use crate::common::{ transact, transact_default, transact_with_gas_limit, Outcome, CALLEE, CONTRACT, ONE_ETH, }; -use alloy_primitives::{Bytes, U256}; +use alloy_primitives::{Address, Bytes, U256}; use mega_evm::{ test_utils::{BytecodeBuilder, MemoryDatabase}, EvmTxRuntimeLimits, MegaHaltReason, MegaSpecId, }; use revm::bytecode::opcode::{ - CALL, DUP1, GAS, JUMPDEST, JUMPI, MSTORE, POP, RETURN, SSTORE, STOP, SUB, SWAP1, TIMESTAMP, + CALL, DUP1, EXTCODECOPY, GAS, JUMPDEST, JUMPI, MSTORE, POP, RETURN, SSTORE, STOP, SUB, SWAP1, + TIMESTAMP, }; /// Slot the outer contract stores the CALL success flag into. @@ -442,3 +443,70 @@ fn assert_outcomes_identical(r6: &Outcome, r7: &Outcome) { "the non-compute dimensions must be identical", ); } + +/// A volatile checkpoint whose own body crosses the compute limit must behave identically under +/// both accounting models. +/// +/// The prologue restores the clamp before the body runs, so an `EXTCODECOPY` large enough to cross +/// the limit is metered on the true counter and recorded per opcode exactly as REX6 records it — +/// the clamp plays no part. What the checkpoint form has to reproduce is the tail: the detention +/// cap is applied on a frame-local exceed (a revert the per-opcode layering carries past the cap) +/// and skipped on a TX-level exceed (an out-of-gas halt that layering short-circuits on). Pinning +/// the halt, the recorded usage and the resulting detained limit together covers both the metering +/// and that ordering. +#[test] +fn test_volatile_body_crossing_the_limit_matches_per_opcode() { + // ~1.5 MB of EXTCODECOPY against the block beneficiary: the copy plus the memory expansion cost + // millions of gas, and the account load marks beneficiary access. + let callee = BytecodeBuilder::default() + .push_number(1_500_000u64) // length + .push_number(0u64) // offset + .push_number(0u64) // destOffset + .push_address(Address::ZERO) // the default block beneficiary + .append(EXTCODECOPY) + .append(STOP) + .build(); + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(50_000_000u64) // gas + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let full = transact_default(MegaSpecId::REX7, build_db()).compute_gas; + assert!(full > 4_000_000, "the copy must dominate the transaction; compute={full}"); + + // Just under what the transaction needs, so the crossing lands inside the EXTCODECOPY body + // rather than in a plain segment. + let tx_limit = full - full / 100; + let limits = move |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(tx_limit); + limits.block_env_access_compute_gas_limit = 1_000; + limits + }; + + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + + assert!(!r6.is_success(), "REX6 must stop on the tight compute limit: {:?}", r6.result); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the halt must be identical", + ); + assert_eq!( + r6.compute_gas, r7.compute_gas, + "the body is metered on the true counter under both models; REX6={} REX7={}", + r6.compute_gas, r7.compute_gas + ); + assert_eq!( + r6.detained_compute_gas_limit, r7.detained_compute_gas_limit, + "the detention tail must fire — or not fire — at the same point under both models", + ); +} From fc53eed7908b7b3931ad9ea977fd935a04c16daa Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:04:52 +0800 Subject: [PATCH 07/43] docs(rex7): document checkpoint compute gas accounting Record REX7 checkpoint settlement and V0 gas-clamp enforcement on the upgrade page, gate matching rules under details on compute-gas and related metering pages, and update AGENTS.md protocol wording. --- AGENTS.md | 5 +- docs/spec/evm/compute-gas.md | 54 ++++++++++++ docs/spec/evm/dual-gas-model.md | 11 +++ docs/spec/evm/gas-detention.md | 10 +++ docs/spec/evm/resource-accounting.md | 1 + docs/spec/evm/resource-limits.md | 1 + docs/spec/hardfork-spec.md | 9 +- docs/spec/overview.md | 2 +- docs/spec/upgrades/overview.md | 2 +- docs/spec/upgrades/rex7.md | 119 ++++++++++++++++++++++++--- 10 files changed, 197 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4024f7e0..0c086a9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,8 @@ Consequently: MegaETH separates EVM gas into two independent dimensions tracked during execution: - **Compute gas**: Measures pure computational cost. - Every opcode's gas consumption is recorded via wrapped instructions in `evm/instructions.rs` — `compute_gas_ext::*` for plain opcodes and `storage_gas_ext::*` for storage-affecting opcodes (SSTORE, LOG, CALL-family, CREATE/CREATE2, SELFDESTRUCT) — both invoking the shared `record_storage_compute_gas!` primitive after the opcode body completes. + Through REX6 every opcode's gas consumption is recorded via wrapped instructions in `evm/instructions.rs` — `compute_gas_ext::*` for plain opcodes and `storage_gas_ext::*` for storage-affecting opcodes (SSTORE, LOG, CALL-family, CREATE/CREATE2, SELFDESTRUCT) — both invoking the shared `record_storage_compute_gas!` primitive after the opcode body completes. + REX7 settles compute gas at checkpoints (storage-gas opcodes, CALL/CREATE family, volatile opcodes, `GAS`, frame entry/resume/exit) rather than after every plain opcode, and enforces limits inside plain segments with a V0 gas clamp. Subject to a per-spec compute gas limit and further restricted by gas detention (see below). - **Storage gas**: Charges for persistent state modifications (SSTORE, account creation, contract deployment). These costs scale dynamically with SALT bucket capacity (see External Environment Dependencies below). @@ -222,7 +223,7 @@ Correctness of the other three dimensions (data size, KV updates, state growth) 1. **Every non-compute mutation site must latch.** Any code that records data-size/KV/state-growth usage during execution (`on_sstore`, `on_log`, `record_oracle_hint_bytes`, the frame-lifecycle hooks) must run `check_limit()` itself, latching any exceed into `has_exceeded_limit`. - The latch is surfaced by the leading short-circuit of the next `record_compute_gas` call, so the halt lands on the same opcode as the pre-protocol fan-out did. + The latch is surfaced by the leading short-circuit of the next `record_compute_gas` call (through REX6, that is the next metered opcode; under REX7 checkpoint accounting it is the next checkpoint), so the halt lands on the same site as the pre-protocol fan-out did. 2. **Pre-inner recorders must NOT latch.** A site that records usage _before_ its inner instruction executes (currently SELFDESTRUCT's two beneficiary recorders: empty-beneficiary creation and the REX6+ existing-beneficiary credit) must record without latching: the inner instruction can still fail, the frame then discards the usage, and an early latch would stick and rewrite the frame's real result. Such opcodes use a trailing all-dimension check (`record_compute_gas_all_dims`) that runs only after the inner instruction succeeds. diff --git a/docs/spec/evm/compute-gas.md b/docs/spec/evm/compute-gas.md index 5a1069d9..151fe335 100644 --- a/docs/spec/evm/compute-gas.md +++ b/docs/spec/evm/compute-gas.md @@ -444,6 +444,59 @@ The transaction's standard EVM `gas_limit` remains the only bound that can halt A node MUST record compute gas before evaluating any exceed, including an exceed already latched on another resource dimension. The compute work was performed, and the recorded total feeds the transaction outcome and the block-level compute accounting even for a transaction halted on a different dimension. +
+Rex7 (unstable): checkpoint settlement and gas-clamp enforcement + +Rex7 replaces per-opcode recording for plain opcodes with checkpoint settlement, and enforces compute-gas and detention limits inside plain segments by clamping interpreter-visible gas. +The full previous/new pairing is on the [Rex7 Network Upgrade](../upgrades/rex7.md) page; the normative rules for implementers follow. + +#### Checkpoint set + +A node MUST settle compute gas at each of the following **checkpoints**, and MUST NOT open a per-opcode measurement window for any other opcode: + +- storage-gas opcodes: `SSTORE`, `LOG0`–`LOG4`, `SELFDESTRUCT`; +- call-family opcodes: `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`; +- create opcodes: `CREATE`, `CREATE2`; +- volatile / detention-guarded opcodes: the unconditional block-environment set, the beneficiary-conditional set, and oracle-conditional `SLOAD` (same membership as the Volatile class and the call-family / `SELFDESTRUCT` beneficiary guards above); +- the `GAS` opcode; +- frame entry, frame resume after a child returns, and frame exit. + +Plain opcodes between checkpoints MUST run without recording compute gas when they finish. + +#### Segment settlement + +At each checkpoint a node MUST: + +1. Settle the open plain-opcode segment as the interpreter-gas delta since the previous checkpoint or frame open/resume, applying the same storage-gas and forwarded-child exclusions as the checkpoint opcode's measurement window under this page's stable rules. +2. Record that segment amount as compute gas and evaluate the compute-gas limit (and any latched non-compute resource-limit exceed) at that checkpoint — the latch-surface point is the next checkpoint rather than the next per-opcode recording site. +3. Record the checkpoint opcode's own body under the measurement-window rules for its metering class, then re-open the settlement window. + +Non-opcode recording sites on this page (intrinsic gas, precompiles, code deposit, KeylessDeploy) are unchanged. + +For every transaction that stays within every runtime resource limit, a node MUST produce the same recorded compute-gas total, the same four-dimension usage, the same receipt `gas_used`, the same execution result, and the same state as under Rex6. + +#### Gas-clamp enforcement + +After settlement and body recording at a checkpoint (and at frame entry and resume), a node MUST clamp the interpreter-visible remaining gas to the remaining compute headroom — the minimum of the current frame's remaining per-frame compute budget and the transaction-level remaining budget under the effective limit (including detention) — and MUST restore the hidden amount before the next checkpoint body, before `GAS` is observed, before call-gas forwarding, and before storage-gas charges. + +Inside a plain-opcode segment: + +- An opcode that would cost more than the clamped visible remainder MUST NOT execute. +- The frame's final result MUST restore the hidden gas. +- The node MUST reclassify that out-of-gas as the resource-limit exceed the clamp stood for: frame-local budget → frame revert with `MegaLimitExceeded`; transaction-level compute → transaction halt with `OutOfGas` and rescued remaining gas; detained limit → transaction halt with `VolatileDataAccessOutOfGas` and rescued remaining gas. + +Because the crossing opcode never executes, a node MUST NOT include its cost in recorded compute-gas usage. + +When the crossing opcode would exhaust both the true remaining EVM gas and the compute headroom, a node MUST attribute the halt to the compute-gas or detention limit (with rescue) rather than to ordinary EVM out-of-gas. + +#### Exceptional-halt frame carve-out + +When a frame ends in an exceptional halt — including ordinary out-of-gas and memory out-of-gas — the interpreter zeros the frame's remaining gas before frame-exit settlement. +A node MUST settle that entire burned remainder as compute gas at frame exit. +Under per-opcode recording through Rex6 neither the failing opcode nor the burn is attributed to compute gas, so a transaction that contains an inner out-of-gas call frame MAY report a strictly higher compute-gas total under Rex7 while EVM gas and the receipt remain identical. + +
+ #### Keyless Deploy Exceed When recording the [KeylessDeploy](../system-contracts/keyless-deploy.md) dispatch overhead exceeds a compute gas limit, the outcome follows the frame-local / transaction-level split above, but the two branches are not observably the same: @@ -572,3 +625,4 @@ System-granted gas leaks to the sender, who recovers gas that was never theirs t - [Rex4](../upgrades/rex4.md) — introduced the per-call-frame compute gas budget; made gas detention caps relative to usage at the access point; added beneficiary volatile-access guards to the `CALL` family, `SELFDESTRUCT`, and `SELFBALANCE`. - [Rex5](../upgrades/rex5.md) — excluded the `CALL_STIPEND` from the forwarded-gas deduction; moved `CREATE2` memory-expansion recording ahead of the storage-gas charge; made contract-creation code-deposit compute gas atomic with the deployment commit; refined precompile compute-gas recording and bounded it by the remaining compute budget; added the `SELFDESTRUCT` empty-beneficiary storage-gas charge; removed `CALLCODE` from the cold first-touch charge and added `SELFDESTRUCT`'s beneficiary to it; stopped following EIP-7702 delegation in the pre-execution inspection, restoring inherited warmth for delegates. - [Rex6](../upgrades/rex6.md) — unified the measurement window across all storage-affecting opcodes and folded `CREATE2` memory expansion into it, ending the two-window exception; returned forwarded gas to the failing frame on a compute-gas exceed; rescued the unused envelope on a keyless-deploy dispatch exceed; made beneficiary detection delegation-aware, returning `CALLCODE` call targets to the cold first-touch charge; exempted system-originated transactions from the compute gas limit and gas detention. +- [Rex7](../upgrades/rex7.md) _(unstable)_ — settles compute gas at checkpoints rather than after every plain opcode; enforces compute and detention limits inside plain segments by clamping interpreter-visible gas so a crossing opcode does not execute; records an exceptional-halt frame's burned remainder as compute gas at frame exit. diff --git a/docs/spec/evm/dual-gas-model.md b/docs/spec/evm/dual-gas-model.md index 374eb057..60043b5e 100644 --- a/docs/spec/evm/dual-gas-model.md +++ b/docs/spec/evm/dual-gas-model.md @@ -99,6 +99,16 @@ When more than one dimension is over its limit on that opcode, the reported dime A node MUST record an opcode's compute gas in exactly one step, after the opcode body has fully executed — with no `CREATE2` exception. The no-record rule when the body does not run to completion is specified in [Single-Record Rule](compute-gas.md#single-record-rule). +
+Rex7 (unstable): checkpoint settlement of compute gas + +Under Rex7, the metering order above continues to govern every **checkpoint** opcode — the storage-affecting set listed in this section, the volatile / detention-guarded set, and `GAS` — and those checkpoints still charge storage gas before the body and record compute gas after it. +Plain opcodes between checkpoints MUST NOT record compute gas when they finish; their compute gas settles as an interpreter-gas segment delta at the next checkpoint or at frame entry, resume, or exit. +Limit enforcement inside a plain-opcode segment uses gas clamping rather than a post-opcode record step: a crossing opcode is stopped before it executes, and its cost is excluded from recorded usage. +See [Compute Gas Accounting](compute-gas.md) and the [Rex7 Network Upgrade](../upgrades/rex7.md) for the full checkpoint set, clamp rules, and the exceptional-halt frame carve-out. + +
+ ### Storage Gas [Storage gas](../glossary.md#storage-gas) is an additional charge for operations that impose persistent storage burden on nodes. @@ -305,3 +315,4 @@ For the historical evolution of storage gas formulas and constants across specs: - [Rex4](../upgrades/rex4.md) — storage gas stipend for value transfers - [Rex5](../upgrades/rex5.md) — reworked the storage gas stipend into a separated-allowance model, derived the top-level contract-creation storage-gas address from the sender's current state nonce, and made contract-creation code-deposit compute gas atomic with the deployment commit - [Rex6](../upgrades/rex6.md) — unified per-opcode gas metering order (compute gas recorded once, after the opcode body, with no `CREATE2` exception); system-originated transactions charge dynamic storage gas at minimum bucket capacity; forwarded gas and the KeylessDeploy envelope are returned on a compute-gas exceed rather than spent +- [Rex7](../upgrades/rex7.md) _(unstable)_ — settles compute gas at checkpoints rather than after every plain opcode; clamps interpreter-visible gas between checkpoints so compute and detention limits stop a crossing opcode before it executes diff --git a/docs/spec/evm/gas-detention.md b/docs/spec/evm/gas-detention.md index 5217179b..d8c5bdac 100644 --- a/docs/spec/evm/gas-detention.md +++ b/docs/spec/evm/gas-detention.md @@ -113,6 +113,15 @@ When a volatile-data trigger occurs, the node MUST perform the following steps i After detention has been applied, any subsequent execution step that would cause `compute_gas_used` to exceed the effective detained limit MUST halt the transaction with `VolatileDataAccessOutOfGas`. +
+Rex7 (unstable): clamp-based detention enforcement inside plain segments + +Under Rex7, after a detention cap has been installed the remaining compute headroom includes that detained limit, and the gas clamp applied at checkpoints and frame boundaries restricts interpreter-visible gas to that headroom. +A plain-opcode segment that would cross the detained limit is therefore stopped at the clamp boundary before the crossing opcode executes, reclassified as `VolatileDataAccessOutOfGas`, with remaining gas rescued for the sender — the same halt reason and refund shape as through Rex6, but without executing the crossing opcode or recording its cost. +See [Compute Gas Accounting](compute-gas.md) and the [Rex7 Network Upgrade](../upgrades/rex7.md). + +
+ The detained compute-gas limit MUST NOT halt a [system-originated transaction](../system-contracts/system-tx.md#system-originated-transaction-metering-exemption). Volatile-data accesses by such a transaction are still tracked, but the detention cap is not enforced against it; its standard EVM `gas_limit` remains the only halting bound. @@ -204,3 +213,4 @@ Gas detention semantics evolved across specs: - [Rex3](../upgrades/rex3.md) — raised oracle cap to 20M and changed oracle detection from CALL-based to SLOAD-based - [Rex4](../upgrades/rex4.md) — changes absolute detention to relative detention and adds additional beneficiary-triggered behavior - [Rex6](../upgrades/rex6.md) — adds a beneficiary-detention trigger for an applied EIP-7702 authorization whose authority equals the block beneficiary; resolves a CALL-family target's EIP-7702 delegation one hop before the beneficiary comparison, so a call through a delegator whose delegate is the beneficiary triggers detention (through Rex5 only the raw target is compared); and stops enforcing the detention cap against system-originated transactions, whose volatile accesses are still tracked +- [Rex7](../upgrades/rex7.md) _(unstable)_ — enforces the detained limit inside plain-opcode segments by gas clamping, stopping a crossing opcode before it executes while preserving `VolatileDataAccessOutOfGas` and gas rescue diff --git a/docs/spec/evm/resource-accounting.md b/docs/spec/evm/resource-accounting.md index 354006bd..6a63ae2c 100644 --- a/docs/spec/evm/resource-accounting.md +++ b/docs/spec/evm/resource-accounting.md @@ -273,3 +273,4 @@ This page describes the current accounting behavior. - [Rex6](../upgrades/rex6.md) — counted the account-info write of a `SELFDESTRUCT` balance credit to an already-existing beneficiary: through Rex5 only a `SELFDESTRUCT` that created a new beneficiary was metered, so a balance credit to an existing beneficiary (which does not flow through the frame-initialization or caller-dedup path) recorded nothing. - [Rex6](../upgrades/rex6.md) — added a per-log data-size base: through Rex5, an empty `LOG0` contributed zero data size because the log address was not counted. - [Rex6](../upgrades/rex6.md) — deduplicated the value self-transfer account-info write: when a value-transferring call's target equals its caller, the caller-side and target-side writes refer to the same account, but through Rex5 the data-size and KV-update charges were recorded for both, over-counting the one account (it never under-charges). This extends the Rex5 caller-account deduplication above to the self-transfer case. +- [Rex7](../upgrades/rex7.md) _(unstable)_ — does not change data-size, KV-update, or state-growth counting; compute-gas settlement moves to checkpoints (see [Compute Gas Accounting](compute-gas.md)). diff --git a/docs/spec/evm/resource-limits.md b/docs/spec/evm/resource-limits.md index 1ceedc5c..969826f7 100644 --- a/docs/spec/evm/resource-limits.md +++ b/docs/spec/evm/resource-limits.md @@ -246,3 +246,4 @@ Including failed transactions ensures the sender always pays for consumed resour - [Rex4](../upgrades/rex4.md) — added per-call-frame runtime budgets; intrinsic resource costs (always deducted before execution) are now reflected in the top-level frame budget before it is forwarded to child frames. - [Rex5](../upgrades/rex5.md) — bounded a precompile invocation's compute-gas consumption by the remaining compute-gas budget, failing the precompile with `PrecompileOOG` rather than letting it overshoot the budget. - [Rex6](../upgrades/rex6.md) — moved EIP-7702 authority state-growth resolution from pre-execution (after the caller nonce bump) to validation, and added dynamic SALT account-creation gas for each net-new applied authority to the pre-frame intrinsic gas deduction; removed the keyless-deploy exception to gas preservation, so remaining gas is now rescued on every transaction-level exceed; and stopped enforcing the four runtime transaction-level limits against system-originated transactions, whose usage is still recorded. +- [Rex7](../upgrades/rex7.md) _(unstable)_ — does not change the limit ceilings or the success/failed/skipped/rejected outcomes; a compute-gas or detention exceed inside a plain-opcode segment is stopped before the crossing opcode executes (see [Compute Gas Accounting](compute-gas.md)). diff --git a/docs/spec/hardfork-spec.md b/docs/spec/hardfork-spec.md index ff10aee4..b717ea36 100644 --- a/docs/spec/hardfork-spec.md +++ b/docs/spec/hardfork-spec.md @@ -150,7 +150,10 @@ _See [Rex6 Network Upgrade](upgrades/rex6.md) for full details._ ### REX7 -REX7 is the current **unstable** spec under active development. -It introduces no behavioral change over REX6 yet; its semantics may change at any time before it is frozen. +REX7 is the current **unstable** spec under active development; its semantics may change at any time before it is frozen. -_See [Rex7 Network Upgrade](upgrades/rex7.md) for the current state._ +- **Checkpoint-settled compute gas** — Plain opcodes record no compute gas between checkpoints; settlement runs at storage-gas opcodes, the CALL / CREATE family, volatile opcodes, `GAS`, and frame entry / resume / exit. +- **Gas-clamp enforcement** — Between checkpoints the interpreter-visible remaining gas is clamped to the remaining compute headroom, so a compute-gas or detention exceed stops the crossing opcode before it executes (zero overshoot; crossing cost excluded from recorded usage). +- **Exceptional-halt frame carve-out** — A frame that ends in an exceptional halt (including out-of-gas) settles its burned remainder as compute gas, so nested out-of-gas calls may report higher compute usage than REX6 while EVM gas and the receipt stay the same. + +_See [Rex7 Network Upgrade](upgrades/rex7.md) for the full previous/new pairing._ diff --git a/docs/spec/overview.md b/docs/spec/overview.md index bd84fd7e..4eaa0dc9 100644 --- a/docs/spec/overview.md +++ b/docs/spec/overview.md @@ -71,7 +71,7 @@ Contracts deployed under a given spec will continue to behave identically, regar - **REX4** — Per-call-frame resource budgets, relative gas detention, [storage gas stipend](glossary.md#storage-gas-stipend), MegaAccessControl and MegaLimitControl system contracts. - **REX5** — SequencerRegistry system contract, Oracle v2.0.0 with dynamic system address, caller-account update deduplication, storage-gas-stipend separated-allowance model, value-transfer CALL/CALLCODE parent compute-gas attribution, CREATE code-deposit compute-gas atomicity, EIP-2935/EIP-4788 pre-block gas floor with fail-closed block rejection, CREATE2 empty-initcode short-circuit, KeylessDeploy trailing-bytes rejection and empty-code log forwarding. - **REX6** — Unified per-opcode gas metering order, consolidated EIP-7702 authorization accounting, CREATE-frame accounting corrections, KeylessDeploy sandbox hardening, post-execution fee-reward accounting, system-originated transaction metering exemption, extended beneficiary detention coverage, and SequencerRegistry v2.0.0 rotation hardening. -- **REX7** — The **unstable** spec, currently open for development. No behavioral change over REX6 yet. +- **REX7** — The **unstable** spec, currently open for development. Checkpoint-settled compute gas accounting with gas-clamp enforcement; plain opcodes record no compute gas between checkpoints. See [Hardforks and Specs](hardfork-spec.md) for full details. diff --git a/docs/spec/upgrades/overview.md b/docs/spec/upgrades/overview.md index d544ca07..8898a870 100644 --- a/docs/spec/upgrades/overview.md +++ b/docs/spec/upgrades/overview.md @@ -153,7 +153,7 @@ Not yet scheduled {% endtabs %} Unstable; under active development. -No behavioral change over Rex6 yet. +Checkpoint-settled [compute gas](../glossary.md#compute-gas) accounting with gas-clamp enforcement: plain opcodes record nothing between checkpoints; within-limit transactions stay bit-identical to Rex6; a compute-gas or detention exceed inside a plain segment stops the crossing opcode before it executes. ## How to Read These Pages diff --git a/docs/spec/upgrades/rex7.md b/docs/spec/upgrades/rex7.md index dcdaba79..aa58f935 100644 --- a/docs/spec/upgrades/rex7.md +++ b/docs/spec/upgrades/rex7.md @@ -1,5 +1,5 @@ --- -description: Rex7 network upgrade — the current unstable spec, open for development and carrying no behavioral change over Rex6 yet. +description: Rex7 network upgrade — checkpoint-settled compute gas accounting with gas-clamp enforcement; plain opcodes record no compute gas between checkpoints, within-limit transactions stay bit-identical to Rex6, and limit-exceeding opcodes are stopped before they execute. --- # Rex7 Network Upgrade @@ -14,34 +14,133 @@ Anything recorded on this page may change before Rex7 is frozen, and nothing her ## Summary -Rex7 is the spec currently open for development. -It inherits every [Rex6](rex6.md) behavior and, as of this page, changes none of them: a transaction executed under Rex7 produces the same result as the same transaction executed under Rex6. +Rex7 changes how a node records and enforces [compute gas](../glossary.md#compute-gas) during execution. -Rex7 exists so that new behavior has somewhere to land. -[Rex6](rex6.md) is frozen — its semantics are fixed and may no longer be modified — so any change to gas costs, opcode behavior, resource accounting, or a system contract must be introduced under Rex7. +Through [Rex6](rex6.md), every metered opcode records its own compute gas after it finishes, and a compute-limit exceed is evaluated at that opcode. +Rex7 replaces that per-opcode recording for ordinary opcodes with **checkpoint settlement**: plain opcodes run without a compute-gas recording step, and the node settles the compute gas of an entire segment when it reaches a checkpoint. + +Rex7 also introduces **gas-clamp enforcement**: between checkpoints the node restricts the interpreter-visible remaining gas to the remaining compute headroom, so the inherited EVM's own per-opcode gas check stops a limit-crossing opcode before that opcode executes. + +For a transaction that never crosses a compute-gas, detention, or other resource limit, Rex7 is bit-identical to Rex6: the same gas, the same receipt, the same state, and the same `GAS` opcode readings. +For a transaction that does cross a compute-gas or detention limit inside a plain-opcode segment, the halt lands before the crossing opcode rather than after it, the crossing opcode's cost is excluded from recorded compute usage, and remaining gas remains refundable under the same rescue rules as other transaction-level compute-limit halts. + +One deliberate accounting carve-out remains: a frame that ends in an exceptional halt (including ordinary out-of-gas) settles its entire burned EVM-gas budget as compute gas at frame exit, so a transaction that contains an inner out-of-gas call can report higher compute usage under Rex7 than under Rex6 even though EVM gas and the receipt are unchanged. ## What Changed -Nothing yet. +### Checkpoint-Settled Compute Gas Accounting + +#### Previous behavior + +From [MiniRex](minirex.md) through [Rex6](rex6.md), a node records compute gas at every metered opcode: + +- Each opcode belongs to a metering class defined in [Compute Gas Accounting](../evm/compute-gas.md). +- After the opcode body completes (or at the equivalent single measurement window for storage-affecting opcodes), the node records `(gas_before − gas_after)` less any storage-gas and forwarded-child exclusions, and evaluates the compute-gas limit. +- Plain opcodes (arithmetic, stack, memory, jumps, and similar) each open and close their own measurement window. +- A compute-gas or detention exceed is evaluated after the opcode that crossed the limit has finished, so that opcode's cost is included in recorded usage and the recorded total can land strictly above the limit. + +Frame entry, frame resume, and frame exit do not themselves settle a multi-opcode segment; they only participate in per-frame budget push/pop and in the non-opcode recording sites listed in [Compute Gas Accounting](../evm/compute-gas.md#non-opcode-recording-sites). + +#### New behavior + +Under Rex7, a node MUST settle compute gas at **checkpoints** rather than after every plain opcode. + +A **checkpoint** is any of the following: + +1. A storage-gas opcode: `SSTORE`, `LOG0`–`LOG4`, `SELFDESTRUCT`. +2. A call-family opcode: `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`. +3. A create opcode: `CREATE`, `CREATE2`. +4. A volatile / detention-guarded opcode: the unconditional block-environment set (`BLOCKHASH`, `COINBASE`, `TIMESTAMP`, `NUMBER`, `DIFFICULTY` / `PREVRANDAO`, `GASLIMIT`, `BASEFEE`, `BLOBBASEFEE`, `BLOBHASH`), the beneficiary-conditional set (`BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, `EXTCODEHASH`, `SELFBALANCE`), and oracle-conditional `SLOAD`. +5. The `GAS` opcode. +6. Frame entry, frame resume after a child returns, and frame exit. + +Every other opcode is a **plain opcode** for settlement purposes. +A plain opcode MUST NOT open a compute-gas measurement window of its own and MUST NOT record compute gas when it finishes. + +At each checkpoint a node MUST: + +1. Settle the open plain-opcode segment as the interpreter-gas delta since the previous checkpoint (or since the frame opened / resumed), excluding storage gas and forwarded child gas that the checkpoint itself charges or forwards under the same exclusion rules as [Compute Gas Accounting](../evm/compute-gas.md). +2. Record that segment amount as compute gas and evaluate the compute-gas limit (and any latched non-compute resource-limit exceed) at that checkpoint. +3. Record the checkpoint opcode's own body compute gas under the same measurement-window rules that apply through Rex6 for that opcode class, then re-open the settlement window for the next segment. + +Non-opcode recording sites (transaction intrinsic gas, precompiles, contract-creation code deposit, KeylessDeploy overhead and sandbox merge) are unchanged. + +**Precision invariant.** +For every transaction that stays within every runtime resource limit, a node MUST produce the same recorded compute-gas total, the same four-dimension resource usage, the same receipt `gas_used`, the same execution result, and the same state under Rex7 as under Rex6. +The interpreter's gas counter already meters every opcode; settling by segment reproduces the per-opcode sum exactly when no limit is crossed. -Each change landed under Rex7 will be recorded here as a **Previous behavior** / **New behavior** pair, in the order it is specified. +**Exceptional-halt frame carve-out.** +When a frame ends in an exceptional halt — including ordinary out-of-gas and memory out-of-gas — the interpreter zeros the frame's remaining gas before the frame-exit settlement runs. +A node MUST therefore settle the entire burned remainder of that frame's budget as compute gas at frame exit. +Under per-opcode recording through Rex6, neither the failing opcode nor the burn is attributed to compute gas. +Consequently, a transaction that contains an inner call frame which runs out of gas MAY report a **strictly higher** compute-gas total under Rex7 than under Rex6, while EVM gas accounting and the receipt remain identical. + +### Gas-Clamp Enforcement + +#### Previous behavior + +Through Rex6, the compute-gas limit and the detained compute-gas limit are enforced when an opcode records its compute gas after it has finished. +The crossing opcode therefore executes fully, its cost is recorded, and recorded usage can land strictly above the limit (overshoot of one opcode). +Frame-local budget exceeds become frame reverts with `MegaLimitExceeded`; transaction-level and detention exceeds become transaction halts with remaining gas rescued for the sender. + +#### New behavior + +Under Rex7, a node MUST enforce compute-gas and detention limits inside plain-opcode segments by **clamping** the interpreter-visible remaining gas. + +At each checkpoint, after settlement and after the checkpoint body has recorded its own compute gas (and after any detention cap the checkpoint installs), and again at frame entry and resume, a node MUST: + +1. Compute the remaining compute headroom as the minimum of the current frame's remaining per-frame compute budget and the transaction-level remaining budget under the effective limit (including detention). +2. Hide any interpreter remaining gas above that headroom from the interpreter, remembering both the hidden amount and which constraint bound the clamp (frame-local budget vs transaction-level / detained limit). +3. Leave the true remaining gas available again before the next checkpoint body runs, before `GAS` is observed, before call-gas forwarding is computed, and before storage-gas charges are taken, so those sites always see the unclamped counter. + +Inside a plain-opcode segment only plain opcodes run, so the inherited EVM's ordinary per-opcode gas check is the enforcement tool: + +- When an opcode would cost more gas than the clamped visible remainder, the opcode MUST NOT execute. +- The frame's final result MUST restore the hidden gas into the gas counter. +- The node MUST reclassify that out-of-gas as the resource-limit exceed that the clamp stood for: + - **Frame-local binding** → the frame reverts with `MegaLimitExceeded(uint8 kind, uint64 limit)`, and unspent gas returns to the parent through ordinary frame accounting. + - **Transaction-level compute binding** → the transaction halts with `OutOfGas`, and remaining gas is rescued and refunded to the sender. + - **Detained-limit binding** → the transaction halts with `VolatileDataAccessOutOfGas`, with the same gas rescue. + +Because the crossing opcode never executes, a node MUST NOT include its cost in recorded compute-gas usage. +Recorded usage at a clamp-induced halt therefore ends at the limit (or strictly below it if settlement had not yet closed a partial segment), not strictly above it. + +**Double-exceed preference.** +When the crossing opcode would have exhausted both the true remaining EVM gas and the compute headroom at the same point, a node MUST attribute the halt to the compute-gas (or detention) limit rather than to ordinary EVM out-of-gas, so remaining gas stays refundable under the rescue rules. +The two cases are indistinguishable once the frame has already reported out-of-gas, and the compute classification is the one that preserves the sender refund. + +**Within-limit observability.** +For a transaction that never crosses a compute or detention limit, the clamp MUST be unobservable: `GAS` returns the true remaining gas, call forwarding and storage-gas charges see the true counter, and gas, receipt, and state match Rex6. ## Developer Impact -None. +Rex7 is not scheduled on any network. +Its semantics may still change before it is frozen. -Rex7 is not scheduled on any network, and it is behaviorally identical to Rex6, so no contract, tool, or integration needs to do anything today. +Contracts and tools that assume per-opcode compute-gas attribution for every instruction MUST treat that assumption as false under Rex7: only checkpoints settle compute gas during execution, and a plain-opcode segment has no intermediate recording. + +Contracts that stay within every resource limit see no behavioral change relative to Rex6. +Contracts that trip the compute-gas or detention limit inside a plain-opcode segment halt one opcode earlier than under Rex6, with the crossing opcode excluded from recorded compute usage and with remaining gas still refundable on a transaction-level halt. + +A parent that calls into a child which runs out of ordinary EVM gas may observe a higher transaction-level compute-gas total under Rex7 than under Rex6; the receipt `gas_used` and the execution success or failure of the outer transaction are unchanged by that carve-out alone. ## Safety and Compatibility Rex7 changes nothing about how blocks under earlier specs are executed. -Every spec through Rex6 is frozen: a node replaying historical blocks resolves each block's spec from its timestamp and applies that spec's semantics, unaffected by Rex7's existence. +Every spec through Rex6 remains frozen: a node replaying historical blocks resolves each block's spec from its timestamp and applies that spec's semantics. Because Rex7 is unstable, its semantics may change in either direction until it is frozen. Any node, tool, or test fixture pinned to Rex7 must expect its results to move. A deployment that needs stable semantics must select a frozen spec explicitly rather than relying on the latest one. +The gas clamp is strictly tighter than Rex6's post-opcode enforcement on the overshoot axis: the crossing opcode does not run, and recorded usage does not pass the limit by that opcode's cost. +The exceptional-halt frame carve-out is the only path on which Rex7 can report more compute gas than Rex6 for the same inputs; it over-reports rather than under-reports. + ## References - [Hardforks and Specs](../hardfork-spec.md) — how specs are versioned, frozen, and activated. - [Rex6 Network Upgrade](rex6.md) — the frozen spec Rex7 inherits from. +- [Compute Gas Accounting](../evm/compute-gas.md) — measurement windows, metering classes, and exceed behavior (Rex7 details on that page). +- [Dual Gas Model](../evm/dual-gas-model.md) — total gas, storage gas, and metering order. +- [Gas Detention](../evm/gas-detention.md) — detained compute-gas caps. +- [Multidimensional Resource Limits](../evm/resource-limits.md) — transaction- and frame-level limit outcomes. From 66dd95665d0a58eb1593052ed750d90e577aaa63 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:11:31 +0800 Subject: [PATCH 08/43] test(rex7): cover interceptor and precompile resume settlement --- crates/mega-evm/tests/rex7/common.rs | 95 +++- .../mega-evm/tests/rex7/interceptor_resume.rs | 421 ++++++++++++++++++ crates/mega-evm/tests/rex7/main.rs | 1 + 3 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 crates/mega-evm/tests/rex7/interceptor_resume.rs diff --git a/crates/mega-evm/tests/rex7/common.rs b/crates/mega-evm/tests/rex7/common.rs index 5e2392df..066df1de 100644 --- a/crates/mega-evm/tests/rex7/common.rs +++ b/crates/mega-evm/tests/rex7/common.rs @@ -6,7 +6,7 @@ use mega_evm::{ MegaSpecId, MegaTransaction, MegaTransactionNew as _, TestExternalEnvs, }; use revm::{ - context::{result::ExecutionResult, tx::TxEnvBuilder}, + context::{result::ExecutionResult, tx::TxEnvBuilder, TxEnv}, handler::EvmTr, state::EvmState, }; @@ -123,6 +123,99 @@ pub(crate) fn transact_default(spec: MegaSpecId, db: MemoryDatabase) -> Outcome transact(spec, db, EvmTxRuntimeLimits::from_spec(spec)) } +/// The transaction shape [`transact`] runs: a plain call from [`CALLER`] to [`CONTRACT`]. +pub(crate) fn default_tx() -> TxEnv { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill() +} + +/// The external environment [`transact_tx`] runs with when a test does not need SALT buckets or +/// oracle storage of its own. Equivalent to the empty environment the other helpers use: every +/// bucket reports the minimum capacity and the oracle has no data. +pub(crate) fn default_envs() -> TestExternalEnvs { + TestExternalEnvs::new() +} + +/// The general entry point: an explicit transaction and an explicit external environment. +/// +/// The other helpers in this module fix the transaction to a plain call into [`CONTRACT`]; the +/// shapes that need a different one — EIP-7702 authorizations, system-originated callers, direct +/// calls into a system contract — build their own `TxEnv` and come through here. `envs` is borrowed +/// so a test can read back what execution recorded into it (oracle hints, for instance). +pub(crate) fn transact_tx( + spec: MegaSpecId, + mut db: MemoryDatabase, + limits: EvmTxRuntimeLimits, + tx: TxEnv, + envs: &TestExternalEnvs, +) -> Outcome { + let mut context = MegaContext::new(&mut db, spec) + .with_external_envs(envs.into()) + .with_tx_runtime_limits(limits); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + let mut evm = MegaEvm::new(context); + let result = + alloy_evm::Evm::transact_raw(&mut evm, tx).expect("tx should not surface EVMError"); + let (usage, detained_compute_gas_limit) = { + let additional_limit = evm.ctx_ref().additional_limit.borrow(); + (additional_limit.get_usage(), additional_limit.detained_compute_gas_limit()) + }; + let gas_used = result.result.tx_gas_used(); + Outcome { + result: result.result, + compute_gas: usage.compute_gas, + data_size: usage.data_size, + kv_updates: usage.kv_updates, + state_growth: usage.state_growth, + gas_used, + detained_compute_gas_limit, + state: result.state, + } +} + +/// Asserts that two outcomes are indistinguishable: same execution result, same four-dimension +/// usage, same receipt `gas_used`, and the same detained compute-gas limit. +/// +/// This is the precision invariant in assertion form — what a transaction that stays inside every +/// per-tx limit must produce under both accounting models. +pub(crate) fn assert_outcomes_identical(label: &str, r6: &Outcome, r7: &Outcome) { + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "{label}: execution result must be identical; REX6={:?} REX7={:?}", + r6.result, + r7.result + ); + assert_eq!( + r6.compute_gas, r7.compute_gas, + "{label}: compute gas must be identical; REX6={} REX7={}", + r6.compute_gas, r7.compute_gas + ); + assert_eq!( + r6.gas_used, r7.gas_used, + "{label}: receipt gas_used must be identical; REX6={} REX7={}", + r6.gas_used, r7.gas_used + ); + assert_eq!( + (r6.data_size, r6.kv_updates, r6.state_growth), + (r7.data_size, r7.kv_updates, r7.state_growth), + "{label}: the non-compute dimensions must be identical", + ); + assert_eq!( + r6.detained_compute_gas_limit, r7.detained_compute_gas_limit, + "{label}: the detained compute-gas limit must be identical; REX6={} REX7={}", + r6.detained_compute_gas_limit, r7.detained_compute_gas_limit + ); +} + /// [`transact`] with every SALT bucket reporting `bucket_capacity`. /// /// The SALT-scaled storage-gas charges (`SSTORE` set, new account, contract creation) are diff --git a/crates/mega-evm/tests/rex7/interceptor_resume.rs b/crates/mega-evm/tests/rex7/interceptor_resume.rs new file mode 100644 index 00000000..0eafcea8 --- /dev/null +++ b/crates/mega-evm/tests/rex7/interceptor_resume.rs @@ -0,0 +1,421 @@ +//! REX7 checkpoint settlement across a CALL that never runs a child frame. +//! +//! Two call targets return to their caller without an EVM frame ever being created for them: +//! +//! - a **system contract interceptor**, which short-circuits inside `frame_init` and hands back a +//! synthetic `FrameResult` carrying the full forwarded gas; +//! - a **precompile**, which revm executes inside `frame_init` and returns as a result rather than +//! as a frame to run. +//! +//! Both take the CALL checkpoint on the way out and the frame-resume clamp on the way back, but +//! neither runs `AdditionalLimit::before_frame_init` against a real child. That makes them the two +//! places where the caller's segment settlement and the clamp round trip have to work without any +//! child-frame bookkeeping to lean on. +//! +//! What these tests pin: +//! +//! - the caller's open segment is settled **before** the interceptor reads the tracker, so a system +//! contract that reports remaining compute gas reports the same number it reports under +//! per-opcode accounting; +//! - the clamp is restored across the boundary, so the forwarded gas returns intact and the receipt +//! does not depend on how much gas was forwarded; +//! - the caller's window re-opens on resume, so a limit crossing in the segment *after* the return +//! is still stopped at the clamp boundary rather than overshooting to the next checkpoint. + +use crate::common::{ + assert_outcomes_identical, transact, transact_default, Outcome, CALLEE, CALLER, CONTRACT, + ONE_ETH, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IMegaLimitControl, MegaHaltReason, MegaSpecId, LIMIT_CONTROL_ADDRESS, + LIMIT_CONTROL_CODE, +}; +use revm::bytecode::opcode::{CALL, MLOAD, POP, SSTORE, STOP, TIMESTAMP}; + +/// The identity precompile: returns its input unchanged, and is executed inside `frame_init`. +const IDENTITY_PRECOMPILE: Address = address!("0000000000000000000000000000000000000004"); + +/// Slot the contract stores the value it observed through the CALL into. +const OBSERVED_SLOT: u64 = 0x20; +/// Slot a downstream checkpoint writes, so a halt before it is observable in state. +const DOWNSTREAM_SLOT: u64 = 0x21; + +/// Memory offset the CALL's return data lands at, clear of the calldata at `0x00`. +const RET_OFFSET: u64 = 0x40; + +/// Gas forwarded to the call target unless a test varies it. +const FORWARDED_GAS: u64 = 1_000_000; + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) + .account_code(LIMIT_CONTROL_ADDRESS, LIMIT_CONTROL_CODE) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that record nothing of their own. +fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// Per-spec runtime limits with the TX compute gas limit replaced. +fn compute_limit(limit: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit) +} + +/// Per-spec runtime limits with the block-environment detention cap replaced. +fn detention_cap(cap: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = cap; + limits + } +} + +/// A CALL to `target` forwarding `forwarded_gas`, with `args_size` bytes of calldata taken from +/// `mem[0..]` and 32 bytes of return data written to `mem[RET_OFFSET..]`. +fn call_with_return_data( + builder: BytecodeBuilder, + target: Address, + args_size: u64, + forwarded_gas: u64, +) -> BytecodeBuilder { + builder + .push_number(32u64) // retSize + .push_number(RET_OFFSET) // retOffset + .push_number(args_size) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(target) + .push_number(forwarded_gas) + .append(CALL) + .append(POP) +} + +/// Everything up to and including the CALL into `MegaLimitControl.remainingComputeGas()`: +/// a plain run, the calldata write, and the CALL itself. +fn interceptor_prefix(prologue_volatile: bool, forwarded_gas: u64) -> BytecodeBuilder { + let mut builder = plain_filler(BytecodeBuilder::default(), 5); + if prologue_volatile { + builder = builder.append(TIMESTAMP).append(POP); + } + let builder = + plain_filler(builder, 5).mstore(0, IMegaLimitControl::remainingComputeGasCall::SELECTOR); + call_with_return_data(builder, LIMIT_CONTROL_ADDRESS, 4, forwarded_gas) +} + +/// Everything up to and including the CALL into the identity precompile. +fn precompile_prefix(prologue_volatile: bool, forwarded_gas: u64) -> BytecodeBuilder { + let mut builder = plain_filler(BytecodeBuilder::default(), 5); + if prologue_volatile { + builder = builder.append(TIMESTAMP).append(POP); + } + let builder = plain_filler(builder, 5).mstore(0, [0x5au8; 32]); + call_with_return_data(builder, IDENTITY_PRECOMPILE, 32, forwarded_gas) +} + +/// Stores the 32 bytes the CALL returned into [`OBSERVED_SLOT`]. +fn store_returned_word(builder: BytecodeBuilder) -> BytecodeBuilder { + builder + .push_number(RET_OFFSET) + .append(MLOAD) + .push_u256(U256::from(OBSERVED_SLOT)) + .append(SSTORE) +} + +/// The compute gas a transaction running `code` uses when nothing constrains it. +fn unconstrained_compute_gas(code: Bytes) -> u64 { + transact_default(MegaSpecId::REX7, base_db(code)).compute_gas +} + +/// Runs `code` under both specs with `limits` and returns `(REX6, REX7)`. +fn run_both( + code: &Bytes, + limits: &impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code.clone()), limits(MegaSpecId::REX7)); + (r6, r7) +} + +/// The interceptor reports the caller's remaining compute gas straight out of the tracker, so the +/// number it returns is a direct readout of how much of the caller's execution had been settled at +/// the moment `frame_init` ran. +/// +/// Under per-opcode accounting every opcode ahead of the CALL is already recorded. Under checkpoint +/// accounting the whole plain segment ahead of it is still open until the CALL's checkpoint +/// prologue settles it — which runs at the CALL opcode, before `frame_init`. If that settlement +/// were deferred (to the resume, or to the frame's tail), the contract would observe more remaining +/// compute gas than REX6 reports. Comparing the stored word is what pins the ordering. +#[test] +fn test_interceptor_observes_the_settled_remaining_compute_gas() { + let code = plain_filler( + store_returned_word(plain_filler(interceptor_prefix(false, FORWARDED_GAS), 10)), + 10, + ) + .append(STOP) + .build(); + let (r6, r7) = run_both(&code, &EvmTxRuntimeLimits::from_spec); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + + let slot = U256::from(OBSERVED_SLOT); + let observed = r7.storage_value(CONTRACT, slot); + assert!(!observed.is_zero(), "the interceptor must have returned a remaining-gas reading"); + assert_eq!( + r6.storage_value(CONTRACT, slot), + observed, + "the interceptor must observe the caller's segment already settled", + ); + assert_outcomes_identical("limit-control interception", &r6, &r7); +} + +/// The same readout while a clamp is outstanding. +/// +/// A detention cap engaged before the CALL leaves the interpreter running on clamped gas through +/// the plain segment ahead of it. The CALL checkpoint has to restore the hidden gas before +/// `frame_init` runs, or the interceptor and the forwarding math would both be computed against a +/// counter missing the hidden remainder. +#[test] +fn test_interceptor_observes_the_same_reading_under_an_active_clamp() { + let code = store_returned_word(plain_filler(interceptor_prefix(true, FORWARDED_GAS), 10)) + .append(STOP) + .build(); + // Well above what this transaction spends, so detention is engaged but never binding. + let (r6, r7) = run_both(&code, &detention_cap(1_000_000)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + + let slot = U256::from(OBSERVED_SLOT); + let observed = r7.storage_value(CONTRACT, slot); + assert!(!observed.is_zero(), "the interceptor must have returned a remaining-gas reading"); + assert_eq!( + r6.storage_value(CONTRACT, slot), + observed, + "an outstanding clamp must not change what the interceptor observes", + ); + assert_outcomes_identical("limit-control interception under a clamp", &r6, &r7); +} + +/// Gas leakage path 1 — the system contract interception short-circuit. +/// +/// The synthetic result carries `Gas::new(call_inputs.gas_limit)`: nothing is spent, so every gas +/// unit forwarded comes back. The receipt is therefore independent of the forwarded amount, and +/// that independence is what catches a clamp leak — if the clamp were still outstanding across +/// `frame_init`, or if the resume restored the forwarded amount rather than the hidden one, the two +/// runs below would not cost the same. +#[test] +fn test_interception_returns_the_forwarded_gas_regardless_of_the_amount() { + let build = |forwarded| { + store_returned_word(plain_filler(interceptor_prefix(true, forwarded), 10)) + .append(STOP) + .build() + }; + // Two PUSH3 operands, so the programs are byte-for-byte the same length and the only difference + // is how much gas the interceptor is handed. + let small = build(0x10_0000u64); + let large = build(0x50_0000u64); + assert_eq!(small.len(), large.len(), "the two programs must differ only in the operand"); + // A detention cap nowhere near binding, so a clamp is outstanding at the CALL in both REX7 + // arms. + let limits = detention_cap(2_000_000); + + let (small6, small7) = run_both(&small, &limits); + let (large6, large7) = run_both(&large, &limits); + + for (label, r) in [ + ("small REX6", &small6), + ("small REX7", &small7), + ("large REX6", &large6), + ("large REX7", &large7), + ] { + assert!(r.is_success(), "{label}: must succeed: {:?}", r.result); + } + assert_eq!( + small7.gas_used, large7.gas_used, + "REX7: the interception must return the forwarded gas intact; 1M forwarded={} 5M \ + forwarded={}", + small7.gas_used, large7.gas_used + ); + assert_eq!( + small6.gas_used, large6.gas_used, + "REX6: same invariant, as the baseline the REX7 arm has to reproduce", + ); + assert_outcomes_identical("1M forwarded to the interceptor", &small6, &small7); + assert_outcomes_identical("5M forwarded to the interceptor", &large6, &large7); +} + +/// Enforcement after an interceptor resume: the caller's settlement window re-opens at the resume, +/// so a crossing in the segment that follows is stopped at the clamp boundary. +/// +/// The limit is placed inside the tail plain run, after the CALL has already returned. REX6 +/// executes the crossing opcode and records it, so its usage ends up over the limit; REX7 stops +/// exactly at the limit and the downstream SSTORE never runs. +#[test] +fn test_crossing_after_an_interceptor_resume_stops_at_the_clamp_boundary() { + let tail_pairs = 200; + let code = plain_filler(interceptor_prefix(false, FORWARDED_GAS), tail_pairs) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + // The same program truncated at the resume, and at the end of the tail: the crossing goes + // halfway between them, which is inside the tail and clear of both checkpoints. + let at_resume = + unconstrained_compute_gas(interceptor_prefix(false, FORWARDED_GAS).append(STOP).build()); + let after_tail = unconstrained_compute_gas( + plain_filler(interceptor_prefix(false, FORWARDED_GAS), tail_pairs).append(STOP).build(), + ); + assert!(after_tail > at_resume, "the tail must cost something; {at_resume} -> {after_tail}"); + let limit = at_resume + (after_tail - at_resume) / 2; + + let (r6, r7) = run_both(&code, &compute_limit(limit)); + + assert!(!r6.is_success(), "REX6 must halt on the tight compute limit: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must halt on the tight compute limit: {:?}", r7.result); + assert!( + r6.compute_gas > limit, + "REX6 records the crossing opcode before halting; compute={} limit={limit}", + r6.compute_gas + ); + assert_eq!( + r7.compute_gas, limit, + "REX7 must stop exactly at the limit in the segment opened by the resume", + ); + // The top-level frame's compute budget equals the TX-level remaining, and the clamp breaks that + // tie towards the TX-level constraint, so a REX7 crossing reports the compute-gas limit. + // (REX6's per-opcode check tries the frame budget first and reports the tie as a + // frame-local exceed, which the top-level frame absorbs into a revert — the models classify + // the tie differently.) + assert!( + matches!(r7.halt_reason("REX7"), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "REX7: the halt must report the compute-gas limit; got {:?}", + r7.halt_reason("REX7"), + ); + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.storage_value(CONTRACT, U256::from(DOWNSTREAM_SLOT)).is_zero(), + "{label}: the stop lands inside the tail, so the SSTORE after it never runs", + ); + } +} + +/// A precompile is executed inside `frame_init` and returns as a result, so like an interceptor it +/// resumes the caller without a child frame ever running. Unlike an interceptor it does spend gas, +/// so the resume merges a partially consumed budget back into the caller. +#[test] +fn test_precompile_resume_matches_per_opcode_accounting() { + let code = plain_filler( + store_returned_word(plain_filler(precompile_prefix(false, FORWARDED_GAS), 10)), + 10, + ) + .append(STOP) + .build(); + let (r6, r7) = run_both(&code, &EvmTxRuntimeLimits::from_spec); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + assert_eq!( + r7.storage_value(CONTRACT, U256::from(OBSERVED_SLOT)), + U256::from_be_bytes([0x5au8; 32]), + "the identity precompile must have returned its input", + ); + assert_outcomes_identical("identity precompile", &r6, &r7); +} + +/// The precompile resume with a clamp outstanding across the CALL. +#[test] +fn test_precompile_resume_under_an_active_clamp() { + let code = store_returned_word(plain_filler(precompile_prefix(true, FORWARDED_GAS), 10)) + .append(STOP) + .build(); + let (r6, r7) = run_both(&code, &detention_cap(1_000_000)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + assert_outcomes_identical("identity precompile under a clamp", &r6, &r7); +} + +/// Enforcement after a precompile resume, the counterpart of the interceptor case: the precompile +/// consumed part of the forwarded gas, so the resume re-clamps against a counter the child moved. +#[test] +fn test_crossing_after_a_precompile_resume_stops_at_the_clamp_boundary() { + let tail_pairs = 200; + let code = plain_filler(precompile_prefix(false, FORWARDED_GAS), tail_pairs) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + let at_resume = + unconstrained_compute_gas(precompile_prefix(false, FORWARDED_GAS).append(STOP).build()); + let after_tail = unconstrained_compute_gas( + plain_filler(precompile_prefix(false, FORWARDED_GAS), tail_pairs).append(STOP).build(), + ); + let limit = at_resume + (after_tail - at_resume) / 2; + + let (r6, r7) = run_both(&code, &compute_limit(limit)); + + assert!(!r6.is_success(), "REX6 must halt on the tight compute limit: {:?}", r6.result); + assert!(!r7.is_success(), "REX7 must halt on the tight compute limit: {:?}", r7.result); + assert!( + r6.compute_gas > limit, + "REX6 records the crossing opcode before halting; compute={} limit={limit}", + r6.compute_gas + ); + assert_eq!( + r7.compute_gas, limit, + "REX7 must stop exactly at the limit in the segment opened by the resume", + ); + assert!( + r7.storage_value(CONTRACT, U256::from(DOWNSTREAM_SLOT)).is_zero(), + "the halt lands inside the tail, so the SSTORE after it never runs", + ); +} + +/// The interception resume one frame down: the caller is a sub-frame, so the settlement and the +/// re-clamp happen against a frame-local compute budget rather than the TX-level remaining. +#[test] +fn test_nested_frame_interceptor_resume_matches_per_opcode_accounting() { + let callee = + plain_filler(store_returned_word(plain_filler(interceptor_prefix(false, 200_000), 10)), 10) + .append(STOP) + .build(); + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(2_000_000u64) // gas + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + + let r6 = transact_default(MegaSpecId::REX6, build_db()); + let r7 = transact_default(MegaSpecId::REX7, build_db()); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + let slot = U256::from(OBSERVED_SLOT); + assert!( + !r7.storage_value(CALLEE, slot).is_zero(), + "the nested interception must have returned a remaining-gas reading", + ); + assert_eq!( + r6.storage_value(CALLEE, slot), + r7.storage_value(CALLEE, slot), + "a nested caller must observe the same settled remaining compute gas", + ); + assert_outcomes_identical("nested interception", &r6, &r7); +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index edd2739c..03364b24 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -7,5 +7,6 @@ mod checkpoint_settlement; mod common; +mod interceptor_resume; mod modexp_gas; mod v0_clamp; From 40d7ce93bacce09cc311d1aa981f9edecdd9279c Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:15:36 +0800 Subject: [PATCH 09/43] test(rex7): pin where a latched non-compute exceed surfaces --- crates/mega-evm/tests/rex7/latch_surfacing.rs | 331 ++++++++++++++++++ crates/mega-evm/tests/rex7/main.rs | 1 + 2 files changed, 332 insertions(+) create mode 100644 crates/mega-evm/tests/rex7/latch_surfacing.rs diff --git a/crates/mega-evm/tests/rex7/latch_surfacing.rs b/crates/mega-evm/tests/rex7/latch_surfacing.rs new file mode 100644 index 00000000..6fc8bad7 --- /dev/null +++ b/crates/mega-evm/tests/rex7/latch_surfacing.rs @@ -0,0 +1,331 @@ +//! REX7: where a latched non-compute limit exceed surfaces. +//! +//! Only the compute dimension is checked on the hot path. The other three — data size, KV updates, +//! state growth — are recorded at their own mutation sites, and each site latches the exceed into +//! `has_exceeded_limit` itself. The latch becomes a stop at the next position that consults it, +//! which under per-opcode accounting is the next metered opcode and under checkpoint accounting is +//! the next checkpoint. +//! +//! Every non-compute mutation site reachable from bytecode is *itself* a checkpoint (SSTORE, the +//! LOG family, SELFDESTRUCT, the CALL / CREATE family), and each of those settles its own body +//! after the mutation has run. The two surfacing rules therefore land on the same opcode, and these +//! tests pin that they do — by construction, not by coincidence: +//! +//! - the recorded compute gas is exactly what a run truncated at the mutation site records, so the +//! plain segment *after* the site never executed; +//! - the checkpoint downstream of that segment never ran, which its absent storage write shows; +//! - the reported dimension, limit and usage are identical to per-opcode accounting. +//! +//! The oracle-hint case at the bottom covers the one non-compute mutation site that is not an +//! opcode: it records inside `frame_init`, one step past the CALL checkpoint that settled the +//! caller's segment. + +use crate::common::{ + assert_outcomes_identical, transact, transact_default, transact_tx, Outcome, CALLER, CONTRACT, + DEFAULT_TX_GAS_LIMIT, ONE_ETH, +}; +use alloy_primitives::{Bytes, B256, U256}; +use alloy_sol_types::{SolCall as _, SolError as _}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IOracle, LimitKind, MegaHaltReason, MegaLimitExceeded, MegaSpecId, + TestExternalEnvs, ORACLE_CONTRACT_ADDRESS, ORACLE_CONTRACT_CODE_REX2, +}; +use revm::{ + bytecode::opcode::{CALL, LOG1, POP, STOP}, + context::{result::ExecutionResult, tx::TxEnvBuilder}, +}; + +/// Slot written by the checkpoint downstream of the latching site; its absence proves the stop +/// landed at the site and not after it. +const DOWNSTREAM_SLOT: u64 = 0x31; +/// Slot written by the latching SSTORE itself. +const LATCHING_SLOT: u64 = 0x30; + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that record nothing of their own. +fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// The plain segment placed between the latching site and the checkpoint downstream of it. Long +/// enough that including it in the recorded compute gas would be unmistakable. +const GAP_PAIRS: usize = 40; + +/// The dimension a failed transaction blamed, read out of whichever failure shape it produced. +/// +/// A TX-level exceed halts and carries the dimension in the halt reason; a frame-local exceed is +/// absorbed into a revert carrying `MegaLimitExceeded(uint8 kind, uint64 limit)`. Both are in +/// scope here, since which one a given limit produces is not what these tests are about. +fn blamed_dimension(label: &str, outcome: &Outcome) -> LimitKind { + match &outcome.result { + ExecutionResult::Halt { reason, .. } => match reason { + MegaHaltReason::DataLimitExceeded { .. } => LimitKind::DataSize, + MegaHaltReason::KVUpdateLimitExceeded { .. } => LimitKind::KVUpdate, + MegaHaltReason::ComputeGasLimitExceeded { .. } => LimitKind::ComputeGas, + MegaHaltReason::StateGrowthLimitExceeded { .. } => LimitKind::StateGrowth, + other => panic!("{label}: not a limit halt: {other:?}"), + }, + ExecutionResult::Revert { output, .. } => { + let decoded = MegaLimitExceeded::abi_decode(output) + .unwrap_or_else(|e| panic!("{label}: revert data is not MegaLimitExceeded: {e}")); + LimitKind::from_u8(decoded.kind) + .unwrap_or_else(|| panic!("{label}: unknown limit kind {}", decoded.kind)) + } + other => panic!("{label}: expected a limit failure, got {other:?}"), + } +} + +/// Runs `code` under both specs with `limits` and returns `(REX6, REX7)`. +fn run_both( + code: &Bytes, + limits: &impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code.clone()), limits(MegaSpecId::REX7)); + (r6, r7) +} + +/// The shared body of the three per-dimension cases. +/// +/// `full` runs the latching site, a plain gap, and a downstream SSTORE. `truncated` is the same +/// program cut off immediately after the latching site. Asserting the failing run's compute gas +/// against the truncated run's is what pins the stop to the site: the gap contributes nothing. +fn assert_stops_at_the_latching_site( + label: &str, + full: Bytes, + truncated: Bytes, + expected: LimitKind, + limits: impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let at_site = transact_default(MegaSpecId::REX7, base_db(truncated)).compute_gas; + let (r6, r7) = run_both(&full, &limits); + + for (spec, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + !r.is_success(), + "{label}/{spec}: the tight limit must stop the tx: {:?}", + r.result + ); + assert_eq!( + blamed_dimension(&format!("{label}/{spec}"), r), + expected, + "{label}/{spec}: the wrong dimension was blamed", + ); + assert!( + r.storage_value(CONTRACT, U256::from(DOWNSTREAM_SLOT)).is_zero(), + "{label}/{spec}: the checkpoint downstream of the gap must never run", + ); + } + assert_eq!( + r7.compute_gas, at_site, + "{label}: REX7 must stop at the latching site — the plain gap after it must contribute no \ + compute gas; stopped at {} vs {at_site} recorded up to the site", + r7.compute_gas + ); + assert_outcomes_identical(label, &r6, &r7); + (r6, r7) +} + +/// Data size: `on_log` records the log's topics and payload, then latches. LOG1 is a checkpoint, so +/// its own trailing settlement surfaces the latch on the spot. +#[test] +fn test_data_size_latch_stops_at_the_log_that_recorded_it() { + let log = |builder: BytecodeBuilder| { + builder + .mstore(0, [0x11u8; 32]) + .push_number(0xabcu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(LOG1) + }; + let truncated = log(plain_filler(BytecodeBuilder::default(), 10)).append(STOP).build(); + let full = plain_filler(log(plain_filler(BytecodeBuilder::default(), 10)), GAP_PAIRS) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + + // One byte under what the log needs, so the log's own recording is what overflows. + let before = transact_default(MegaSpecId::REX7, base_db(truncated.clone())); + let intrinsic = transact_default( + MegaSpecId::REX7, + base_db(BytecodeBuilder::default().append(STOP).build()), + ) + .data_size; + assert!( + intrinsic < before.data_size, + "the log must be what pushes data size past the intrinsic footprint; {intrinsic} vs {}", + before.data_size + ); + let limit = before.data_size - 1; + + assert_stops_at_the_latching_site( + "data size / LOG1", + full, + truncated, + LimitKind::DataSize, + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_data_size_limit(limit), + ); +} + +/// KV updates: `on_sstore` records the storage write, then latches. SSTORE is a checkpoint, so the +/// stop lands on it. +#[test] +fn test_kv_update_latch_stops_at_the_sstore_that_recorded_it() { + let truncated = plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(LATCHING_SLOT), U256::from(0x77)) + .append(STOP) + .build(); + let full = plain_filler( + plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(LATCHING_SLOT), U256::from(0x77)), + GAP_PAIRS, + ) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + + let before = transact_default(MegaSpecId::REX7, base_db(truncated.clone())); + let intrinsic = transact_default( + MegaSpecId::REX7, + base_db(BytecodeBuilder::default().append(STOP).build()), + ) + .kv_updates; + assert!( + intrinsic < before.kv_updates, + "the store must be what pushes KV updates past the intrinsic footprint; {intrinsic} vs {}", + before.kv_updates + ); + let limit = before.kv_updates - 1; + + let (_, r7) = assert_stops_at_the_latching_site( + "KV updates / SSTORE", + full, + truncated, + LimitKind::KVUpdate, + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_kv_updates_limit(limit), + ); + // The store's KV usage is frame-discardable, so popping the stopped frame takes it back out + // again — the post-transaction reading is the intrinsic footprint, under the limit that the + // store transiently crossed. + assert!( + r7.kv_updates <= limit, + "the stopped frame's KV usage must be discarded; usage={} limit={limit}", + r7.kv_updates + ); +} + +/// State growth: the same SSTORE records a net-new storage slot. With only the state-growth limit +/// tightened, that is the dimension `check_limit` reports. +#[test] +fn test_state_growth_latch_stops_at_the_sstore_that_recorded_it() { + let truncated = plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(LATCHING_SLOT), U256::from(0x77)) + .append(STOP) + .build(); + let full = plain_filler( + plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(LATCHING_SLOT), U256::from(0x77)), + GAP_PAIRS, + ) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + + let before = transact_default(MegaSpecId::REX7, base_db(truncated.clone())); + assert!(before.state_growth > 0, "the store must create a net-new slot"); + let limit = before.state_growth - 1; + + assert_stops_at_the_latching_site( + "state growth / SSTORE", + full, + truncated, + LimitKind::StateGrowth, + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_state_growth_limit(limit), + ); +} + +/// The one non-compute mutation site that is not an opcode: the oracle-hint interceptor meters the +/// payload inside `frame_init`, one step past the CALL checkpoint. +/// +/// On overflow the interceptor deliberately synthesizes nothing and returns `None`, leaving +/// `before_frame_init` to produce the canonical TX-level halt. Under checkpoint accounting the +/// caller's segment was already settled by the CALL's own checkpoint, which runs before +/// `frame_init` — so the halt reports the same usage REX6 reports, and the caller's plain segment +/// ahead of the CALL is fully accounted for despite the frame never starting. +#[test] +fn test_oracle_hint_data_size_latch_halts_at_the_frame_boundary() { + let payload = vec![0u8; 256]; + let calldata = + IOracle::sendHintCall { topic: B256::repeat_byte(0x5a), data: payload.into() }.abi_encode(); + let len = calldata.len() as u64; + let mut builder = plain_filler(BytecodeBuilder::default(), 20); + builder = builder.mstore(0, &calldata); + let code = builder + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(len) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(ORACLE_CONTRACT_ADDRESS) + .push_number(1_000_000u64) // gas + .append(CALL) + .append(POP) + .sstore(U256::from(DOWNSTREAM_SLOT), U256::from(1)) + .append(STOP) + .build(); + let build_db = + || base_db(code.clone()).account_code(ORACLE_CONTRACT_ADDRESS, ORACLE_CONTRACT_CODE_REX2); + + // Enough for the calldata footprint but not for the hint payload the interceptor meters on top. + let unconstrained = transact_default(MegaSpecId::REX7, build_db()); + assert!( + unconstrained.is_success(), + "the unconstrained run must succeed: {:?}", + unconstrained.result + ); + let limit = unconstrained.data_size - len; + let limits = move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_data_size_limit(limit); + + let envs = TestExternalEnvs::new(); + let tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill() + }; + let r6 = transact_tx(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6), tx(), &envs); + let hints_after_rex6 = envs.recorded_hints().len(); + let r7 = transact_tx(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7), tx(), &envs); + let hints_after_rex7 = envs.recorded_hints().len(); + + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!(!r.is_success(), "{label}: the tight data-size limit must halt: {:?}", r.result); + assert_eq!( + blamed_dimension(label, r), + LimitKind::DataSize, + "{label}: the halt must blame data size", + ); + assert!( + r.storage_value(CONTRACT, U256::from(DOWNSTREAM_SLOT)).is_zero(), + "{label}: the store after the CALL must never run", + ); + } + assert_eq!( + (hints_after_rex6, hints_after_rex7), + (0, 0), + "an over-budget hint must never reach the oracle backend under either spec", + ); + assert_outcomes_identical("oracle hint data-size overflow", &r6, &r7); +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index 03364b24..26f9f39f 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -8,5 +8,6 @@ mod checkpoint_settlement; mod common; mod interceptor_resume; +mod latch_surfacing; mod modexp_gas; mod v0_clamp; From 4f7e6ddf904ee8ac7cfe4a788f5b43082df686d2 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:18:35 +0800 Subject: [PATCH 10/43] test(rex7): cover the three gas-leakage paths under an active clamp --- crates/mega-evm/tests/rex7/gas_leakage.rs | 397 ++++++++++++++++++++++ crates/mega-evm/tests/rex7/main.rs | 1 + 2 files changed, 398 insertions(+) create mode 100644 crates/mega-evm/tests/rex7/gas_leakage.rs diff --git a/crates/mega-evm/tests/rex7/gas_leakage.rs b/crates/mega-evm/tests/rex7/gas_leakage.rs new file mode 100644 index 00000000..7cb35001 --- /dev/null +++ b/crates/mega-evm/tests/rex7/gas_leakage.rs @@ -0,0 +1,397 @@ +//! REX7: the three gas-leakage paths, exercised with a clamp outstanding. +//! +//! Any mechanism that hides, grants or adjusts gas per frame has to be unwound on every way out of +//! a frame, or system-held gas leaks back to the parent or the sender. The V0 clamp is such a +//! mechanism — it hides part of the interpreter's gas — and the three paths that have to handle it +//! are the ones the leakage checklist names: +//! +//! 1. **System contract interception** short-circuits `frame_init` and synthesizes a result with no +//! child frame. The clamp must already be restored when the CALL checkpoint publishes the frame, +//! and the caller's counter must come back whole on resume. +//! 2. **Gas rescue on a TX-level exceed** captures the frame's remaining gas for the sender. It +//! must capture the true remaining — neither the clamped view (which would burn the hidden gas) +//! nor the sum of both (which would refund it twice). +//! 3. **Frame return** hands the frame's gas back to its parent. The restore must happen before +//! anything reads or charges that gas, and identically on success, on revert and on a limit +//! exceed. +//! +//! The probes are chosen so a leak changes an observable, not just an internal: the parent's own +//! `GAS` reading after the child returns, the receipt's independence from the transaction gas +//! limit, and whether a code deposit that costs more than the clamp left visible can be paid at +//! all. + +use crate::common::{ + assert_outcomes_identical, transact, transact_with_gas_limit, Outcome, CALLEE, CALLER, + CONTRACT, ONE_ETH, +}; +use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IMegaLimitControl, MegaHaltReason, MegaSpecId, LIMIT_CONTROL_ADDRESS, + LIMIT_CONTROL_CODE, +}; +use revm::bytecode::opcode::{ + CALL, CREATE, DUP1, GAS, JUMPDEST, JUMPI, MSTORE, POP, RETURN, SSTORE, STOP, SUB, SWAP1, + TIMESTAMP, +}; + +/// Slot the caller stores its post-return `GAS` reading into. +const GAS_READING_SLOT: u64 = 0x40; +/// Slot a callee writes, so a committed sub-frame can be told from a reverted one. +const CALLEE_SLOT: u64 = 0x41; + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) + .account_code(LIMIT_CONTROL_ADDRESS, LIMIT_CONTROL_CODE) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that record nothing of their own. +fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// A countdown loop of cheap opcodes with no checkpoint anywhere inside the loop body. +fn countdown_loop_code(prefix: &[u8], iterations: u16) -> Bytes { + let mut code = prefix.to_vec(); + code.push(0x61); // PUSH2 + code.extend_from_slice(&iterations.to_be_bytes()); + let loop_target = u8::try_from(code.len()).expect("loop target must fit in a PUSH1"); + code.push(JUMPDEST); + code.extend_from_slice(&[0x60, 0x01]); // PUSH1 1 + code.push(SWAP1); + code.push(SUB); + code.push(DUP1); + code.extend_from_slice(&[0x60, loop_target]); // PUSH1 loop + code.push(JUMPI); + code.push(STOP); + Bytes::from(code) +} + +/// Per-spec runtime limits with the TX compute gas limit replaced. +fn compute_limit(limit: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit) +} + +/// Per-spec runtime limits with the block-environment detention cap replaced. +fn detention_cap(cap: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = cap; + limits + } +} + +/// A CALL to `target` forwarding `gas`, with no arguments and no return data, followed by the +/// caller reading `GAS` and storing it. +/// +/// The stored reading is the probe: it is the caller's own view of its counter after the child's +/// gas has been merged back, so any gas the child failed to hand back — or handed back twice — +/// shows up in it. +fn call_then_store_gas(target: revm::primitives::Address, forwarded: u64) -> Bytes { + plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(target) + .push_number(forwarded) + .append(CALL) + .append(POP) + .append(GAS) + .push_u256(U256::from(GAS_READING_SLOT)) + .append(SSTORE) + .append(STOP) + .build() +} + +/// Runs `code` under both specs with `limits` and returns `(REX6, REX7)`. +fn run_both( + build_db: impl Fn() -> MemoryDatabase, + limits: &impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + (r6, r7) +} + +/// Asserts both arms succeeded and read back the same post-return `GAS` value. +fn assert_same_gas_reading(label: &str, r6: &Outcome, r7: &Outcome) { + assert!(r6.is_success(), "{label}/REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "{label}/REX7 must succeed: {:?}", r7.result); + let slot = U256::from(GAS_READING_SLOT); + let reading = r7.storage_value(CONTRACT, slot); + assert!(!reading.is_zero(), "{label}: the GAS reading must be non-zero"); + assert_eq!( + r6.storage_value(CONTRACT, slot), + reading, + "{label}: the caller's counter after the return must match per-opcode accounting", + ); + assert_outcomes_identical(label, r6, r7); +} + +/// Leakage path 1 — the interception short-circuit, probed from the caller's own counter. +/// +/// The interceptor produces a synthetic result without a child frame ever existing, so nothing on +/// that path unwinds a clamp. The clamp therefore has to be already restored when the CALL +/// checkpoint publishes the frame, and re-applied only once the caller resumes. Reading `GAS` right +/// after the CALL is the caller's direct view of that: a clamp that survived into `frame_init`, or +/// one restored twice, moves this reading. +#[test] +fn test_interception_short_circuit_leaves_the_callers_counter_whole() { + let code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .mstore(0, IMegaLimitControl::remainingComputeGasCall::SELECTOR) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(4u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(LIMIT_CONTROL_ADDRESS) + .push_number(1_000_000u64) + .append(CALL) + .append(POP) + .append(GAS) + .push_u256(U256::from(GAS_READING_SLOT)) + .append(SSTORE) + .append(STOP) + .build(); + let (r6, r7) = run_both(|| base_db(code.clone()), &detention_cap(1_000_000)); + assert_same_gas_reading("interception short-circuit", &r6, &r7); +} + +/// Leakage path 2 — the TX-level rescue, probed by varying the transaction gas limit. +/// +/// A TX-level compute exceed stops at the compute limit, so how much EVM gas the transaction was +/// given cannot change what it consumed. The clamp-hidden amount, on the other hand, is exactly +/// `gas_limit − headroom` and moves one-for-one with the gas limit — so if the rescue captured the +/// clamped view (burning the hidden gas) or the true remaining plus the hidden amount (refunding it +/// twice), the receipt would track the gas limit. It must not. +#[test] +fn test_tx_level_rescue_is_independent_of_the_transaction_gas_limit() { + let code = countdown_loop_code(&[], 10_000); + let intrinsic = transact( + MegaSpecId::REX7, + base_db(BytecodeBuilder::default().append(STOP).build()), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + ) + .compute_gas; + let limit = intrinsic + 5_000; + let limits = compute_limit(limit); + + let mut readings = Vec::new(); + for spec in [MegaSpecId::REX6, MegaSpecId::REX7] { + let small = transact_with_gas_limit(spec, base_db(code.clone()), limits(spec), 1_000_000); + let large = transact_with_gas_limit(spec, base_db(code.clone()), limits(spec), 50_000_000); + assert!(!small.is_success(), "{spec:?}: the tight compute limit must stop the tx"); + assert!(!large.is_success(), "{spec:?}: the tight compute limit must stop the tx"); + assert_eq!( + small.compute_gas, large.compute_gas, + "{spec:?}: the stop point must not depend on the transaction gas limit", + ); + assert_eq!( + small.gas_used, large.gas_used, + "{spec:?}: the rescued gas must be the true remaining, so the receipt cannot track the \ + transaction gas limit; 1M limit -> {} and 50M limit -> {}", + small.gas_used, large.gas_used + ); + readings.push((small.compute_gas, small.gas_used)); + } + let (r7_compute, r7_gas_used) = readings[1]; + assert_eq!(r7_compute, limit, "REX7 stops exactly at the compute limit"); + assert!( + r7_gas_used < 1_000_000, + "the clamp-hidden gas must reach the sender, not the burn; gas_used={r7_gas_used}", + ); +} + +/// The same rescue probe with gas detention as the binding constraint, so the reclassification path +/// (`VolatileDataAccessOutOfGas` on a clamp-latched detention exceed) is the one under test. +#[test] +fn test_detained_rescue_is_independent_of_the_transaction_gas_limit() { + let code = countdown_loop_code(&[TIMESTAMP, POP], 10_000); + let limits = detention_cap(1_000); + + for spec in [MegaSpecId::REX6, MegaSpecId::REX7] { + let small = transact_with_gas_limit(spec, base_db(code.clone()), limits(spec), 1_000_000); + let large = transact_with_gas_limit(spec, base_db(code.clone()), limits(spec), 50_000_000); + for (label, r) in [("1M", &small), ("50M", &large)] { + assert!(!r.is_success(), "{spec:?}/{label}: the detention cap must stop the tx"); + assert!( + matches!(r.halt_reason(label), MegaHaltReason::VolatileDataAccessOutOfGas { .. }), + "{spec:?}/{label}: the halt must keep the volatile attribution; got {:?}", + r.halt_reason(label), + ); + } + assert_eq!( + small.gas_used, large.gas_used, + "{spec:?}: a detained stop must rescue the true remaining, so the receipt cannot track \ + the transaction gas limit; 1M limit -> {} and 50M limit -> {}", + small.gas_used, large.gas_used + ); + } +} + +/// Leakage path 3 — frame return, on the success arm. +/// +/// The callee ends inside a plain segment, so its clamp is still outstanding when the frame +/// produces its result. Restoring it there is what lets the unspent remainder flow back to the +/// caller; the caller's `GAS` reading is what shows whether it did. +#[test] +fn test_frame_return_restores_the_clamp_on_success() { + let callee = plain_filler(BytecodeBuilder::default(), 20).append(STOP).build(); + let code = call_then_store_gas(CALLEE, 1_000_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let (r6, r7) = run_both(build_db, &detention_cap(1_000_000)); + assert_same_gas_reading("frame return / success", &r6, &r7); +} + +/// The same probe on the revert arm: the unwinding has to be unconditional, or one of the two exit +/// paths leaks. +#[test] +fn test_frame_return_restores_the_clamp_on_revert() { + let callee = plain_filler(BytecodeBuilder::default(), 20) + .sstore(U256::from(CALLEE_SLOT), U256::from(0x77)) + .revert() + .build(); + let code = call_then_store_gas(CALLEE, 1_000_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let (r6, r7) = run_both(build_db, &detention_cap(1_000_000)); + assert_same_gas_reading("frame return / revert", &r6, &r7); + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.storage_value(CALLEE, U256::from(CALLEE_SLOT)).is_zero(), + "{label}: the reverted sub-frame's write must be discarded", + ); + } +} + +/// Frame return on the exceed arm: the callee outruns its own frame-local compute budget, so its +/// clamp turns into a fake out-of-gas that is restored and reclassified into a revert. The gas the +/// clamp was hiding still belongs to the caller. +/// +/// The two models stop the callee at different points — REX7 stops the crossing opcode before it +/// runs — so the caller resumes with different amounts and the readings are not comparable. What is +/// comparable is that the caller survives, sees a failed CALL, and is left with gas of the right +/// order rather than a burned or doubled counter. +#[test] +fn test_frame_local_exceed_returns_the_hidden_gas_to_the_parent() { + let prologue = + BytecodeBuilder::default().sstore(U256::from(CALLEE_SLOT), U256::from(0x77)).build_vec(); + let callee = countdown_loop_code(&prologue, 10_000); + let code = plain_filler(BytecodeBuilder::default(), 5) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(50_000_000u64) + .append(CALL) + .push_number(0u64) + .append(MSTORE) + .push_number(32u64) + .push_number(0u64) + .append(RETURN) + .build(); + let intrinsic = transact( + MegaSpecId::REX7, + base_db(BytecodeBuilder::default().append(STOP).build()), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + ) + .compute_gas; + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let (r6, r7) = run_both(build_db, &compute_limit(intrinsic + 100_000)); + + for (label, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.is_success(), + "{label}: the caller survives a frame-local exceed: {:?}", + r.result + ); + assert_eq!( + r.result.output().map(|o| U256::from_be_slice(o)), + Some(U256::ZERO), + "{label}: the CALL must report failure", + ); + assert!( + r.storage_value(CALLEE, U256::from(CALLEE_SLOT)).is_zero(), + "{label}: the reverted sub-frame's write must be discarded", + ); + } + // The caller forwarded (63/64 of) tens of millions of gas and got a failed call back. Only the + // callee's actual work may be gone: a clamp that was not restored would have stranded the + // hidden millions in the child. + assert!( + r7.gas_used < 1_000_000, + "the gas the clamp hid inside the callee must return to the caller; gas_used={}", + r7.gas_used + ); + assert!( + r7.gas_used < r6.gas_used + 100_000, + "REX7 must not consume materially more than per-opcode accounting; REX6={} REX7={}", + r6.gas_used, + r7.gas_used + ); +} + +/// Frame return, ordering arm: the clamp must be restored *before* the code-deposit charge. +/// +/// The deposit costs `CODEDEPOSIT_STORAGE_GAS` (10,000) per byte of deployed code, while the +/// compute it records is 200 per byte. Sizing the detention cap between the two — comfortably above +/// the deposit's compute, far below its EVM gas — leaves a CREATE that can only be paid for out of +/// the counter the clamp was hiding. If the charge saw the clamped copy, this deployment would fail +/// out of gas despite the transaction being nowhere near any limit. +#[test] +fn test_create_return_restores_the_clamp_before_the_code_deposit_charge() { + let runtime = vec![STOP; 100]; + let initcode = BytecodeBuilder::default().return_with_data(&runtime).build_vec(); + let len = initcode.len() as u64; + let code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .mstore(0, &initcode) + .push_number(len) // length + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .push_number(0u64) + .append(MSTORE) + .push_number(32u64) + .push_number(0u64) + .append(RETURN) + .build(); + // 100 bytes of runtime code: 20,000 compute for the deposit, 1,000,000 EVM gas for it. + let deposit_compute = runtime.len() as u64 * 200; + let deposit_evm_gas = runtime.len() as u64 * 10_000; + let cap = 80_000; + assert!( + deposit_compute < cap && cap < deposit_evm_gas, + "the cap has to sit between the deposit's compute and its EVM gas", + ); + + let (r6, r7) = run_both(|| base_db(code.clone()), &detention_cap(cap)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + let created = + r7.result.output().map(|o| U256::from_be_slice(o)).expect("CREATE must return output"); + assert!(!created.is_zero(), "the CREATE must have succeeded; a zero address means it OOG'd"); + assert_eq!( + r6.result.output().map(|o| U256::from_be_slice(o)), + Some(created), + "both models must deploy to the same address", + ); + assert_outcomes_identical("CREATE under a clamp", &r6, &r7); +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index 26f9f39f..499b0e84 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -7,6 +7,7 @@ mod checkpoint_settlement; mod common; +mod gas_leakage; mod interceptor_resume; mod latch_surfacing; mod modexp_gas; From 4a209944835a561a151e57b119f6745b974bfc80 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:21:48 +0800 Subject: [PATCH 11/43] test(rex7): extend REX6/REX7 parity to system-path transaction shapes --- crates/mega-evm/tests/rex7/common.rs | 9 - crates/mega-evm/tests/rex7/main.rs | 1 + crates/mega-evm/tests/rex7/parity_shapes.rs | 469 ++++++++++++++++++++ 3 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 crates/mega-evm/tests/rex7/parity_shapes.rs diff --git a/crates/mega-evm/tests/rex7/common.rs b/crates/mega-evm/tests/rex7/common.rs index 066df1de..c3b3d8ed 100644 --- a/crates/mega-evm/tests/rex7/common.rs +++ b/crates/mega-evm/tests/rex7/common.rs @@ -123,15 +123,6 @@ pub(crate) fn transact_default(spec: MegaSpecId, db: MemoryDatabase) -> Outcome transact(spec, db, EvmTxRuntimeLimits::from_spec(spec)) } -/// The transaction shape [`transact`] runs: a plain call from [`CALLER`] to [`CONTRACT`]. -pub(crate) fn default_tx() -> TxEnv { - TxEnvBuilder::default() - .caller(CALLER) - .call(CONTRACT) - .gas_limit(DEFAULT_TX_GAS_LIMIT) - .build_fill() -} - /// The external environment [`transact_tx`] runs with when a test does not need SALT buckets or /// oracle storage of its own. Equivalent to the empty environment the other helpers use: every /// bucket reports the minimum capacity and the oracle has no data. diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index 499b0e84..ed499d68 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -11,4 +11,5 @@ mod gas_leakage; mod interceptor_resume; mod latch_surfacing; mod modexp_gas; +mod parity_shapes; mod v0_clamp; diff --git a/crates/mega-evm/tests/rex7/parity_shapes.rs b/crates/mega-evm/tests/rex7/parity_shapes.rs new file mode 100644 index 00000000..7855077a --- /dev/null +++ b/crates/mega-evm/tests/rex7/parity_shapes.rs @@ -0,0 +1,469 @@ +//! REX6 ↔ REX7 bit-for-bit parity on the transaction shapes the settlement suite does not reach. +//! +//! The precision invariant is that a transaction which stays inside every per-tx limit is +//! indistinguishable under the two accounting models. `checkpoint_settlement` establishes that for +//! bytecode shapes reached from a plain call; the shapes here are the ones that enter or leave the +//! interpreter through a different door: +//! +//! - **EIP-7702 authorizations** — accounted in validate / pre-execution, before any frame exists, +//! and able to re-derive the beneficiary detention cap from usage the checkpoint model settles +//! differently; +//! - **`KeylessDeploy`** — a system contract intercepted at depth 0, whose sandbox runs a whole +//! nested transaction and merges its usage back; +//! - **system-originated transactions** — exempt from per-tx metering, which also switches the +//! clamp off entirely, so an exempt transaction must run to completion under a limit that would +//! stop a user transaction; +//! - **the REX5 storage-call stipend** — a per-frame allowance drawn only at the storage-gas +//! surcharge sites, which are exactly the checkpoints; +//! - **oracle hints** — metered from inside `frame_init`, one step past the CALL checkpoint. +//! +//! Every case asserts the full outcome tuple: execution result, compute gas, all four dimensions, +//! receipt `gas_used` and the detained compute-gas limit. + +use std::vec::Vec; + +use crate::common::{ + assert_outcomes_identical, default_envs, transact_tx, Outcome, CALLEE, CALLER, CONTRACT, + DEFAULT_TX_GAS_LIMIT, EMPTY_TARGET, ONE_ETH, +}; +use alloy_eips::eip7702::{Authorization, RecoveredAuthority, RecoveredAuthorization}; +use alloy_primitives::{address, hex, Address, Bytes, Signature, TxKind, B256, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + alloy_consensus::{Signed, TxLegacy}, + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, IKeylessDeploy, IOracle, MegaSpecId, TestExternalEnvs, + KEYLESS_DEPLOY_ADDRESS, ORACLE_CONTRACT_ADDRESS, ORACLE_CONTRACT_CODE_REX2, +}; +use revm::{ + bytecode::opcode::{CALL, LOG1, POP, SLOAD, STOP, TIMESTAMP}, + context::{tx::TxEnvBuilder, TxEnv}, +}; + +/// The protocol's own system caller (EIP-4788 / EIP-2935 pre-block calls). A transaction from this +/// address is system-originated, and REX6+ exempts it from per-tx metering. +const PROTOCOL_SYSTEM_CALLER: Address = address!("fffffffffffffffffffffffffffffffffffffffe"); + +/// The address authorizations in this file delegate to. +const DELEGATE: Address = address!("0000000000000000000000000000000000330001"); +/// An authority that already exists in state. +const EXISTING_AUTHORITY: Address = address!("0000000000000000000000000000000000330002"); +/// An authority that does not exist yet, so applying its authorization grows state. +const NEW_AUTHORITY: Address = address!("0000000000000000000000000000000000330003"); + +const KEYLESS_RELAYER: Address = address!("0000000000000000000000000000000000330004"); + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that record nothing of their own. +fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// A mixed body: plain opcodes around one of every checkpoint family that does not need operands +/// from the caller — a storage read, a storage write, a log, and a volatile opcode. +fn mixed_checkpoint_body() -> Bytes { + let builder = plain_filler(BytecodeBuilder::default(), 10) + .append(TIMESTAMP) + .append(POP) + .push_u256(U256::from(3)) + .append(SLOAD) + .append(POP) + .sstore(U256::from(1), U256::from(0x11)); + let builder = plain_filler(builder, 10) + .mstore(0, [0x22u8; 32]) + .push_number(0xabcu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(LOG1); + plain_filler(builder, 10).append(STOP).build() +} + +/// Runs `tx` under both specs against a freshly built database and asserts the two are +/// indistinguishable. Returns `(REX6, REX7)` for any case-specific assertions on top. +fn assert_parity( + label: &str, + build_db: impl Fn() -> MemoryDatabase, + build_tx: impl Fn() -> TxEnv, + limits: impl Fn(MegaSpecId) -> EvmTxRuntimeLimits, +) -> (Outcome, Outcome) { + let envs6 = default_envs(); + let r6 = + transact_tx(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6), build_tx(), &envs6); + let envs7 = default_envs(); + let r7 = + transact_tx(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7), build_tx(), &envs7); + assert_outcomes_identical(label, &r6, &r7); + (r6, r7) +} + +fn recovered_auth(authority: Address, nonce: u64) -> RecoveredAuthorization { + RecoveredAuthorization::new_unchecked( + Authorization { chain_id: U256::from(1), address: DELEGATE, nonce }, + RecoveredAuthority::Valid(authority), + ) +} + +/// EIP-7702 authorization accounting happens in `validate` / pre-execution — before the first +/// frame, and therefore before any checkpoint exists. It also charges dynamic SALT account-creation +/// gas into the transaction's intrinsic gas, which the first frame's settlement window then has to +/// open on top of. One net-new authority and one existing one exercise both arms of +/// `on_rex6_eip7702_authority_applied`. +#[test] +fn test_eip7702_authorization_accounting_matches_per_opcode() { + let code = mixed_checkpoint_body(); + let build_db = || { + base_db(code.clone()) + .account_balance(EXISTING_AUTHORITY, U256::from(1u64)) + .account_code(DELEGATE, BytecodeBuilder::default().append(STOP).build()) + }; + let build_tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .chain_id(Some(1)) + .authorization_list_recovered(Vec::from([ + recovered_auth(EXISTING_AUTHORITY, 0), + recovered_auth(NEW_AUTHORITY, 0), + ])) + .build_fill() + }; + + let (_, r7) = + assert_parity("EIP-7702 authorizations", build_db, build_tx, EvmTxRuntimeLimits::from_spec); + assert!(r7.is_success(), "the authorized transaction must succeed: {:?}", r7.result); + assert!( + r7.state_growth > 0, + "the net-new authority must register state growth; growth={}", + r7.state_growth + ); +} + +/// The same shape with the authorizations applied under an engaged detention cap. The cap is +/// re-derived from settled usage when an applied authority is the block beneficiary, so this also +/// checks that a cap installed outside any frame lands on the same number under both models. +#[test] +fn test_eip7702_authorization_under_detention_matches_per_opcode() { + let code = mixed_checkpoint_body(); + let build_db = || { + base_db(code.clone()) + .account_balance(EXISTING_AUTHORITY, U256::from(1u64)) + .account_code(DELEGATE, BytecodeBuilder::default().append(STOP).build()) + }; + let build_tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .chain_id(Some(1)) + .authorization_list_recovered(Vec::from([recovered_auth(NEW_AUTHORITY, 0)])) + .build_fill() + }; + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = 1_000_000; + limits + }; + + let (_, r7) = assert_parity("EIP-7702 under detention", build_db, build_tx, limits); + assert!(r7.is_success(), "the authorized transaction must succeed: {:?}", r7.result); +} + +/// Builds a deterministic pre-EIP-155 keyless deployment transaction. +fn keyless_tx_bytes(init_code: Bytes) -> Bytes { + let tx = TxLegacy { + nonce: 0, + gas_price: 100_000_000_000, + gas_limit: 200_000, + to: TxKind::Create, + value: U256::ZERO, + input: init_code, + chain_id: None, + }; + let word = U256::from_be_bytes(hex!( + "3333333333333333333333333333333333333333333333333333333333333333" + )); + let signed = Signed::new_unchecked(tx, Signature::new(word, word, false), B256::ZERO); + let mut buf = Vec::new(); + signed.rlp_encode(&mut buf); + Bytes::from(buf) +} + +/// `KeylessDeploy` is intercepted at depth 0, so the interception happens before any frame — and +/// therefore before any checkpoint — has been created. Its sandbox then runs a whole nested +/// transaction under the same spec, with its own tracker, and merges the usage back. +/// +/// Two accounting models have to agree across all of that: the sandbox's own checkpoint settlement, +/// the merge, and the outer transaction's view of it. +#[test] +fn test_keyless_deploy_sandbox_accounting_matches_per_opcode() { + // Initcode that runs some plain opcodes and a storage write, then deploys a small runtime. + let runtime = BytecodeBuilder::default().append(STOP).build_vec(); + let init_code = plain_filler(BytecodeBuilder::default(), 10) + .sstore(U256::from(7), U256::from(0x99)) + .return_with_data(&runtime) + .build(); + let call_data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes(init_code), + gasLimitOverride: U256::from(1_000_000u64), + } + .abi_encode(); + + let build_db = + || MemoryDatabase::default().account_balance(KEYLESS_RELAYER, U256::from(10 * ONE_ETH)); + let build_tx = || { + TxEnvBuilder::default() + .caller(KEYLESS_RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(30_000_000) + .chain_id(Some(1)) + .data(Bytes::from(call_data.clone())) + .build_fill() + }; + + let (_, r7) = + assert_parity("keyless deploy sandbox", build_db, build_tx, EvmTxRuntimeLimits::from_spec); + assert!(r7.is_success(), "the keyless deployment must succeed: {:?}", r7.result); + let returns = IKeylessDeploy::keylessDeployCall::abi_decode_returns( + r7.result.output().expect("the interceptor must return data"), + ) + .expect("the output must decode as keylessDeployReturn"); + assert!( + !returns.deployedAddress.is_zero(), + "the sandbox must report a deployed address; errorData={}", + returns.errorData + ); +} + +/// The same keyless deployment under a detention cap engaged by the sandboxed code, so the sandbox +/// runs with a clamp of its own. +#[test] +fn test_keyless_deploy_sandbox_under_detention_matches_per_opcode() { + let runtime = BytecodeBuilder::default().append(STOP).build_vec(); + let init_code = plain_filler(BytecodeBuilder::default(), 5) + .append(TIMESTAMP) + .append(POP) + .return_with_data(&runtime) + .build(); + let call_data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes(init_code), + gasLimitOverride: U256::from(1_000_000u64), + } + .abi_encode(); + + let build_db = + || MemoryDatabase::default().account_balance(KEYLESS_RELAYER, U256::from(10 * ONE_ETH)); + let build_tx = || { + TxEnvBuilder::default() + .caller(KEYLESS_RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(30_000_000) + .chain_id(Some(1)) + .data(Bytes::from(call_data.clone())) + .build_fill() + }; + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = 500_000; + limits + }; + + let (_, r7) = assert_parity("keyless deploy under detention", build_db, build_tx, limits); + assert!(r7.is_success(), "the keyless deployment must succeed: {:?}", r7.result); +} + +/// A system-originated transaction is exempt from per-tx metering, and the exemption also switches +/// the clamp off: `checkpoint_clamp_amount` refuses to hide anything once the tracker is not in the +/// `WithinLimit` state. +/// +/// The compute limit here is far below what the transaction spends, so a user transaction running +/// the same code would be stopped. The exempt one must run to completion under both models — and +/// still report the same compute usage, since the recording continues while only the halt decision +/// is suppressed. +#[test] +fn test_system_originated_transaction_is_unclamped_under_both_models() { + let code = { + let mut code = Vec::new(); + code.extend_from_slice(&[0x61, 0x03, 0xe8]); // PUSH2 1000 + let target = code.len() as u8; + code.extend_from_slice(&[0x5b, 0x60, 0x01, 0x90, 0x03, 0x80, 0x60, target, 0x57, 0x00]); + Bytes::from(code) + }; + let build_db = || { + MemoryDatabase::default() + .account_code(CONTRACT, code.clone()) + .account_balance(PROTOCOL_SYSTEM_CALLER, U256::from(ONE_ETH)) + }; + let build_tx = || { + TxEnvBuilder::default() + .caller(PROTOCOL_SYSTEM_CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .gas_price(0) + .build_fill() + }; + // Well under the loop's cost: binding for a user transaction, ignored for this one. + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(25_000); + + let (_, r7) = assert_parity("system-originated exemption", build_db, build_tx, limits); + assert!(r7.is_success(), "an exempt transaction must not be stopped: {:?}", r7.result); + assert!( + r7.compute_gas > 25_000, + "the exempt transaction must have spent past the limit it ignores; compute={}", + r7.compute_gas + ); +} + +/// The REX5 storage-call stipend is a per-frame allowance drawn only at MegaETH's storage-gas +/// surcharge sites — which are exactly the checkpoints. A value-transferring internal CALL into a +/// callee that logs and writes storage draws on it at three of them. +/// +/// Under checkpoint accounting the same sites do the drawing, but the compute they record is now a +/// segment delta rather than a per-opcode capture, so the subtraction of the drawn storage gas has +/// to land on the same number. +#[test] +fn test_storage_call_stipend_allowance_matches_per_opcode() { + // The callee is reached by a value-transferring CALL with no gas of its own beyond the stipend + // revm adds, so its storage work is paid for out of the allowance. + let callee = BytecodeBuilder::default() + .mstore(0, [0x33u8; 32]) + .push_number(0xdefu64) // topic0 + .push_number(32u64) // len + .push_number(0u64) // offset + .append(LOG1) + .append(STOP) + .build(); + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(1u64) // value — arms the stipend + .push_address(CALLEE) + .push_number(0u64) // gas — only the stipend is available + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let build_tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill() + }; + + let (_, r7) = + assert_parity("storage-call stipend", build_db, build_tx, EvmTxRuntimeLimits::from_spec); + assert!(r7.is_success(), "the stipend-funded call must succeed: {:?}", r7.result); + assert_eq!( + r7.result.logs().len(), + 1, + "the callee's log must have been emitted out of the stipend allowance; logs={:?}", + r7.result.logs() + ); +} + +/// The stipend's other arm: a value transfer to an account that does not exist yet, so the +/// new-account materialisation surcharge is what draws on the allowance. +#[test] +fn test_storage_call_stipend_new_account_matches_per_opcode() { + let code = plain_filler(BytecodeBuilder::default(), 10) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(1u64) // value + .push_address(EMPTY_TARGET) + .push_number(0u64) // gas + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill() + }; + + let (_, r7) = assert_parity( + "storage-call stipend / new account", + || base_db(code.clone()), + build_tx, + EvmTxRuntimeLimits::from_spec, + ); + assert!(r7.is_success(), "the value transfer must succeed: {:?}", r7.result); +} + +/// The oracle-hint site on its success arm: the payload is metered into the data-size lane from +/// inside `frame_init`, then forwarded to the backend, then the inner Oracle frame runs. +/// +/// Under checkpoint accounting the caller's segment was settled at the CALL checkpoint one step +/// earlier, so both the metering and the forwarding observe the same state they observe under +/// per-opcode accounting — and the hint that reaches the backend has to be identical. +#[test] +fn test_oracle_hint_forwarding_matches_per_opcode() { + let payload = Bytes::from(vec![0xa5u8; 96]); + let topic = B256::repeat_byte(0x5a); + let calldata = IOracle::sendHintCall { topic, data: payload.clone() }.abi_encode(); + let len = calldata.len() as u64; + let code = plain_filler(BytecodeBuilder::default(), 10) + .mstore(0, &calldata) + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(len) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(ORACLE_CONTRACT_ADDRESS) + .push_number(1_000_000u64) // gas + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let build_db = + || base_db(code.clone()).account_code(ORACLE_CONTRACT_ADDRESS, ORACLE_CONTRACT_CODE_REX2); + let tx = || { + TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill() + }; + + let envs6 = TestExternalEnvs::new(); + let r6 = transact_tx( + MegaSpecId::REX6, + build_db(), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX6), + tx(), + &envs6, + ); + let envs7 = TestExternalEnvs::new(); + let r7 = transact_tx( + MegaSpecId::REX7, + build_db(), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + tx(), + &envs7, + ); + + let hints6 = envs6.recorded_hints(); + let hints7 = envs7.recorded_hints(); + assert_eq!(hints7.len(), 1, "the hint must have reached the backend; got {hints7:?}"); + assert_eq!(hints6, hints7, "the forwarded hint must be identical under both models"); + assert_eq!(hints7[0].data, payload, "the payload must survive intact"); + assert_outcomes_identical("oracle hint forwarding", &r6, &r7); +} From dad83f735b59597caea06c156d46cd036c9eace6 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:27:13 +0800 Subject: [PATCH 12/43] test(rex7): sweep the double-exceed corner across the knife edge --- .../tests/rex7/double_exceed_corner.rs | 315 ++++++++++++++++++ crates/mega-evm/tests/rex7/main.rs | 1 + 2 files changed, 316 insertions(+) create mode 100644 crates/mega-evm/tests/rex7/double_exceed_corner.rs diff --git a/crates/mega-evm/tests/rex7/double_exceed_corner.rs b/crates/mega-evm/tests/rex7/double_exceed_corner.rs new file mode 100644 index 00000000..d62e1ddb --- /dev/null +++ b/crates/mega-evm/tests/rex7/double_exceed_corner.rs @@ -0,0 +1,315 @@ +//! REX7: the double-exceed corner, swept one gas at a time across the knife edge. +//! +//! The corner is the single opcode whose cost outruns *both* the true EVM remaining and the compute +//! headroom. The adjudication is that the compute classification wins: the transaction reports the +//! resource limit and the sender keeps the remaining gas, instead of revm's out-of-gas burning the +//! frame. The reason it can be adjudicated at all is that the two are indistinguishable at the +//! frame boundary — an out-of-gas carries no opcode cost — so there is nothing to tell them apart +//! with. +//! +//! A single case at the corner cannot show that the rule is *stable*: pick the transaction gas +//! limit one gas differently and the crossing opcode may become affordable in true gas while still +//! crossing the compute headroom. These tests calibrate the exact gas limit at which the crossing +//! opcode becomes affordable and sweep ±3 gas around it, asserting that +//! +//! - REX7 reports the same classification on every point of the sweep, and rescues the same amount +//! at every point — the receipt does not notice the edge at all; +//! - the window really does straddle the edge, which the REX6 arm shows by flipping from a burned +//! out-of-gas to a resource stop partway through. +//! +//! The calibration runs a probe truncated just before the crossing opcode and reads two numbers off +//! it: the compute gas recorded there (which fixes where to put the compute limit) and the +//! receipt's `gas_used` (the EVM gas spent there, which fixes the transaction gas limit at which +//! the crossing opcode is exactly affordable). The two are not the same number — MegaETH's +//! intrinsic transaction gas is larger than the intrinsic compute it records — so both have to be +//! measured. + +use crate::common::{ + transact_default, transact_with_gas_limit, Outcome, CALLER, CONTRACT, ONE_ETH, +}; +use alloy_primitives::{Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaHaltReason, MegaSpecId, +}; +use revm::bytecode::opcode::{CALL, MSTORE, POP, RETURN, STOP, TIMESTAMP}; + +/// Memory offset the crossing MSTORE writes to. Far enough out that the expansion dominates the +/// opcode's cost, close enough that the cost stays in the hundreds of gas. +const CROSSING_OFFSET: u64 = 0x2000; + +/// How far either side of the knife edge to sweep. +const SWEEP: i64 = 3; + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that record nothing of their own. +fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// The run leading up to the crossing MSTORE: an optional volatile access, a plain segment, and the +/// MSTORE's two stack operands. Everything here is cheap and fully paid for in every sweep point. +fn approach(volatile: bool) -> BytecodeBuilder { + let mut builder = BytecodeBuilder::default(); + if volatile { + builder = builder.append(TIMESTAMP).append(POP); + } + plain_filler(builder, 20).push_number(0u64).push_number(CROSSING_OFFSET) +} + +/// Runs `code` with nothing constraining it, for calibration. +fn unconstrained(code: Bytes) -> Outcome { + let outcome = transact_default(MegaSpecId::REX7, base_db(code)); + assert!(outcome.is_success(), "the calibration run must succeed: {:?}", outcome.result); + outcome +} + +/// The compute gas `code` records when neither EVM gas nor any resource limit constrains it. +fn unconstrained_compute_gas(code: Bytes) -> u64 { + unconstrained(code).compute_gas +} + +/// The calibration a sweep runs against. +struct KnifeEdge { + /// Compute gas recorded up to (not including) the crossing opcode. + compute_before: u64, + /// The crossing opcode's own cost. + cost: u64, + /// The transaction gas limit at which the crossing opcode is exactly affordable. + gas_limit_at_edge: u64, +} + +fn calibrate(volatile: bool) -> KnifeEdge { + let before = unconstrained(approach(volatile).append(STOP).build()); + let after = approach(volatile).append(MSTORE).append(STOP).build(); + let cost = unconstrained_compute_gas(after) - before.compute_gas; + assert!( + cost > 100, + "the crossing opcode must be expensive enough to sweep around; cost={cost}" + ); + KnifeEdge { + compute_before: before.compute_gas, + cost, + gas_limit_at_edge: before.gas_used + cost, + } +} + +/// The TX-level corner: the compute headroom at the MSTORE is half its cost, so the clamp always +/// stops it, while the transaction gas limit sweeps from one gas short of affording it to two gas +/// more than enough. +#[test] +fn test_tx_level_double_exceed_classification_is_stable_across_the_knife_edge() { + let edge = calibrate(false); + let code = approach(false).append(MSTORE).append(STOP).build(); + // Headroom strictly between zero and the opcode's cost: the clamp is outstanding at the MSTORE + // on every sweep point, and the MSTORE never fits inside it. + let limit = edge.compute_before + edge.cost / 2; + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit); + + let mut rex7_gas_used = Vec::new(); + let mut rex6_burned = Vec::new(); + for delta in -SWEEP..=SWEEP { + let gas_limit = (edge.gas_limit_at_edge as i64 + delta) as u64; + let label = format!("edge{delta:+}"); + let r6 = transact_with_gas_limit( + MegaSpecId::REX6, + base_db(code.clone()), + limits(MegaSpecId::REX6), + gas_limit, + ); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(code.clone()), + limits(MegaSpecId::REX7), + gas_limit, + ); + + assert!(!r7.is_success(), "{label}/REX7 must stop: {:?}", r7.result); + assert!( + matches!(r7.halt_reason(&label), MegaHaltReason::ComputeGasLimitExceeded { .. }), + "{label}: REX7 must classify the corner as a compute exceed on every sweep point; got \ + {:?}", + r7.halt_reason(&label), + ); + // The crossing opcode never ran, so its cost is not in the usage: the recorded total sits + // at or under the limit, and within one crossing-opcode cost of it — the headroom the + // opcode could not pay for is what the frame leaves unspent. + assert!( + r7.compute_gas <= limit && r7.compute_gas + edge.cost > limit, + "{label}: REX7 must stop at the clamp boundary; compute={} limit={limit} cost={}", + r7.compute_gas, + edge.cost, + ); + assert!( + r7.gas_used < gas_limit, + "{label}: REX7 must rescue rather than burn; gas_used={} gas_limit={gas_limit}", + r7.gas_used + ); + rex7_gas_used.push(r7.gas_used); + + assert!(!r6.is_success(), "{label}/REX6 must stop: {:?}", r6.result); + rex6_burned.push(r6.gas_used == gas_limit); + } + + let first = rex7_gas_used[0]; + assert!( + rex7_gas_used.iter().all(|&used| used == first), + "the rescued amount must not notice the edge; gas_used across the sweep = {rex7_gas_used:?}", + ); + // The sweep has to actually straddle the edge, or the stability claim is vacuous: per-opcode + // accounting burns the frame below the edge and stops on the resource limit above it. + assert!( + rex6_burned.contains(&true) && rex6_burned.contains(&false), + "the sweep must straddle the knife edge; REX6 burn pattern = {rex6_burned:?}", + ); +} + +/// The same sweep with gas detention as the binding constraint: the classification that has to stay +/// stable is `VolatileDataAccessOutOfGas`, which the clamp reconstructs from what bound it rather +/// than from usage having crossed the detained limit. +#[test] +fn test_detained_double_exceed_classification_is_stable_across_the_knife_edge() { + let edge = calibrate(true); + let code = approach(true).append(MSTORE).append(STOP).build(); + // The cap is relative to usage at the access, which happens two opcodes in. Sizing it as + // "everything between the access and the MSTORE, plus half the MSTORE" puts the detained + // headroom at the MSTORE at half the opcode's cost, exactly as in the TX-level case. + let at_access = unconstrained_compute_gas( + BytecodeBuilder::default().append(TIMESTAMP).append(STOP).build(), + ); + let cap = edge.compute_before - at_access + edge.cost / 2; + let limits = move |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = cap; + limits + }; + + let mut rex7_gas_used = Vec::new(); + let mut rex6_burned = Vec::new(); + for delta in -SWEEP..=SWEEP { + let gas_limit = (edge.gas_limit_at_edge as i64 + delta) as u64; + let label = format!("edge{delta:+}"); + let r6 = transact_with_gas_limit( + MegaSpecId::REX6, + base_db(code.clone()), + limits(MegaSpecId::REX6), + gas_limit, + ); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(code.clone()), + limits(MegaSpecId::REX7), + gas_limit, + ); + + assert!(!r7.is_success(), "{label}/REX7 must stop: {:?}", r7.result); + assert!( + matches!(r7.halt_reason(&label), MegaHaltReason::VolatileDataAccessOutOfGas { .. }), + "{label}: a detained corner must keep the volatile attribution on every sweep point; \ + got {:?}", + r7.halt_reason(&label), + ); + assert!( + r7.gas_used < gas_limit, + "{label}: REX7 must rescue rather than burn; gas_used={} gas_limit={gas_limit}", + r7.gas_used + ); + rex7_gas_used.push(r7.gas_used); + + assert!(!r6.is_success(), "{label}/REX6 must stop: {:?}", r6.result); + rex6_burned.push(r6.gas_used == gas_limit); + } + + let first = rex7_gas_used[0]; + assert!( + rex7_gas_used.iter().all(|&used| used == first), + "the rescued amount must not notice the edge; gas_used across the sweep = {rex7_gas_used:?}", + ); + assert!( + rex6_burned.contains(&true) && rex6_burned.contains(&false), + "the sweep must straddle the knife edge; REX6 burn pattern = {rex6_burned:?}", + ); +} + +/// The corner one frame down, where the clamp is bound frame-locally rather than TX-level. +/// +/// A frame-local exceed is absorbed into a revert, so the caller survives and sees a failed CALL — +/// and it must keep doing so across the edge, where the crossing opcode flips from unaffordable to +/// affordable in the child's true gas. The caller's own budget is untouched throughout, so the +/// transaction itself must succeed on every sweep point. +#[test] +fn test_frame_local_double_exceed_classification_is_stable_across_the_knife_edge() { + let callee_code = approach(false).append(MSTORE).append(STOP).build(); + let callee_before = approach(false).append(STOP).build(); + // The callee is a whole transaction's worth of work when run on its own, so calibrating it that + // way gives the EVM gas it needs before the MSTORE; inside a frame the intrinsic part is not + // charged again, so the edge is calibrated by sweeping a wide enough window instead. + let callee_intrinsic = + unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); + let before_in_frame = unconstrained_compute_gas(callee_before) - callee_intrinsic; + let cost = unconstrained_compute_gas(callee_code.clone()) - + unconstrained_compute_gas(approach(false).append(STOP).build()); + let forwarded_at_edge = before_in_frame + cost; + + let caller = |forwarded: u64| { + BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(crate::common::CALLEE) + .push_number(forwarded) + .append(CALL) + .push_number(0u64) + .append(MSTORE) + .push_number(32u64) + .push_number(0u64) + .append(RETURN) + .build() + }; + + let mut succeeded = Vec::new(); + for delta in -SWEEP..=SWEEP { + let forwarded = (forwarded_at_edge as i64 + delta) as u64; + let label = format!("edge{delta:+}"); + let code = caller(forwarded); + let build_db = + || base_db(code.clone()).account_code(crate::common::CALLEE, callee_code.clone()); + let r6 = transact_default(MegaSpecId::REX6, build_db()); + let r7 = transact_default(MegaSpecId::REX7, build_db()); + + for (spec, r) in [("REX6", &r6), ("REX7", &r7)] { + assert!( + r.is_success(), + "{label}/{spec}: a sub-frame running out of gas must not stop the transaction: \ + {:?}", + r.result + ); + } + let call_ok = |r: &Outcome| { + r.result.output().map(|o| U256::from_be_slice(o)) == Some(U256::from(1u64)) + }; + assert_eq!( + call_ok(&r6), + call_ok(&r7), + "{label}: both models must agree on whether the sub-frame survived", + ); + succeeded.push(call_ok(&r7)); + } + // The window straddles the point where the forwarded gas starts covering the crossing opcode. + assert!( + succeeded.contains(&true) && succeeded.contains(&false), + "the sweep must straddle the sub-frame's knife edge; outcomes = {succeeded:?}", + ); +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index ed499d68..e949c3a0 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -7,6 +7,7 @@ mod checkpoint_settlement; mod common; +mod double_exceed_corner; mod gas_leakage; mod interceptor_resume; mod latch_surfacing; From 789d67cb228ab2ea1cccc38b206606e9211f4069 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:28:54 +0800 Subject: [PATCH 13/43] test(rex7): add a parity case for every checkpoint opcode --- .../tests/rex7/checkpoint_families.rs | 203 ++++++++++++++++++ crates/mega-evm/tests/rex7/main.rs | 1 + 2 files changed, 204 insertions(+) create mode 100644 crates/mega-evm/tests/rex7/checkpoint_families.rs diff --git a/crates/mega-evm/tests/rex7/checkpoint_families.rs b/crates/mega-evm/tests/rex7/checkpoint_families.rs new file mode 100644 index 00000000..5289f17c --- /dev/null +++ b/crates/mega-evm/tests/rex7/checkpoint_families.rs @@ -0,0 +1,203 @@ +//! REX7: one parity case per checkpoint opcode the REX7 table wires. +//! +//! `checkpoint_settlement` covers the checkpoint families through representative members — one +//! LOG, one SLOAD, the CALL family, CREATE / CREATE2, SELFDESTRUCT, a few volatile opcodes. This +//! file closes the set: every opcode the REX7 instruction table replaces with a checkpoint handler +//! gets its own parity case, so a handler wired with the wrong settlement macro — or left out of a +//! future table edit — fails here rather than only in whichever downstream test happened to use it. +//! +//! Each opcode is run twice: once plainly, and once with a detention cap engaged before it so a +//! clamp is outstanding when its prologue runs. Both runs must be indistinguishable from REX6. + +use crate::common::{assert_outcomes_identical, transact, CALLEE, CALLER, CONTRACT, ONE_ETH}; +use alloy_primitives::{Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaSpecId, +}; +use revm::bytecode::opcode::{ + BALANCE, BASEFEE, BLOBBASEFEE, BLOBHASH, BLOCKHASH, COINBASE, DIFFICULTY, EXTCODECOPY, + EXTCODEHASH, EXTCODESIZE, GAS, GASLIMIT, LOG0, LOG1, LOG2, LOG3, LOG4, NUMBER, POP, + SELFBALANCE, SLOAD, STOP, TIMESTAMP, +}; + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) + .account_code(CALLEE, BytecodeBuilder::default().append(STOP).build()) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that record nothing of their own. +fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// Wraps `snippet` in plain segments on both sides, optionally engaging a detention cap first, so +/// the checkpoint under test has an open segment to settle and a clamp to restore. +fn program(snippet: impl Fn(BytecodeBuilder) -> BytecodeBuilder, volatile_prologue: bool) -> Bytes { + let mut builder = BytecodeBuilder::default(); + if volatile_prologue { + builder = builder.append(TIMESTAMP).append(POP); + } + let builder = snippet(plain_filler(builder, 5)); + plain_filler(builder, 5).append(STOP).build() +} + +/// Runs one checkpoint opcode under both specs, plainly and with a clamp outstanding. +fn assert_checkpoint_parity(label: &str, snippet: impl Fn(BytecodeBuilder) -> BytecodeBuilder) { + for (arm, volatile_prologue, cap) in + [("plain", false, u64::MAX), ("under a clamp", true, 1_000_000)] + { + let code = program(&snippet, volatile_prologue); + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + if cap != u64::MAX { + limits.block_env_access_compute_gas_limit = cap; + } + limits + }; + let r6 = transact(MegaSpecId::REX6, base_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, base_db(code), limits(MegaSpecId::REX7)); + let label = format!("{label} ({arm})"); + assert!(r6.is_success(), "{label}: REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "{label}: REX7 must succeed: {:?}", r7.result); + assert_outcomes_identical(&label, &r6, &r7); + } +} + +/// Pushes `n` LOG topics, then the payload length and offset, in the order the opcode pops them. +fn log_operands(builder: BytecodeBuilder, topics: usize) -> BytecodeBuilder { + let mut builder = builder.mstore(0, [0x44u8; 32]); + for topic in (0..topics).rev() { + builder = builder.push_number(0xabc0u64 + topic as u64); + } + builder.push_number(32u64).push_number(0u64) +} + +/// The block-environment opcodes: each marks volatile access, settles its segment, then installs +/// the detention cap. They take no operands and push one word. +#[test] +fn test_block_env_checkpoints_match_per_opcode() { + for (label, opcode) in [ + ("COINBASE", COINBASE), + ("TIMESTAMP", TIMESTAMP), + ("NUMBER", NUMBER), + ("DIFFICULTY", DIFFICULTY), + ("GASLIMIT", GASLIMIT), + ("BASEFEE", BASEFEE), + ("BLOBBASEFEE", BLOBBASEFEE), + ("SELFBALANCE", SELFBALANCE), + ] { + assert_checkpoint_parity(label, |builder| builder.append(opcode).append(POP)); + } +} + +/// The operand-taking volatile checkpoints. +#[test] +fn test_operand_taking_volatile_checkpoints_match_per_opcode() { + assert_checkpoint_parity("BLOCKHASH", |builder| { + builder.push_number(0u64).append(BLOCKHASH).append(POP) + }); + assert_checkpoint_parity("BLOBHASH", |builder| { + builder.push_number(0u64).append(BLOBHASH).append(POP) + }); + assert_checkpoint_parity("BALANCE", |builder| { + builder.push_address(CALLEE).append(BALANCE).append(POP) + }); + assert_checkpoint_parity("EXTCODESIZE", |builder| { + builder.push_address(CALLEE).append(EXTCODESIZE).append(POP) + }); + assert_checkpoint_parity("EXTCODEHASH", |builder| { + builder.push_address(CALLEE).append(EXTCODEHASH).append(POP) + }); + assert_checkpoint_parity("EXTCODECOPY", |builder| { + builder + .push_number(32u64) // length + .push_number(0u64) // offset + .push_number(0u64) // destOffset + .push_address(CALLEE) + .append(EXTCODECOPY) + }); + assert_checkpoint_parity("SLOAD", |builder| { + builder.push_u256(U256::from(3)).append(SLOAD).append(POP) + }); +} + +/// `GAS` is a checkpoint only because of the clamp: it has to restore the hidden gas before revm's +/// instruction reads the counter. +#[test] +fn test_gas_checkpoint_matches_per_opcode() { + assert_checkpoint_parity("GAS", |builder| builder.append(GAS).append(POP)); +} + +/// Every LOG arity: the storage-gas surcharge scales with the topic count, and each arity is a +/// separate table entry. +#[test] +fn test_every_log_arity_matches_per_opcode() { + for (label, opcode, topics) in [ + ("LOG0", LOG0, 0), + ("LOG1", LOG1, 1), + ("LOG2", LOG2, 2), + ("LOG3", LOG3, 3), + ("LOG4", LOG4, 4), + ] { + assert_checkpoint_parity(label, move |builder| { + log_operands(builder, topics).append(opcode) + }); + } +} + +/// SSTORE across the three write shapes its storage-gas charge distinguishes: a first write to a +/// fresh slot, an overwrite of that slot, and a write back to zero. +#[test] +fn test_sstore_write_shapes_match_per_opcode() { + assert_checkpoint_parity("SSTORE zero -> non-zero", |builder| { + builder.sstore(U256::from(0x50), U256::from(0x11)) + }); + assert_checkpoint_parity("SSTORE non-zero -> non-zero", |builder| { + builder + .sstore(U256::from(0x50), U256::from(0x11)) + .sstore(U256::from(0x50), U256::from(0x22)) + }); + assert_checkpoint_parity("SSTORE non-zero -> zero", |builder| { + builder.sstore(U256::from(0x50), U256::from(0x11)).sstore(U256::from(0x50), U256::ZERO) + }); + assert_checkpoint_parity("SSTORE then SLOAD of the same slot", |builder| { + builder + .sstore(U256::from(0x50), U256::from(0x11)) + .push_u256(U256::from(0x50)) + .append(SLOAD) + .append(POP) + }); + assert_checkpoint_parity("two SSTOREs with a plain gap", |builder| { + let builder = builder.sstore(U256::from(0x50), U256::from(0x11)); + plain_filler(builder, 10).sstore(U256::from(0x51), U256::from(0x22)) + }); +} + +/// Back-to-back checkpoints with no plain opcode between them: the settlement window has to open +/// and close on a zero-length segment without billing anything twice. +#[test] +fn test_adjacent_checkpoints_match_per_opcode() { + assert_checkpoint_parity("TIMESTAMP NUMBER COINBASE", |builder| { + builder + .append(TIMESTAMP) + .append(POP) + .append(NUMBER) + .append(POP) + .append(COINBASE) + .append(POP) + }); + assert_checkpoint_parity("GAS GAS", |builder| { + builder.append(GAS).append(POP).append(GAS).append(POP) + }); + assert_checkpoint_parity("SSTORE SSTORE", |builder| { + builder.sstore(U256::from(0x60), U256::from(1)).sstore(U256::from(0x61), U256::from(2)) + }); +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index e949c3a0..11eeddf8 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -5,6 +5,7 @@ //! - `v0_clamp` — V0 gas-clamp enforcement: a crossing opcode is stopped before it executes, and //! the resulting out-of-gas is restored and reclassified by the constraint that bound the clamp. +mod checkpoint_families; mod checkpoint_settlement; mod common; mod double_exceed_corner; From 0fdb5760ca7f36830b7905f2800cd6280c5da96a Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:30:48 +0800 Subject: [PATCH 14/43] test(rex7): satisfy clippy doc-markdown in the new suites --- crates/mega-evm/tests/rex7/double_exceed_corner.rs | 2 +- crates/mega-evm/tests/rex7/parity_shapes.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/mega-evm/tests/rex7/double_exceed_corner.rs b/crates/mega-evm/tests/rex7/double_exceed_corner.rs index d62e1ddb..aac01651 100644 --- a/crates/mega-evm/tests/rex7/double_exceed_corner.rs +++ b/crates/mega-evm/tests/rex7/double_exceed_corner.rs @@ -20,7 +20,7 @@ //! The calibration runs a probe truncated just before the crossing opcode and reads two numbers off //! it: the compute gas recorded there (which fixes where to put the compute limit) and the //! receipt's `gas_used` (the EVM gas spent there, which fixes the transaction gas limit at which -//! the crossing opcode is exactly affordable). The two are not the same number — MegaETH's +//! the crossing opcode is exactly affordable). The two are not the same number — `MegaETH`'s //! intrinsic transaction gas is larger than the intrinsic compute it records — so both have to be //! measured. diff --git a/crates/mega-evm/tests/rex7/parity_shapes.rs b/crates/mega-evm/tests/rex7/parity_shapes.rs index 7855077a..73a3b146 100644 --- a/crates/mega-evm/tests/rex7/parity_shapes.rs +++ b/crates/mega-evm/tests/rex7/parity_shapes.rs @@ -324,7 +324,7 @@ fn test_system_originated_transaction_is_unclamped_under_both_models() { ); } -/// The REX5 storage-call stipend is a per-frame allowance drawn only at MegaETH's storage-gas +/// The REX5 storage-call stipend is a per-frame allowance drawn only at `MegaETH`'s storage-gas /// surcharge sites — which are exactly the checkpoints. A value-transferring internal CALL into a /// callee that logs and writes storage draws on it at three of them. /// From e481864cc144a4adb3f9c4baae13453d3ca2dd96 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:36:59 +0800 Subject: [PATCH 15/43] test(rex7): describe the new suites in the module docs --- crates/mega-evm/tests/rex7/main.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index 11eeddf8..ffc5d561 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -4,6 +4,19 @@ //! bit-identical to per-opcode recording, and the two places where the models diverge. //! - `v0_clamp` — V0 gas-clamp enforcement: a crossing opcode is stopped before it executes, and //! the resulting out-of-gas is restored and reclassified by the constraint that bound the clamp. +//! - `checkpoint_families` — one parity case per checkpoint opcode the REX7 table wires, so the set +//! is covered exhaustively rather than through representatives. +//! - `interceptor_resume` — the two ways a CALL returns without a child frame ever running: a +//! system contract interceptor's synthetic result, and a precompile. +//! - `latch_surfacing` — where a latched data-size / KV-update / state-growth exceed becomes a +//! stop. +//! - `gas_leakage` — the three paths a per-frame gas mechanism can leak through (interception, +//! TX-level rescue, frame return), each with a clamp outstanding. +//! - `parity_shapes` — parity on the transaction shapes that enter through a different door: +//! EIP-7702 authorizations, the `KeylessDeploy` sandbox, system-originated (exempt) transactions, +//! the REX5 storage-call stipend, and oracle hints. +//! - `double_exceed_corner` — the adjudicated corner swept one gas at a time, so the classification +//! is shown to be stable rather than merely correct at one point. mod checkpoint_families; mod checkpoint_settlement; From efc378c91dea556b7b495d653f876db917c1a329 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:42:04 +0800 Subject: [PATCH 16/43] test(rex7): engage the clamp in the frame-local corner sweep --- .../tests/rex7/double_exceed_corner.rs | 92 +++++++++++++------ 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/crates/mega-evm/tests/rex7/double_exceed_corner.rs b/crates/mega-evm/tests/rex7/double_exceed_corner.rs index aac01651..73293188 100644 --- a/crates/mega-evm/tests/rex7/double_exceed_corner.rs +++ b/crates/mega-evm/tests/rex7/double_exceed_corner.rs @@ -25,7 +25,7 @@ //! measured. use crate::common::{ - transact_default, transact_with_gas_limit, Outcome, CALLER, CONTRACT, ONE_ETH, + transact, transact_default, transact_with_gas_limit, Outcome, CALLER, CONTRACT, ONE_ETH, }; use alloy_primitives::{Bytes, U256}; use mega_evm::{ @@ -67,13 +67,18 @@ fn approach(volatile: bool) -> BytecodeBuilder { plain_filler(builder, 20).push_number(0u64).push_number(CROSSING_OFFSET) } -/// Runs `code` with nothing constraining it, for calibration. -fn unconstrained(code: Bytes) -> Outcome { - let outcome = transact_default(MegaSpecId::REX7, base_db(code)); +/// Runs `db` with nothing constraining it, for calibration. +fn unconstrained_db(db: MemoryDatabase) -> Outcome { + let outcome = transact_default(MegaSpecId::REX7, db); assert!(outcome.is_success(), "the calibration run must succeed: {:?}", outcome.result); outcome } +/// Runs `code` against the default database with nothing constraining it, for calibration. +fn unconstrained(code: Bytes) -> Outcome { + unconstrained_db(base_db(code)) +} + /// The compute gas `code` records when neither EVM gas nor any resource limit constrains it. fn unconstrained_compute_gas(code: Bytes) -> u64 { unconstrained(code).compute_gas @@ -243,22 +248,26 @@ fn test_detained_double_exceed_classification_is_stable_across_the_knife_edge() /// The corner one frame down, where the clamp is bound frame-locally rather than TX-level. /// -/// A frame-local exceed is absorbed into a revert, so the caller survives and sees a failed CALL — -/// and it must keep doing so across the edge, where the crossing opcode flips from unaffordable to -/// affordable in the child's true gas. The caller's own budget is untouched throughout, so the -/// transaction itself must succeed on every sweep point. +/// A nested frame's compute budget is always strictly tighter than the TX-level remaining (98/100 +/// of its parent's), so the clamp inside a sub-frame is always bound frame-locally — and a +/// frame-local exceed is absorbed into a revert rather than halting the transaction. With the +/// compute limit set so the child's headroom runs out inside the crossing opcode, that absorption +/// has to hold on every sweep point, including where the child's *true* forwarded gas flips from +/// too little to enough. +/// +/// The control arm — the same sweep with nothing constraining compute — is what shows the window +/// straddles a real edge: there the sub-frame's outcome does flip. #[test] fn test_frame_local_double_exceed_classification_is_stable_across_the_knife_edge() { let callee_code = approach(false).append(MSTORE).append(STOP).build(); let callee_before = approach(false).append(STOP).build(); - // The callee is a whole transaction's worth of work when run on its own, so calibrating it that - // way gives the EVM gas it needs before the MSTORE; inside a frame the intrinsic part is not - // charged again, so the edge is calibrated by sweeping a wide enough window instead. + // Calibrating the callee as a standalone transaction gives its work up to the MSTORE once the + // intrinsic part — which a sub-frame does not pay again — is taken back out. let callee_intrinsic = unconstrained_compute_gas(BytecodeBuilder::default().append(STOP).build()); - let before_in_frame = unconstrained_compute_gas(callee_before) - callee_intrinsic; - let cost = unconstrained_compute_gas(callee_code.clone()) - - unconstrained_compute_gas(approach(false).append(STOP).build()); + let callee_before_compute = unconstrained_compute_gas(callee_before); + let before_in_frame = callee_before_compute - callee_intrinsic; + let cost = unconstrained_compute_gas(callee_code.clone()) - callee_before_compute; let forwarded_at_edge = before_in_frame + cost; let caller = |forwarded: u64| { @@ -278,18 +287,44 @@ fn test_frame_local_double_exceed_classification_is_stable_across_the_knife_edge .append(RETURN) .build() }; + let build_db = |code: &Bytes| { + base_db(code.clone()).account_code(crate::common::CALLEE, callee_code.clone()) + }; + let call_succeeded = + |r: &Outcome| r.result.output().map(|o| U256::from_be_slice(o)) == Some(U256::from(1u64)); - let mut succeeded = Vec::new(); + // A compute limit half a crossing-opcode short of what the whole transaction needs when the + // sub-frame completes: the child's own budget is what runs out, and it runs out inside the + // MSTORE. + let generous = caller(forwarded_at_edge + SWEEP as u64); + let whole_tx = unconstrained_db(build_db(&generous)); + assert!(call_succeeded(&whole_tx), "the calibration run's sub-frame must complete"); + let limit = whole_tx.compute_gas - cost / 2; + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit); + + let mut control_outcomes = Vec::new(); for delta in -SWEEP..=SWEEP { let forwarded = (forwarded_at_edge as i64 + delta) as u64; let label = format!("edge{delta:+}"); let code = caller(forwarded); - let build_db = - || base_db(code.clone()).account_code(crate::common::CALLEE, callee_code.clone()); - let r6 = transact_default(MegaSpecId::REX6, build_db()); - let r7 = transact_default(MegaSpecId::REX7, build_db()); - for (spec, r) in [("REX6", &r6), ("REX7", &r7)] { + // Treatment: the child's compute headroom is what the crossing opcode cannot pay for. + let r7 = transact(MegaSpecId::REX7, build_db(&code), limits(MegaSpecId::REX7)); + assert!( + r7.is_success(), + "{label}: a frame-local exceed must be absorbed into a revert, not halt the \ + transaction: {:?}", + r7.result + ); + assert!( + !call_succeeded(&r7), + "{label}: the sub-frame must report failure on every sweep point", + ); + + // Control: nothing constrains compute, so only the forwarded gas decides. + let c6 = transact_default(MegaSpecId::REX6, build_db(&code)); + let c7 = transact_default(MegaSpecId::REX7, build_db(&code)); + for (spec, r) in [("REX6", &c6), ("REX7", &c7)] { assert!( r.is_success(), "{label}/{spec}: a sub-frame running out of gas must not stop the transaction: \ @@ -297,19 +332,18 @@ fn test_frame_local_double_exceed_classification_is_stable_across_the_knife_edge r.result ); } - let call_ok = |r: &Outcome| { - r.result.output().map(|o| U256::from_be_slice(o)) == Some(U256::from(1u64)) - }; assert_eq!( - call_ok(&r6), - call_ok(&r7), + call_succeeded(&c6), + call_succeeded(&c7), "{label}: both models must agree on whether the sub-frame survived", ); - succeeded.push(call_ok(&r7)); + control_outcomes.push(call_succeeded(&c7)); } - // The window straddles the point where the forwarded gas starts covering the crossing opcode. + // The window straddles the point where the forwarded gas starts covering the crossing opcode, + // so the stability asserted above is a statement about a real edge. assert!( - succeeded.contains(&true) && succeeded.contains(&false), - "the sweep must straddle the sub-frame's knife edge; outcomes = {succeeded:?}", + control_outcomes.contains(&true) && control_outcomes.contains(&false), + "the sweep must straddle the sub-frame's knife edge; control outcomes = \ + {control_outcomes:?}", ); } From 6648de8576657fe56c7e2116e0e133a2748fccff Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Tue, 11 Aug 2026 17:49:17 +0800 Subject: [PATCH 17/43] docs(rex7): correct halt-field actual/limit contract and top-frame tie-break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document that per-opcode enforcement (through Rex6) reports actual > limit while gas-clamp enforcement (Rex7+) reports actual ≤ limit on compute and detention halts. Normatively state that equal frame and TX remaining headroom binds the clamp to the TX level (halt + rescue), unlike Rex6's frame-local revert classification at the top frame. --- crates/mega-evm/src/evm/result.rs | 20 ++++++++++++++++---- docs/spec/evm/compute-gas.md | 4 ++++ docs/spec/upgrades/rex7.md | 6 ++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/mega-evm/src/evm/result.rs b/crates/mega-evm/src/evm/result.rs index e865360a..8ae66054 100644 --- a/crates/mega-evm/src/evm/result.rs +++ b/crates/mega-evm/src/evm/result.rs @@ -109,9 +109,15 @@ pub enum MegaHaltReason { }, /// Compute gas limit exceeded ComputeGasLimitExceeded { - /// The configured compute gas limit + /// The configured compute gas limit that was exceeded. + /// + /// Relation to `actual` depends on the enforcement model: + /// - Per-opcode enforcement (through Rex6): the crossing opcode has already recorded its + /// cost, so `actual > limit`. + /// - Gas-clamp enforcement (Rex7+): the crossing opcode is stopped before it executes and + /// its cost is not recorded, so `actual ≤ limit`. limit: u64, - /// The actual compute gas usage + /// The actual compute gas usage at the halt. actual: u64, }, /// State growth limit exceeded @@ -136,9 +142,15 @@ pub enum MegaHaltReason { access_type: VolatileDataAccess, /// The effective detained compute gas limit that was exceeded. /// In REX4+ this is `usage_at_access + cap` (relative); pre-REX4 it equals the raw cap - /// (absolute). Always satisfies `actual > limit`. + /// (absolute). + /// + /// Relation to `actual` depends on the enforcement model: + /// - Per-opcode enforcement (through Rex6): the crossing opcode has already recorded its + /// cost, so `actual > limit`. + /// - Gas-clamp enforcement (Rex7+): the crossing opcode is stopped before it executes and + /// its cost is not recorded, so `actual ≤ limit`. limit: u64, - /// The actual compute gas usage + /// The actual compute gas usage at the halt. actual: u64, }, } diff --git a/docs/spec/evm/compute-gas.md b/docs/spec/evm/compute-gas.md index 151fe335..38de1dc6 100644 --- a/docs/spec/evm/compute-gas.md +++ b/docs/spec/evm/compute-gas.md @@ -487,6 +487,10 @@ Inside a plain-opcode segment: Because the crossing opcode never executes, a node MUST NOT include its cost in recorded compute-gas usage. +When the current frame's remaining per-frame compute budget equals the transaction-level remaining budget, a node MUST bind the clamp to the transaction-level constraint (including detention when detention is the effective transaction-level bound). +A clamp-induced exceed under that binding MUST halt the transaction with gas rescue; a node MUST NOT classify the equality as frame-local. +Through Rex6, the same equality is classified by the per-opcode check as a frame-local exceed; at the top-level frame that surfaces as a revert rather than a halt. + When the crossing opcode would exhaust both the true remaining EVM gas and the compute headroom, a node MUST attribute the halt to the compute-gas or detention limit (with rescue) rather than to ordinary EVM out-of-gas. #### Exceptional-halt frame carve-out diff --git a/docs/spec/upgrades/rex7.md b/docs/spec/upgrades/rex7.md index aa58f935..d3c5f667 100644 --- a/docs/spec/upgrades/rex7.md +++ b/docs/spec/upgrades/rex7.md @@ -105,6 +105,12 @@ Inside a plain-opcode segment only plain opcodes run, so the inherited EVM's ord Because the crossing opcode never executes, a node MUST NOT include its cost in recorded compute-gas usage. Recorded usage at a clamp-induced halt therefore ends at the limit (or strictly below it if settlement had not yet closed a partial segment), not strictly above it. +**Top-frame headroom tie-break.** +At the top-level frame the remaining per-frame compute budget equals the transaction-level remaining budget whenever both are still governed by the same base limit. +When those two remaining amounts are equal, a node MUST bind the clamp to the transaction-level constraint (or to the detained limit when detention is the effective transaction-level bound). +A clamp-induced exceed under that binding MUST halt the transaction with gas rescue; a node MUST NOT classify the equality as frame-local. +Through Rex6, the same equality is classified by the per-opcode check as a frame-local exceed, which the top-level frame absorbs into a revert rather than a halt. + **Double-exceed preference.** When the crossing opcode would have exhausted both the true remaining EVM gas and the compute headroom at the same point, a node MUST attribute the halt to the compute-gas (or detention) limit rather than to ordinary EVM out-of-gas, so remaining gas stays refundable under the rescue rules. The two cases are indistinguishable once the frame has already reported out-of-gas, and the compute classification is the one that preserves the sender refund. From ab45e02b0c8c16eeb221d7ffa47794c687a4c3ef Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 00:55:44 +0800 Subject: [PATCH 18/43] fix(rex7): bind the V0 gas clamp on an explicit lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clamp used a zero hidden amount as the sentinel for "no clamp", which also happens to be what an exactly-equal clamp hides. A segment whose true remaining matched the compute headroom therefore enforced the limit but was never reclassified: the crossing opcode's ordinary out-of-gas propagated as an EVM out-of-gas, with no gas rescue and no MegaLimitExceeded payload. Record the clamp as state instead — present exactly while it binds, carrying the constraint it was bound to — so the equal case reclassifies like every other clamp, and a segment whose own gas runs out first records no clamp at all and keeps the EVM's own out-of-gas. --- crates/mega-evm/src/evm/execution.rs | 8 +- crates/mega-evm/src/limit/compute_gas.rs | 30 ++- crates/mega-evm/src/limit/limit.rs | 119 ++++++------ .../tests/rex7/clamp_classification.rs | 171 ++++++++++++++++++ crates/mega-evm/tests/rex7/main.rs | 3 + 5 files changed, 266 insertions(+), 65 deletions(-) create mode 100644 crates/mega-evm/tests/rex7/clamp_classification.rs diff --git a/crates/mega-evm/src/evm/execution.rs b/crates/mega-evm/src/evm/execution.rs index ee3718c2..767e7794 100644 --- a/crates/mega-evm/src/evm/execution.rs +++ b/crates/mega-evm/src/evm/execution.rs @@ -456,10 +456,10 @@ impl MegaEvm { let is_rex5 = ctx.spec.is_enabled(MegaSpecId::REX5); if let InterpreterAction::Return(interpreter_result) = action { - // REX7 V0 clamp: hand any clamp-hidden gas back to the result — and latch a - // clamp-induced out-of-gas as the compute exceed it stands for — before the - // code-deposit charge below observes the result's gas. - ctx.additional_limit.borrow_mut().restore_clamp_into_result(interpreter_result); + // REX7: hand any clamp-hidden gas back to the result and latch a clamp-induced + // out-of-gas as the compute exceed it stands for, before the code-deposit charge below + // observes the result's gas. + ctx.additional_limit.borrow_mut().settle_frame_final_result(interpreter_result); // Charge storage gas cost for the number of bytes if frame.data.is_create() && interpreter_result.is_ok() { diff --git a/crates/mega-evm/src/limit/compute_gas.rs b/crates/mega-evm/src/limit/compute_gas.rs index 90f42953..86264157 100644 --- a/crates/mega-evm/src/limit/compute_gas.rs +++ b/crates/mega-evm/src/limit/compute_gas.rs @@ -6,6 +6,20 @@ use super::{ }; use crate::{JournalInspectTr, MegaSpecId}; +/// The constraint that bounds the V0 gas clamp for one plain-opcode segment. +/// +/// Captured when the clamp is applied, so a clamp-induced out-of-gas can be classified against the +/// constraint that was in force at the time rather than against whatever the tracker looks like +/// once the frame has already failed. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ClampBinding { + /// The compute headroom the interpreter is allowed to keep seeing. + pub(crate) headroom: u64, + /// `true` when the current frame's compute budget is what binds, `false` when the TX-level + /// (possibly detained) limit is. + pub(crate) frame_local: bool, +} + /// A frame-limit-based compute gas tracker using `FrameLimitTracker`. /// /// Unlike the other trackers (`DataSizeTracker`, `KVUpdateTracker`, `StateGrowthTracker`), compute @@ -115,24 +129,26 @@ impl ComputeGasTracker { self.frame_tracker.tx_limit() } - /// Returns the compute gas headroom the V0 gas clamp may leave visible to the interpreter, - /// and whether the binding constraint is the frame-local budget (`true`) or the TX-level - /// (possibly detained) limit (`false`). + /// Returns the constraint the V0 gas clamp must bind to at this point in the transaction. /// /// The headroom is the tighter of the current frame's remaining compute budget (Rex4+) and /// the TX-level remaining under the effective (possibly detained) limit — the same pair /// [`check_limit`](TxRuntimeLimit::check_limit) enforces. Gas hidden beyond this headroom is /// therefore reachable only by a transaction that would exceed one of those two limits. + /// Equal remainders bind to the TX-level constraint, so a top-level frame — where the two are + /// equal whenever the same base limit still governs both — halts with gas rescue rather than + /// absorbing the exceed into a revert. #[inline] - pub(crate) fn clamp_headroom(&self) -> (u64, bool) { - let tx_remaining = self.tx_limit().saturating_sub(self.tx_usage()); + pub(crate) fn clamp_binding(&self) -> ClampBinding { + let tx_limit = self.tx_limit(); + let tx_remaining = tx_limit.saturating_sub(self.tx_usage()); if self.rex4_enabled { let frame_remaining = self.frame_tracker.current_frame_remaining(); if frame_remaining < tx_remaining { - return (frame_remaining, true); + return ClampBinding { headroom: frame_remaining, frame_local: true }; } } - (tx_remaining, false) + ClampBinding { headroom: tx_remaining, frame_local: false } } /// Returns `true` when gas detention is the binding TX-level constraint, i.e., the detained diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index d85e39e6..88473068 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -123,22 +123,15 @@ pub struct AdditionalLimit { /// checkpoint prologue and body recording. checkpoint_baseline: u64, - /// V0 gas-clamp enforcement (REX7+): the part of the executing frame's interpreter gas hidden - /// from the interpreter, so that revm's own per-opcode gas checks enforce the compute headroom - /// inside plain-opcode segments at no per-opcode cost. + /// V0 gas-clamp enforcement (REX7+): the clamp in force for the plain-opcode segment the + /// current frame is inside, so that revm's own per-opcode gas checks enforce the compute + /// headroom at no per-opcode cost. /// - /// Non-zero only while the current frame is inside a plain segment: every checkpoint restores - /// it before running its body — so CALL forwarding, `GAS` and storage charges observe the true - /// counter — and re-applies it on the way out, and the frame's final result restores it via - /// [`restore_clamp_into_result`](Self::restore_clamp_into_result). - clamp_hidden: u64, - - /// Whether the headroom that bound the last clamp was the frame-local compute budget (`true`) - /// or the TX-level (possibly detained) limit (`false`). - /// - /// This decides how a clamp-induced out-of-gas is reclassified: a frame-local exceed reverts - /// to the parent, a TX-level exceed halts the transaction. - clamp_frame_local: bool, + /// Present only while the current frame is inside a plain segment: every checkpoint takes it + /// before running its body — so CALL forwarding, `GAS` and storage charges observe the true + /// counter — and re-applies it on the way out, and the frame's final result takes it via + /// [`settle_frame_final_result`](Self::settle_frame_final_result). + clamp: Option, /// Whether a clamp-induced out-of-gas was latched while gas detention was the binding TX-level /// constraint. @@ -151,6 +144,21 @@ pub struct AdditionalLimit { clamp_latched_detained: bool, } +/// A V0 gas clamp in force for one plain-opcode segment (REX7+). +/// +/// The clamp is a lifecycle, not an amount. It is recorded exactly while it **binds** — while the +/// interpreter's true remaining gas was at or above the compute headroom when the segment opened — +/// and a `hidden` of zero is a binding clamp whose two budgets happened to coincide, not the +/// absence of one. When the frame's own gas would run out ahead of the compute headroom no clamp +/// is recorded at all, and an out-of-gas inside that segment stays the EVM's own. +#[derive(Clone, Copy, Debug)] +struct ClampState { + /// Interpreter gas hidden from the interpreter for this segment. + hidden: u64, + /// The constraint the clamp was bound to, captured at the moment it was applied. + binding: compute_gas::ClampBinding, +} + /// The usage of the additional limits. #[derive(Clone, Copy, Debug, Default)] pub struct LimitUsage { @@ -178,8 +186,7 @@ impl AdditionalLimit { storage_call_stipend: storage_call_stipend::StorageCallStipendTracker::new(spec), checkpoint_accounting: spec.is_enabled(MegaSpecId::REX7), checkpoint_baseline: 0, - clamp_hidden: 0, - clamp_frame_local: false, + clamp: None, clamp_latched_detained: false, } } @@ -223,8 +230,7 @@ impl AdditionalLimit { self.kv_update.reset(); self.storage_call_stipend.reset(); self.checkpoint_baseline = 0; - self.clamp_hidden = 0; - self.clamp_frame_local = false; + self.clamp = None; self.clamp_latched_detained = false; } @@ -253,33 +259,41 @@ impl AdditionalLimit { self.checkpoint_baseline = remaining; } - /// Takes the outstanding clamp-hidden gas so the caller can hand it back to the interpreter. + /// Takes the outstanding clamp so the caller can hand its hidden gas back to the interpreter, + /// returning that amount. /// /// Every checkpoint prologue calls this before running its body, and the frame's final result /// calls it before the result propagates, so the clamp is never observable outside a plain /// segment. #[inline] pub(crate) fn checkpoint_restore_hidden(&mut self) -> u64 { - core::mem::take(&mut self.clamp_hidden) + self.clamp.take().map_or(0, |clamp| clamp.hidden) } - /// Computes how much interpreter gas to hide so the visible remaining equals the compute - /// headroom, records it as outstanding, and returns it for the caller to debit from the - /// interpreter's counter. + /// Applies the V0 gas clamp for the segment that starts at `remaining`, and returns the amount + /// the caller must debit from the interpreter's counter. + /// + /// The clamp is recorded — and the segment therefore enforces the compute limit — whenever the + /// true remaining reaches the compute headroom, including when the two are exactly equal and + /// nothing needs to be hidden. When the frame's own gas would run out first, no clamp is + /// recorded: an out-of-gas in that segment is the EVM's own, and reclassifying it as a compute + /// exceed would rescue gas the transaction never had a claim to. /// - /// Returns 0 when clamping does not apply: the transaction is exempt from per-tx metering, or - /// a limit has already been latched (the enclosing site halts on it instead). + /// Records nothing and returns 0 when clamping does not apply: the transaction is exempt from + /// per-tx metering, or a limit has already been latched (the enclosing site halts on it + /// instead). #[inline] pub(crate) fn checkpoint_clamp_amount(&mut self, remaining: u64) -> u64 { - debug_assert_eq!(self.clamp_hidden, 0, "clamp applied while a clamp is outstanding"); + debug_assert!(self.clamp.is_none(), "clamp applied while a clamp is outstanding"); if !self.has_exceeded_limit.within_limit() { return 0; } - let (headroom, frame_local) = self.compute_gas.clamp_headroom(); - let hide = remaining.saturating_sub(headroom); - self.clamp_hidden = hide; - self.clamp_frame_local = frame_local; - hide + let binding = self.compute_gas.clamp_binding(); + let Some(hidden) = remaining.checked_sub(binding.headroom) else { + return 0; + }; + self.clamp = Some(ClampState { hidden, binding }); + hidden } /// Latches a clamp-induced out-of-gas as a compute gas limit exceed. @@ -291,20 +305,20 @@ impl AdditionalLimit { /// machinery (frame-local absorb to revert; TX-level mark plus gas rescue) produces the halt /// shape it produces for every other compute exceed. #[inline] - fn latch_clamp_exceed(&mut self) { + fn latch_clamp_exceed(&mut self, binding: &compute_gas::ClampBinding) { if !self.has_exceeded_limit.within_limit() { return; } self.has_exceeded_limit = LimitCheck::ExceedsLimit { kind: super::LimitKind::ComputeGas, - frame_local: self.clamp_frame_local, + frame_local: binding.frame_local, limit: self.compute_gas.tx_limit(), used: self.compute_gas.tx_usage(), }; // Preserve the volatile-detention attribution: when the binding TX-level constraint at // clamp time was the detained limit, the halt must classify as `VolatileDataAccessOutOfGas` // exactly as per-opcode enforcement classifies it. - self.clamp_latched_detained = !self.clamp_frame_local && + self.clamp_latched_detained = !binding.frame_local && self.compute_gas.detained_limit() < self.compute_gas.base_tx_limit(); } @@ -316,26 +330,23 @@ impl AdditionalLimit { /// and mis-fire an out-of-gas on a CREATE frame that is nowhere near its limits. /// /// A clamp can only be outstanding when the frame ended inside a plain-opcode segment, because - /// every checkpoint prologue restores it before its body. An out-of-gas exit from such a - /// segment is a clamp artifact: the true counter held `hidden` more gas than the - /// interpreter could see, and the crossing opcode was stopped at the clamp boundary *before - /// executing* — exactly the V0 enforcement point. When the crossing opcode would have - /// exceeded the true remaining as well, the compute classification still wins: the two are - /// indistinguishable here, and attributing the halt to the resource limit keeps the - /// sender's remaining gas refundable. - pub(crate) fn restore_clamp_into_result(&mut self, result: &mut InterpreterResult) { + /// every checkpoint prologue takes it before its body. An out-of-gas exit from such a segment + /// is a clamp artifact: the true counter held `hidden` more gas than the interpreter could see, + /// and the crossing opcode was stopped at the clamp boundary *before executing* — exactly the + /// V0 enforcement point. When the crossing opcode would have exceeded the true remaining as + /// well, the compute classification still wins: the two are indistinguishable here, and + /// attributing the halt to the resource limit keeps the sender's remaining gas refundable. + pub(crate) fn settle_frame_final_result(&mut self, result: &mut InterpreterResult) { if !self.checkpoint_accounting { return; } - let hidden = self.checkpoint_restore_hidden(); - if hidden == 0 { - return; - } - result.gas.erase_cost(hidden); - // `MemoryOOG` is the same gas shortage reported from the memory-expansion path; every other - // result either is unrelated to gas or cannot arise from a plain opcode. - if matches!(result.result, InstructionResult::OutOfGas | InstructionResult::MemoryOOG) { - self.latch_clamp_exceed(); + if let Some(clamp) = self.clamp.take() { + result.gas.erase_cost(clamp.hidden); + // `MemoryOOG` is the same gas shortage reported from the memory-expansion path; every + // other result either is unrelated to gas or cannot arise from a plain opcode. + if matches!(result.result, InstructionResult::OutOfGas | InstructionResult::MemoryOOG) { + self.latch_clamp_exceed(&clamp.binding); + } } } @@ -886,7 +897,7 @@ impl AdditionalLimit { // here: every suspension point (the CALL / CREATE checkpoint prologue) and every // frame end restores it first. if self.checkpoint_accounting { - debug_assert_eq!(self.clamp_hidden, 0, "frame resumed with a clamp outstanding"); + debug_assert!(self.clamp.is_none(), "frame resumed with a clamp outstanding"); let hide = self.checkpoint_clamp_amount(frame.interpreter.gas.remaining()); if hide > 0 { let clamped = frame.interpreter.gas.record_regular_cost(hide); @@ -940,7 +951,7 @@ impl AdditionalLimit { // `erase_cost` can only raise `remaining` above the baseline, which the saturation turns // into 0. Any exceed recorded here is latched, and the frame result marking below / in // `before_frame_return_result` surfaces it. The clamp restore itself already happened, in - // `restore_clamp_into_result`, before the execution-layer hook charged code-deposit storage + // `settle_frame_final_result`, before the execution-layer hook charged code-deposit storage // gas against the action's gas. if self.checkpoint_accounting { if let InterpreterAction::Return(_) = action { diff --git a/crates/mega-evm/tests/rex7/clamp_classification.rs b/crates/mega-evm/tests/rex7/clamp_classification.rs new file mode 100644 index 00000000..a775b343 --- /dev/null +++ b/crates/mega-evm/tests/rex7/clamp_classification.rs @@ -0,0 +1,171 @@ +//! REX7 gas-clamp classification and the payload a clamp-induced exceed reports. +//! +//! The clamp is a lifecycle, not an amount: it is applied at a checkpoint, it binds the segment +//! that follows to one specific constraint, and it is consumed at the next checkpoint or at frame +//! exit. Whether the interpreter's true remaining happened to sit *above* the compute headroom or +//! exactly *on* it changes how much gets hidden — zero in the second case — but not whether the +//! clamp is in force. Both are the compute limit doing the stopping, and both must be reported as +//! such; only a frame whose own EVM gas runs out first is an ordinary out-of-gas. + +use crate::common::{transact_default, transact_with_gas_limit, CALLER, CONTRACT, ONE_ETH}; +use alloy_primitives::{Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaHaltReason, MegaSpecId, +}; +use revm::bytecode::opcode::{MSTORE, POP, STOP}; + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) +} + +/// Per-spec runtime limits with the TX compute gas limit replaced. +fn compute_limit(limit: u64) -> impl Fn(MegaSpecId) -> EvmTxRuntimeLimits { + move |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that record nothing of their own, five gas each. +fn plain_filler(pairs: usize) -> Vec { + let mut builder = BytecodeBuilder::default(); + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder.build_vec() +} + +// --------------------------------------------------------------------------------------------- +// The equal-value clamp: hidden == 0 and the clamp still binds. +// --------------------------------------------------------------------------------------------- + +/// Memory offset the calibrated `MSTORE` writes at — 32 KiB, so its expansion cost is thousands of +/// gas and the knife edge is not sensitive to a one-gas miscount anywhere else. +const MSTORE_OFFSET: u64 = 0x8000; + +/// The two shapes the knife-edge calibration needs: everything up to the `MSTORE`'s operands, and +/// the same thing with the `MSTORE` itself. +fn knife_edge_shapes() -> (Bytes, Bytes) { + let operands = |mut code: Vec| { + let mut builder = BytecodeBuilder::default(); + builder = builder.push_number(0u64).push_number(MSTORE_OFFSET); + code.extend_from_slice(&builder.build_vec()); + code + }; + let mut before = operands(plain_filler(20)); + before.push(STOP); + let mut full = operands(plain_filler(20)); + full.push(MSTORE); + full.push(STOP); + (Bytes::from(before), Bytes::from(full)) +} + +/// The calibrated knife edge: the exact transaction gas limit and compute gas limit that leave the +/// crossing `MSTORE` one gas short on *both* budgets at once. +struct KnifeEdge { + code: Bytes, + gas_limit: u64, + compute_limit: u64, + /// Compute gas the transaction has recorded when the `MSTORE` is reached. + compute_before: u64, +} + +fn calibrate_knife_edge() -> KnifeEdge { + let (before_code, full_code) = knife_edge_shapes(); + let before = transact_default(MegaSpecId::REX7, base_db(before_code)); + let full = transact_default(MegaSpecId::REX7, base_db(full_code.clone())); + assert!(before.is_success(), "calibration run must succeed: {:?}", before.result); + assert!(full.is_success(), "calibration run must succeed: {:?}", full.result); + + let mstore_cost = full.compute_gas - before.compute_gas; + assert!(mstore_cost > 1, "the MSTORE must have a real expansion cost, got {mstore_cost}"); + KnifeEdge { + code: full_code, + // One gas short of the MSTORE on the EVM's own counter... + gas_limit: before.gas_used + mstore_cost - 1, + // ...and one gas short of it on the compute headroom, so the two coincide exactly and the + // clamp hides nothing at all. + compute_limit: before.compute_gas + mstore_cost - 1, + compute_before: before.compute_gas, + } +} + +/// An exact-value clamp — true remaining equal to the compute headroom, nothing hidden — is still +/// the compute limit doing the stopping, and must be reported as a compute exceed rather than as +/// an ordinary EVM out-of-gas. +/// +/// This is the double-exceed preference at its knife edge: the crossing opcode exhausts both +/// budgets at the same gas, and the compute classification is the one that keeps the sender's +/// remaining gas refundable. +#[test] +fn test_exact_value_clamp_is_still_a_compute_exceed() { + let edge = calibrate_knife_edge(); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(edge.code.clone()), + compute_limit(edge.compute_limit)(MegaSpecId::REX7), + edge.gas_limit, + ); + + match r7.halt_reason("REX7") { + MegaHaltReason::ComputeGasLimitExceeded { limit, actual } => { + assert_eq!(*limit, edge.compute_limit, "the reported limit is the TX compute limit"); + assert!( + *actual <= edge.compute_limit, + "the crossing opcode never ran, so usage cannot be past the limit; got {actual}", + ); + } + other => panic!( + "an equal-value clamp must classify as a compute exceed, not an ordinary \ + out-of-gas; got {other:?}", + ), + } +} + +/// The neighbouring points on either side of the knife edge classify the way the equal point does +/// or the way an ordinary out-of-gas does, and nothing in between. +/// +/// One gas more of transaction gas puts the true remaining strictly above the headroom, so the +/// clamp hides one gas — the case that already worked. One gas more of compute limit puts the +/// headroom strictly above the true remaining, so the frame's own gas is what runs out and the +/// halt is an ordinary out-of-gas with no compute attribution. +#[test] +fn test_knife_edge_neighbours_classify_by_which_budget_binds() { + let edge = calibrate_knife_edge(); + + let hidden_one = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(edge.code.clone()), + compute_limit(edge.compute_limit)(MegaSpecId::REX7), + edge.gas_limit + 1, + ); + assert!( + matches!( + hidden_one.halt_reason("hidden=1"), + MegaHaltReason::ComputeGasLimitExceeded { .. } + ), + "one gas above the edge the clamp hides one gas and binds; got {:?}", + hidden_one.result + ); + + let gas_bound = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(edge.code.clone()), + compute_limit(edge.compute_limit + 1)(MegaSpecId::REX7), + edge.gas_limit, + ); + assert!( + !matches!( + gas_bound.halt_reason("gas-bound"), + MegaHaltReason::ComputeGasLimitExceeded { .. } + ), + "one gas of headroom above the true remaining makes this the EVM's own out-of-gas; \ + got {:?}", + gas_bound.result + ); + assert!( + gas_bound.compute_gas > edge.compute_before, + "the EVM out-of-gas burns the frame's remainder, which settles as compute", + ); +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index ffc5d561..27d66790 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -4,6 +4,8 @@ //! bit-identical to per-opcode recording, and the two places where the models diverge. //! - `v0_clamp` — V0 gas-clamp enforcement: a crossing opcode is stopped before it executes, and //! the resulting out-of-gas is restored and reclassified by the constraint that bound the clamp. +//! - `clamp_classification` — which constraint a clamp binds to, including the exact-value case, +//! and the ABI payload / halt fields a clamp-induced exceed reports. //! - `checkpoint_families` — one parity case per checkpoint opcode the REX7 table wires, so the set //! is covered exhaustively rather than through representatives. //! - `interceptor_resume` — the two ways a CALL returns without a child frame ever running: a @@ -20,6 +22,7 @@ mod checkpoint_families; mod checkpoint_settlement; +mod clamp_classification; mod common; mod double_exceed_corner; mod gas_leakage; From 49647658aaadf5fd3c349412d01ad634c8797213 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 01:09:37 +0800 Subject: [PATCH 19/43] fix(rex7): settle every exceptional halt's burned remainder as compute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame-exit settlement read the interpreter's counter, and the interpreter zeroes that counter only for a plain out-of-gas. Memory OOG, stack underflow/overflow, invalid jump and unknown opcode all keep their loop-exit reading and have their remainder burned later by the frame-return rules, so the settlement saw almost none of it: a transaction that burned its whole million-gas envelope on a memory OOG reported 21,009 compute gas, and that figure feeds the block-level compute accounting. Drive the settlement off the halt classification instead, and cover the whole remainder the frame still held at the last checkpoint, including gas the V0 clamp was hiding from the interpreter. The burn is recorded outside limit enforcement. It is gas the EVM destroyed rather than work the network performed, and it is bounded by the sender's gas envelope rather than by the compute limit, so enforcing it would turn an ordinary EVM halt into a resource-limit failure with the remaining gas rescued — changing a receipt the carve-out requires to stay identical. No enforcement is lost: the executed part of an exceptionally halted frame's tail is bounded by the clamp or by a frame gas remainder that was already under the headroom. --- crates/mega-evm/src/limit/compute_gas.rs | 42 ++- crates/mega-evm/src/limit/limit.rs | 63 +++- crates/mega-evm/tests/compute_gas/main.rs | 42 ++- .../mega-evm/tests/compute_gas/snapshot.txt | 2 +- .../mega-evm/tests/rex7/exceptional_halt.rs | 282 ++++++++++++++++++ crates/mega-evm/tests/rex7/main.rs | 3 + 6 files changed, 410 insertions(+), 24 deletions(-) create mode 100644 crates/mega-evm/tests/rex7/exceptional_halt.rs diff --git a/crates/mega-evm/src/limit/compute_gas.rs b/crates/mega-evm/src/limit/compute_gas.rs index 86264157..47fab923 100644 --- a/crates/mega-evm/src/limit/compute_gas.rs +++ b/crates/mega-evm/src/limit/compute_gas.rs @@ -52,6 +52,15 @@ pub(crate) struct ComputeGasTracker { /// The effective compute gas limit, which may be dynamically lowered by gas detention /// (volatile data access). Always <= `frame_tracker.tx_limit()`. detained_limit: u64, + /// Compute gas settled from the burned remainders of exceptionally halted frames (REX7+). + /// + /// Recorded into the TX-level lane of `frame_tracker`, so it shows up in the transaction's + /// reported compute total and in block-level accounting, and subtracted back out of every + /// limit comparison. A burned remainder is gas the EVM destroyed, not work the network + /// performed; letting it trip a limit would turn an ordinary EVM halt into a resource-limit + /// failure with the remaining gas rescued for the sender, changing a receipt that must stay + /// identical to per-opcode accounting. Always 0 before REX7. + burned: u64, frame_tracker: FrameLimitTracker<()>, } @@ -59,6 +68,7 @@ impl ComputeGasTracker { pub(crate) fn new(spec: MegaSpecId, tx_limit: u64) -> Self { Self { detained_limit: tx_limit, + burned: 0, frame_tracker: FrameLimitTracker::new(spec, tx_limit), rex1_enabled: spec.is_enabled(MegaSpecId::REX1), rex4_enabled: spec.is_enabled(MegaSpecId::REX4), @@ -88,7 +98,7 @@ impl ComputeGasTracker { pub(crate) fn set_detained_limit(&mut self, cap: u64) { let new_limit = if self.rex4_enabled { // REX4+: cap is relative to current usage (limits post-access computation) - self.tx_usage().saturating_add(cap) + self.enforced_tx_usage().saturating_add(cap) } else { // Pre-REX4: cap is absolute cap @@ -111,7 +121,7 @@ impl ComputeGasTracker { /// At that point `frame_stack.last()` is the caller's frame, so /// `current_frame_remaining()` gives the caller's remaining compute gas. pub(crate) fn current_call_remaining(&self) -> u64 { - let tx_remaining = self.tx_limit().saturating_sub(self.tx_usage()); + let tx_remaining = self.tx_limit().saturating_sub(self.enforced_tx_usage()); if self.rex4_enabled { self.frame_tracker.current_frame_remaining().min(tx_remaining) } else { @@ -141,7 +151,7 @@ impl ComputeGasTracker { #[inline] pub(crate) fn clamp_binding(&self) -> ClampBinding { let tx_limit = self.tx_limit(); - let tx_remaining = tx_limit.saturating_sub(self.tx_usage()); + let tx_remaining = tx_limit.saturating_sub(self.enforced_tx_usage()); if self.rex4_enabled { let frame_remaining = self.frame_tracker.current_frame_remaining(); if frame_remaining < tx_remaining { @@ -154,7 +164,7 @@ impl ComputeGasTracker { /// Returns `true` when gas detention is the binding TX-level constraint, i.e., the detained /// limit is tighter than the base TX limit AND actual usage exceeds it. pub(crate) fn is_detained_exceed(&self) -> bool { - let used = self.tx_usage(); + let used = self.enforced_tx_usage(); used > self.detained_limit && self.detained_limit < self.frame_tracker.tx_limit() } @@ -187,6 +197,21 @@ impl ComputeGasTracker { } } + /// Records a burned remainder from an exceptionally halted frame (REX7+). + /// + /// Counts toward the transaction's reported compute total and block-level accounting, and is + /// excluded from every limit comparison — see [`burned`](Self::burned). + pub(crate) fn record_burned_gas(&mut self, amount: u64) { + self.burned = self.burned.saturating_add(amount); + self.frame_tracker.add_tx_persistent(amount); + } + + /// Total recorded usage minus the burned remainders that must not enforce. + #[inline] + fn enforced_tx_usage(&self) -> u64 { + self.frame_tracker.net_usage().saturating_sub(self.burned) + } + /// Merges external persistent usage into the TX-level entry. /// /// Used by `KeylessDeploy` (REX5+) to propagate sandbox compute gas consumption @@ -213,6 +238,7 @@ impl TxRuntimeLimit for ComputeGasTracker { #[inline] fn reset(&mut self) { self.frame_tracker.reset(); + self.burned = 0; // Rex1+: reset detained limit to original TX limit between transactions. // Pre-Rex1: the detained limit persists across transactions. if self.rex1_enabled { @@ -244,14 +270,16 @@ impl TxRuntimeLimit for ComputeGasTracker { // So TX-level detained check must still run even when frame check is within limit. } // TX-level detained check (all specs): total usage vs effective limit (min of tx/detained). + // The comparison runs on enforced usage — burned remainders are excluded — while the + // reported `used` is the full settled total, so a halt reason states the usage the + // transaction actually ends with. The two coincide on every spec before REX7. let limit = self.tx_limit(); - let used = self.tx_usage(); - if used > limit { + if self.enforced_tx_usage() > limit { LimitCheck::ExceedsLimit { kind: LimitKind::ComputeGas, frame_local: false, limit, - used, + used: self.tx_usage(), } } else { LimitCheck::WithinLimit diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index 88473068..f80e23ae 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -133,6 +133,15 @@ pub struct AdditionalLimit { /// [`settle_frame_final_result`](Self::settle_frame_final_result). clamp: Option, + /// The clamp-hidden gas [`settle_frame_final_result`](Self::settle_frame_final_result) just + /// handed back to the frame's result, carried to the frame-exit settlement in + /// [`after_frame_run_instructions`](Self::after_frame_run_instructions). + /// + /// The two hooks are split by the execution layer's code-deposit charge, which has to observe + /// unclamped gas; the settlement that follows still needs to know how much of the frame's true + /// remainder the interpreter could not see. Written and consumed on the same frame exit. + restored_clamp_hidden: u64, + /// Whether a clamp-induced out-of-gas was latched while gas detention was the binding TX-level /// constraint. /// @@ -187,6 +196,7 @@ impl AdditionalLimit { checkpoint_accounting: spec.is_enabled(MegaSpecId::REX7), checkpoint_baseline: 0, clamp: None, + restored_clamp_hidden: 0, clamp_latched_detained: false, } } @@ -231,6 +241,7 @@ impl AdditionalLimit { self.storage_call_stipend.reset(); self.checkpoint_baseline = 0; self.clamp = None; + self.restored_clamp_hidden = 0; self.clamp_latched_detained = false; } @@ -322,8 +333,9 @@ impl AdditionalLimit { self.compute_gas.detained_limit() < self.compute_gas.base_tx_limit(); } - /// Restores any outstanding V0 clamp into the frame's final interpreter result, and latches a - /// clamp-induced out-of-gas as a compute exceed. + /// Finalises the compute accounting a frame's own result decides: restores any outstanding V0 + /// clamp, latches a clamp-induced out-of-gas as a compute exceed, and settles an exceptional + /// halt's burned remainder. /// /// Must run before anything reads or charges the result's gas — in particular before the /// execution-layer code-deposit storage charge, which would otherwise observe the clamped copy @@ -340,8 +352,12 @@ impl AdditionalLimit { if !self.checkpoint_accounting { return; } + self.restored_clamp_hidden = 0; if let Some(clamp) = self.clamp.take() { result.gas.erase_cost(clamp.hidden); + // Handed to the frame-exit settlement, which runs after the execution layer's + // code-deposit charge and can no longer see the clamp itself. + self.restored_clamp_hidden = clamp.hidden; // `MemoryOOG` is the same gas shortage reported from the memory-expansion path; every // other result either is unrelated to gas or cannot arise from a plain opcode. if matches!(result.result, InstructionResult::OutOfGas | InstructionResult::MemoryOOG) { @@ -953,12 +969,22 @@ impl AdditionalLimit { // `before_frame_return_result` surfaces it. The clamp restore itself already happened, in // `settle_frame_final_result`, before the execution-layer hook charged code-deposit storage // gas against the action's gas. + // + // A frame that ended in an exceptional halt takes the burn branch instead: it returns none + // of its remaining budget, so the whole remainder — not just what the counter shows — + // settles, and it settles outside limit enforcement. See `settle_exceptional_halt_burn`. if self.checkpoint_accounting { - if let InterpreterAction::Return(_) = action { + if let InterpreterAction::Return(interpreter_result) = action { + let exceptional_halt = !interpreter_result.result.is_ok_or_revert(); + let hidden = core::mem::take(&mut self.restored_clamp_hidden); let remaining = frame.interpreter.gas.remaining(); - let gas_used = self.checkpoint_baseline.saturating_sub(remaining); + if exceptional_halt && !self.limit_exceeded() { + self.settle_exceptional_halt_burn(hidden); + } else { + let gas_used = self.checkpoint_baseline.saturating_sub(remaining); + let _ = self.record_compute_gas_unguarded(gas_used); + } self.checkpoint_baseline = remaining; - let _ = self.record_compute_gas_unguarded(gas_used); } } @@ -1052,6 +1078,33 @@ impl AdditionalLimit { } } + /// Settles the entire remainder an exceptionally halted frame burns, as compute gas. + /// + /// An exceptional halt returns no gas: the top-level frame's whole envelope is spent by the + /// transaction's final gas accounting, and an inner frame's remainder is simply never handed + /// back to its caller. The interpreter zeroes its own counter only for a plain `OutOfGas`, + /// so the frame-exit delta cannot see the burn on any other classification. This settles it + /// directly instead: everything the frame still held at the last checkpoint — the open segment + /// (`baseline`, measured against a zero remainder) plus `hidden`, the part of the true + /// remainder the clamp was keeping out of the interpreter's sight. + /// + /// The whole amount goes to the tracker's non-enforcing lane, not just the part beyond the + /// compute headroom. Nothing is lost by that: a segment that runs under a clamp can never + /// consume past the headroom (the clamp is the enforcement), and a segment with no clamp is + /// bounded by a frame gas remainder that was already below the headroom — so the executed part + /// of an exceptionally halted frame's tail could not have exceeded a limit either way. What + /// enforcing the *burn* would do is turn an ordinary EVM halt into a resource-limit failure + /// with the remaining gas rescued for the sender, which is exactly the receipt change the + /// exceptional-halt carve-out forbids. + /// + /// Not reached when a resource limit is already latched: that path burns nothing, because the + /// frame either reverts to its parent (frame-local) or halts the transaction with its gas + /// rescued (TX-level) — including a clamp-induced out-of-gas, which + /// [`settle_frame_final_result`](Self::settle_frame_final_result) latches just before this. + fn settle_exceptional_halt_burn(&mut self, hidden: u64) { + self.compute_gas.record_burned_gas(self.checkpoint_baseline.saturating_add(hidden)); + } + /// Merges resource usage from a sandbox execution into this tracker. /// /// Used by `KeylessDeploy` (REX5+) to propagate sandbox resource consumption diff --git a/crates/mega-evm/tests/compute_gas/main.rs b/crates/mega-evm/tests/compute_gas/main.rs index 4cae4622..b2e0c0ab 100644 --- a/crates/mega-evm/tests/compute_gas/main.rs +++ b/crates/mega-evm/tests/compute_gas/main.rs @@ -849,33 +849,53 @@ fn test_compute_gas_snapshot_matches() { } } -/// Rex7 is the unstable spec and carries no behavior of its own yet: it delegates its instruction -/// table, runtime limits, and precompile set to Rex6 unchanged. +/// Rex7's checkpoint settlement is a precision-preserving change: every program that stays inside +/// its resource limits records the same compute gas, spends the same EVM gas, and ends the same +/// way as it does under Rex6. /// /// The snapshot alone does not pin this. Its rows differ by the spec-name column, so a Rex7 row /// that drifted from its Rex6 counterpart would still render as a well-formed snapshot and could be /// blessed by a regeneration. Comparing the readings directly makes the first accidental Rex7 -/// divergence a failure. When Rex7 gains its first deliberate behavior change, this test is -/// expected to fail and should be narrowed to the corpus entries that behavior does not reach. +/// divergence a failure. +/// +/// The one sanctioned divergence is the exceptional-halt carve-out: a frame that halts +/// exceptionally returns none of its remaining budget, and Rex7 settles that burned remainder as +/// compute gas where per-opcode recording attributes nothing to it. That moves compute gas upward +/// only — the receipt and the outcome still have to match exactly. `tests/rex7/exceptional_halt.rs` +/// pins the settled amount itself. #[test] fn test_rex7_matches_rex6_on_every_program() { for program in corpus() { let rex6 = transact(MegaSpecId::REX6, (program.build_db)()); let rex7 = transact(MegaSpecId::REX7, (program.build_db)()); assert_eq!( - (rex7.compute_gas, rex7.gas_used, &rex7.outcome), - (rex6.compute_gas, rex6.gas_used, &rex6.outcome), - "{}: Rex7 must be behaviorally identical to Rex6 \ - (Rex6: compute_gas={} gas_used={} outcome={}; \ - Rex7: compute_gas={} gas_used={} outcome={})", + (rex7.gas_used, &rex7.outcome), + (rex6.gas_used, &rex6.outcome), + "{}: Rex7 must spend the same gas and end the same way as Rex6 \ + (Rex6: gas_used={} outcome={}; Rex7: gas_used={} outcome={})", program.name, - rex6.compute_gas, rex6.gas_used, rex6.outcome, - rex7.compute_gas, rex7.gas_used, rex7.outcome, ); + if rex7.outcome.starts_with("halt ") { + assert!( + rex7.compute_gas >= rex6.compute_gas, + "{}: the exceptional-halt carve-out only ever moves compute gas up \ + (Rex6={} Rex7={})", + program.name, + rex6.compute_gas, + rex7.compute_gas, + ); + continue; + } + assert_eq!( + rex7.compute_gas, rex6.compute_gas, + "{}: checkpoint settlement must telescope to the per-opcode sum \ + (Rex6={} Rex7={})", + program.name, rex6.compute_gas, rex7.compute_gas, + ); } } diff --git a/crates/mega-evm/tests/compute_gas/snapshot.txt b/crates/mega-evm/tests/compute_gas/snapshot.txt index d6e976fb..c417e4b2 100644 --- a/crates/mega-evm/tests/compute_gas/snapshot.txt +++ b/crates/mega-evm/tests/compute_gas/snapshot.txt @@ -274,7 +274,7 @@ create2_oversized_initcode Rex3 21012 100000000 halt Ba create2_oversized_initcode Rex4 21012 100000000 halt Base(Base(CreateInitCodeSizeLimit)) create2_oversized_initcode Rex5 763907 100000000 halt Base(Base(CreateInitCodeSizeLimit)) create2_oversized_initcode Rex6 21012 100000000 halt Base(Base(CreateInitCodeSizeLimit)) -create2_oversized_initcode Rex7 21012 100000000 halt Base(Base(CreateInitCodeSizeLimit)) +create2_oversized_initcode Rex7 99961000 100000000 halt Base(Base(CreateInitCodeSizeLimit)) selfdestruct_to_empty Equivalence 0 53603 success selfdestruct_to_empty MiniRex 21003 100000000 halt Base(Base(InvalidFEOpcode)) diff --git a/crates/mega-evm/tests/rex7/exceptional_halt.rs b/crates/mega-evm/tests/rex7/exceptional_halt.rs new file mode 100644 index 00000000..db189e6c --- /dev/null +++ b/crates/mega-evm/tests/rex7/exceptional_halt.rs @@ -0,0 +1,282 @@ +//! REX7 exceptional-halt frame settlement. +//! +//! A frame that ends in an exceptional halt never returns its remaining budget: the top-level +//! frame's whole envelope is spent by the transaction's final gas accounting, and an inner frame's +//! remainder is simply not handed back to its caller. REX7 settles that burned remainder as compute +//! gas at frame exit, so the recorded compute total covers the entire budget the sender's gas paid +//! for. Per-opcode recording through REX6 attributes neither the failing opcode nor the burn, so it +//! reports strictly less. +//! +//! The interpreter only zeroes its own counter for a plain `OutOfGas`; every other exceptional halt +//! keeps the loop-exit reading and has its remainder burned later, by the frame-return rules. The +//! settlement therefore cannot be read off the counter — it has to be driven by the halt +//! classification, which is what these tests sweep. +//! +//! The invariant each case pins is the same one in both frame positions. These transactions spend +//! EVM gas on exactly two things: compute, and the transaction-intrinsic storage gas that is +//! excluded from compute accounting by definition. So "the whole burned budget settled as compute" +//! is `compute_gas == gas_used − intrinsic storage gas`. At the top level that covers the entire +//! transaction envelope; in the nested shape it covers the caller's own consumption plus the whole +//! budget it forwarded, because the halting callee returns none of it. +//! +//! `MemoryLimitOOG` is not in the sweep: it needs revm's `memory_limit` cfg, which this workspace +//! does not enable, so no bytecode can reach it. + +use crate::common::{ + transact, transact_with_gas_limit, Outcome, CALLEE, CALLER, CONTRACT, ONE_ETH, +}; +use alloy_primitives::{Bytes, U256}; +use mega_evm::{ + test_utils::{BytecodeBuilder, MemoryDatabase}, + EvmTxRuntimeLimits, MegaSpecId, +}; +use revm::bytecode::opcode::{ + ADD, CALL, DUP1, JUMP, JUMPDEST, JUMPI, MSTORE, POP, STOP, SUB, SWAP1, +}; + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) +} + +/// The two transaction-intrinsic readings every case below calibrates against, measured from a +/// transaction that runs a single `STOP`: the total EVM gas the receipt charges before the frame +/// does anything (`gas`), and the part of it that counts as compute (`compute`). +/// +/// The difference is the intrinsic storage gas — charged to EVM gas but excluded from compute +/// accounting. Measuring it keeps the budget identity exact rather than pinned to a constant. +struct Intrinsic { + gas: u64, + compute: u64, +} + +impl Intrinsic { + fn measure(spec: MegaSpecId) -> Self { + let code = BytecodeBuilder::default().append(STOP).build(); + let outcome = transact_with_gas_limit( + spec, + base_db(code), + EvmTxRuntimeLimits::from_spec(spec), + 1_000_000, + ); + Self { gas: outcome.gas_used, compute: outcome.compute_gas } + } + + /// The storage gas the receipt carries that compute accounting never sees. + fn storage_gas(&self) -> u64 { + self.gas - self.compute + } +} + +/// A countdown loop of cheap plain opcodes, sized to outrun any budget below its own cost. +fn countdown_loop_code(iterations: u16) -> Vec { + let mut code = vec![0x61]; // PUSH2 + code.extend_from_slice(&iterations.to_be_bytes()); + let loop_target = u8::try_from(code.len()).expect("loop target must fit in a PUSH1"); + code.push(JUMPDEST); + code.extend_from_slice(&[0x60, 0x01]); // PUSH1 1 + code.push(SWAP1); + code.push(SUB); + code.push(DUP1); + code.extend_from_slice(&[0x60, loop_target]); // PUSH1 loop + code.push(JUMPI); + code.push(STOP); + code +} + +/// One exceptional-halt shape. +struct HaltCase { + /// Case name, used in assertion messages. + name: &'static str, + /// Bytecode that ends the frame it runs in with an exceptional halt. + code: Vec, + /// The budget the halting frame is given — large enough for the shape to reach its halt with + /// gas to spare, so the burned remainder is substantial. + frame_gas: u64, +} + +/// Every exceptional-halt classification a plain-opcode segment can produce, other than the +/// `memory_limit` cfg-gated one. +fn halt_cases() -> Vec { + vec![ + // Plain out-of-gas: the interpreter zeroes its own counter here, so this is the one shape + // that already settled its burn before the classification-driven settlement existed. + HaltCase { name: "plain OOG", code: countdown_loop_code(10_000), frame_gas: 60_000 }, + // Memory out-of-gas: expanding to one MiB costs ~2.2M gas, far past the budget. + HaltCase { + name: "memory OOG", + code: BytecodeBuilder::default() + .push_number(0u64) + .push_number(0x10_0000u64) + .append(MSTORE) + .append(STOP) + .build_vec(), + frame_gas: 60_000, + }, + // Stack underflow: ADD with nothing on the stack. + HaltCase { + name: "stack underflow", + code: BytecodeBuilder::default().append(ADD).append(STOP).build_vec(), + frame_gas: 60_000, + }, + // Stack overflow: each iteration pushes one word and jumps back, so the stack passes 1024 + // long before the budget runs out. + HaltCase { + name: "stack overflow", + code: vec![JUMPDEST, 0x60, 0x01, 0x60, 0x00, JUMP], + frame_gas: 60_000, + }, + // Invalid jump: a destination that is not a JUMPDEST. + HaltCase { + name: "invalid jump", + code: BytecodeBuilder::default().push_number(0xffu64).append(JUMP).build_vec(), + frame_gas: 60_000, + }, + // Unknown opcode: 0x0c is unassigned on every spec this table covers. + HaltCase { name: "unknown opcode", code: vec![0x0c], frame_gas: 60_000 }, + ] +} + +/// Runs `code` as the transaction's direct target, with exactly `frame_gas` beyond intrinsic. +fn top_level(spec: MegaSpecId, code: &[u8], frame_gas: u64) -> Outcome { + transact_with_gas_limit( + spec, + base_db(Bytes::copy_from_slice(code)), + EvmTxRuntimeLimits::from_spec(spec), + Intrinsic::measure(spec).gas + frame_gas, + ) +} + +/// Runs `code` in an inner frame that [`CONTRACT`] calls with exactly `frame_gas` forwarded, then +/// pops the failure flag and stops — so the caller survives its callee's halt. +fn nested(spec: MegaSpecId, code: &[u8], frame_gas: u64) -> Outcome { + let caller = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(frame_gas) + .append(CALL) + .append(POP) + .append(STOP) + .build(); + let db = base_db(caller).account_code(CALLEE, Bytes::copy_from_slice(code)); + transact(spec, db, EvmTxRuntimeLimits::from_spec(spec)) +} + +/// The frame's entire budget must settle as compute gas, in both frame positions, for every +/// exceptional-halt classification. +#[test] +fn test_every_exceptional_halt_settles_its_burned_budget_as_compute() { + /// Runs one halt shape in one frame position. + type Runner = fn(MegaSpecId, &[u8], u64) -> Outcome; + + let storage_gas = Intrinsic::measure(MegaSpecId::REX7).storage_gas(); + for case in halt_cases() { + for (position, run) in [("top-level", top_level as Runner), ("nested", nested as Runner)] { + let label = format!("{} ({position})", case.name); + let r6 = run(MegaSpecId::REX6, &case.code, case.frame_gas); + let r7 = run(MegaSpecId::REX7, &case.code, case.frame_gas); + + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "{label}: the halt itself must be unchanged", + ); + assert_eq!( + r6.gas_used, r7.gas_used, + "{label}: receipt gas_used must be unchanged; REX6={} REX7={}", + r6.gas_used, r7.gas_used + ); + assert_eq!( + r7.compute_gas, + r7.gas_used - storage_gas, + "{label}: REX7 must settle the whole burned budget as compute; \ + compute={} gas_used={} intrinsic storage gas={storage_gas}", + r7.compute_gas, + r7.gas_used, + ); + assert!( + r6.compute_gas < r7.compute_gas, + "{label}: per-opcode recording attributes neither the failing opcode nor the \ + burn, so it must report strictly less; REX6={} REX7={}", + r6.compute_gas, + r7.compute_gas + ); + } + } +} + +/// A frame that halts exceptionally while a non-zero gas clamp is outstanding burns the hidden gas +/// too, so the settlement has to cover the true remainder, not just the visible one. +/// +/// A tight compute limit keeps a large amount hidden; the frame then hits a stack underflow, which +/// is not a gas shortage at all and must not be reclassified into a compute exceed. +#[test] +fn test_exceptional_halt_under_an_active_clamp_settles_the_hidden_gas_too() { + let code = BytecodeBuilder::default().append(ADD).append(STOP).build(); + let intrinsic = Intrinsic::measure(MegaSpecId::REX7); + let limits = |spec| { + EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(intrinsic.compute + 1_000) + }; + let gas_limit = intrinsic.gas + 500_000; + + let r6 = transact_with_gas_limit( + MegaSpecId::REX6, + base_db(code.clone()), + limits(MegaSpecId::REX6), + gas_limit, + ); + let r7 = transact_with_gas_limit( + MegaSpecId::REX7, + base_db(code), + limits(MegaSpecId::REX7), + gas_limit, + ); + + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the stack underflow must not be reclassified as a resource-limit exceed", + ); + assert_eq!(r6.gas_used, r7.gas_used, "receipt gas_used must be unchanged"); + assert_eq!( + r7.compute_gas, + intrinsic.compute + 500_000, + "the clamp hides most of the frame's budget, and all of it is still burned", + ); +} + +/// The burn settlement must not retroactively fail the transaction. +/// +/// The burned budget is whatever the sender's gas envelope allowed, not what the compute limit +/// allowed, so the settlement can push the recorded total past the compute limit. Turning that into +/// a compute-limit halt would rescue gas the EVM already burned and change the receipt, which the +/// exceptional-halt carve-out must not do. +#[test] +fn test_burn_settlement_does_not_retroactively_halt_on_the_compute_limit() { + let code = BytecodeBuilder::default().append(ADD).append(STOP).build(); + let intrinsic = Intrinsic::measure(MegaSpecId::REX7); + let compute_limit = intrinsic.compute + 1_000; + let limits = + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7).with_tx_compute_gas_limit(compute_limit); + + let r7 = + transact_with_gas_limit(MegaSpecId::REX7, base_db(code), limits, intrinsic.gas + 500_000); + + let reason = format!("{:?}", r7.halt_reason("REX7")); + assert!( + reason.contains("StackUnderflow"), + "the halt must stay the EVM's own, not become a compute-limit exceed; got {reason}", + ); + assert!( + r7.compute_gas > compute_limit, + "the settled burn is expected to exceed the compute limit here; compute={} limit={}", + r7.compute_gas, + compute_limit + ); +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index 27d66790..8a0847bb 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -19,12 +19,15 @@ //! the REX5 storage-call stipend, and oracle hints. //! - `double_exceed_corner` — the adjudicated corner swept one gas at a time, so the classification //! is shown to be stable rather than merely correct at one point. +//! - `exceptional_halt` — every exceptional-halt classification, in both frame positions: the +//! frame's whole burned budget settles as compute gas without changing the receipt. mod checkpoint_families; mod checkpoint_settlement; mod clamp_classification; mod common; mod double_exceed_corner; +mod exceptional_halt; mod gas_leakage; mod interceptor_resume; mod latch_surfacing; From 1b3008e73806751e3f490641c375aa139370be22 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 01:10:53 +0800 Subject: [PATCH 20/43] fix(rex7): report the binding budget in a clamp-induced exceed A clamp bound to a sub-frame's compute budget latched the transaction-level limit into the exceed. The frame-local revert then carried that number in its MegaLimitExceeded payload, where the calling contract can decode it and branch on it: the same nested call that reverts with limit=956851 under per-opcode enforcement reverted with limit=1000000 under the clamp. Carry the binding constraint's own limit on the clamp and latch that, so both paths report the budget that actually stopped execution. --- crates/mega-evm/src/limit/compute_gas.rs | 13 ++- crates/mega-evm/src/limit/frame_limit.rs | 11 ++ crates/mega-evm/src/limit/limit.rs | 11 +- .../tests/rex7/clamp_classification.rs | 107 +++++++++++++++++- 4 files changed, 132 insertions(+), 10 deletions(-) diff --git a/crates/mega-evm/src/limit/compute_gas.rs b/crates/mega-evm/src/limit/compute_gas.rs index 47fab923..d6e61fef 100644 --- a/crates/mega-evm/src/limit/compute_gas.rs +++ b/crates/mega-evm/src/limit/compute_gas.rs @@ -18,6 +18,11 @@ pub(crate) struct ClampBinding { /// `true` when the current frame's compute budget is what binds, `false` when the TX-level /// (possibly detained) limit is. pub(crate) frame_local: bool, + /// The binding constraint's own limit. A clamp-induced exceed reports this — as + /// `MegaLimitExceeded.limit` in the frame-local revert payload, or as + /// `ComputeGasLimitExceeded.limit` in the transaction halt — so it has to be the budget that + /// actually stopped execution, exactly as the non-clamp check path reports it. + pub(crate) limit: u64, } /// A frame-limit-based compute gas tracker using `FrameLimitTracker`. @@ -155,10 +160,14 @@ impl ComputeGasTracker { if self.rex4_enabled { let frame_remaining = self.frame_tracker.current_frame_remaining(); if frame_remaining < tx_remaining { - return ClampBinding { headroom: frame_remaining, frame_local: true }; + return ClampBinding { + headroom: frame_remaining, + frame_local: true, + limit: self.frame_tracker.current_frame_limit(), + }; } } - ClampBinding { headroom: tx_remaining, frame_local: false } + ClampBinding { headroom: tx_remaining, frame_local: false, limit: tx_limit } } /// Returns `true` when gas detention is the binding TX-level constraint, i.e., the detained diff --git a/crates/mega-evm/src/limit/frame_limit.rs b/crates/mega-evm/src/limit/frame_limit.rs index 03d1a34b..8d05ac6b 100644 --- a/crates/mega-evm/src/limit/frame_limit.rs +++ b/crates/mega-evm/src/limit/frame_limit.rs @@ -236,6 +236,17 @@ impl FrameLimitTracker { } } + /// Returns the budget of the current frame, in the same form + /// [`exceeds_current_frame_limit`](Self::exceeds_current_frame_limit) reports it on an exceed. + /// + /// If the frame stack is empty (before the first frame is pushed), returns the TX-level limit. + pub(crate) fn current_frame_limit(&self) -> u64 { + match self.frame_stack.last() { + Some(entry) => entry.limit, + None => self.tx_entry.limit, + } + } + /// Returns a mutable reference to the current (top) frame entry. pub(crate) fn frame_mut(&mut self) -> Option<&mut FrameLimitEntry> { self.frame_stack.last_mut() diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index f80e23ae..887bda1d 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -311,10 +311,11 @@ impl AdditionalLimit { /// /// The crossing opcode never executed — revm's own gas check stopped it at the clamp boundary — /// so its cost is not in the recorded usage and an ordinary [`check_limit`](Self::check_limit) - /// pass sees usage at or below the limit. The latch is therefore stamped directly, with - /// `frame_local` taken from the constraint that bound the clamp, so the existing frame-result - /// machinery (frame-local absorb to revert; TX-level mark plus gas rescue) produces the halt - /// shape it produces for every other compute exceed. + /// pass sees usage at or below the limit. The latch is therefore stamped directly, from the + /// constraint that bound the clamp: `frame_local` decides the shape the existing frame-result + /// machinery produces (frame-local absorb to revert; TX-level mark plus gas rescue), and the + /// constraint's own `limit` is what that shape reports — the sub-frame budget for a frame-local + /// binding, the effective TX limit otherwise, matching what the non-clamp check path writes. #[inline] fn latch_clamp_exceed(&mut self, binding: &compute_gas::ClampBinding) { if !self.has_exceeded_limit.within_limit() { @@ -323,7 +324,7 @@ impl AdditionalLimit { self.has_exceeded_limit = LimitCheck::ExceedsLimit { kind: super::LimitKind::ComputeGas, frame_local: binding.frame_local, - limit: self.compute_gas.tx_limit(), + limit: binding.limit, used: self.compute_gas.tx_usage(), }; // Preserve the volatile-detention attribution: when the binding TX-level constraint at diff --git a/crates/mega-evm/tests/rex7/clamp_classification.rs b/crates/mega-evm/tests/rex7/clamp_classification.rs index a775b343..b0a5a79d 100644 --- a/crates/mega-evm/tests/rex7/clamp_classification.rs +++ b/crates/mega-evm/tests/rex7/clamp_classification.rs @@ -6,14 +6,28 @@ //! exactly *on* it changes how much gets hidden — zero in the second case — but not whether the //! clamp is in force. Both are the compute limit doing the stopping, and both must be reported as //! such; only a frame whose own EVM gas runs out first is an ordinary out-of-gas. +//! +//! The payload has to match too. A frame-local binding reverts with +//! `MegaLimitExceeded(uint8 kind, uint64 limit)`, which the caller can decode and branch on, so its +//! `limit` must be the sub-frame budget that actually bound the clamp rather than the +//! transaction-level limit. -use crate::common::{transact_default, transact_with_gas_limit, CALLER, CONTRACT, ONE_ETH}; +use crate::common::{ + transact, transact_default, transact_with_gas_limit, Outcome, CALLEE, CALLER, CONTRACT, ONE_ETH, +}; use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::SolError; use mega_evm::{ test_utils::{BytecodeBuilder, MemoryDatabase}, - EvmTxRuntimeLimits, MegaHaltReason, MegaSpecId, + EvmTxRuntimeLimits, LimitKind, MegaHaltReason, MegaLimitExceeded, MegaSpecId, +}; +use revm::{ + bytecode::opcode::{ + CALL, DUP1, JUMPDEST, JUMPI, MSTORE, POP, RETURN, RETURNDATACOPY, RETURNDATASIZE, STOP, + SUB, SWAP1, + }, + context::result::ExecutionResult, }; -use revm::bytecode::opcode::{MSTORE, POP, STOP}; fn base_db(code: Bytes) -> MemoryDatabase { MemoryDatabase::default() @@ -169,3 +183,90 @@ fn test_knife_edge_neighbours_classify_by_which_budget_binds() { "the EVM out-of-gas burns the frame's remainder, which settles as compute", ); } + +// --------------------------------------------------------------------------------------------- +// The payload a clamp-induced exceed reports. +// --------------------------------------------------------------------------------------------- + +/// A countdown loop of cheap plain opcodes, prefixed verbatim. +fn countdown_loop_code(prefix: &[u8], iterations: u16) -> Bytes { + let mut code = prefix.to_vec(); + code.push(0x61); // PUSH2 + code.extend_from_slice(&iterations.to_be_bytes()); + let loop_target = u8::try_from(code.len()).expect("loop target must fit in a PUSH1"); + code.push(JUMPDEST); + code.extend_from_slice(&[0x60, 0x01]); // PUSH1 1 + code.push(SWAP1); + code.push(SUB); + code.push(DUP1); + code.extend_from_slice(&[0x60, loop_target]); // PUSH1 loop + code.push(JUMPI); + code.push(STOP); + Bytes::from(code) +} + +/// A caller that CALLs [`CALLEE`] and returns the sub-frame's return data verbatim, so the +/// `MegaLimitExceeded` payload the sub-frame reverted with is observable from the receipt. +fn call_and_return_revert_data(gas: u64) -> Bytes { + BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(gas) + .append(CALL) + .append(POP) + .append(RETURNDATASIZE) + .push_number(0u64) // dataOffset + .push_number(0u64) // destOffset + .append(RETURNDATACOPY) + .append(RETURNDATASIZE) + .push_number(0u64) // offset + .append(RETURN) + .build() +} + +/// Decodes the `MegaLimitExceeded` payload a successful transaction returned. +fn decode_limit_exceeded(label: &str, outcome: &Outcome) -> MegaLimitExceeded { + let output = match &outcome.result { + ExecutionResult::Success { output, .. } => output.data().clone(), + other => panic!("{label}: expected success carrying the sub-frame payload, got {other:?}"), + }; + MegaLimitExceeded::abi_decode(&output) + .unwrap_or_else(|e| panic!("{label}: return data is not MegaLimitExceeded: {e}")) +} + +/// A frame-local clamp exceed must report the sub-frame budget that bound it, byte for byte the +/// same payload per-opcode enforcement produces. +/// +/// The revert data is visible to the calling contract, which can decode `limit` and branch on it, +/// so a transaction-level value here is not a diagnostic difference — it is a different observable +/// return value for the same execution. +#[test] +fn test_frame_local_clamp_exceed_reports_the_sub_frame_budget() { + let callee = countdown_loop_code(&[], 40_000); + let code = call_and_return_revert_data(50_000_000); + let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let limits = compute_limit(1_000_000); + + let r6 = transact(MegaSpecId::REX6, build_db(), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, build_db(), limits(MegaSpecId::REX7)); + + let d6 = decode_limit_exceeded("REX6", &r6); + let d7 = decode_limit_exceeded("REX7", &r7); + + assert_eq!(d6.kind, LimitKind::ComputeGas.as_u8(), "REX6 must blame compute gas"); + assert_eq!(d7.kind, d6.kind, "REX7 must blame the same dimension"); + assert_eq!( + d7.limit, d6.limit, + "the ABI-visible limit must be the sub-frame budget on both specs; REX6={} REX7={}", + d6.limit, d7.limit + ); + assert!( + d7.limit < 1_000_000, + "the sub-frame budget is a fraction of the TX limit, not the TX limit itself; got {}", + d7.limit + ); +} From a3d41e1c5ea59298adb75cefc7eb5a9c873ce51e Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 01:12:24 +0800 Subject: [PATCH 21/43] fix(rex7): report the final compute usage in a clamp-induced halt The clamp exceed is latched at the frame's final result, and the frame-exit settlement that closes the partial plain segment runs after it. The latch is sticky, so the halt reason kept the pre-settlement snapshot: a transaction ending on 21,500 compute gas reported ComputeGasLimitExceeded.actual = 21,000. Re-read the usage from the tracker once the settlement has closed, which is what the detention path already effectively does by rebuilding its reason from live usage. --- crates/mega-evm/src/limit/limit.rs | 27 ++++++++++ .../tests/rex7/clamp_classification.rs | 53 +++++++++++++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index 887bda1d..c122874a 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -984,6 +984,7 @@ impl AdditionalLimit { } else { let gas_used = self.checkpoint_baseline.saturating_sub(remaining); let _ = self.record_compute_gas_unguarded(gas_used); + self.refresh_latched_compute_usage(); } self.checkpoint_baseline = remaining; } @@ -1079,6 +1080,32 @@ impl AdditionalLimit { } } + /// Re-reads a latched TX-level compute exceed's usage from the tracker (REX7+). + /// + /// A clamp-induced exceed is latched at the frame's final result, before the frame-exit + /// settlement closes the plain segment the crossing opcode stopped inside. The latch is sticky, + /// so the halt reason built later from [`check_limit`](Self::check_limit) would otherwise + /// report the usage as it stood one settlement short of final — which is not the number the + /// transaction's compute total ends on. The detention path never had this problem: it rebuilds + /// its halt reason from live tracker usage. + /// + /// Only TX-level exceeds are refreshed. A frame-local exceed's `used` is the frame's own + /// figure, which the frame-local revert payload does not carry, so rewriting it with a + /// transaction-level total would only blur what it means. + #[inline] + fn refresh_latched_compute_usage(&mut self) { + let usage = self.compute_gas.tx_usage(); + if let LimitCheck::ExceedsLimit { + kind: super::LimitKind::ComputeGas, + frame_local: false, + used, + .. + } = &mut self.has_exceeded_limit + { + *used = usage; + } + } + /// Settles the entire remainder an exceptionally halted frame burns, as compute gas. /// /// An exceptional halt returns no gas: the top-level frame's whole envelope is spent by the diff --git a/crates/mega-evm/tests/rex7/clamp_classification.rs b/crates/mega-evm/tests/rex7/clamp_classification.rs index b0a5a79d..55caa9af 100644 --- a/crates/mega-evm/tests/rex7/clamp_classification.rs +++ b/crates/mega-evm/tests/rex7/clamp_classification.rs @@ -10,7 +10,9 @@ //! The payload has to match too. A frame-local binding reverts with //! `MegaLimitExceeded(uint8 kind, uint64 limit)`, which the caller can decode and branch on, so its //! `limit` must be the sub-frame budget that actually bound the clamp rather than the -//! transaction-level limit. +//! transaction-level limit. A transaction-level binding halts with `ComputeGasLimitExceeded`, whose +//! `actual` must be the compute usage the transaction ends with — including the frame-exit +//! settlement that runs after the exceed is latched. use crate::common::{ transact, transact_default, transact_with_gas_limit, Outcome, CALLEE, CALLER, CONTRACT, ONE_ETH, @@ -125,9 +127,9 @@ fn test_exact_value_clamp_is_still_a_compute_exceed() { match r7.halt_reason("REX7") { MegaHaltReason::ComputeGasLimitExceeded { limit, actual } => { assert_eq!(*limit, edge.compute_limit, "the reported limit is the TX compute limit"); - assert!( - *actual <= edge.compute_limit, - "the crossing opcode never ran, so usage cannot be past the limit; got {actual}", + assert_eq!( + *actual, r7.compute_gas, + "the reported usage must be the transaction's final compute usage", ); } other => panic!( @@ -270,3 +272,46 @@ fn test_frame_local_clamp_exceed_reports_the_sub_frame_budget() { d7.limit ); } + +/// A transaction-level clamp exceed must report the usage the transaction actually ends with. +/// +/// The exceed is latched at the frame's final result, but the frame-exit settlement that closes +/// the partial plain segment runs after that. A halt reason frozen at latch time reports a usage +/// the transaction never had. +#[test] +fn test_tx_level_clamp_halt_reports_the_final_usage() { + let mut code = plain_filler(200); + code.push(STOP); + let code = Bytes::from(code); + + // The unconstrained run tells us both ends of the plain segment; putting the limit in the + // middle of it guarantees the halt lands with a partial segment still unsettled. + let free = transact_default(MegaSpecId::REX7, base_db(code.clone())); + assert!(free.is_success(), "the unconstrained run must succeed: {:?}", free.result); + let intrinsic = transact_default(MegaSpecId::REX7, base_db(Bytes::from(vec![STOP]))); + let midpoint = (intrinsic.compute_gas + free.compute_gas) / 2; + + let r7 = transact(MegaSpecId::REX7, base_db(code), compute_limit(midpoint)(MegaSpecId::REX7)); + + let (limit, actual) = match r7.halt_reason("REX7") { + MegaHaltReason::ComputeGasLimitExceeded { limit, actual } => (*limit, *actual), + other => panic!("expected a compute-gas halt, got {other:?}"), + }; + assert_eq!(limit, midpoint, "the reported limit is the configured TX compute limit"); + assert_eq!( + r7.compute_gas, midpoint, + "clamp enforcement stops the crossing opcode, so usage lands exactly on the limit", + ); + assert_eq!( + actual, r7.compute_gas, + "the reported usage must be the tracker's final reading, not a pre-settlement snapshot; \ + reported={actual} tracker={}", + r7.compute_gas + ); + assert_eq!( + r7.gas_used, + r7.compute_gas + (intrinsic.gas_used - intrinsic.compute_gas), + "the receipt must charge exactly the compute the transaction was allowed plus the \ + intrinsic storage gas", + ); +} From 4a4f53c85c8ffb13f3477e14d0c0c2ddf0690eda Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 01:14:23 +0800 Subject: [PATCH 22/43] test(rex7): compare state in the shared parity assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper's contract said the two runs must be indistinguishable, and the precision invariant names state explicitly, but the assertion never looked at it: two specs producing the same result and the same usage from different account or storage state passed. Compare a normalised view — account info, code, status flags, and each slot's original/present pair. Raw EvmState carries journal bookkeeping (`transaction_id`, per-slot `is_cold`) that identical runs can legitimately differ on. --- crates/mega-evm/tests/rex7/common.rs | 70 +++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/mega-evm/tests/rex7/common.rs b/crates/mega-evm/tests/rex7/common.rs index c3b3d8ed..4f52d7f9 100644 --- a/crates/mega-evm/tests/rex7/common.rs +++ b/crates/mega-evm/tests/rex7/common.rs @@ -1,6 +1,6 @@ //! Shared helpers for the REX7 test suite. -use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_primitives::{address, Address, Bytes, B256, U256}; use mega_evm::{ test_utils::MemoryDatabase, EvmTxRuntimeLimits, MegaContext, MegaEvm, MegaHaltReason, MegaSpecId, MegaTransaction, MegaTransactionNew as _, TestExternalEnvs, @@ -10,6 +10,7 @@ use revm::{ handler::EvmTr, state::EvmState, }; +use std::collections::BTreeMap; /// Transaction sender. pub(crate) const CALLER: Address = address!("0000000000000000000000000000000000300000"); @@ -172,8 +173,57 @@ pub(crate) fn transact_tx( } } +/// The part of an account a transaction's state actually asserts. +/// +/// Raw [`EvmState`] cannot be compared directly: `Account::transaction_id` and each storage slot's +/// `is_cold` are journal bookkeeping with no consensus meaning, and two runs that produce identical +/// state can still differ there. This keeps the account info, the deployed code, the status flags +/// that decide how the account is applied, and every slot's original/present pair. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct AccountView { + balance: U256, + nonce: u64, + code_hash: B256, + code: Bytes, + touched: bool, + created: bool, + selfdestructed: bool, + loaded_as_not_existing: bool, + storage: BTreeMap, +} + +/// Normalises an [`EvmState`] into a stable, order-independent view. +pub(crate) fn state_view(state: &EvmState) -> BTreeMap { + state + .iter() + .map(|(address, account)| { + let view = AccountView { + balance: account.info.balance, + nonce: account.info.nonce, + code_hash: account.info.code_hash, + code: account + .info + .code + .as_ref() + .map(|code| code.original_bytes()) + .unwrap_or_default(), + touched: account.is_touched(), + created: account.is_created(), + selfdestructed: account.is_selfdestructed(), + loaded_as_not_existing: account.is_loaded_as_not_existing(), + storage: account + .storage + .iter() + .map(|(slot, value)| (*slot, (value.original_value, value.present_value))) + .collect(), + }; + (*address, view) + }) + .collect() +} + /// Asserts that two outcomes are indistinguishable: same execution result, same four-dimension -/// usage, same receipt `gas_used`, and the same detained compute-gas limit. +/// usage, same receipt `gas_used`, the same detained compute-gas limit, and the same state. /// /// This is the precision invariant in assertion form — what a transaction that stays inside every /// per-tx limit must produce under both accounting models. @@ -205,6 +255,22 @@ pub(crate) fn assert_outcomes_identical(label: &str, r6: &Outcome, r7: &Outcome) "{label}: the detained compute-gas limit must be identical; REX6={} REX7={}", r6.detained_compute_gas_limit, r7.detained_compute_gas_limit ); + let (s6, s7) = (state_view(&r6.state), state_view(&r7.state)); + if s6 != s7 { + // Report the first address the two disagree on; dumping both whole states buries it. + let mut addresses: Vec<&Address> = s6.keys().chain(s7.keys()).collect(); + addresses.sort_unstable(); + addresses.dedup(); + let culprit = addresses + .into_iter() + .find(|address| s6.get(*address) != s7.get(*address)) + .expect("the maps differ, so some address must"); + panic!( + "{label}: the produced state must be identical; {culprit} is\n REX6: {:?}\n REX7: {:?}", + s6.get(culprit), + s7.get(culprit), + ); + } } /// [`transact`] with every SALT bucket reporting `bucket_capacity`. From c3a82301e3065d54adb44bbc7b5aa4cc016b301d Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 01:17:20 +0800 Subject: [PATCH 23/43] docs(rex7): align the carve-out and clamp rules with the fixed behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exceptional-halt carve-out was written around the interpreter zeroing its own gas counter, which it does only for ordinary out-of-gas, and said nothing about whether the burned remainder enforces. State the rule by halt classification, and state that the burn is reported but never evaluated against a limit. The clamp section now says when the clamp is in force — an exact equality binds and hides nothing — and pins the two fields a clamp-induced exceed reports: the binding constraint's own limit, and the transaction's final compute usage rather than a pre-settlement snapshot. --- docs/spec/evm/compute-gas.md | 20 +++++++++++++++++--- docs/spec/upgrades/rex7.md | 29 +++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/docs/spec/evm/compute-gas.md b/docs/spec/evm/compute-gas.md index 38de1dc6..b42ca17a 100644 --- a/docs/spec/evm/compute-gas.md +++ b/docs/spec/evm/compute-gas.md @@ -479,13 +479,20 @@ For every transaction that stays within every runtime resource limit, a node MUS After settlement and body recording at a checkpoint (and at frame entry and resume), a node MUST clamp the interpreter-visible remaining gas to the remaining compute headroom — the minimum of the current frame's remaining per-frame compute budget and the transaction-level remaining budget under the effective limit (including detention) — and MUST restore the hidden amount before the next checkpoint body, before `GAS` is observed, before call-gas forwarding, and before storage-gas charges. +The clamp is in force for the segment that follows whenever the true remaining gas is at or above the headroom, and a node MUST remember which constraint bound it along with that constraint's own limit value. +An exact equality is a binding clamp that hides nothing, not the absence of a clamp. +When the true remaining gas is below the headroom, no clamp is in force and an out-of-gas inside the segment is the inherited EVM's own. + Inside a plain-opcode segment: - An opcode that would cost more than the clamped visible remainder MUST NOT execute. - The frame's final result MUST restore the hidden gas. - The node MUST reclassify that out-of-gas as the resource-limit exceed the clamp stood for: frame-local budget → frame revert with `MegaLimitExceeded`; transaction-level compute → transaction halt with `OutOfGas` and rescued remaining gas; detained limit → transaction halt with `VolatileDataAccessOutOfGas` and rescued remaining gas. +The `limit` reported by either shape MUST be the constraint that bound the clamp — the frame's own compute budget for a frame-local binding, the effective transaction-level limit otherwise — matching what the per-opcode check path on this page reports. + Because the crossing opcode never executes, a node MUST NOT include its cost in recorded compute-gas usage. +The `actual` a transaction-level clamp halt reports MUST be the transaction's final compute usage, after the frame-exit settlement has closed the partial segment the crossing opcode stopped inside. When the current frame's remaining per-frame compute budget equals the transaction-level remaining budget, a node MUST bind the clamp to the transaction-level constraint (including detention when detention is the effective transaction-level bound). A clamp-induced exceed under that binding MUST halt the transaction with gas rescue; a node MUST NOT classify the equality as frame-local. @@ -495,9 +502,16 @@ When the crossing opcode would exhaust both the true remaining EVM gas and the c #### Exceptional-halt frame carve-out -When a frame ends in an exceptional halt — including ordinary out-of-gas and memory out-of-gas — the interpreter zeros the frame's remaining gas before frame-exit settlement. -A node MUST settle that entire burned remainder as compute gas at frame exit. -Under per-opcode recording through Rex6 neither the failing opcode nor the burn is attributed to compute gas, so a transaction that contains an inner out-of-gas call frame MAY report a strictly higher compute-gas total under Rex7 while EVM gas and the receipt remain identical. +A frame that ends in an exceptional halt — ordinary out-of-gas, memory out-of-gas, stack underflow or overflow, invalid jump, unknown opcode, and every other error result — returns none of its remaining budget. +A node MUST settle that entire burned remainder as compute gas at frame exit: the open plain-opcode segment measured against a zero remainder, plus any gas the clamp was hiding. +The rule is driven by the halt classification rather than by the interpreter's own counter, which an inherited EVM zeroes for ordinary out-of-gas only. +Under per-opcode recording through Rex6 neither the failing opcode nor the burn is attributed to compute gas, so a transaction that halts exceptionally, or that contains an inner call frame which does, MAY report a strictly higher compute-gas total under Rex7 while EVM gas and the receipt remain identical. + +A node MUST NOT evaluate any resource limit against the burned remainder: it is bounded by the sender's gas envelope rather than by the compute limit, and halting on it would rescue gas the EVM already burned and change the receipt this carve-out requires to stay identical. +The usage a limit is evaluated against therefore excludes the burn, while the reported compute-gas total and the block-level compute accounting include it. +Nothing is lost by the exclusion: the executed part of an exceptionally halted frame's tail is bounded either by the clamp or by a frame gas remainder that was already below the headroom. + +A clamp-induced out-of-gas is not an exceptional halt for this rule — the crossing opcode never executed and the remaining gas is rescued rather than burned. diff --git a/docs/spec/upgrades/rex7.md b/docs/spec/upgrades/rex7.md index d3c5f667..f5db7714 100644 --- a/docs/spec/upgrades/rex7.md +++ b/docs/spec/upgrades/rex7.md @@ -70,10 +70,21 @@ For every transaction that stays within every runtime resource limit, a node MUS The interpreter's gas counter already meters every opcode; settling by segment reproduces the per-opcode sum exactly when no limit is crossed. **Exceptional-halt frame carve-out.** -When a frame ends in an exceptional halt — including ordinary out-of-gas and memory out-of-gas — the interpreter zeros the frame's remaining gas before the frame-exit settlement runs. -A node MUST therefore settle the entire burned remainder of that frame's budget as compute gas at frame exit. +A frame that ends in an exceptional halt — ordinary out-of-gas, memory out-of-gas, stack underflow or overflow, invalid jump, unknown opcode, and every other error result — returns none of its remaining budget. +The top-level frame's whole envelope is spent by the transaction's final gas accounting, and an inner frame's remainder is never handed back to its caller. +A node MUST settle that entire burned remainder as compute gas at frame exit: the open plain-opcode segment measured against a zero remainder, plus any gas the clamp was hiding from the interpreter. +The rule is driven by the halt classification, not by the interpreter's own counter — an inherited EVM zeroes that counter for ordinary out-of-gas only. + Under per-opcode recording through Rex6, neither the failing opcode nor the burn is attributed to compute gas. -Consequently, a transaction that contains an inner call frame which runs out of gas MAY report a **strictly higher** compute-gas total under Rex7 than under Rex6, while EVM gas accounting and the receipt remain identical. +Consequently, a transaction that halts exceptionally, or that contains an inner call frame which does, MAY report a **strictly higher** compute-gas total under Rex7 than under Rex6, while EVM gas accounting and the receipt remain identical. + +A node MUST NOT evaluate any resource limit against the burned remainder. +The burn is bounded by the sender's gas envelope rather than by the compute limit, so it can carry the recorded total past that limit; halting on it would rescue gas the EVM has already burned and change a receipt this carve-out requires to stay identical. +The usage a limit is evaluated against therefore excludes the burn, while the transaction's reported compute-gas total and the block-level compute accounting include it. +Nothing is lost by that exclusion: the part of an exceptionally halted frame's tail that was actually executed is bounded either by the gas clamp or by a frame gas remainder that was already below the compute headroom, so it could not have exceeded a limit in the first place. + +A clamp-induced out-of-gas is not an exceptional halt for this rule. +The crossing opcode was stopped before it executed and the remaining gas is rescued for the sender rather than burned, so the reclassification rules below apply instead. ### Gas-Clamp Enforcement @@ -90,8 +101,9 @@ Under Rex7, a node MUST enforce compute-gas and detention limits inside plain-op At each checkpoint, after settlement and after the checkpoint body has recorded its own compute gas (and after any detention cap the checkpoint installs), and again at frame entry and resume, a node MUST: 1. Compute the remaining compute headroom as the minimum of the current frame's remaining per-frame compute budget and the transaction-level remaining budget under the effective limit (including detention). -2. Hide any interpreter remaining gas above that headroom from the interpreter, remembering both the hidden amount and which constraint bound the clamp (frame-local budget vs transaction-level / detained limit). -3. Leave the true remaining gas available again before the next checkpoint body runs, before `GAS` is observed, before call-gas forwarding is computed, and before storage-gas charges are taken, so those sites always see the unclamped counter. +2. When the interpreter's true remaining gas is at or above that headroom, put the clamp in force for the segment that follows: hide the excess from the interpreter and remember which constraint bound the clamp — the frame-local budget or the transaction-level / detained limit — together with that constraint's own limit value. Equality is a binding clamp that hides nothing, not the absence of a clamp. +3. When the true remaining gas is below the headroom, no clamp is in force: the frame's own gas runs out ahead of the compute headroom, and an out-of-gas inside the segment is the inherited EVM's own rather than a resource-limit exceed. +4. Leave the true remaining gas available again before the next checkpoint body runs, before `GAS` is observed, before call-gas forwarding is computed, and before storage-gas charges are taken, so those sites always see the unclamped counter. Inside a plain-opcode segment only plain opcodes run, so the inherited EVM's ordinary per-opcode gas check is the enforcement tool: @@ -102,8 +114,12 @@ Inside a plain-opcode segment only plain opcodes run, so the inherited EVM's ord - **Transaction-level compute binding** → the transaction halts with `OutOfGas`, and remaining gas is rescued and refunded to the sender. - **Detained-limit binding** → the transaction halts with `VolatileDataAccessOutOfGas`, with the same gas rescue. +The reported `limit` MUST be the constraint that bound the clamp, not whichever limit is largest or most convenient: the frame's own compute budget for a frame-local binding, the effective transaction-level limit otherwise. +The revert payload is visible to the calling contract, so a frame-local exceed that reported the transaction-level limit would be a different observable return value for the same execution, not merely a different diagnostic. + Because the crossing opcode never executes, a node MUST NOT include its cost in recorded compute-gas usage. Recorded usage at a clamp-induced halt therefore ends at the limit (or strictly below it if settlement had not yet closed a partial segment), not strictly above it. +The `actual` a transaction-level clamp halt reports MUST be that final usage — the frame-exit settlement closes the partial segment after the exceed is identified, and a node MUST NOT report the usage as it stood before that settlement. **Top-frame headroom tie-break.** At the top-level frame the remaining per-frame compute budget equals the transaction-level remaining budget whenever both are still governed by the same base limit. @@ -128,7 +144,8 @@ Contracts and tools that assume per-opcode compute-gas attribution for every ins Contracts that stay within every resource limit see no behavioral change relative to Rex6. Contracts that trip the compute-gas or detention limit inside a plain-opcode segment halt one opcode earlier than under Rex6, with the crossing opcode excluded from recorded compute usage and with remaining gas still refundable on a transaction-level halt. -A parent that calls into a child which runs out of ordinary EVM gas may observe a higher transaction-level compute-gas total under Rex7 than under Rex6; the receipt `gas_used` and the execution success or failure of the outer transaction are unchanged by that carve-out alone. +A transaction that halts exceptionally, or that calls into a child frame which does, may report a higher transaction-level compute-gas total under Rex7 than under Rex6 — for any exceptional halt, not just out-of-gas. +The receipt `gas_used`, the halt or revert reported, and the execution success or failure of the outer transaction are unchanged by that carve-out: the burned remainder is reported, never enforced. ## Safety and Compatibility From 918868015f7cacc753e00d7e5919476510fc2f4a Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 01:19:49 +0800 Subject: [PATCH 24/43] docs(rex7): correct the frame-exit hook docstrings after the split --- crates/mega-evm/src/limit/limit.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index c122874a..37597ef6 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -334,9 +334,9 @@ impl AdditionalLimit { self.compute_gas.detained_limit() < self.compute_gas.base_tx_limit(); } - /// Finalises the compute accounting a frame's own result decides: restores any outstanding V0 - /// clamp, latches a clamp-induced out-of-gas as a compute exceed, and settles an exceptional - /// halt's burned remainder. + /// Finalises what the frame's own result decides about the clamp: restores any outstanding V0 + /// clamp into the result's gas, latches a clamp-induced out-of-gas as the compute exceed it + /// stands for, and records how much was hidden for the frame-exit settlement that follows. /// /// Must run before anything reads or charges the result's gas — in particular before the /// execution-layer code-deposit storage charge, which would otherwise observe the clamped copy @@ -1137,6 +1137,10 @@ impl AdditionalLimit { /// /// Used by `KeylessDeploy` (REX5+) to propagate sandbox resource consumption /// back to the parent transaction. + /// + /// [`LimitUsage`] carries one compute-gas total, so a REX7 sandbox that halted exceptionally + /// merges its burned remainder as ordinary enforcing usage rather than into the parent's + /// non-enforcing lane. The amount is bounded by the sandbox's gas reservation. pub(crate) fn merge_usage(&mut self, usage: LimitUsage) { self.compute_gas.merge_persistent_usage(usage.compute_gas); self.data_size.merge_persistent_usage(usage.data_size); From 3ba8273b2e138b3d9732025056f6eed38c780b4b Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 01:22:09 +0800 Subject: [PATCH 25/43] docs: note the REX7 exceptional-halt burn lane in AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 0c086a9a..1d1f87d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,7 @@ MegaETH separates EVM gas into two independent dimensions tracked during executi - **Compute gas**: Measures pure computational cost. Through REX6 every opcode's gas consumption is recorded via wrapped instructions in `evm/instructions.rs` — `compute_gas_ext::*` for plain opcodes and `storage_gas_ext::*` for storage-affecting opcodes (SSTORE, LOG, CALL-family, CREATE/CREATE2, SELFDESTRUCT) — both invoking the shared `record_storage_compute_gas!` primitive after the opcode body completes. REX7 settles compute gas at checkpoints (storage-gas opcodes, CALL/CREATE family, volatile opcodes, `GAS`, frame entry/resume/exit) rather than after every plain opcode, and enforces limits inside plain segments with a V0 gas clamp. + A REX7 frame that ends in an exceptional halt additionally settles its whole burned remainder into a lane of `ComputeGasTracker` that the reported total and block accounting include but no limit comparison sees — the burn is destroyed gas, not work performed, and enforcing it would turn an EVM halt into a resource-limit failure with the gas rescued. Subject to a per-spec compute gas limit and further restricted by gas detention (see below). - **Storage gas**: Charges for persistent state modifications (SSTORE, account creation, contract deployment). These costs scale dynamically with SALT bucket capacity (see External Environment Dependencies below). From d7ba3cc2a88e9a1c59146eb0e237b34715349257 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 01:24:26 +0800 Subject: [PATCH 26/43] test(rex7): explain the callee loop size in the payload case --- crates/mega-evm/tests/rex7/clamp_classification.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/mega-evm/tests/rex7/clamp_classification.rs b/crates/mega-evm/tests/rex7/clamp_classification.rs index 55caa9af..2ef235d3 100644 --- a/crates/mega-evm/tests/rex7/clamp_classification.rs +++ b/crates/mega-evm/tests/rex7/clamp_classification.rs @@ -248,6 +248,7 @@ fn decode_limit_exceeded(label: &str, outcome: &Outcome) -> MegaLimitExceeded { /// return value for the same execution. #[test] fn test_frame_local_clamp_exceed_reports_the_sub_frame_budget() { + // Enough iterations to outrun the sub-frame's 98/100 share of a one-million compute budget. let callee = countdown_loop_code(&[], 40_000); let code = call_and_return_revert_data(50_000_000); let build_db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); From ae0ad80eb6a01b2b28da4f2463fd174e4c43a5bc Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 09:01:43 +0800 Subject: [PATCH 27/43] fix(rex7): enforce the work an exceptionally halted frame performed An exceptional halt settled its whole open segment plus the clamp-hidden gas into the non-enforcing lane, so the opcodes the frame had already run stopped counting against the parent frame and the transaction. Code that keeps executing after absorbing the failure could then spend the same compute headroom a second time. Split the settlement in two: the executed tail settles through the ordinary enforcing path at frame exit, and only the remainder the frame destroys goes to the non-enforcing lane. The destroyed part is read from the frame's final result after action processing, which is also the first point the classification is final -- revm's create-return can still turn a successful constructor into a code-deposit out-of-gas, an EIP-3541 reject or a runtime code-size reject. The reported total is unchanged for every shape that was already correct; what moves is which half of it enforces. --- crates/mega-evm/src/limit/compute_gas.rs | 37 ++++-- crates/mega-evm/src/limit/limit.rs | 144 ++++++++++++++--------- 2 files changed, 118 insertions(+), 63 deletions(-) diff --git a/crates/mega-evm/src/limit/compute_gas.rs b/crates/mega-evm/src/limit/compute_gas.rs index d6e61fef..4dffb7c9 100644 --- a/crates/mega-evm/src/limit/compute_gas.rs +++ b/crates/mega-evm/src/limit/compute_gas.rs @@ -57,14 +57,21 @@ pub(crate) struct ComputeGasTracker { /// The effective compute gas limit, which may be dynamically lowered by gas detention /// (volatile data access). Always <= `frame_tracker.tx_limit()`. detained_limit: u64, - /// Compute gas settled from the burned remainders of exceptionally halted frames (REX7+). + /// Compute gas settled from the **destroyed** remainders of exceptionally halted frames + /// (REX7+). /// /// Recorded into the TX-level lane of `frame_tracker`, so it shows up in the transaction's /// reported compute total and in block-level accounting, and subtracted back out of every - /// limit comparison. A burned remainder is gas the EVM destroyed, not work the network - /// performed; letting it trip a limit would turn an ordinary EVM halt into a resource-limit - /// failure with the remaining gas rescued for the sender, changing a receipt that must stay - /// identical to per-opcode accounting. Always 0 before REX7. + /// limit comparison. A destroyed remainder is gas the EVM threw away without executing + /// anything for it; letting it trip a limit would turn an ordinary EVM halt into a + /// resource-limit failure with the remaining gas rescued for the sender, changing a receipt + /// that must stay identical to per-opcode accounting. + /// + /// Only the remainder lands here. The work an exceptionally halted frame actually performed + /// before it failed is recorded through [`record_gas_used`](Self::record_gas_used) like any + /// other work, so it shrinks the parent frame's and the transaction's budgets — otherwise the + /// code that runs after the failed frame returns could spend the same headroom twice. Always 0 + /// before REX7. burned: u64, frame_tracker: FrameLimitTracker<()>, } @@ -206,7 +213,7 @@ impl ComputeGasTracker { } } - /// Records a burned remainder from an exceptionally halted frame (REX7+). + /// Records the destroyed remainder of an exceptionally halted frame (REX7+). /// /// Counts toward the transaction's reported compute total and block-level accounting, and is /// excluded from every limit comparison — see [`burned`](Self::burned). @@ -215,7 +222,23 @@ impl ComputeGasTracker { self.frame_tracker.add_tx_persistent(amount); } - /// Total recorded usage minus the burned remainders that must not enforce. + /// Reclassifies `amount` of already-merged usage as a destroyed remainder (REX7+). + /// + /// The sandbox path merges one compute total through + /// [`merge_persistent_usage`](Self::merge_persistent_usage) and then declares how much of it + /// the sandbox destroyed, rather than adding the amount a second time. + pub(crate) fn merge_burned_usage(&mut self, amount: u64) { + self.burned = self.burned.saturating_add(amount); + } + + /// The destroyed remainders inside [`tx_usage`](TxRuntimeLimit::tx_usage) — see + /// [`burned`](Self::burned). + #[inline] + pub(crate) fn burned_usage(&self) -> u64 { + self.burned + } + + /// Total recorded usage minus the destroyed remainders that must not enforce. #[inline] fn enforced_tx_usage(&self) -> u64 { self.frame_tracker.net_usage().saturating_sub(self.burned) diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index 37597ef6..6f4ecb85 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -133,15 +133,6 @@ pub struct AdditionalLimit { /// [`settle_frame_final_result`](Self::settle_frame_final_result). clamp: Option, - /// The clamp-hidden gas [`settle_frame_final_result`](Self::settle_frame_final_result) just - /// handed back to the frame's result, carried to the frame-exit settlement in - /// [`after_frame_run_instructions`](Self::after_frame_run_instructions). - /// - /// The two hooks are split by the execution layer's code-deposit charge, which has to observe - /// unclamped gas; the settlement that follows still needs to know how much of the frame's true - /// remainder the interpreter could not see. Written and consumed on the same frame exit. - restored_clamp_hidden: u64, - /// Whether a clamp-induced out-of-gas was latched while gas detention was the binding TX-level /// constraint. /// @@ -196,7 +187,6 @@ impl AdditionalLimit { checkpoint_accounting: spec.is_enabled(MegaSpecId::REX7), checkpoint_baseline: 0, clamp: None, - restored_clamp_hidden: 0, clamp_latched_detained: false, } } @@ -241,7 +231,6 @@ impl AdditionalLimit { self.storage_call_stipend.reset(); self.checkpoint_baseline = 0; self.clamp = None; - self.restored_clamp_hidden = 0; self.clamp_latched_detained = false; } @@ -270,6 +259,24 @@ impl AdditionalLimit { self.checkpoint_baseline = remaining; } + /// Moves the open segment's baseline down by `amount` of `MegaETH` storage gas just charged to + /// the interpreter, so the charge sits outside the segment rather than inside it. + /// + /// A checkpoint body normally subtracts its own storage charge when it closes its measurement + /// window. A body that aborts — a static-context `LOG`, a `SELFDESTRUCT` whose inner + /// instruction runs out of gas — never reaches that subtraction, and the frame-exit settlement + /// that follows would then bill the charge as compute. Excluding it from the baseline as it is + /// charged makes the exclusion hold on both paths; on the normal path the body's own window + /// re-syncs the baseline afterwards, so this is invisible there. + /// + /// No-op before REX7, where nothing measures against a baseline. + #[inline] + pub(crate) fn exclude_storage_gas_from_segment(&mut self, amount: u64) { + if self.checkpoint_accounting { + self.checkpoint_baseline = self.checkpoint_baseline.saturating_sub(amount); + } + } + /// Takes the outstanding clamp so the caller can hand its hidden gas back to the interpreter, /// returning that amount. /// @@ -335,12 +342,14 @@ impl AdditionalLimit { } /// Finalises what the frame's own result decides about the clamp: restores any outstanding V0 - /// clamp into the result's gas, latches a clamp-induced out-of-gas as the compute exceed it - /// stands for, and records how much was hidden for the frame-exit settlement that follows. + /// clamp into the result's gas and latches a clamp-induced out-of-gas as the compute exceed it + /// stands for. /// /// Must run before anything reads or charges the result's gas — in particular before the /// execution-layer code-deposit storage charge, which would otherwise observe the clamped copy - /// and mis-fire an out-of-gas on a CREATE frame that is nowhere near its limits. + /// and mis-fire an out-of-gas on a CREATE frame that is nowhere near its limits. It is also + /// what puts the result's gas into the true domain, which is where the destroyed remainder of + /// an exceptionally halted frame is later read from. /// /// A clamp can only be outstanding when the frame ended inside a plain-opcode segment, because /// every checkpoint prologue takes it before its body. An out-of-gas exit from such a segment @@ -353,12 +362,8 @@ impl AdditionalLimit { if !self.checkpoint_accounting { return; } - self.restored_clamp_hidden = 0; if let Some(clamp) = self.clamp.take() { result.gas.erase_cost(clamp.hidden); - // Handed to the frame-exit settlement, which runs after the execution layer's - // code-deposit charge and can no longer see the clamp itself. - self.restored_clamp_hidden = clamp.hidden; // `MemoryOOG` is the same gas shortage reported from the memory-expansion path; every // other result either is unrelated to gas or cannot arise from a plain opcode. if matches!(result.result, InstructionResult::OutOfGas | InstructionResult::MemoryOOG) { @@ -400,6 +405,18 @@ impl AdditionalLimit { self.has_exceeded_limit = LimitCheck::Exempt; } + /// The part of [`get_usage`](Self::get_usage)'s `compute_gas` that exceptionally halted frames + /// destroyed rather than performed (REX7+, always 0 before). + /// + /// Reported and accounted like the rest of the total, never enforced. A caller that merges + /// this transaction's usage into another tracker — today the `KeylessDeploy` sandbox boundary — + /// has to carry it alongside the total, or the receiving tracker re-enforces gas the EVM + /// already destroyed. + #[inline] + pub(crate) fn burned_compute_gas(&self) -> u64 { + self.compute_gas.burned_usage() + } + /// Gets the usage of the additional limits. #[inline] pub fn get_usage(&self) -> LimitUsage { @@ -928,8 +945,9 @@ impl AdditionalLimit { /// Hook called after frame action processing in `frame_run`. /// /// Records compute gas cost induced in frame action processing (e.g., code deposit cost), - /// marks the frame result as exceeding limit if needed, and rescues gas if a TX-level limit - /// was exceeded (before any inspector callback that might modify gas). + /// marks the frame result as exceeding limit if needed, settles an exceptionally halted + /// frame's destroyed remainder (REX7+), and rescues gas if a TX-level limit was exceeded + /// (before any inspector callback that might modify gas). pub(crate) fn after_frame_run( &mut self, result: &mut FrameResult, @@ -945,6 +963,7 @@ impl AdditionalLimit { ); } } + self.settle_exceptional_halt_burn(result); // Rescue gas if a TX-level additional limit has been exceeded. // This must happen before any inspector callback (`frame_end`) that might modify // the gas via `spend_all()`, so the correct `gas.remaining()` value is captured. @@ -971,21 +990,19 @@ impl AdditionalLimit { // `settle_frame_final_result`, before the execution-layer hook charged code-deposit storage // gas against the action's gas. // - // A frame that ended in an exceptional halt takes the burn branch instead: it returns none - // of its remaining budget, so the whole remainder — not just what the counter shows — - // settles, and it settles outside limit enforcement. See `settle_exceptional_halt_burn`. + // This delta is the work the frame *performed*, so it settles the same way — through the + // enforcing path — however the frame ended. A frame that halts exceptionally still ran the + // opcodes ahead of its failure, and a parent frame keeps executing after absorbing that + // failure; leaving the executed tail out of enforcement would let the code after the failed + // frame spend the same headroom a second time. What such a frame additionally destroys — + // the budget it never gets to spend — is settled after action processing, outside + // enforcement, by `settle_exceptional_halt_burn`. if self.checkpoint_accounting { - if let InterpreterAction::Return(interpreter_result) = action { - let exceptional_halt = !interpreter_result.result.is_ok_or_revert(); - let hidden = core::mem::take(&mut self.restored_clamp_hidden); + if let InterpreterAction::Return(_) = action { let remaining = frame.interpreter.gas.remaining(); - if exceptional_halt && !self.limit_exceeded() { - self.settle_exceptional_halt_burn(hidden); - } else { - let gas_used = self.checkpoint_baseline.saturating_sub(remaining); - let _ = self.record_compute_gas_unguarded(gas_used); - self.refresh_latched_compute_usage(); - } + let gas_used = self.checkpoint_baseline.saturating_sub(remaining); + let _ = self.record_compute_gas_unguarded(gas_used); + self.refresh_latched_compute_usage(); self.checkpoint_baseline = remaining; } } @@ -1106,31 +1123,43 @@ impl AdditionalLimit { } } - /// Settles the entire remainder an exceptionally halted frame burns, as compute gas. + /// Settles the remainder an exceptionally halted frame destroys, as non-enforcing compute gas + /// (REX7+). /// /// An exceptional halt returns no gas: the top-level frame's whole envelope is spent by the /// transaction's final gas accounting, and an inner frame's remainder is simply never handed - /// back to its caller. The interpreter zeroes its own counter only for a plain `OutOfGas`, - /// so the frame-exit delta cannot see the burn on any other classification. This settles it - /// directly instead: everything the frame still held at the last checkpoint — the open segment - /// (`baseline`, measured against a zero remainder) plus `hidden`, the part of the true - /// remainder the clamp was keeping out of the interpreter's sight. - /// - /// The whole amount goes to the tracker's non-enforcing lane, not just the part beyond the - /// compute headroom. Nothing is lost by that: a segment that runs under a clamp can never - /// consume past the headroom (the clamp is the enforcement), and a segment with no clamp is - /// bounded by a frame gas remainder that was already below the headroom — so the executed part - /// of an exceptionally halted frame's tail could not have exceeded a limit either way. What - /// enforcing the *burn* would do is turn an ordinary EVM halt into a resource-limit failure - /// with the remaining gas rescued for the sender, which is exactly the receipt change the + /// back to its caller. The interpreter zeroes its own counter only for a plain `OutOfGas`, so + /// the frame-exit delta cannot see that destroyed budget on any other classification. The + /// result's own gas can: by the time this runs, + /// [`settle_frame_final_result`](Self::settle_frame_final_result) has handed back whatever the + /// V0 clamp was hiding and the code-deposit storage charge has been taken, so + /// `result.gas().remaining()` is exactly what the frame still held and will not get to keep. + /// + /// Runs **after** action processing, which is the first point the classification is final: + /// revm's create-return can still turn a successful constructor into a canonical code-deposit + /// out-of-gas, an EIP-3541 reject or a runtime code-size reject, and each of those destroys the + /// frame's remainder just like a halt from the interpreter loop. + /// + /// Only the destroyed part goes to the tracker's non-enforcing lane — the work performed ahead + /// of the failure already settled through the enforcing path in + /// [`after_frame_run_instructions`](Self::after_frame_run_instructions). Enforcing the + /// destroyed part would turn an ordinary EVM halt into a resource-limit failure with the + /// remaining gas rescued for the sender, which is exactly the receipt change the /// exceptional-halt carve-out forbids. /// - /// Not reached when a resource limit is already latched: that path burns nothing, because the - /// frame either reverts to its parent (frame-local) or halts the transaction with its gas + /// Not reached when a resource limit is already latched: that path destroys nothing, because + /// the frame either reverts to its parent (frame-local) or halts the transaction with its gas /// rescued (TX-level) — including a clamp-induced out-of-gas, which - /// [`settle_frame_final_result`](Self::settle_frame_final_result) latches just before this. - fn settle_exceptional_halt_burn(&mut self, hidden: u64) { - self.compute_gas.record_burned_gas(self.checkpoint_baseline.saturating_add(hidden)); + /// [`settle_frame_final_result`](Self::settle_frame_final_result) latches earlier in this + /// frame exit. + fn settle_exceptional_halt_burn(&mut self, result: &FrameResult) { + if !self.checkpoint_accounting || + self.limit_exceeded() || + result.instruction_result().is_ok_or_revert() + { + return; + } + self.compute_gas.record_burned_gas(result.gas().remaining()); } /// Merges resource usage from a sandbox execution into this tracker. @@ -1138,11 +1167,14 @@ impl AdditionalLimit { /// Used by `KeylessDeploy` (REX5+) to propagate sandbox resource consumption /// back to the parent transaction. /// - /// [`LimitUsage`] carries one compute-gas total, so a REX7 sandbox that halted exceptionally - /// merges its burned remainder as ordinary enforcing usage rather than into the parent's - /// non-enforcing lane. The amount is bounded by the sandbox's gas reservation. - pub(crate) fn merge_usage(&mut self, usage: LimitUsage) { + /// `burned_compute_gas` is the part of `usage.compute_gas` the sandbox destroyed rather than + /// performed (REX7+, always 0 before). It is already inside the merged total, so it is only + /// reclassified here — the parent reports it and never enforces it, exactly as the sandbox + /// did. Merging it as ordinary usage instead would let a sandbox frame's ordinary EVM halt + /// fail the outer transaction on a resource limit. + pub(crate) fn merge_usage(&mut self, usage: LimitUsage, burned_compute_gas: u64) { self.compute_gas.merge_persistent_usage(usage.compute_gas); + self.compute_gas.merge_burned_usage(burned_compute_gas); self.data_size.merge_persistent_usage(usage.data_size); self.kv_update.merge_persistent_usage(usage.kv_updates); self.state_growth.merge_persistent_usage(usage.state_growth); From 113fd7b1936ac4760d344fa73eecc787e02672a7 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 09:01:50 +0800 Subject: [PATCH 28/43] fix(rex7): keep an aborted checkpoint's storage charge out of compute A checkpoint body charges its storage gas before running the raw opcode and subtracts it back out when it records its own compute window. A body that halts in between -- LOG in a static frame, SELFDESTRUCT whose inner instruction runs out of gas -- never reaches that subtraction, so the frame-exit settlement reported the charge as compute gas. Exclude the charge from the open segment as it is made, at every site that debits MegaETH storage gas from inside a checkpoint body. The normal path re-syncs the segment right afterwards, so nothing changes there. --- crates/mega-evm/src/evm/instructions.rs | 53 ++++++++++++++++++++----- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index f40f01e9..7a566ada 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -168,8 +168,12 @@ use revm::{ /// - **REX7** (extends REX6): switches to **checkpoint compute-gas settlement**. The plain opcodes /// are revm's own instructions with no recording wrapper at all; compute gas settles as an /// interpreter-gas delta at each checkpoint — the storage-gas opcodes, the CALL / CREATE family, -/// the volatile opcodes, and frame entry / resume / exit. Per-transaction totals are unchanged; a -/// limit exceed surfaces at the next checkpoint rather than at the opcode that crossed it. +/// the volatile opcodes, and frame entry / resume / exit. Per-transaction totals are unchanged +/// for a transaction that stays inside every limit and never halts exceptionally; a frame that +/// does halt exceptionally additionally reports the budget it destroyed, which is enforced +/// against nothing. Enforcement inside a plain segment is the V0 gas clamp, which stops the +/// crossing opcode before it executes; an exceed detected by a settlement instead surfaces at the +/// checkpoint that settled it rather than at the opcode that crossed the limit. /// - Volatile opcodes: `volatile_data_ext::*_checkpoint` (raw instruction + segment settlement + /// detention cap) in place of the `compute_gas_ext` delegation /// - Storage-gas, CALL-family, CREATE and SELFDESTRUCT: the REX6 handler chains, settling from @@ -928,6 +932,29 @@ macro_rules! record_checkpoint_body_compute_gas { }; } +/// Charges `$amount` of `MegaETH` storage gas to the interpreter's counter and keeps it out of the +/// REX7 settlement segment that is currently open, returning the amount charged. +/// +/// Storage gas is never compute gas. A checkpoint body normally subtracts its own charge when +/// [`record_storage_compute_gas!`] closes the body's measurement window — but a body that halts +/// before reaching that macro (a static-context `LOG`, an inner instruction that runs out of gas) +/// leaves the frame-exit settlement measuring a segment the charge is still inside, which would +/// report storage gas as compute gas. Excluding it from the segment as it is charged makes the +/// exclusion hold on both paths; on the normal path the body's own window re-syncs the segment +/// afterwards, so this is invisible there. +/// +/// Returns `Err(OutOfGas)` from the enclosing handler when the frame cannot afford the charge, +/// exactly as a bare `gas!` would — with nothing debited and so nothing to exclude. No-op before +/// REX7, where nothing measures against a segment. +macro_rules! charge_storage_gas { + ($context:expr, $amount:expr) => {{ + let amount: u64 = $amount; + gas!($context.interpreter, amount); + $context.host.additional_limit().borrow_mut().exclude_storage_gas_from_segment(amount); + amount + }}; +} + /// Records an opcode's compute gas in a single measurement window and enforces the compute-gas /// limit. The REX6 storage-affecting handlers invoke it directly with the storage gas they /// charged; plain opcodes use the leaner inline recording in @@ -2478,9 +2505,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(new_account_storage_gas); - let charged = new_account_storage_gas - drained; - gas!(context.interpreter, charged); - charged + charge_storage_gas!(context, new_account_storage_gas - drained) } else { 0 }; @@ -2826,8 +2851,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(create_contract_storage_gas); - let storage_charged = create_contract_storage_gas - drained; - gas!(context.interpreter, storage_charged); + let storage_charged = charge_storage_gas!(context, create_contract_storage_gas - drained); // Run the raw inner create opcode (no `compute_gas_ext` wrapper — REX6 records compute gas // once below). @@ -2905,6 +2929,15 @@ pub mod storage_gas_ext { // storage cost. let storage_charged = log_storage_cost.expect("gas_or_fail! above halts and returns on None"); + // The `gas_or_fail!` above is the storage-gas charge, so it gets the same segment + // exclusion `charge_storage_gas!` applies at every other charge site: the raw opcode below + // can halt (a static frame rejects `LOG` outright) before the recording that would + // otherwise subtract it. + context + .host + .additional_limit() + .borrow_mut() + .exclude_storage_gas_from_segment(storage_charged); // Run the raw opcode and record compute gas once after the body completes (canonical // metering order). Byte-equivalent to the pre-REX6 per-`N` `compute_gas_ext::logK` @@ -2973,9 +3006,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(sstore_set_storage_gas); - let charged = sstore_set_storage_gas - drained; - gas!(context.interpreter, charged); - charged + charge_storage_gas!(context, sstore_set_storage_gas - drained) } else { 0 }; @@ -3068,7 +3099,7 @@ pub mod storage_gas_ext { }; let drained = context.host.additional_limit().borrow_mut().try_consume_storage_stipend(cost); - gas!(context.interpreter, cost - drained); + charge_storage_gas!(context, cost - drained); // Record resource usage for new beneficiary account context.host.additional_limit().borrow_mut().on_selfdestruct_new_account(); From 9c6b609a2ad9a50f9e0dae689c92986a97da61eb Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 09:01:59 +0800 Subject: [PATCH 29/43] fix(rex7): keep the sandbox's compute-gas split across the merge The KeylessDeploy sandbox exported one compute total, whose REX7 reading already includes the remainders its exceptionally halted frames destroyed. The parent merged that as ordinary usage and then ran a post-merge limit check, so a burn that the sandbox itself never enforced became enforcing the moment it crossed the boundary -- turning a constructor's ordinary EVM halt into an outer ComputeGasLimitExceeded with the gas rescued. Carry the split across in SandboxUsage and merge the two lanes separately, so the parent reports the sandbox's whole total and enforces only the part the sandbox performed. --- crates/mega-evm/src/sandbox/execution.rs | 47 ++++++++++++++++++------ 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/crates/mega-evm/src/sandbox/execution.rs b/crates/mega-evm/src/sandbox/execution.rs index 177653a1..7edb82ef 100644 --- a/crates/mega-evm/src/sandbox/execution.rs +++ b/crates/mega-evm/src/sandbox/execution.rs @@ -648,7 +648,13 @@ fn run_sandbox_ctx( let is_rex6_enabled = sandbox_ctx.mega_spec().is_enabled(MegaSpecId::REX6); let mut sandbox_evm = MegaEvm::new(sandbox_ctx); let result = sandbox_evm.transact_raw(sandbox_tx); - let limit_usage = sandbox_evm.ctx.additional_limit.borrow().get_usage(); + let limit_usage = { + let additional_limit = sandbox_evm.ctx.additional_limit.borrow(); + SandboxUsage { + usage: additional_limit.get_usage(), + burned_compute_gas: additional_limit.burned_compute_gas(), + } + }; let volatile_accesses = sandbox_evm.ctx.volatile_data_tracker.borrow().get_volatile_data_accessed(); process_sandbox_transact_result( @@ -686,7 +692,7 @@ pub enum SandboxOutcome { /// Wire-shape dispatch for what the outer caller should report. completion: SandboxCompletion, /// Resource usage from the sandbox's additional limit trackers. - limit_usage: LimitUsage, + limit_usage: SandboxUsage, /// Volatile-access footprint to merge into the parent after sandbox return. volatile_accesses: VolatileDataAccess, }, @@ -696,6 +702,22 @@ pub enum SandboxOutcome { Rejected(KeylessDeployError), } +/// Resource usage a completed sandbox hands to the parent, with Rex7's compute-gas split intact. +/// +/// [`LimitUsage`] carries one number per dimension, which is all the parent needs for three of +/// them. Compute gas needs two: the parent must report the sandbox's whole total but must enforce +/// only the part the sandbox performed, exactly as the sandbox itself did. Collapsing the two at +/// this boundary would let an ordinary EVM halt inside a sandboxed constructor fail the outer +/// transaction on a resource limit. +#[derive(Debug, Clone, Copy, Default)] +pub struct SandboxUsage { + /// Full reported usage. `compute_gas` includes `burned_compute_gas`. + pub usage: LimitUsage, + /// The part of `usage.compute_gas` the sandbox destroyed rather than performed — the + /// remainders of exceptionally halted sandbox frames (Rex7+, always 0 before). + pub burned_compute_gas: u64, +} + /// Wire-shape dispatch for a completed sandbox execution. /// /// `Deployed` and `EmptyCode` both surface as success-shape outer returns: the @@ -788,7 +810,7 @@ impl SandboxCompletion { /// surface (`Deployed { addr }` returned for create+SELFDESTRUCT) for replay parity. fn process_sandbox_transact_result( result: Result, E>, - limit_usage: LimitUsage, + limit_usage: SandboxUsage, volatile_accesses: VolatileDataAccess, is_rex5_enabled: bool, is_rex6_enabled: bool, @@ -930,7 +952,7 @@ fn process_sandbox_transact_result( fn apply_sandbox_post_accounting( ctx: &MegaContext, gas: &mut Gas, - limit_usage: LimitUsage, + limit_usage: SandboxUsage, volatile_accesses: VolatileDataAccess, reservation: u64, sandbox_gas_used: u64, @@ -1003,15 +1025,18 @@ fn charge_caller_materialization_pre_sandbox( ctx: &MegaContext, - limit_usage: LimitUsage, + limit_usage: SandboxUsage, ) { - ctx.additional_limit.borrow_mut().merge_usage(limit_usage); + ctx.additional_limit + .borrow_mut() + .merge_usage(limit_usage.usage, limit_usage.burned_compute_gas); } /// Returns the unused portion of the sandbox's pre-debited gas reservation to the @@ -1254,7 +1279,7 @@ mod tests { }); let out = process_sandbox_transact_result( result, - LimitUsage::default(), + SandboxUsage::default(), VolatileDataAccess::empty(), true, false, @@ -1280,7 +1305,7 @@ mod tests { }); let out = process_sandbox_transact_result( result, - LimitUsage::default(), + SandboxUsage::default(), VolatileDataAccess::empty(), true, false, @@ -1301,7 +1326,7 @@ mod tests { Err(FakeTxErr { is_tx: false, msg: "db blew up" }); let out = process_sandbox_transact_result( result, - LimitUsage::default(), + SandboxUsage::default(), VolatileDataAccess::empty(), true, false, @@ -1320,7 +1345,7 @@ mod tests { Err(FakeTxErr { is_tx: true, msg: "intrinsic gas too low" }); let out = process_sandbox_transact_result( result, - LimitUsage::default(), + SandboxUsage::default(), VolatileDataAccess::empty(), true, false, From 201fa4ec46d6c3ca120d9346b712630b4bee4a8e Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 09:02:00 +0800 Subject: [PATCH 30/43] docs(rex7): state what a clamp-induced exceed's actual can exceed The clamp stops the crossing opcode before it executes, so the usage being enforced stays at or below the limit -- but the reported actual is the transaction's full total, which also carries the remainders of any frame that halted exceptionally earlier. Those are reported and never enforced, so actual can be larger than limit. --- crates/mega-evm/src/evm/result.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/mega-evm/src/evm/result.rs b/crates/mega-evm/src/evm/result.rs index 8ae66054..0969d9da 100644 --- a/crates/mega-evm/src/evm/result.rs +++ b/crates/mega-evm/src/evm/result.rs @@ -115,7 +115,10 @@ pub enum MegaHaltReason { /// - Per-opcode enforcement (through Rex6): the crossing opcode has already recorded its /// cost, so `actual > limit`. /// - Gas-clamp enforcement (Rex7+): the crossing opcode is stopped before it executes and - /// its cost is not recorded, so `actual ≤ limit`. + /// its cost is not recorded, so the *enforced* usage stays at or below `limit`. `actual` + /// is the transaction's full reported total, which also carries the remainders of any + /// frame that halted exceptionally earlier in the transaction — those are reported but + /// never enforced, and they can push `actual` above `limit`. limit: u64, /// The actual compute gas usage at the halt. actual: u64, @@ -148,7 +151,10 @@ pub enum MegaHaltReason { /// - Per-opcode enforcement (through Rex6): the crossing opcode has already recorded its /// cost, so `actual > limit`. /// - Gas-clamp enforcement (Rex7+): the crossing opcode is stopped before it executes and - /// its cost is not recorded, so `actual ≤ limit`. + /// its cost is not recorded, so the *enforced* usage stays at or below `limit`. `actual` + /// is the transaction's full reported total, which also carries the remainders of any + /// frame that halted exceptionally earlier in the transaction — those are reported but + /// never enforced, and they can push `actual` above `limit`. limit: u64, /// The actual compute gas usage at the halt. actual: u64, From db8a414eb3048f2f588b3e06f8556bb1999f24ce Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 09:02:07 +0800 Subject: [PATCH 31/43] test(rex7): pin which half of an exceptional frame enforces Covers the four faces the executed half has to bind (transaction compute limit, the caller's remaining-budget reading, the detention cap base), the two boundaries that decide what belongs to which half (a checkpoint's storage charge, revm's post-action create rejects), the sandbox merge, and the inspected execution loop. The code-deposit claim in the compute-gas suite moves from an EIP-3541 rejection to an empty deploy: under REX7 a failed deposit destroys the CREATE frame's whole remainder, which dwarfs the charge under test. --- crates/mega-evm/tests/compute_gas/claims.rs | 37 +- crates/mega-evm/tests/rex7/burn_split.rs | 712 ++++++++++++++++++++ crates/mega-evm/tests/rex7/main.rs | 4 + 3 files changed, 738 insertions(+), 15 deletions(-) create mode 100644 crates/mega-evm/tests/rex7/burn_split.rs diff --git a/crates/mega-evm/tests/compute_gas/claims.rs b/crates/mega-evm/tests/compute_gas/claims.rs index 7f4fa05f..b70aff44 100644 --- a/crates/mega-evm/tests/compute_gas/claims.rs +++ b/crates/mega-evm/tests/compute_gas/claims.rs @@ -819,24 +819,31 @@ fn test_first_call_to_the_beneficiary_is_charged_cold_from_minirex() { /// Pins "Code Deposit": the deposit's compute gas is recorded exactly once when the deposit /// occurs, and nothing is recorded when it does not. /// -/// The two initcodes have identical length and opcode sequence and differ only in the byte they -/// store, so every other cost in the transaction cancels and the difference between the two -/// recorded totals is the code-deposit charge alone. `0xEF` makes EIP-3541 reject the runtime -/// code, so the deposit never happens. +/// The two initcodes have identical length and opcode sequence and differ only in the length they +/// return, so every other cost in the transaction cancels — the `MSTORE8` has already expanded +/// memory past both `RETURN` windows — and the difference between the two recorded totals is the +/// code-deposit charge alone. +/// +/// The zero-length return is what makes "the deposit does not happen" observable on every spec: +/// the CREATE still succeeds, so the frame returns its unspent budget to the caller. A CREATE that +/// fails the deposit instead (EIP-3541, the code-size limit, an unaffordable code-deposit charge) +/// is an exceptional halt that returns nothing, and Rex7 settles that destroyed budget as compute +/// gas — a much larger number than the charge under test. Those shapes are pinned by the Rex7 +/// exceptional-halt suite rather than here. /// /// Two different mechanisms produce this number — `MiniRex` through Rex4 measure it over the /// frame-action window, Rex5+ pre-charge the canonical amount before the checkpoint commits — so /// the assertion runs on every tracked spec to keep them agreeing. #[test] fn test_code_deposit_recorded_only_when_deposit_occurs() { - /// `PUSH1 , PUSH0, MSTORE8, PUSH1 32, PUSH0, RETURN` — returns 32 bytes of runtime - /// code whose first byte is `first`. - fn initcode(first: u8) -> [u8; 8] { - [0x60, first, 0x5f, 0x53, 0x60, 0x20, 0x5f, 0xf3] + /// `PUSH1 0, PUSH0, MSTORE8, PUSH1 , PUSH0, RETURN` — returns `len` bytes of runtime + /// code. + fn initcode(len: u8) -> [u8; 8] { + [0x60, 0x00, 0x5f, 0x53, 0x60, len, 0x5f, 0xf3] } - fn creator(first: u8) -> MemoryDatabase { - let code = initcode(first); + fn creator(len: u8) -> MemoryDatabase { + let code = initcode(len); base_db( BytecodeBuilder::default() .mstore(0, code) @@ -856,10 +863,10 @@ fn test_code_deposit_recorded_only_when_deposit_occurs() { if !spec.is_enabled(MegaSpecId::MINI_REX) { continue; // Equivalence records no compute gas at all. } - let deposited = transact(spec, creator(0x00)); - let skipped = transact(spec, creator(0xef)); - // Both transactions succeed: the EIP-3541 rejection fails the CREATE (it pushes zero), - // not the transaction. A non-success outcome means the fixture itself drifted. + let deposited = transact(spec, creator(32)); + let skipped = transact(spec, creator(0)); + // Both transactions succeed: an empty deploy leaves the CREATE successful (it pushes the + // created address). A non-success outcome means the fixture itself drifted. assert_eq!(deposited.outcome, "success", "{spec_name}: depositing run should succeed"); assert_eq!(skipped.outcome, "success", "{spec_name}: skipped-deposit run should succeed"); let (deposited, skipped) = (deposited.compute_gas, skipped.compute_gas); @@ -872,7 +879,7 @@ fn test_code_deposit_recorded_only_when_deposit_occurs() { }); assert_eq!( delta, EXPECTED_DEPOSIT_GAS, - "{spec_name}: the only compute gas separating a deposit from an EIP-3541 rejection \ + "{spec_name}: the only compute gas separating a 32-byte deposit from an empty one \ must be the code-deposit charge (deposited={deposited} skipped={skipped})" ); } diff --git a/crates/mega-evm/tests/rex7/burn_split.rs b/crates/mega-evm/tests/rex7/burn_split.rs new file mode 100644 index 00000000..6fc2b5e8 --- /dev/null +++ b/crates/mega-evm/tests/rex7/burn_split.rs @@ -0,0 +1,712 @@ +//! REX7 splits an exceptionally halted frame into the work it performed and the budget it +//! destroyed. +//! +//! The two halves are accounted differently, and both halves have to be right: +//! +//! - **Executed** — everything the frame ran before it failed. It settles through the ordinary +//! enforcing path, so it shrinks the parent frame's budget, the transaction's compute budget, the +//! reading `MegaLimitControl.remainingComputeGas` returns, and the base a detention cap is built +//! on. A parent frame keeps executing after it absorbs a failed child; if the child's work left +//! enforcement, the code that follows could spend the same headroom a second time. +//! - **Destroyed** — the budget the frame never gets to spend, and never hands back. It is reported +//! and block-accounted but never enforced: halting on it would turn an ordinary EVM halt into a +//! resource-limit failure with the gas rescued, which is the receipt change the exceptional-halt +//! carve-out forbids. +//! +//! Two boundaries decide what belongs to which half, and both are exercised here: +//! +//! - the **storage gas** a checkpoint body charged before aborting is neither — it is storage gas, +//! and the body never reached the recording that would have subtracted it; +//! - the classification is only final **after action processing**, because revm's create-return can +//! still turn a successful constructor into a code-deposit out-of-gas, an EIP-3541 reject or a +//! runtime code-size reject. +//! +//! [`exceptional_halt`](crate::exceptional_halt) covers the reported totals; this module covers +//! which side of the enforcing boundary each part lands on. + +use crate::common::{ + transact, transact_default, transact_tx, transact_with_bucket_capacity, + transact_with_gas_limit, Outcome, CALLEE, CALLER, CONTRACT, DEFAULT_TX_GAS_LIMIT, ONE_ETH, +}; +use alloy_primitives::{address, hex, Address, Bytes, Signature, TxKind, B256, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + alloy_consensus::{Signed, TxLegacy}, + alloy_op_evm::OpTxError, + constants::mini_rex::{CODEDEPOSIT_STORAGE_GAS, LOG_DATA_STORAGE_GAS, MAX_CONTRACT_SIZE}, + test_utils::{BytecodeBuilder, MemoryDatabase}, + EVMError, EvmTxRuntimeLimits, IKeylessDeploy, IMegaLimitControl, MegaContext, MegaEvm, + MegaHaltReason, MegaSpecId, MegaTransaction, MegaTransactionNew as _, KEYLESS_DEPLOY_ADDRESS, + LIMIT_CONTROL_ADDRESS, +}; +use revm::{ + bytecode::opcode::{ + ADD, CALL, CREATE, LOG0, MLOAD, MSTORE8, POP, RETURN, SSTORE, STATICCALL, STOP, TIMESTAMP, + }, + context::{result::ResultAndState, tx::TxEnvBuilder, CfgEnv}, + handler::EvmTr, + inspector::NoOpInspector, +}; +use std::{convert::Infallible, vec::Vec}; + +/// Relayer that sends the keyless-deploy transactions. +const KEYLESS_RELAYER: Address = address!("0000000000000000000000000000000000340004"); + +/// Storage slot the caller writes its `remainingComputeGas` readings to. +const BEFORE_SLOT: u64 = 0xb0; +/// Second `remainingComputeGas` reading slot. +const AFTER_SLOT: u64 = 0xb1; + +/// Plain-opcode pairs the failing child runs before it underflows. Chosen large enough that the +/// work it performs dominates every other term in these fixtures. +const CHILD_PAIRS: usize = 1_000; +/// Compute gas one `PUSH1 1; POP` pair costs: `PUSH1` is 3, `POP` is 2. +const PAIR_GAS: u64 = 5; + +fn base_db(code: Bytes) -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CALLER, U256::from(10 * ONE_ETH)) + .account_code(CONTRACT, code) + .account_balance(CONTRACT, U256::from(ONE_ETH)) +} + +/// `pairs` PUSH1/POP pairs — plain opcodes that settle only at the next checkpoint. +fn plain_filler(builder: BytecodeBuilder, pairs: usize) -> BytecodeBuilder { + let mut builder = builder; + for _ in 0..pairs { + builder = builder.push_number(1u64).append(POP); + } + builder +} + +/// A callee that performs [`CHILD_PAIRS`] pairs of real work and then ends its frame with a stack +/// underflow — an exceptional halt that is not a gas shortage, so the interpreter keeps its +/// counter and nothing about the failure is a resource-limit exceed. +fn working_then_underflowing_callee() -> Bytes { + plain_filler(BytecodeBuilder::default(), CHILD_PAIRS).append(ADD).append(STOP).build() +} + +/// A CALL into [`CALLEE`] forwarding `gas`, with the success flag popped so the caller survives +/// whatever the callee did. +fn call_callee(builder: BytecodeBuilder, gas: u64) -> BytecodeBuilder { + builder + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_number(0u64) // value + .push_address(CALLEE) + .push_number(gas) + .append(CALL) + .append(POP) +} + +/// A STATICCALL into `MegaLimitControl.remainingComputeGas()` whose returned word is written to +/// `slot`. The selector is written to memory first; the reading comes back into offset 0 as well. +fn store_remaining_compute_gas(builder: BytecodeBuilder, slot: u64) -> BytecodeBuilder { + builder + .mstore(0, IMegaLimitControl::remainingComputeGasCall::SELECTOR) + .push_number(32u64) // retSize + .push_number(0u64) // retOffset + .push_number(4u64) // argsSize + .push_number(0u64) // argsOffset + .push_address(LIMIT_CONTROL_ADDRESS) + .push_number(1_000_000u64) + .append(STATICCALL) + .append(POP) + .push_number(0u64) + .append(MLOAD) + .push_u256(U256::from(slot)) + .append(SSTORE) +} + +/// The caller shape every blocker-A case shares: work, an exceptional child, then the same amount +/// of work again. Whether the transaction survives the second half is what the child's executed +/// work decides. +fn work_call_work(child_gas: u64, tail_pairs: usize) -> Bytes { + let builder = plain_filler(BytecodeBuilder::default(), 10); + let builder = call_callee(builder, child_gas); + plain_filler(builder, tail_pairs).append(STOP).build() +} + +fn caller_db(caller_code: Bytes) -> MemoryDatabase { + base_db(caller_code).account_code(CALLEE, working_then_underflowing_callee()) +} + +/// The work an exceptionally halted child performed still binds the transaction's compute limit. +/// +/// This is the shape a fail-open shows up in: the child runs [`CHILD_PAIRS`] pairs of plain +/// opcodes and then underflows, the caller absorbs the failure and runs the same amount of work +/// again. Per-opcode accounting charges the child's work as it happens, so REX6's total is the +/// calibration point — set the limit one below it and the transaction must not finish. REX7 has to +/// stop as well: the child's executed work is real work, whatever the frame did afterwards. +#[test] +fn test_executed_work_of_an_exceptional_child_still_binds_the_tx_limit() { + let code = work_call_work(1_000_000, CHILD_PAIRS); + + // Calibrate against REX6 running the same program with nothing in its way. + let unconstrained = transact_default(MegaSpecId::REX6, caller_db(code.clone())); + assert!( + unconstrained.is_success(), + "the calibration run must succeed: {:?}", + unconstrained.result + ); + let limit = unconstrained.compute_gas - 1; + assert!( + limit > u64::try_from(CHILD_PAIRS).unwrap() * PAIR_GAS, + "the fixture must do more work than the child alone, or the limit proves nothing", + ); + + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(limit); + let r6 = transact(MegaSpecId::REX6, caller_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, caller_db(code), limits(MegaSpecId::REX7)); + + assert!(!r6.is_success(), "REX6 must stop at the limit: {:?}", r6.result); + assert!( + !r7.is_success(), + "REX7 must stop at the same limit — the child's executed work is enforced even though its \ + frame ended in an exceptional halt; got {:?} with compute={}", + r7.result, + r7.compute_gas, + ); +} + +/// The same shape from the caller's own point of view: `MegaLimitControl.remainingComputeGas()` +/// reports the minimum of the caller's per-frame budget and the transaction-level remaining, so +/// one reading pins both. The drop across the failing child must cover the work the child did. +#[test] +fn test_exceptional_child_shrinks_the_callers_remaining_compute_budget() { + let builder = store_remaining_compute_gas(BytecodeBuilder::default(), BEFORE_SLOT); + let builder = call_callee(builder, 1_000_000); + let code = store_remaining_compute_gas(builder, AFTER_SLOT).append(STOP).build(); + + let child_work = u64::try_from(CHILD_PAIRS).unwrap() * PAIR_GAS; + let mut drops = Vec::new(); + for spec in [MegaSpecId::REX6, MegaSpecId::REX7] { + let outcome = transact_default(spec, caller_db(code.clone())); + assert!( + outcome.is_success(), + "{spec:?}: the caller must survive its child: {:?}", + outcome.result, + ); + let before: u64 = outcome + .storage_value(CONTRACT, U256::from(BEFORE_SLOT)) + .try_into() + .expect("a compute-gas reading fits in u64"); + let after: u64 = outcome + .storage_value(CONTRACT, U256::from(AFTER_SLOT)) + .try_into() + .expect("a compute-gas reading fits in u64"); + assert!(before > after, "{spec:?}: the reading must fall across the child"); + let drop = before - after; + assert!( + drop >= child_work, + "{spec:?}: the caller's remaining budget must fall by at least the child's executed \ + work; drop={drop} child work={child_work}", + ); + drops.push(drop); + } + // Both models see the same child work; the only slack is which opcodes each attributes to the + // failing frame, so the two readings must agree to within one opcode's static gas. + let (r6, r7) = (drops[0], drops[1]); + assert!( + r7.abs_diff(r6) <= 32, + "the two models must charge the caller the same for a failed child; REX6={r6} REX7={r7}", + ); +} + +/// A detention cap is built relative to the usage already enforced at the access point +/// (`usage + cap`), so a fail-open on an exceptional child does not just widen the compute limit — +/// it widens every cap installed afterwards. Reading the post-transaction detained limit back is a +/// direct check on the base the cap was built from. +#[test] +fn test_detention_cap_after_an_exceptional_child_counts_its_executed_work() { + let builder = plain_filler(BytecodeBuilder::default(), 10); + let builder = call_callee(builder, 1_000_000); + let code = builder.append(TIMESTAMP).append(POP).append(STOP).build(); + + let limits = |spec| { + let mut limits = EvmTxRuntimeLimits::from_spec(spec); + limits.block_env_access_compute_gas_limit = 1_000_000; + limits + }; + let r6 = transact(MegaSpecId::REX6, caller_db(code.clone()), limits(MegaSpecId::REX6)); + let r7 = transact(MegaSpecId::REX7, caller_db(code), limits(MegaSpecId::REX7)); + + assert!(r6.is_success(), "REX6 must succeed: {:?}", r6.result); + assert!(r7.is_success(), "REX7 must succeed: {:?}", r7.result); + assert!( + r7.detained_compute_gas_limit.abs_diff(r6.detained_compute_gas_limit) <= 32, + "the cap must be built on the same enforced usage under both models; REX6={} REX7={}", + r6.detained_compute_gas_limit, + r7.detained_compute_gas_limit, + ); +} + +/// The transaction-wide identity the storage-exclusion cases assert: every EVM gas a transaction +/// spends is either compute gas or `MegaETH` storage gas, so +/// `compute_gas == gas_used − storage gas`. Measuring the transaction-intrinsic part from a bare +/// `STOP` keeps it exact rather than pinned to a constant. +fn intrinsic_storage_gas(spec: MegaSpecId) -> u64 { + let outcome = transact_with_gas_limit( + spec, + base_db(BytecodeBuilder::default().append(STOP).build()), + EvmTxRuntimeLimits::from_spec(spec), + 1_000_000, + ); + outcome.gas_used - outcome.compute_gas +} + +/// A checkpoint body charges its storage gas before running the raw opcode, and subtracts it back +/// out when it records its own compute window. A body that halts in between never reaches that +/// subtraction — so the charge has to leave the open segment as it is made, or the frame-exit +/// settlement reports storage gas as compute gas. +/// +/// `LOG0` in a static frame is the shape that isolates it: the storage surcharge is a flat +/// per-byte rate that is already paid when revm rejects the state change. +#[test] +fn test_aborted_log_checkpoint_does_not_report_its_storage_charge_as_compute() { + const LOG_BYTES: u64 = 32; + let callee = BytecodeBuilder::default() + .push_number(LOG_BYTES) // len + .push_number(0u64) // offset + .append(LOG0) + .append(STOP) + .build(); + let code = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64) // argsOffset + .push_address(CALLEE) + .push_number(77_777u64) + .append(STATICCALL) + .append(POP) + .append(STOP) + .build(); + + let db = || base_db(code.clone()).account_code(CALLEE, callee.clone()); + let r7 = transact_default(MegaSpecId::REX7, db()); + assert!( + r7.is_success(), + "the caller must survive the static-context rejection: {:?}", + r7.result, + ); + + let log_storage_gas = LOG_DATA_STORAGE_GAS * LOG_BYTES; + assert_eq!( + r7.compute_gas, + r7.gas_used - intrinsic_storage_gas(MegaSpecId::REX7) - log_storage_gas, + "the LOG storage surcharge is storage gas on the halting path too; compute={} gas_used={}", + r7.compute_gas, + r7.gas_used, + ); +} + +/// The same exclusion for the other storage-charging checkpoint family that can abort after +/// charging: `SSTORE`. Its surcharge is SALT-scaled, so it is only non-zero above the minimum +/// bucket size — the elevated capacity is what makes this case exist at all. +/// +/// The surcharge is measured from a control run that performs the same write outside a static +/// frame, so the assertion states the amount rather than assuming a constant: the aborted body +/// pays exactly that much storage gas, and none of it may reach the compute total. +#[test] +fn test_aborted_sstore_checkpoint_does_not_report_its_storage_charge_as_compute() { + /// Twice the minimum bucket size, so the SALT multiplier makes the `SSTORE` set charge + /// non-zero. + const BUCKET_CAPACITY: u64 = 2 * mega_evm::MIN_BUCKET_SIZE as u64; + + let callee = BytecodeBuilder::default().sstore(U256::from(9), U256::from(0x77)).build(); + let caller = |call_opcode: u8| { + let mut builder = BytecodeBuilder::default() + .push_number(0u64) // retSize + .push_number(0u64) // retOffset + .push_number(0u64) // argsSize + .push_number(0u64); // argsOffset + if call_opcode == CALL { + builder = builder.push_number(0u64); // value + } + builder + .push_address(CALLEE) + .push_number(10_000_000u64) + .append(call_opcode) + .append(POP) + .append(STOP) + .build() + }; + + let run = |call_opcode| { + transact_with_bucket_capacity( + MegaSpecId::REX7, + base_db(caller(call_opcode)).account_code(CALLEE, callee.clone()), + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7), + BUCKET_CAPACITY, + ) + }; + let intrinsic_storage = intrinsic_storage_gas(MegaSpecId::REX7); + + // Control: the same write in a frame that is allowed to make it. Its non-intrinsic storage gas + // is the surcharge the aborted run below also pays, before revm rejects the state change. + let committed = run(CALL); + assert!(committed.is_success(), "the control write must succeed: {:?}", committed.result); + let surcharge = committed.gas_used - committed.compute_gas - intrinsic_storage; + assert!( + surcharge > 0, + "the elevated bucket capacity must make the SSTORE set charge non-zero; gas_used={} \ + compute={}", + committed.gas_used, + committed.compute_gas, + ); + + let aborted = run(STATICCALL); + assert!( + aborted.is_success(), + "the caller must survive the static-context rejection: {:?}", + aborted.result, + ); + assert_eq!( + aborted.compute_gas, + aborted.gas_used - intrinsic_storage - surcharge, + "the SSTORE surcharge stays storage gas when the body it paid for never runs; compute={} \ + gas_used={} surcharge={surcharge}", + aborted.compute_gas, + aborted.gas_used, + ); +} + +/// Init code returning `len` bytes of runtime code whose first byte is `first`. +fn deploying_initcode(first: u8, len: u64) -> Bytes { + BytecodeBuilder::default() + .push_number(u64::from(first)) + .push_number(0u64) + .append(MSTORE8) + .push_number(len) + .push_number(0u64) + .append(RETURN) + .build() +} + +/// A contract whose body CREATEs `initcode` with all the gas it has, then stops. +fn creator_code(initcode: &Bytes) -> Bytes { + BytecodeBuilder::default() + .mstore(0, initcode.as_ref()) + .push_number(initcode.len() as u64) // length + .push_number(0u64) // offset + .push_number(0u64) // value + .append(CREATE) + .append(POP) + .append(STOP) + .build() +} + +/// Runs a REX7 transaction into [`CONTRACT`] with an explicit gas limit and, optionally, a lowered +/// `limit_contract_code_size`. +/// +/// The shared helpers in [`crate::common`] all take the context's default configuration, which +/// pins the contract-size limit to `MegaETH`'s 512 KiB. Reaching revm's size reject needs a +/// smaller one, so this builds the context itself. +fn transact_create_reject( + mut db: MemoryDatabase, + gas_limit: u64, + code_size_limit: Option, +) -> Outcome { + let mut cfg = CfgEnv::default(); + cfg.spec = MegaSpecId::REX7; + cfg.limit_contract_code_size = code_size_limit.or(Some(MAX_CONTRACT_SIZE)); + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7) + .with_cfg(cfg) + .with_tx_runtime_limits(EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7)); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let tx = + TxEnvBuilder::default().caller(CALLER).call(CONTRACT).gas_limit(gas_limit).build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + let mut evm = MegaEvm::new(context); + let result = + alloy_evm::Evm::transact_raw(&mut evm, tx).expect("tx should not surface EVMError"); + let (usage, detained_compute_gas_limit) = { + let additional_limit = EvmTr::ctx_ref(&evm).additional_limit.borrow(); + (additional_limit.get_usage(), additional_limit.detained_compute_gas_limit()) + }; + let gas_used = result.result.tx_gas_used(); + Outcome { + result: result.result, + compute_gas: usage.compute_gas, + data_size: usage.data_size, + kv_updates: usage.kv_updates, + state_growth: usage.state_growth, + gas_used, + detained_compute_gas_limit, + state: result.state, + } +} + +/// Runtime length the CREATE cases deploy — small enough that the per-byte code-deposit storage +/// charge stays affordable at the gas limits below. +const RUNTIME_LEN: u64 = 100; + +/// revm's per-byte code-deposit gas (`revm::interpreter::gas::CODEDEPOSIT`). +const CANONICAL_CODE_DEPOSIT_GAS: u64 = 200; + +/// The transaction-wide gas identity for the CREATE fixtures: the receipt's EVM gas is compute gas +/// plus `MegaETH` storage gas, and the only storage gas beyond the transaction intrinsic is the +/// per-byte code-deposit charge the execution layer takes before revm's create-return runs. +/// +/// It holds whether or not the deposit is ultimately rejected — which is the point. A reject +/// destroys the CREATE frame's whole remainder, and that destroyed budget is EVM gas the receipt +/// charges, so it has to appear in the compute total like any other exceptionally halted frame's. +fn assert_create_gas_identity(label: &str, outcome: &Outcome, intrinsic_storage: u64) { + let code_deposit_storage = CODEDEPOSIT_STORAGE_GAS * RUNTIME_LEN; + assert!( + outcome.is_success(), + "{label}: the creator must survive the CREATE: {:?}", + outcome.result, + ); + assert!( + outcome.gas_used > intrinsic_storage + code_deposit_storage, + "{label}: the fixture must reach the create-return, not run out paying the code-deposit \ + storage charge; gas_used={}", + outcome.gas_used, + ); + assert_eq!( + outcome.compute_gas, + outcome.gas_used - intrinsic_storage - code_deposit_storage, + "{label}: every EVM gas the transaction spent must be compute gas or storage gas; \ + compute={} gas_used={} code-deposit storage={code_deposit_storage}", + outcome.compute_gas, + outcome.gas_used, + ); +} + +/// revm's create-return rejects a successful constructor's runtime code **after** action +/// processing. EIP-3541 and the code-size limit are the two rejects that need no gas pressure at +/// all: the constructor returned normally, the frame's result was `Return` when the frame-exit +/// settlement ran, and only the create-return turned it into a halt that destroys the frame's +/// whole remainder. +/// +/// The code-size case runs against a lowered `limit_contract_code_size`. `MegaETH`'s own per-byte +/// code-deposit storage charge is 10,000 gas, so a runtime code long enough to pass the 512 KiB +/// consensus limit would need billions of gas to reach the reject and would run out paying that +/// charge first — reaching revm's size check at all needs a configured limit, not a longer +/// contract. +#[test] +fn test_create_rejected_after_action_processing_settles_its_destroyed_remainder() { + let intrinsic_storage = intrinsic_storage_gas(MegaSpecId::REX7); + let deployed = transact_create_reject( + base_db(creator_code(&deploying_initcode(0x00, RUNTIME_LEN))), + DEFAULT_TX_GAS_LIMIT, + None, + ); + assert_create_gas_identity("successful deposit", &deployed, intrinsic_storage); + + for (label, first, code_size_limit) in [ + // Runtime code starting with 0xEF: EIP-3541 rejects the deposit. + ("EIP-3541", 0xefu8, None), + // Runtime code past a configured contract-size limit. + ("code size", 0x00, Some(RUNTIME_LEN as usize - 1)), + ] { + let rejected = transact_create_reject( + base_db(creator_code(&deploying_initcode(first, RUNTIME_LEN))), + DEFAULT_TX_GAS_LIMIT, + code_size_limit, + ); + assert_create_gas_identity(label, &rejected, intrinsic_storage); + assert!( + rejected.gas_used > deployed.gas_used, + "{label}: the reject must destroy the CREATE frame's remainder, so it costs strictly \ + more than the deposit it replaced; rejected={} deployed={}", + rejected.gas_used, + deployed.gas_used, + ); + } +} + +/// The third post-action reject is the canonical code-deposit charge itself running out of gas — +/// the one that exists only inside a narrow gas window: too little gas and the frame fails earlier, +/// paying `MegaETH`'s own per-byte code-deposit storage charge; too much and the deposit goes +/// through. +/// +/// Sweeping across that window covers it without pinning the boundary. What every point has to +/// satisfy is that no EVM gas goes missing: the receipt's gas is compute gas plus storage gas, and +/// the only storage gas a run can carry beyond the transaction intrinsic is the per-byte +/// code-deposit charge — all of it, or none of it, depending on whether the frame could afford it. +/// A destroyed CREATE remainder that never reached the compute total would show up here as a third +/// value. +#[test] +fn test_create_code_deposit_out_of_gas_settles_its_destroyed_remainder() { + let intrinsic_storage = intrinsic_storage_gas(MegaSpecId::REX7); + let code_deposit_storage = CODEDEPOSIT_STORAGE_GAS * RUNTIME_LEN; + let initcode = deploying_initcode(0x00, RUNTIME_LEN); + let run = |gas_limit| transact_create_reject(base_db(creator_code(&initcode)), gas_limit, None); + + let deployed = run(DEFAULT_TX_GAS_LIMIT); + assert_create_gas_identity("successful deposit", &deployed, intrinsic_storage); + + // A successful deposit costs a fixed amount, and the frame that pays it keeps back the 2% the + // creator retained — so the window where the canonical charge alone is unaffordable sits just + // above that fixed cost, sized by the charge itself. + let canonical_deposit = CANONICAL_CODE_DEPOSIT_GAS * RUNTIME_LEN; + let mut code_deposit_oog_points = 0; + for step in 0..=canonical_deposit / 1_000 { + let gas_limit = deployed.gas_used + step * 1_000; + let outcome = run(gas_limit); + assert!( + outcome.is_success(), + "gas_limit={gas_limit}: the creator must survive the CREATE: {:?}", + outcome.result, + ); + let storage = outcome + .gas_used + .checked_sub(outcome.compute_gas) + .and_then(|total| total.checked_sub(intrinsic_storage)) + .unwrap_or_else(|| { + panic!( + "gas_limit={gas_limit}: compute gas exceeds the receipt's non-intrinsic gas; \ + compute={} gas_used={}", + outcome.compute_gas, outcome.gas_used, + ) + }); + assert!( + storage == 0 || storage == code_deposit_storage, + "gas_limit={gas_limit}: the only storage gas past the intrinsic is the per-byte \ + code-deposit charge, taken in full or not at all — anything else is EVM gas missing \ + from the compute total; storage={storage} compute={} gas_used={}", + outcome.compute_gas, + outcome.gas_used, + ); + // The charge was affordable but the deposit still did not happen: revm's create-return + // rejected it, after action processing, for the canonical code-deposit gas. + if storage == code_deposit_storage && outcome.gas_used != deployed.gas_used { + code_deposit_oog_points += 1; + } + } + assert!( + code_deposit_oog_points > 0, + "the sweep must contain at least one canonical code-deposit out-of-gas; a successful \ + deposit costs the same {} gas at every limit above the window", + deployed.gas_used, + ); +} + +/// Runs the blocker-A shape through `inspect_frame_run` instead of `frame_run`. +/// +/// The two loops are hand-maintained copies of the same body, and the split is settled across both +/// of their hooks — the executed tail before action processing, the destroyed remainder after. A +/// drop of either on the inspected copy alone would silently re-open the fail-open for any node +/// running with a tracer attached. +fn transact_inspected(mut db: MemoryDatabase, limits: EvmTxRuntimeLimits, inspected: bool) -> u64 { + let mut context = MegaContext::new(&mut db, MegaSpecId::REX7).with_tx_runtime_limits(limits); + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::from(0)); + chain.operator_fee_constant = Some(U256::from(0)); + }); + let tx = TxEnvBuilder::default() + .caller(CALLER) + .call(CONTRACT) + .gas_limit(DEFAULT_TX_GAS_LIMIT) + .build_fill(); + let mut tx = MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + // Both arms must produce the same `MegaEvm` type, so build the inspected one by toggling the + // inspector flag rather than by changing the inspector type. + let mut evm = MegaEvm::new(context).with_inspector(NoOpInspector); + if !inspected { + alloy_evm::Evm::set_inspector_enabled(&mut evm, false); + } + let result: Result, EVMError> = + alloy_evm::Evm::transact_raw(&mut evm, tx); + result.expect("tx should not surface EVMError"); + let usage = EvmTr::ctx_ref(&evm).additional_limit.borrow().get_usage(); + usage.compute_gas +} + +/// The inspected execution loop must split an exceptional frame exactly like the plain one. +#[test] +fn test_the_split_is_the_same_under_an_inspector() { + let db = || caller_db(work_call_work(1_000_000, CHILD_PAIRS)); + let limits = EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7); + assert_eq!( + transact_inspected(db(), limits, false), + transact_inspected(db(), limits, true), + "an attached inspector must not move the executed / destroyed split", + ); +} + +/// Builds a deterministic pre-EIP-155 keyless deployment transaction. +fn keyless_tx_bytes(init_code: Bytes) -> Bytes { + let tx = TxLegacy { + nonce: 0, + gas_price: 100_000_000_000, + gas_limit: 200_000, + to: TxKind::Create, + value: U256::ZERO, + input: init_code, + chain_id: None, + }; + let word = U256::from_be_bytes(hex!( + "3333333333333333333333333333333333333333333333333333333333333333" + )); + let signed = Signed::new_unchecked(tx, Signature::new(word, word, false), B256::ZERO); + let mut buf = Vec::new(); + signed.rlp_encode(&mut buf); + Bytes::from(buf) +} + +/// The `KeylessDeploy` sandbox runs a whole nested transaction with its own tracker and merges the +/// usage back, so the executed / destroyed split has to survive that boundary. A sandbox whose +/// constructor halts exceptionally reports its destroyed remainder like any other frame; if the +/// merge dropped the classification, the parent would enforce it — and a constructor's ordinary EVM +/// halt would rewrite the outer transaction into a compute-limit exceed with the gas rescued. +/// +/// The parent's compute limit is set well below the sandbox's gas override so the destroyed +/// remainder alone would be enough to trip it. +#[test] +fn test_sandbox_destroyed_remainder_stays_non_enforcing_across_the_merge() { + // `ADD` on an empty stack: the constructor halts immediately, so almost the whole sandbox + // envelope is destroyed rather than performed. + let init_code = BytecodeBuilder::default().append(ADD).append(STOP).build(); + let call_data = IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: keyless_tx_bytes(init_code), + gasLimitOverride: U256::from(1_000_000u64), + } + .abi_encode(); + + let build_tx = || { + TxEnvBuilder::default() + .caller(KEYLESS_RELAYER) + .call(KEYLESS_DEPLOY_ADDRESS) + .gas_limit(30_000_000) + .chain_id(Some(1)) + .data(Bytes::from(call_data.clone())) + .build_fill() + }; + let limits = |spec| EvmTxRuntimeLimits::from_spec(spec).with_tx_compute_gas_limit(300_000); + let run = |spec| { + let db = + MemoryDatabase::default().account_balance(KEYLESS_RELAYER, U256::from(10 * ONE_ETH)); + transact_tx(spec, db, limits(spec), build_tx(), &crate::common::default_envs()) + }; + + let r6 = run(MegaSpecId::REX6); + let r7 = run(MegaSpecId::REX7); + + assert!( + r6.is_success(), + "REX6 returns the constructor failure through the keyless-deploy wire contract: {:?}", + r6.result, + ); + assert_eq!( + format!("{:?}", r6.result), + format!("{:?}", r7.result), + "the outer transaction must keep the wire contract REX6 defines — the sandbox's destroyed \ + budget is reported, never enforced, on either side of the merge", + ); + assert!( + r7.compute_gas > 300_000, + "the sandbox's destroyed remainder must still be reported past the limit; compute={}", + r7.compute_gas, + ); +} diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index 8a0847bb..dee25dd0 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -21,7 +21,11 @@ //! is shown to be stable rather than merely correct at one point. //! - `exceptional_halt` — every exceptional-halt classification, in both frame positions: the //! frame's whole burned budget settles as compute gas without changing the receipt. +//! - `burn_split` — which half of that budget enforces: the work the frame performed does, the +//! remainder it destroyed does not, and both boundaries (a checkpoint's storage charge, revm's +//! post-action create rejects) land on the right side. +mod burn_split; mod checkpoint_families; mod checkpoint_settlement; mod clamp_classification; From b16e94adf66a58c61cd568fb4371593dd454ca27 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 09:04:41 +0800 Subject: [PATCH 32/43] docs(rex7): specify the executed / destroyed split of an exceptional frame Rewrites the carve-out in the upgrade page and the compute-gas spec: which half enforces, where the split is taken from, what happens at the sandbox boundary, and the one shape the split cannot recover -- an ordinary out-of-gas with no clamp in force, whose zeroed counter makes the whole segment measure as executed. Also corrects the clamp contract: what a clamp keeps at or below the limit is the enforced usage, and the reported actual can be larger because it carries earlier frames' destroyed remainders. --- AGENTS.md | 3 ++- docs/spec/evm/compute-gas.md | 27 ++++++++++++++++------ docs/spec/upgrades/rex7.md | 43 ++++++++++++++++++++++++------------ 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d1f87d4..476900ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,8 @@ MegaETH separates EVM gas into two independent dimensions tracked during executi - **Compute gas**: Measures pure computational cost. Through REX6 every opcode's gas consumption is recorded via wrapped instructions in `evm/instructions.rs` — `compute_gas_ext::*` for plain opcodes and `storage_gas_ext::*` for storage-affecting opcodes (SSTORE, LOG, CALL-family, CREATE/CREATE2, SELFDESTRUCT) — both invoking the shared `record_storage_compute_gas!` primitive after the opcode body completes. REX7 settles compute gas at checkpoints (storage-gas opcodes, CALL/CREATE family, volatile opcodes, `GAS`, frame entry/resume/exit) rather than after every plain opcode, and enforces limits inside plain segments with a V0 gas clamp. - A REX7 frame that ends in an exceptional halt additionally settles its whole burned remainder into a lane of `ComputeGasTracker` that the reported total and block accounting include but no limit comparison sees — the burn is destroyed gas, not work performed, and enforcing it would turn an EVM halt into a resource-limit failure with the gas rescued. + A REX7 frame that ends in an exceptional halt splits its remaining budget: the work it performed before failing settles through the ordinary enforcing path, while the remainder it destroyed goes into a lane of `ComputeGasTracker` that the reported total and block accounting include but no limit comparison sees — destroyed gas is not work performed, and enforcing it would turn an EVM halt into a resource-limit failure with the gas rescued. + The destroyed half is read from the frame's final result after action processing, so revm's post-action create rejects are covered; storage gas a checkpoint body charged before aborting belongs to neither half. Subject to a per-spec compute gas limit and further restricted by gas detention (see below). - **Storage gas**: Charges for persistent state modifications (SSTORE, account creation, contract deployment). These costs scale dynamically with SALT bucket capacity (see External Environment Dependencies below). diff --git a/docs/spec/evm/compute-gas.md b/docs/spec/evm/compute-gas.md index b42ca17a..3a36e054 100644 --- a/docs/spec/evm/compute-gas.md +++ b/docs/spec/evm/compute-gas.md @@ -503,15 +503,28 @@ When the crossing opcode would exhaust both the true remaining EVM gas and the c #### Exceptional-halt frame carve-out A frame that ends in an exceptional halt — ordinary out-of-gas, memory out-of-gas, stack underflow or overflow, invalid jump, unknown opcode, and every other error result — returns none of its remaining budget. -A node MUST settle that entire burned remainder as compute gas at frame exit: the open plain-opcode segment measured against a zero remainder, plus any gas the clamp was hiding. -The rule is driven by the halt classification rather than by the interpreter's own counter, which an inherited EVM zeroes for ordinary out-of-gas only. -Under per-opcode recording through Rex6 neither the failing opcode nor the burn is attributed to compute gas, so a transaction that halts exceptionally, or that contains an inner call frame which does, MAY report a strictly higher compute-gas total under Rex7 while EVM gas and the receipt remain identical. +A node MUST settle that whole budget as compute gas, split into two parts that are accounted differently: -A node MUST NOT evaluate any resource limit against the burned remainder: it is bounded by the sender's gas envelope rather than by the compute limit, and halting on it would rescue gas the EVM already burned and change the receipt this carve-out requires to stay identical. -The usage a limit is evaluated against therefore excludes the burn, while the reported compute-gas total and the block-level compute accounting include it. -Nothing is lost by the exclusion: the executed part of an exceptionally halted frame's tail is bounded either by the clamp or by a frame gas remainder that was already below the headroom. +- **Executed** — the open plain-opcode segment, measured as the interpreter-gas delta since the previous checkpoint, less any storage gas a checkpoint body charged before aborting. This is work the network performed, and a node MUST record it through the ordinary path: it counts toward the transaction's reported total **and** toward the usage every resource limit is evaluated against, exactly as the same opcodes would if the frame had returned normally. +- **Destroyed** — whatever the frame still held when its result became final, including any gas the clamp was hiding. A node MUST record it in the reported compute-gas total and in block-level compute accounting, and MUST NOT evaluate any resource limit against it. -A clamp-induced out-of-gas is not an exceptional halt for this rule — the crossing opcode never executed and the remaining gas is rescued rather than burned. +The destroyed part is bounded by the sender's gas envelope rather than by the compute limit, and halting on it would rescue gas the EVM already destroyed and change the receipt this carve-out requires to stay identical. +The executed part carries no such problem: it is work, and leaving it out of enforcement would let a frame that keeps executing after absorbing a failed child spend the same compute headroom a second time. + +The split MUST be driven by the halt classification rather than by the interpreter's own counter, which an inherited EVM zeroes for ordinary out-of-gas only. +That zeroing has one consequence a node MUST accept: for an ordinary out-of-gas taken with no clamp in force, the counter is already zero when the frame exits, so the whole segment measures as executed and is enforced in full. +A node MUST NOT try to recover the split in that case. +It is the one shape where Rex7 enforcement is stricter than per-opcode enforcement through Rex6, which attributes the failing opcode to neither part. + +A node MUST take the split from the frame's **final** result, after the create-return processing that can still turn a successful constructor into a canonical code-deposit out-of-gas, an EIP-3541 reject or a runtime code-size reject. +Each of those destroys the frame's remainder just as a halt from the interpreter loop does. + +Under per-opcode recording through Rex6 neither the failing opcode nor the destroyed remainder is attributed to compute gas, so a transaction that halts exceptionally, or that contains an inner call frame which does, MAY report a strictly higher compute-gas total under Rex7 while EVM gas and the receipt remain identical. + +A clamp-induced out-of-gas is not an exceptional halt for this rule — the crossing opcode never executed and the remaining gas is rescued rather than destroyed. +A frame whose exit latches a resource-limit exceed destroys nothing either: it reverts to its parent (frame-local) or halts the transaction with its gas rescued (transaction-level). + +When a nested execution merges its usage into an outer one — the [KeylessDeploy](../system-contracts/keyless-deploy.md) sandbox is the only such boundary — a node MUST carry the split across it, reporting the inner total in full while enforcing only the executed part. diff --git a/docs/spec/upgrades/rex7.md b/docs/spec/upgrades/rex7.md index f5db7714..f3acada6 100644 --- a/docs/spec/upgrades/rex7.md +++ b/docs/spec/upgrades/rex7.md @@ -24,7 +24,8 @@ Rex7 also introduces **gas-clamp enforcement**: between checkpoints the node res For a transaction that never crosses a compute-gas, detention, or other resource limit, Rex7 is bit-identical to Rex6: the same gas, the same receipt, the same state, and the same `GAS` opcode readings. For a transaction that does cross a compute-gas or detention limit inside a plain-opcode segment, the halt lands before the crossing opcode rather than after it, the crossing opcode's cost is excluded from recorded compute usage, and remaining gas remains refundable under the same rescue rules as other transaction-level compute-limit halts. -One deliberate accounting carve-out remains: a frame that ends in an exceptional halt (including ordinary out-of-gas) settles its entire burned EVM-gas budget as compute gas at frame exit, so a transaction that contains an inner out-of-gas call can report higher compute usage under Rex7 than under Rex6 even though EVM gas and the receipt are unchanged. +One deliberate accounting carve-out remains: a frame that ends in an exceptional halt (including ordinary out-of-gas) settles its entire EVM-gas budget as compute gas, so a transaction that contains an inner out-of-gas call can report higher compute usage under Rex7 than under Rex6 even though EVM gas and the receipt are unchanged. +That budget is split — the work the frame performed enforces like any other work, while the remainder it destroyed is reported but never enforced. ## What Changed @@ -72,19 +73,29 @@ The interpreter's gas counter already meters every opcode; settling by segment r **Exceptional-halt frame carve-out.** A frame that ends in an exceptional halt — ordinary out-of-gas, memory out-of-gas, stack underflow or overflow, invalid jump, unknown opcode, and every other error result — returns none of its remaining budget. The top-level frame's whole envelope is spent by the transaction's final gas accounting, and an inner frame's remainder is never handed back to its caller. -A node MUST settle that entire burned remainder as compute gas at frame exit: the open plain-opcode segment measured against a zero remainder, plus any gas the clamp was hiding from the interpreter. -The rule is driven by the halt classification, not by the interpreter's own counter — an inherited EVM zeroes that counter for ordinary out-of-gas only. +A node MUST settle that whole budget as compute gas, in two parts that are accounted differently. -Under per-opcode recording through Rex6, neither the failing opcode nor the burn is attributed to compute gas. -Consequently, a transaction that halts exceptionally, or that contains an inner call frame which does, MAY report a **strictly higher** compute-gas total under Rex7 than under Rex6, while EVM gas accounting and the receipt remain identical. +The **executed** part is the open plain-opcode segment, measured as the interpreter-gas delta since the previous checkpoint, less any storage gas a checkpoint body charged before aborting. +A node MUST record it through the ordinary path, so it counts toward the transaction's reported total **and** toward the usage every resource limit is evaluated against — exactly as the same opcodes would if the frame had returned normally. +A parent frame keeps executing after it absorbs a failed child; excluding the child's work from enforcement would let the code that follows spend the same compute headroom a second time. + +The **destroyed** part is whatever the frame still held when its result became final, including any gas the clamp was hiding from the interpreter. +A node MUST record it in the transaction's reported compute-gas total and in block-level compute accounting, and MUST NOT evaluate any resource limit against it. +It is bounded by the sender's gas envelope rather than by the compute limit, so it can carry the reported total past that limit; halting on it would rescue gas the EVM has already destroyed and change a receipt this carve-out requires to stay identical. + +The split MUST be driven by the halt classification, not by the interpreter's own counter — an inherited EVM zeroes that counter for ordinary out-of-gas only. +That zeroing has one consequence a node MUST accept rather than work around: for an ordinary out-of-gas taken with no clamp in force, the counter is already zero at frame exit, so the whole segment measures as executed and is enforced in full. +This is the one shape where Rex7 enforcement is stricter than Rex6's, which attributes the failing opcode to neither part. -A node MUST NOT evaluate any resource limit against the burned remainder. -The burn is bounded by the sender's gas envelope rather than by the compute limit, so it can carry the recorded total past that limit; halting on it would rescue gas the EVM has already burned and change a receipt this carve-out requires to stay identical. -The usage a limit is evaluated against therefore excludes the burn, while the transaction's reported compute-gas total and the block-level compute accounting include it. -Nothing is lost by that exclusion: the part of an exceptionally halted frame's tail that was actually executed is bounded either by the gas clamp or by a frame gas remainder that was already below the compute headroom, so it could not have exceeded a limit in the first place. +A node MUST take the split from the frame's **final** result, after the create-return processing that can still turn a successful constructor into a canonical code-deposit out-of-gas, an EIP-3541 reject or a runtime code-size reject — each of which destroys the frame's remainder just as a halt from the interpreter loop does. +When a nested execution merges its usage into an outer one, which today is only the `KeylessDeploy` sandbox boundary, a node MUST carry the split across that boundary: the outer transaction reports the inner total in full and enforces only its executed part. + +Under per-opcode recording through Rex6, neither the failing opcode nor the destroyed remainder is attributed to compute gas. +Consequently, a transaction that halts exceptionally, or that contains an inner call frame which does, MAY report a **strictly higher** compute-gas total under Rex7 than under Rex6, while EVM gas accounting and the receipt remain identical. A clamp-induced out-of-gas is not an exceptional halt for this rule. -The crossing opcode was stopped before it executed and the remaining gas is rescued for the sender rather than burned, so the reclassification rules below apply instead. +The crossing opcode was stopped before it executed and the remaining gas is rescued for the sender rather than destroyed, so the reclassification rules below apply instead. +A frame whose exit latches a resource-limit exceed destroys nothing either: it reverts to its parent, or halts the transaction with its gas rescued. ### Gas-Clamp Enforcement @@ -118,8 +129,10 @@ The reported `limit` MUST be the constraint that bound the clamp, not whichever The revert payload is visible to the calling contract, so a frame-local exceed that reported the transaction-level limit would be a different observable return value for the same execution, not merely a different diagnostic. Because the crossing opcode never executes, a node MUST NOT include its cost in recorded compute-gas usage. -Recorded usage at a clamp-induced halt therefore ends at the limit (or strictly below it if settlement had not yet closed a partial segment), not strictly above it. -The `actual` a transaction-level clamp halt reports MUST be that final usage — the frame-exit settlement closes the partial segment after the exceed is identified, and a node MUST NOT report the usage as it stood before that settlement. +The usage the clamp **enforces** therefore ends at or below the limit, not strictly above it. +The `actual` a transaction-level clamp halt reports MUST be the transaction's final reported compute usage — the frame-exit settlement closes the partial segment after the exceed is identified, and a node MUST NOT report the usage as it stood before that settlement. +Reported usage is not the same quantity as enforced usage: it also carries the destroyed remainders of any frame that halted exceptionally earlier in the transaction, which are reported and never enforced. +A node MUST NOT assume `actual` is at most `limit`. **Top-frame headroom tie-break.** At the top-level frame the remaining per-frame compute budget equals the transaction-level remaining budget whenever both are still governed by the same base limit. @@ -145,7 +158,8 @@ Contracts that stay within every resource limit see no behavioral change relativ Contracts that trip the compute-gas or detention limit inside a plain-opcode segment halt one opcode earlier than under Rex6, with the crossing opcode excluded from recorded compute usage and with remaining gas still refundable on a transaction-level halt. A transaction that halts exceptionally, or that calls into a child frame which does, may report a higher transaction-level compute-gas total under Rex7 than under Rex6 — for any exceptional halt, not just out-of-gas. -The receipt `gas_used`, the halt or revert reported, and the execution success or failure of the outer transaction are unchanged by that carve-out: the burned remainder is reported, never enforced. +The receipt `gas_used`, the halt or revert reported, and the execution success or failure of the outer transaction are unchanged by the destroyed half of that carve-out: it is reported, never enforced. +The executed half does enforce, so a contract that calls into a failing child and keeps working can trip a resource limit at the same point it would under Rex6 — and, for a child that ran out of gas with no clamp in force, marginally earlier. ## Safety and Compatibility @@ -156,8 +170,9 @@ Because Rex7 is unstable, its semantics may change in either direction until it Any node, tool, or test fixture pinned to Rex7 must expect its results to move. A deployment that needs stable semantics must select a frozen spec explicitly rather than relying on the latest one. -The gas clamp is strictly tighter than Rex6's post-opcode enforcement on the overshoot axis: the crossing opcode does not run, and recorded usage does not pass the limit by that opcode's cost. +The gas clamp is strictly tighter than Rex6's post-opcode enforcement on the overshoot axis: the crossing opcode does not run, and enforced usage does not pass the limit by that opcode's cost. The exceptional-halt frame carve-out is the only path on which Rex7 can report more compute gas than Rex6 for the same inputs; it over-reports rather than under-reports. +Its enforcing half is never looser than Rex6's, and is stricter in exactly one shape: an ordinary out-of-gas taken with no clamp in force, whose zeroed counter leaves the whole segment measuring as executed. ## References From 476c7609c8c538d846112129c4b69d6524e699b9 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 10:37:16 +0800 Subject: [PATCH 33/43] fix(rex7): stop enforcing destroyed compute gas at the block limit The executed / destroyed split stopped at the transaction tracker. The outcome carried one compute number -- the full reported total, destroyed remainders included -- and the block limiter accumulated it into the one counter it compares against the block compute-gas limit. A transaction that destroyed a large gas envelope while performing almost no work therefore closed the block's compute capacity for the transactions behind it, re-enforcing at block level exactly what the transaction level had excluded. Carry the destroyed part through MegaTransactionOutcome into the commit path, and give the limiter two counters: block_compute_gas_used keeps reporting every transaction's whole total, while a new enforced counter carries only the work performed and is what admission, the block-full predicate and the ComputeGasLimit error read. Nothing is destroyed before Rex7, so the two counters advance in lockstep on every frozen spec. --- crates/mega-evm/src/block/executor.rs | 7 ++ crates/mega-evm/src/block/limit.rs | 127 ++++++++++++++++++++++-- crates/mega-evm/src/block/result.rs | 2 + crates/mega-evm/src/evm/mod.rs | 2 + crates/mega-evm/src/evm/result.rs | 13 +++ crates/mega-evm/tests/mutation/block.rs | 21 +++- 6 files changed, 160 insertions(+), 12 deletions(-) diff --git a/crates/mega-evm/src/block/executor.rs b/crates/mega-evm/src/block/executor.rs index ed89c762..7e32f103 100644 --- a/crates/mega-evm/src/block/executor.rs +++ b/crates/mega-evm/src/block/executor.rs @@ -598,6 +598,7 @@ where data_size, kv_updates, compute_gas_used, + compute_gas_destroyed, state_growth_used, }, } = result; @@ -616,6 +617,11 @@ where // Accumulate post-execution resource usage into block-level counters. This does not // validate limits; over-limit enforcement happens in `pre_execution_check` before the // next transaction. The deposit-nonce record doubles as the deposit signal here. + // + // Compute gas crosses this boundary as the pair execution produced it — the full reported + // total and the destroyed part of it — so the limiter can report one and enforce the + // other. Collapsing them here would hand the block a single number that is right for + // reporting and wrong for admission. self.block_limiter.post_execution_update_raw( result.tx_gas_used(), tx_size, @@ -623,6 +629,7 @@ where data_size, kv_updates, compute_gas_used, + compute_gas_destroyed, state_growth_used, depositor.is_some(), ); diff --git a/crates/mega-evm/src/block/limit.rs b/crates/mega-evm/src/block/limit.rs index 58b6b23d..05026140 100644 --- a/crates/mega-evm/src/block/limit.rs +++ b/crates/mega-evm/src/block/limit.rs @@ -128,6 +128,8 @@ //! - Accumulates resource usage from the executed transaction into block-level counters //! - Does not validate post-execution limits; over-limit enforcement happens before admitting //! the next transaction in [`BlockLimiter::pre_execution_check`] +//! - Compute gas accumulates into two counters, one reported and one enforced; see +//! [`BlockLimiter::block_compute_gas_used`] //! //! 4. **Commit transaction** - [`crate::MegaBlockExecutor::commit_execution_outcome`] //! - Include in block (with success or failed receipt) @@ -604,6 +606,7 @@ impl BlockLimits { block_tx_size_used: 0, block_da_size_used: 0, block_compute_gas_used: 0, + block_compute_gas_enforced: 0, block_state_growth_used: 0, } } @@ -653,7 +656,9 @@ impl BlockLimits { /// let outcome = execute_transaction(tx); /// /// // Post-execution update (the executor commit path drives this internally) -/// limiter.post_execution_update_raw(gas, tx_size, da_size, data, kv, compute, growth, is_deposit); +/// limiter.post_execution_update_raw( +/// gas, tx_size, da_size, data, kv, compute, destroyed_compute, growth, is_deposit, +/// ); /// } /// ``` #[derive(Debug, Clone)] @@ -681,9 +686,25 @@ pub struct BlockLimiter { /// This tracks the total number of SSTORE operations across all transactions. pub block_kv_updates_used: u64, - /// Cumulative compute gas consumed by all transactions in the block. + /// Cumulative compute gas consumed by all transactions in the block, as reported. + /// + /// This is the block's public compute-gas statistic: every transaction's full reported total, + /// including the remainders Rex7+ exceptionally halted frames destroyed rather than performed. + /// Admission does not read it — + /// [`block_compute_gas_enforced`](Self::block_compute_gas_enforced) is the counter the + /// block compute-gas limit is evaluated against. pub block_compute_gas_used: u64, + /// The part of [`block_compute_gas_used`](Self::block_compute_gas_used) the block enforces: + /// the same total with each transaction's destroyed remainder subtracted. + /// + /// Destroyed gas is not work the network performed, and no resource limit is evaluated against + /// it at any level. A transaction whose reported total dwarfs its executed work would + /// otherwise close the block's compute capacity for everyone behind it while having computed + /// almost nothing. Before Rex7 nothing is ever destroyed, so this counter and the reported one + /// advance in lockstep. + pub block_compute_gas_enforced: u64, + /// Cumulative state growth consumed by all transactions in the block. pub block_state_growth_used: u64, } @@ -709,6 +730,7 @@ impl BlockLimiter { block_tx_size_used: 0, block_da_size_used: 0, block_compute_gas_used: 0, + block_compute_gas_enforced: 0, block_state_growth_used: 0, } } @@ -866,12 +888,14 @@ impl BlockLimiter { })); } - // Check block-level compute gas limit - if self.block_compute_gas_used >= self.limits.block_compute_gas_limit { + // Check block-level compute gas limit. The enforced counter is the one compared, and so + // the one the error reports: destroyed remainders are reported in + // `block_compute_gas_used` but never close the block's compute capacity. + if self.block_compute_gas_enforced >= self.limits.block_compute_gas_limit { return Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx { hash: tx_hash, error: Box::new(MegaBlockLimitExceededError::ComputeGasLimit { - block_used: self.block_compute_gas_used, + block_used: self.block_compute_gas_enforced, limit: self.limits.block_compute_gas_limit, }), })); @@ -900,6 +924,12 @@ impl BlockLimiter { /// the transaction may push the block over a limit, which is intentional to maximize block /// utilization. `is_deposit` gates only the DA-size counter: deposits are exempt from DA /// accounting. + /// + /// Compute gas arrives as two numbers, not one: `compute_gas_used` is the transaction's full + /// reported total and `compute_gas_destroyed` is the part of it that Rex7+ exceptionally + /// halted frames destroyed rather than performed (0 before Rex7). The reported total lands in + /// the public statistic and the difference in the counter the block compute-gas limit is + /// evaluated against. #[allow(clippy::too_many_arguments)] pub fn post_execution_update_raw( &mut self, @@ -909,6 +939,7 @@ impl BlockLimiter { tx_data: u64, kv_updates: u64, compute_gas_used: u64, + compute_gas_destroyed: u64, state_growth_used: u64, is_deposit: bool, ) { @@ -934,8 +965,11 @@ impl BlockLimiter { self.block_kv_updates_used = self.block_kv_updates_used.saturating_add(kv_updates); // Block compute gas limit, no need to check here since we allow the last transaction to - // exceed the limit. + // exceed the limit. Only the executed part advances the enforced counter. self.block_compute_gas_used = self.block_compute_gas_used.saturating_add(compute_gas_used); + self.block_compute_gas_enforced = self + .block_compute_gas_enforced + .saturating_add(compute_gas_used.saturating_sub(compute_gas_destroyed)); // Block state growth limit, no need to check here since we allow the last transaction to // exceed the limit. @@ -944,13 +978,16 @@ impl BlockLimiter { } /// Returns true if any block-level limit has been reached or exceeded. + /// + /// Compute gas answers on the enforced counter, matching what + /// [`pre_execution_check`](Self::pre_execution_check) would reject the next transaction on. pub fn is_block_limit_reached(&self) -> bool { self.block_gas_used >= self.limits.block_gas_limit || self.block_tx_size_used >= self.limits.block_txs_encode_size_limit || self.block_da_size_used >= self.limits.block_da_size_limit || self.block_data_used >= self.limits.block_txs_data_limit || self.block_kv_updates_used >= self.limits.block_kv_update_limit || - self.block_compute_gas_used >= self.limits.block_compute_gas_limit || + self.block_compute_gas_enforced >= self.limits.block_compute_gas_limit || self.block_state_growth_used >= self.limits.block_state_growth_limit } } @@ -1014,6 +1051,7 @@ mod tests { limiter.block_data_used = u64::MAX - 1; limiter.block_kv_updates_used = u64::MAX - 1; limiter.block_compute_gas_used = u64::MAX - 1; + limiter.block_compute_gas_enforced = u64::MAX - 1; limiter.block_state_growth_used = u64::MAX - 1; limiter.post_execution_update_raw( @@ -1023,6 +1061,7 @@ mod tests { u64::MAX, u64::MAX, u64::MAX, + 0, u64::MAX, false, ); @@ -1033,6 +1072,7 @@ mod tests { assert_eq!(limiter.block_data_used, u64::MAX); assert_eq!(limiter.block_kv_updates_used, u64::MAX); assert_eq!(limiter.block_compute_gas_used, u64::MAX); + assert_eq!(limiter.block_compute_gas_enforced, u64::MAX); assert_eq!(limiter.block_state_growth_used, u64::MAX); } @@ -1043,8 +1083,79 @@ mod tests { let mut limiter = BlockLimiter::new(BlockLimits::no_limits()); limiter.block_da_size_used = 100; - limiter.post_execution_update_raw(0, 0, u64::MAX, 0, 0, 0, 0, true); + limiter.post_execution_update_raw(0, 0, u64::MAX, 0, 0, 0, 0, 0, true); assert_eq!(limiter.block_da_size_used, 100); } + + /// The two compute-gas counters accumulate different things: the reported one takes the + /// transaction's whole total, the enforced one only the part the transaction performed. A + /// destroyed remainder that leaked into the enforced counter would close the block's compute + /// capacity for work that never happened. + #[test] + fn test_post_execution_update_raw_splits_the_compute_gas_lanes() { + let mut limiter = BlockLimiter::new(BlockLimits::no_limits()); + + limiter.post_execution_update_raw(0, 0, 0, 0, 0, 1_000_000, 900_000, 0, false); + assert_eq!(limiter.block_compute_gas_used, 1_000_000, "the report takes the whole total"); + assert_eq!(limiter.block_compute_gas_enforced, 100_000, "enforcement takes only the work"); + + // A second transaction that destroyed nothing advances both counters by the same amount. + limiter.post_execution_update_raw(0, 0, 0, 0, 0, 50_000, 0, 0, false); + assert_eq!(limiter.block_compute_gas_used, 1_050_000); + assert_eq!(limiter.block_compute_gas_enforced, 150_000); + } + + /// Nothing is ever destroyed before Rex7, so a block of transactions that report a zero + /// destroyed part leaves the two counters equal at every step — the pre-Rex7 behaviour, which + /// the split must reproduce byte for byte. + #[test] + fn test_compute_gas_lanes_coincide_without_a_destroyed_part() { + let mut limiter = BlockLimiter::new(BlockLimits::no_limits()); + + for compute in [21_000, 500, 1_234_567, 0] { + limiter.post_execution_update_raw(0, 0, 0, 0, 0, compute, 0, 0, false); + assert_eq!( + limiter.block_compute_gas_used, limiter.block_compute_gas_enforced, + "with nothing destroyed the reported and enforced counters must not diverge" + ); + } + } + + /// Admission compares the enforced counter, and the error it raises must state that same + /// number: a rejected transaction's operator reads `block_used` to understand what filled the + /// block, and the reported total would name a budget the block never spent. + #[test] + fn test_block_compute_gas_admission_reads_the_enforced_counter() { + let mut limits = BlockLimits::no_limits(); + limits.block_compute_gas_limit = 1_000_000; + let mut limiter = BlockLimiter::new(limits); + + // One transaction reporting far past the block limit, having performed almost none of it. + limiter.post_execution_update_raw(0, 0, 0, 0, 0, 5_000_000, 4_950_000, 0, false); + assert!( + limiter.block_compute_gas_used > limits.block_compute_gas_limit, + "the reported total must carry the destroyed remainder past the limit" + ); + assert!( + !limiter.is_block_limit_reached(), + "a destroyed remainder must not fill the block's compute capacity" + ); + assert!( + limiter.pre_execution_check(B256::ZERO, 0, 0, 0, false).is_ok(), + "the next transaction must still be admitted" + ); + + // Executed work fills it, and the error names the enforced counter. + limiter.post_execution_update_raw(0, 0, 0, 0, 0, 950_000, 0, 0, false); + assert!(limiter.is_block_limit_reached(), "executed work does fill the block"); + let error = limiter + .pre_execution_check(B256::ZERO, 0, 0, 0, false) + .expect_err("a full block must reject the next transaction"); + let message = format!("{error:?}"); + assert!( + message.contains("ComputeGasLimit") && message.contains("block_used: 1000000"), + "the error must report the counter that was compared, got {message}" + ); + } } diff --git a/crates/mega-evm/src/block/result.rs b/crates/mega-evm/src/block/result.rs index 8fcf9743..2387ce2a 100644 --- a/crates/mega-evm/src/block/result.rs +++ b/crates/mega-evm/src/block/result.rs @@ -280,6 +280,7 @@ mod tests { data_size: 1, kv_updates: 2, compute_gas_used: 3, + compute_gas_destroyed: 1, state_growth_used: 4, }; @@ -301,6 +302,7 @@ mod tests { // One hop for the resource dimensions (`Copy` scalars may leave through a deref). let kv: u64 = outcome.kv_updates; assert_eq!((kv, outcome.compute_gas_used, outcome.state_growth_used), (2, 3, 4)); + assert_eq!(outcome.compute_gas_destroyed, 1); } #[test] diff --git a/crates/mega-evm/src/evm/mod.rs b/crates/mega-evm/src/evm/mod.rs index a536920d..b8173420 100644 --- a/crates/mega-evm/src/evm/mod.rs +++ b/crates/mega-evm/src/evm/mod.rs @@ -367,6 +367,7 @@ where data_size, kv_updates, compute_gas_used: compute_gas, + compute_gas_destroyed: additional_limit.burned_compute_gas(), state_growth_used: state_growth, }) } @@ -398,6 +399,7 @@ where data_size, kv_updates, compute_gas_used: compute_gas, + compute_gas_destroyed: additional_limit.burned_compute_gas(), state_growth_used: state_growth, }) } diff --git a/crates/mega-evm/src/evm/result.rs b/crates/mega-evm/src/evm/result.rs index 0969d9da..a14d4358 100644 --- a/crates/mega-evm/src/evm/result.rs +++ b/crates/mega-evm/src/evm/result.rs @@ -29,7 +29,20 @@ pub struct MegaTransactionOutcome { /// The number of KV updates. pub kv_updates: u64, /// The compute gas used. + /// + /// This is the transaction's full reported total, which under Rex7+ also carries whatever an + /// exceptionally halted frame destroyed rather than performed. It is the number to report and + /// to accumulate into block-level compute accounting; it is not the number to compare against + /// a limit — see [`compute_gas_destroyed`](Self::compute_gas_destroyed). pub compute_gas_used: u64, + /// The part of [`compute_gas_used`](Self::compute_gas_used) that exceptionally halted frames + /// destroyed rather than performed (Rex7+, always 0 before). + /// + /// Destroyed gas is not work the network did, so no resource limit is evaluated against it. + /// The transaction's own limits already excluded it while executing; a consumer that + /// accumulates this outcome into a further limit — today the block compute-gas counter — must + /// subtract it too, and compare `compute_gas_used - compute_gas_destroyed` instead. + pub compute_gas_destroyed: u64, /// The state growth used. pub state_growth_used: u64, } diff --git a/crates/mega-evm/tests/mutation/block.rs b/crates/mega-evm/tests/mutation/block.rs index 727e4881..d66eb34c 100644 --- a/crates/mega-evm/tests/mutation/block.rs +++ b/crates/mega-evm/tests/mutation/block.rs @@ -425,10 +425,10 @@ fn test_pre_execution_check_block_da_size_boundary() { fn test_post_execution_update_raw_da_gated_by_deposit_flag() { let mut limiter = BlockLimiter::new(BlockLimits::no_limits()); - limiter.post_execution_update_raw(0, 0, 1_234, 0, 0, 0, 0, false); + limiter.post_execution_update_raw(0, 0, 1_234, 0, 0, 0, 0, 0, false); assert_eq!(limiter.block_da_size_used, 1_234, "a non-deposit call must accumulate da_size"); - limiter.post_execution_update_raw(0, 0, 5_000, 0, 0, 0, 0, true); + limiter.post_execution_update_raw(0, 0, 5_000, 0, 0, 0, 0, 0, true); assert_eq!( limiter.block_da_size_used, 1_234, "a deposit call must leave the da counter untouched" @@ -457,6 +457,7 @@ fn test_is_block_limit_reached_all_below_is_false() { limiter.block_data_used = 9; limiter.block_kv_updates_used = 9; limiter.block_compute_gas_used = 9; + limiter.block_compute_gas_enforced = 9; limiter.block_state_growth_used = 9; assert!( @@ -487,6 +488,7 @@ macro_rules! only_dimension_at_limit { limiter.block_data_used = 0; limiter.block_kv_updates_used = 0; limiter.block_compute_gas_used = 0; + limiter.block_compute_gas_enforced = 0; limiter.block_state_growth_used = 0; // ...except the one under test, which sits exactly at its (5) limit. limiter.$used_field = 5; @@ -524,10 +526,21 @@ fn test_is_block_limit_reached_kv_updates_dimension() { assert!(limiter.is_block_limit_reached(), "kv updates at limit ⇒ block full"); } +/// Compute gas is the one dimension whose clause reads a counter other than the `*_used` one: +/// admission is evaluated against the enforced counter, so the reported total sitting at the +/// limit must leave the block open. #[test] fn test_is_block_limit_reached_compute_gas_dimension() { - let limiter = only_dimension_at_limit!(block_compute_gas_limit, block_compute_gas_used); - assert!(limiter.is_block_limit_reached(), "compute gas at limit ⇒ block full"); + let limiter = only_dimension_at_limit!(block_compute_gas_limit, block_compute_gas_enforced); + assert!(limiter.is_block_limit_reached(), "enforced compute gas at limit ⇒ block full"); + + let mut reported_only = + only_dimension_at_limit!(block_compute_gas_limit, block_compute_gas_used); + reported_only.block_compute_gas_enforced = 0; + assert!( + !reported_only.is_block_limit_reached(), + "a reported total at the limit with nothing enforced must leave the block open" + ); } #[test] From 432c4ce6dc3d90f6f86facf2d7f329c9bc73d50f Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 10:41:02 +0800 Subject: [PATCH 34/43] test(rex7): admit a cheap transaction behind a destroyed remainder Drives both block compute-gas counters through the real run/commit path in the two shapes that destroy a remainder: an ordinary frame that halts on its first opcode, and one nested inside the KeylessDeploy sandbox, where the outer transaction succeeds and nothing in its result hints that a remainder was destroyed at all. Each asserts the block reports the whole total, enforces only the work performed, and still admits the cheap transaction behind it. Two more pin the other directions: executed work does still close a block, and Rex6 keeps the two counters identical. --- .../tests/block_executor/compute_gas_lanes.rs | 324 ++++++++++++++++++ crates/mega-evm/tests/block_executor/main.rs | 1 + 2 files changed, 325 insertions(+) create mode 100644 crates/mega-evm/tests/block_executor/compute_gas_lanes.rs diff --git a/crates/mega-evm/tests/block_executor/compute_gas_lanes.rs b/crates/mega-evm/tests/block_executor/compute_gas_lanes.rs new file mode 100644 index 00000000..9a3895ca --- /dev/null +++ b/crates/mega-evm/tests/block_executor/compute_gas_lanes.rs @@ -0,0 +1,324 @@ +//! The Rex7 executed / destroyed compute-gas split, as the block sees it. +//! +//! A frame that halts exceptionally destroys the budget it was still holding. Rex7 reports that +//! remainder as compute gas but enforces no limit against it — the transaction level already +//! settled that, and the block level has to reach the same answer, because a transaction that +//! destroyed a large gas envelope while performing almost no work would otherwise close the +//! block's compute capacity for every transaction behind it. +//! +//! So the block keeps two compute-gas counters: `block_compute_gas_used` reports every +//! transaction's whole total, and `block_compute_gas_enforced` carries only the work performed and +//! is what admission compares. These tests drive both counters through the real commit path, in +//! the two shapes that produce a destroyed remainder — an ordinary exceptional frame, and one +//! nested inside the `KeylessDeploy` sandbox, which merges a whole separate tracker back across a +//! boundary the classification has to survive. +//! +//! Nothing is destroyed before Rex7, so the frozen specs are pinned here too: the two counters +//! must not diverge by so much as a gas unit under Rex6. + +use std::convert::Infallible; + +use alloy_evm::{block::BlockExecutor, EvmEnv, EvmFactory}; +use alloy_op_evm::block::receipt_builder::OpAlloyReceiptBuilder; +use alloy_primitives::{address, Address, Bytes, Signature, TxKind, B256, U256}; +use alloy_sol_types::SolCall as _; +use mega_evm::{ + alloy_consensus::{transaction::Recovered, Signed, TxLegacy}, + test_utils::{BytecodeBuilder, MemoryDatabase}, + BlockLimits, IKeylessDeploy, MegaBlockExecutionCtx, MegaBlockExecutor, MegaEvmFactory, + MegaHardforkConfig, MegaSpecId, MegaTxEnvelope, TestExternalEnvs, KEYLESS_DEPLOY_ADDRESS, +}; +use revm::{ + bytecode::opcode::{ADD, STOP}, + context::BlockEnv, + database::State, +}; + +/// Sends every transaction in these tests. +const CALLER: Address = address!("2000000000000000000000000000000000000002"); +/// `ADD` on an empty stack: the call halts on its first opcode, so nearly the whole envelope it +/// was forwarded is destroyed rather than performed. +const HALTING: Address = address!("1000000000000000000000000000000000000001"); +/// `STOP`: the cheap transaction that has to still fit in the block afterwards. +const CHEAP: Address = address!("1000000000000000000000000000000000000002"); + +/// Gas envelope the halting transaction destroys. Large enough that its reported compute total +/// alone dwarfs [`BLOCK_COMPUTE_GAS_LIMIT`], while the work it performed before failing — one +/// three-gas `ADD` on top of the intrinsic cost — stays far below it. +const HALTING_TX_GAS_LIMIT: u64 = 5_000_000; + +/// Gas envelope the `KeylessDeploy` sandbox destroys, passed as the call's gas-limit override. +const SANDBOX_GAS_OVERRIDE: u64 = 4_000_000; + +/// The block compute-gas ceiling these tests build around: above what any of these transactions +/// executes, below what the halting ones report. +const BLOCK_COMPUTE_GAS_LIMIT: u64 = 1_000_000; + +/// Builds a legacy transaction from `CALLER`. +fn envelope(nonce: u64, gas_limit: u64, to: Address, input: Bytes) -> MegaTxEnvelope { + let tx = TxLegacy { + chain_id: Some(8453), + nonce, + gas_price: 1_000_000, + gas_limit, + to: TxKind::Call(to), + value: U256::ZERO, + input, + }; + MegaTxEnvelope::Legacy(Signed::new_unchecked(tx, Signature::test_signature(), B256::ZERO)) +} + +/// The pre-EIP-155 deployment transaction the `KeylessDeploy` sandbox replays, carrying a +/// constructor that halts on its first opcode. +fn keyless_deploy_call_data() -> Bytes { + let init_code = BytecodeBuilder::default().append(ADD).append(STOP).build(); + let tx = TxLegacy { + nonce: 0, + gas_price: 100_000_000_000, + gas_limit: 200_000, + to: TxKind::Create, + value: U256::ZERO, + input: init_code, + chain_id: None, + }; + let word = U256::from_be_bytes([0x33; 32]); + let signed = Signed::new_unchecked(tx, Signature::new(word, word, false), B256::ZERO); + let mut encoded = Vec::new(); + signed.rlp_encode(&mut encoded); + + Bytes::from( + IKeylessDeploy::keylessDeployCall { + keylessDeploymentTransaction: Bytes::from(encoded), + gasLimitOverride: U256::from(SANDBOX_GAS_OVERRIDE), + } + .abi_encode(), + ) +} + +/// The database every test here runs against: the two callees plus a funded sender. +fn build_db() -> MemoryDatabase { + let mut db = MemoryDatabase::default(); + db.set_account_code(HALTING, BytecodeBuilder::default().append(ADD).append(STOP).build()); + db.set_account_code(CHEAP, BytecodeBuilder::default().stop().build()); + db.set_account_balance(CALLER, U256::from(1_000_000_000_000_000_000u64)); + db +} + +/// What one committed transaction contributed, and where the block's counters stood afterwards. +#[derive(Debug, Clone, Copy)] +struct Contribution { + /// Whether the transaction's own execution result reports success. + succeeded: bool, + /// The transaction's full reported compute total. + reported: u64, + /// The part of `reported` its exceptionally halted frames destroyed. + destroyed: u64, + /// The block's reported compute counter after the commit. + block_reported: u64, + /// The block's enforced compute counter after the commit. + block_enforced: u64, + /// Whether the block considers itself full after the commit. + block_full: bool, +} + +/// Runs `txs` through one block at `spec`, committing each in turn, and returns what each +/// contributed. Stops at the first transaction the block refuses to admit, so the returned vector +/// is shorter than `txs` exactly when the block closed early. +fn run_block( + spec: MegaSpecId, + block_compute_gas_limit: u64, + txs: &[MegaTxEnvelope], +) -> Vec { + let mut db = build_db(); + let mut state = State::builder().with_database(&mut db).build(); + let external_envs = TestExternalEnvs::::new(); + let evm_factory = MegaEvmFactory::new().with_external_env_factory(external_envs); + + let mut cfg_env = revm::context::CfgEnv::default(); + cfg_env.spec = spec; + let block_env = BlockEnv { + number: U256::from(1000), + timestamp: U256::from(1_800_000_000), + gas_limit: 30_000_000, + ..Default::default() + }; + let evm = evm_factory.create_evm(&mut state, EvmEnv::new(cfg_env, block_env)); + + let block_ctx = MegaBlockExecutionCtx::new( + B256::ZERO, + None, + Bytes::new(), + BlockLimits::no_limits().with_block_compute_gas_limit(block_compute_gas_limit), + ); + let chain_spec = MegaHardforkConfig::default().with_all_activated_through(spec); + let mut executor = + MegaBlockExecutor::new(evm, block_ctx, chain_spec, OpAlloyReceiptBuilder::default()); + + let mut contributions = Vec::new(); + for tx in txs { + let Ok(outcome) = executor.run_transaction(Recovered::new_unchecked(tx, CALLER)) else { + break; + }; + let succeeded = outcome.result.is_success(); + let reported = outcome.compute_gas_used; + let destroyed = outcome.compute_gas_destroyed; + executor.commit_transaction_outcome(outcome).expect("the commit must be admitted too"); + + let limiter = &executor.block_limiter; + contributions.push(Contribution { + succeeded, + reported, + destroyed, + block_reported: limiter.block_compute_gas_used, + block_enforced: limiter.block_compute_gas_enforced, + block_full: limiter.is_block_limit_reached(), + }); + } + + let (_, receipts) = executor.finish().expect("the block must finish"); + assert_eq!( + receipts.receipts.len(), + contributions.len(), + "every admitted transaction must have produced a receipt" + ); + contributions +} + +/// Asserts the shape both destroyed-remainder tests need from their first transaction: it reported +/// past the block's compute ceiling, but performed far too little to have earned that. +fn assert_reports_past_the_limit_without_performing_it(label: &str, first: &Contribution) { + assert!(first.destroyed > 0, "{label}: the frame must have destroyed a remainder"); + assert!( + first.reported > BLOCK_COMPUTE_GAS_LIMIT, + "{label}: the reported total must exceed the block limit, got {}", + first.reported, + ); + assert!( + first.reported - first.destroyed < BLOCK_COMPUTE_GAS_LIMIT, + "{label}: the work performed must stay under the block limit, got {}", + first.reported - first.destroyed, + ); +} + +/// An ordinary exceptional frame: a call that halts on its first opcode with a large envelope +/// still in hand. +/// +/// The block must report what the transaction reported — destroyed remainder included, which is +/// what makes the reported counter cross the ceiling — and must still admit the cheap transaction +/// behind it, because the enforced counter only ever saw the three gas the `ADD` charged before +/// underflowing. +#[test] +fn test_rex7_destroyed_remainder_reports_at_block_level_without_closing_the_block() { + let txs = [ + envelope(0, HALTING_TX_GAS_LIMIT, HALTING, Bytes::new()), + envelope(1, 100_000, CHEAP, Bytes::new()), + ]; + let block = run_block(MegaSpecId::REX7, BLOCK_COMPUTE_GAS_LIMIT, &txs); + + assert_eq!(block.len(), 2, "the cheap transaction must still fit in the block"); + let [halting, cheap] = [block[0], block[1]]; + + assert!(!halting.succeeded, "the first transaction must halt"); + assert_reports_past_the_limit_without_performing_it("ordinary frame", &halting); + + assert_eq!( + halting.block_reported, halting.reported, + "the block's reported statistic must carry the destroyed remainder" + ); + assert_eq!( + halting.block_enforced, + halting.reported - halting.destroyed, + "the block must enforce only the work performed" + ); + assert!( + !halting.block_full, + "a destroyed remainder must not fill the block's compute capacity" + ); + + assert!(cheap.succeeded, "the second transaction must execute normally"); + assert_eq!( + cheap.block_reported, + halting.reported + cheap.reported, + "the reported counter keeps accumulating whole totals" + ); + assert_eq!( + cheap.block_enforced, + halting.block_enforced + cheap.reported, + "a transaction that destroys nothing advances both counters by the same amount" + ); +} + +/// The same shape, one boundary deeper: the frame that halts lives inside the `KeylessDeploy` +/// sandbox, whose usage is merged back into the outer transaction through a separate tracker. +/// +/// The outer transaction succeeds — the sandbox reports a failed deployment through the +/// keyless-deploy wire contract rather than failing itself — so nothing about the outer result +/// hints that a remainder was destroyed. If the merge or the outcome dropped the classification, +/// the block would silently enforce a sandbox's destroyed budget against every transaction behind +/// it. +#[test] +fn test_rex7_sandbox_destroyed_remainder_does_not_close_the_block_either() { + let txs = [ + envelope(0, 30_000_000, KEYLESS_DEPLOY_ADDRESS, keyless_deploy_call_data()), + envelope(1, 100_000, CHEAP, Bytes::new()), + ]; + let block = run_block(MegaSpecId::REX7, BLOCK_COMPUTE_GAS_LIMIT, &txs); + + assert_eq!(block.len(), 2, "the cheap transaction must still fit in the block"); + let [sandbox, cheap] = [block[0], block[1]]; + + assert!(sandbox.succeeded, "the keyless deploy reports the constructor failure, it is not one"); + assert_reports_past_the_limit_without_performing_it("sandbox frame", &sandbox); + + assert_eq!( + sandbox.block_reported, sandbox.reported, + "the block's reported statistic must carry the sandbox's destroyed remainder" + ); + assert_eq!( + sandbox.block_enforced, + sandbox.reported - sandbox.destroyed, + "the block must enforce only the work the sandbox performed" + ); + assert!(!sandbox.block_full, "a sandbox's destroyed remainder must not fill the block"); + assert!(cheap.succeeded, "the second transaction must execute normally"); +} + +/// Executed work still fills the block: the split is a classification, not a way out of the block +/// compute-gas limit. +#[test] +fn test_rex7_executed_work_still_closes_the_block() { + // A ceiling below what a single halting transaction's `ADD`-plus-intrinsic work costs. + let txs = [ + envelope(0, HALTING_TX_GAS_LIMIT, HALTING, Bytes::new()), + envelope(1, 100_000, CHEAP, Bytes::new()), + ]; + let block = run_block(MegaSpecId::REX7, 1, &txs); + + assert_eq!(block.len(), 1, "the block must close once the enforced counter reaches the limit"); + assert!(block[0].block_full, "the work performed does fill a block this small"); +} + +/// Rex6 destroys nothing, so the enforced counter must track the reported one exactly — through a +/// block that mixes a successful transaction with one that halts on its first opcode, the shape +/// that diverges under Rex7. +#[test] +fn test_rex6_block_compute_counters_never_diverge() { + let txs = [ + envelope(0, HALTING_TX_GAS_LIMIT, HALTING, Bytes::new()), + envelope(1, 100_000, CHEAP, Bytes::new()), + envelope(2, HALTING_TX_GAS_LIMIT, HALTING, Bytes::new()), + ]; + let block = run_block(MegaSpecId::REX6, u64::MAX, &txs); + + assert_eq!(block.len(), 3, "no transaction here approaches an unlimited block"); + for (index, contribution) in block.iter().enumerate() { + assert_eq!( + contribution.destroyed, 0, + "tx {index}: no spec before Rex7 destroys compute gas" + ); + assert_eq!( + contribution.block_reported, contribution.block_enforced, + "tx {index}: the two counters must stay identical on a frozen spec" + ); + } +} diff --git a/crates/mega-evm/tests/block_executor/main.rs b/crates/mega-evm/tests/block_executor/main.rs index 663e9301..5538bff0 100644 --- a/crates/mega-evm/tests/block_executor/main.rs +++ b/crates/mega-evm/tests/block_executor/main.rs @@ -2,6 +2,7 @@ mod accessed_block_hashes; mod block_limits; +mod compute_gas_lanes; mod deposit_da_exemption; mod inspector; mod sequencer_registry; From bcff49cfb76fc276cfa8ddc04a4a76b0c6dac590 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 10:42:03 +0800 Subject: [PATCH 35/43] docs: split block compute accounting into reported and enforced State the block-level half of the exceptional-halt carve-out: a node that tracks cumulative block compute gas tracks two readings, reports the one that carries destroyed remainders and compares the one that does not. The carve-out already said no resource limit is evaluated against a destroyed remainder; it did not say which counter a block-level ceiling is allowed to read, which is where the rule was lost. --- AGENTS.md | 1 + docs/spec/evm/compute-gas.md | 2 +- docs/spec/evm/resource-limits.md | 8 +++++++- docs/spec/upgrades/rex7.md | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 476900ff..88a83c4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ MegaETH separates EVM gas into two independent dimensions tracked during executi REX7 settles compute gas at checkpoints (storage-gas opcodes, CALL/CREATE family, volatile opcodes, `GAS`, frame entry/resume/exit) rather than after every plain opcode, and enforces limits inside plain segments with a V0 gas clamp. A REX7 frame that ends in an exceptional halt splits its remaining budget: the work it performed before failing settles through the ordinary enforcing path, while the remainder it destroyed goes into a lane of `ComputeGasTracker` that the reported total and block accounting include but no limit comparison sees — destroyed gas is not work performed, and enforcing it would turn an EVM halt into a resource-limit failure with the gas rescued. The destroyed half is read from the frame's final result after action processing, so revm's post-action create rejects are covered; storage gas a checkpoint body charged before aborting belongs to neither half. + The split crosses the transaction boundary: `MegaTransactionOutcome` carries the destroyed part alongside the reported total, and `BlockLimiter` keeps `block_compute_gas_used` (reported) separate from `block_compute_gas_enforced` (the counter block admission compares). Subject to a per-spec compute gas limit and further restricted by gas detention (see below). - **Storage gas**: Charges for persistent state modifications (SSTORE, account creation, contract deployment). These costs scale dynamically with SALT bucket capacity (see External Environment Dependencies below). diff --git a/docs/spec/evm/compute-gas.md b/docs/spec/evm/compute-gas.md index 3a36e054..54cb1bb6 100644 --- a/docs/spec/evm/compute-gas.md +++ b/docs/spec/evm/compute-gas.md @@ -506,7 +506,7 @@ A frame that ends in an exceptional halt — ordinary out-of-gas, memory out-of- A node MUST settle that whole budget as compute gas, split into two parts that are accounted differently: - **Executed** — the open plain-opcode segment, measured as the interpreter-gas delta since the previous checkpoint, less any storage gas a checkpoint body charged before aborting. This is work the network performed, and a node MUST record it through the ordinary path: it counts toward the transaction's reported total **and** toward the usage every resource limit is evaluated against, exactly as the same opcodes would if the frame had returned normally. -- **Destroyed** — whatever the frame still held when its result became final, including any gas the clamp was hiding. A node MUST record it in the reported compute-gas total and in block-level compute accounting, and MUST NOT evaluate any resource limit against it. +- **Destroyed** — whatever the frame still held when its result became final, including any gas the clamp was hiding. A node MUST record it in the reported compute-gas total and in block-level compute accounting, and MUST NOT evaluate any resource limit against it, at transaction level or at block level (see [Resource Limits](resource-limits.md)). The destroyed part is bounded by the sender's gas envelope rather than by the compute limit, and halting on it would rescue gas the EVM already destroyed and change the receipt this carve-out requires to stay identical. The executed part carries no such problem: it is work, and leaving it out of enforcement would let a frame that keeps executing after absorbing a failed child spend the same compute headroom a second time. diff --git a/docs/spec/evm/resource-limits.md b/docs/spec/evm/resource-limits.md index 969826f7..131d18a3 100644 --- a/docs/spec/evm/resource-limits.md +++ b/docs/spec/evm/resource-limits.md @@ -131,6 +131,12 @@ Subsequent candidate transactions MUST be skipped before execution once the bloc Although block compute gas usage MAY be tracked, the protocol does not impose a separate block-level compute gas cap. +From [Rex7](../upgrades/rex7.md) onward, a node that tracks cumulative block compute gas MUST track it as two readings, because the [exceptional-halt frame carve-out](compute-gas.md#exceptional-halt-frame-carve-out) makes them differ. +The **reported** reading accumulates each transaction's full compute-gas total, destroyed remainders included; it is the block's compute-gas statistic. +The **enforced** reading accumulates only the part each transaction performed, and is the only one a node MAY compare against a configured block compute-gas ceiling, and the only one such a ceiling's rejection MUST report as the block's usage. +Comparing the reported reading instead would let a transaction that destroyed a large gas envelope while performing almost no work close the block's compute capacity for every transaction behind it. +Before Rex7 nothing is destroyed, so the two readings coincide. + ### Two-Phase Block Building Workflow When constructing a block, a node or sequencer MUST process candidate transactions in the following order: @@ -246,4 +252,4 @@ Including failed transactions ensures the sender always pays for consumed resour - [Rex4](../upgrades/rex4.md) — added per-call-frame runtime budgets; intrinsic resource costs (always deducted before execution) are now reflected in the top-level frame budget before it is forwarded to child frames. - [Rex5](../upgrades/rex5.md) — bounded a precompile invocation's compute-gas consumption by the remaining compute-gas budget, failing the precompile with `PrecompileOOG` rather than letting it overshoot the budget. - [Rex6](../upgrades/rex6.md) — moved EIP-7702 authority state-growth resolution from pre-execution (after the caller nonce bump) to validation, and added dynamic SALT account-creation gas for each net-new applied authority to the pre-frame intrinsic gas deduction; removed the keyless-deploy exception to gas preservation, so remaining gas is now rescued on every transaction-level exceed; and stopped enforcing the four runtime transaction-level limits against system-originated transactions, whose usage is still recorded. -- [Rex7](../upgrades/rex7.md) _(unstable)_ — does not change the limit ceilings or the success/failed/skipped/rejected outcomes; a compute-gas or detention exceed inside a plain-opcode segment is stopped before the crossing opcode executes (see [Compute Gas Accounting](compute-gas.md)). +- [Rex7](../upgrades/rex7.md) _(unstable)_ — does not change the limit ceilings or the success/failed/skipped/rejected outcomes; a compute-gas or detention exceed inside a plain-opcode segment is stopped before the crossing opcode executes, and cumulative block compute gas splits into a reported and an enforced reading (see [Compute Gas Accounting](compute-gas.md)). diff --git a/docs/spec/upgrades/rex7.md b/docs/spec/upgrades/rex7.md index f3acada6..dbc284e8 100644 --- a/docs/spec/upgrades/rex7.md +++ b/docs/spec/upgrades/rex7.md @@ -80,7 +80,7 @@ A node MUST record it through the ordinary path, so it counts toward the transac A parent frame keeps executing after it absorbs a failed child; excluding the child's work from enforcement would let the code that follows spend the same compute headroom a second time. The **destroyed** part is whatever the frame still held when its result became final, including any gas the clamp was hiding from the interpreter. -A node MUST record it in the transaction's reported compute-gas total and in block-level compute accounting, and MUST NOT evaluate any resource limit against it. +A node MUST record it in the transaction's reported compute-gas total and in block-level compute accounting, and MUST NOT evaluate any resource limit against it — at transaction level or at block level, where a destroyed remainder that counted toward admission would close the block's compute capacity for the transactions behind it (see [Resource Limits](../evm/resource-limits.md)). It is bounded by the sender's gas envelope rather than by the compute limit, so it can carry the reported total past that limit; halting on it would rescue gas the EVM has already destroyed and change a receipt this carve-out requires to stay identical. The split MUST be driven by the halt classification, not by the interpreter's own counter — an inherited EVM zeroes that counter for ordinary out-of-gas only. From cbf4f4d6b2723d210354bc98202faca20de458e3 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 10:49:16 +0800 Subject: [PATCH 36/43] docs: say which compute counter a block ComputeGasLimit rejection reports --- crates/mega-evm/src/block/result.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/mega-evm/src/block/result.rs b/crates/mega-evm/src/block/result.rs index 2387ce2a..3aa3c6f7 100644 --- a/crates/mega-evm/src/block/result.rs +++ b/crates/mega-evm/src/block/result.rs @@ -170,7 +170,11 @@ pub enum MegaBlockLimitExceededError { /// Block compute gas limit reached. #[error("Block compute gas limit reached: block_used={block_used} >= limit={limit}")] ComputeGasLimit { - /// Compute gas used by block so far + /// Compute gas used by block so far, as the limit measures it. + /// + /// This is the enforced reading — the counter that was actually compared — so it excludes + /// the remainders Rex7+ exceptionally halted frames destroyed. The block's full reported + /// compute statistic, which includes them, can be higher. block_used: u64, /// Block compute gas limit limit: u64, From 54d95b4dfa06a5e9b2487a20ed19389cb6fdf219 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Wed, 12 Aug 2026 11:18:53 +0800 Subject: [PATCH 37/43] docs(rex7): qualify precision invariant to exclude exceptional-halt frames The within-limit bit-identical claim conflicted with the exceptional-halt carve-out: a StackUnderflow inside all resource limits can still diverge on reported compute total. Require that no frame ends in an exceptional halt before asserting compute-total / four-dimension parity with Rex6. --- docs/spec/evm/compute-gas.md | 2 +- docs/spec/upgrades/overview.md | 2 +- docs/spec/upgrades/rex7.md | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/spec/evm/compute-gas.md b/docs/spec/evm/compute-gas.md index 54cb1bb6..ed6e7035 100644 --- a/docs/spec/evm/compute-gas.md +++ b/docs/spec/evm/compute-gas.md @@ -473,7 +473,7 @@ At each checkpoint a node MUST: Non-opcode recording sites on this page (intrinsic gas, precompiles, code deposit, KeylessDeploy) are unchanged. -For every transaction that stays within every runtime resource limit, a node MUST produce the same recorded compute-gas total, the same four-dimension usage, the same receipt `gas_used`, the same execution result, and the same state as under Rex6. +For every transaction that stays within every runtime resource limit and in which no frame ends in an exceptional halt, a node MUST produce the same recorded compute-gas total, the same four-dimension usage, the same receipt `gas_used`, the same execution result, and the same state as under Rex6. #### Gas-clamp enforcement diff --git a/docs/spec/upgrades/overview.md b/docs/spec/upgrades/overview.md index 8898a870..d19dad89 100644 --- a/docs/spec/upgrades/overview.md +++ b/docs/spec/upgrades/overview.md @@ -153,7 +153,7 @@ Not yet scheduled {% endtabs %} Unstable; under active development. -Checkpoint-settled [compute gas](../glossary.md#compute-gas) accounting with gas-clamp enforcement: plain opcodes record nothing between checkpoints; within-limit transactions stay bit-identical to Rex6; a compute-gas or detention exceed inside a plain segment stops the crossing opcode before it executes. +Checkpoint-settled [compute gas](../glossary.md#compute-gas) accounting with gas-clamp enforcement: plain opcodes record nothing between checkpoints; within-limit transactions that never end a frame in an exceptional halt stay bit-identical to Rex6; a compute-gas or detention exceed inside a plain segment stops the crossing opcode before it executes. ## How to Read These Pages diff --git a/docs/spec/upgrades/rex7.md b/docs/spec/upgrades/rex7.md index dbc284e8..a2132726 100644 --- a/docs/spec/upgrades/rex7.md +++ b/docs/spec/upgrades/rex7.md @@ -1,5 +1,5 @@ --- -description: Rex7 network upgrade — checkpoint-settled compute gas accounting with gas-clamp enforcement; plain opcodes record no compute gas between checkpoints, within-limit transactions stay bit-identical to Rex6, and limit-exceeding opcodes are stopped before they execute. +description: Rex7 network upgrade — checkpoint-settled compute gas accounting with gas-clamp enforcement; plain opcodes record no compute gas between checkpoints, within-limit transactions that never end a frame in an exceptional halt stay bit-identical to Rex6, and limit-exceeding opcodes are stopped before they execute. --- # Rex7 Network Upgrade @@ -21,7 +21,7 @@ Rex7 replaces that per-opcode recording for ordinary opcodes with **checkpoint s Rex7 also introduces **gas-clamp enforcement**: between checkpoints the node restricts the interpreter-visible remaining gas to the remaining compute headroom, so the inherited EVM's own per-opcode gas check stops a limit-crossing opcode before that opcode executes. -For a transaction that never crosses a compute-gas, detention, or other resource limit, Rex7 is bit-identical to Rex6: the same gas, the same receipt, the same state, and the same `GAS` opcode readings. +For a transaction that never crosses a compute-gas, detention, or other resource limit and in which no frame ends in an exceptional halt, Rex7 is bit-identical to Rex6: the same gas, the same receipt, the same state, and the same `GAS` opcode readings. For a transaction that does cross a compute-gas or detention limit inside a plain-opcode segment, the halt lands before the crossing opcode rather than after it, the crossing opcode's cost is excluded from recorded compute usage, and remaining gas remains refundable under the same rescue rules as other transaction-level compute-limit halts. One deliberate accounting carve-out remains: a frame that ends in an exceptional halt (including ordinary out-of-gas) settles its entire EVM-gas budget as compute gas, so a transaction that contains an inner out-of-gas call can report higher compute usage under Rex7 than under Rex6 even though EVM gas and the receipt are unchanged. @@ -67,8 +67,8 @@ At each checkpoint a node MUST: Non-opcode recording sites (transaction intrinsic gas, precompiles, contract-creation code deposit, KeylessDeploy overhead and sandbox merge) are unchanged. **Precision invariant.** -For every transaction that stays within every runtime resource limit, a node MUST produce the same recorded compute-gas total, the same four-dimension resource usage, the same receipt `gas_used`, the same execution result, and the same state under Rex7 as under Rex6. -The interpreter's gas counter already meters every opcode; settling by segment reproduces the per-opcode sum exactly when no limit is crossed. +For every transaction that stays within every runtime resource limit and in which no frame ends in an exceptional halt, a node MUST produce the same recorded compute-gas total, the same four-dimension resource usage, the same receipt `gas_used`, the same execution result, and the same state under Rex7 as under Rex6. +The interpreter's gas counter already meters every opcode; settling by segment reproduces the per-opcode sum exactly when no limit is crossed and no frame ends in an exceptional halt. **Exceptional-halt frame carve-out.** A frame that ends in an exceptional halt — ordinary out-of-gas, memory out-of-gas, stack underflow or overflow, invalid jump, unknown opcode, and every other error result — returns none of its remaining budget. @@ -154,7 +154,7 @@ Its semantics may still change before it is frozen. Contracts and tools that assume per-opcode compute-gas attribution for every instruction MUST treat that assumption as false under Rex7: only checkpoints settle compute gas during execution, and a plain-opcode segment has no intermediate recording. -Contracts that stay within every resource limit see no behavioral change relative to Rex6. +Contracts that stay within every resource limit and never end a frame in an exceptional halt see no behavioral change relative to Rex6. Contracts that trip the compute-gas or detention limit inside a plain-opcode segment halt one opcode earlier than under Rex6, with the crossing opcode excluded from recorded compute usage and with remaining gas still refundable on a transaction-level halt. A transaction that halts exceptionally, or that calls into a child frame which does, may report a higher transaction-level compute-gas total under Rex7 than under Rex6 — for any exceptional halt, not just out-of-gas. From d35d293774e1e267b762d160ecbf29c74e962c67 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Thu, 13 Aug 2026 11:14:21 +0800 Subject: [PATCH 38/43] perf(evm): monomorphize checkpoint gating off the frozen-spec hot path --- crates/mega-evm/src/evm/execution.rs | 4 +- crates/mega-evm/src/evm/instructions.rs | 272 ++++++++++++++++-------- 2 files changed, 181 insertions(+), 95 deletions(-) diff --git a/crates/mega-evm/src/evm/execution.rs b/crates/mega-evm/src/evm/execution.rs index 53c4718f..e6e4b5ee 100644 --- a/crates/mega-evm/src/evm/execution.rs +++ b/crates/mega-evm/src/evm/execution.rs @@ -459,7 +459,9 @@ impl MegaEvm { // REX7: hand any clamp-hidden gas back to the result and latch a clamp-induced // out-of-gas as the compute exceed it stands for, before the code-deposit charge below // observes the result's gas. - ctx.additional_limit.borrow_mut().settle_frame_final_result(interpreter_result); + if ctx.spec.is_enabled(MegaSpecId::REX7) { + ctx.additional_limit.borrow_mut().settle_frame_final_result(interpreter_result); + } // Charge storage gas cost for the number of bytes if frame.data.is_create() && interpreter_result.is_ok() { diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index 7a566ada..c25d23f8 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -313,9 +313,10 @@ mod rex { let mut table = mini_rex::instruction_table::(); // Mini-Rex mistakenly not modifying these three call-like opcodes. They are fixed in Rex - table[CALLCODE as usize] = Instruction::new(forward_gas_ext::call_code); - table[DELEGATECALL as usize] = Instruction::new(forward_gas_ext::delegate_call); - table[STATICCALL as usize] = Instruction::new(forward_gas_ext::static_call); + table[CALLCODE as usize] = Instruction::new(forward_gas_ext::call_code::); + table[DELEGATECALL as usize] = + Instruction::new(forward_gas_ext::delegate_call::); + table[STATICCALL as usize] = Instruction::new(forward_gas_ext::static_call::); table } @@ -423,10 +424,12 @@ mod rex4 { let mut table = rex3::instruction_table::(); // Rex4: CALL-like opcodes check for beneficiary volatile access disabled. - table[CALL as usize] = Instruction::new(volatile_data_ext::call); - table[STATICCALL as usize] = Instruction::new(volatile_data_ext::static_call); - table[DELEGATECALL as usize] = Instruction::new(volatile_data_ext::delegate_call); - table[CALLCODE as usize] = Instruction::new(volatile_data_ext::call_code); + table[CALL as usize] = Instruction::new(volatile_data_ext::call::); + table[STATICCALL as usize] = + Instruction::new(volatile_data_ext::static_call::); + table[DELEGATECALL as usize] = + Instruction::new(volatile_data_ext::delegate_call::); + table[CALLCODE as usize] = Instruction::new(volatile_data_ext::call_code::); // Rex4: SELFDESTRUCT checks for beneficiary volatile access. table[SELFDESTRUCT as usize] = Instruction::new(volatile_data_ext::selfdestruct); @@ -480,7 +483,7 @@ mod rex5 { // REX5: SELFDESTRUCT charges storage gas for new beneficiary accounts, // gated behind the beneficiary-volatile guard. table[SELFDESTRUCT as usize] = - Instruction::new(volatile_data_ext::selfdestruct_with_beneficiary_guard); + Instruction::new(volatile_data_ext::selfdestruct_with_beneficiary_guard::); table } @@ -660,20 +663,21 @@ mod rex7 { // Storage-gas and frame-spawning checkpoints: the Rex6 handler chains unchanged. Under // Rex7 they open with a checkpoint prologue and close with an epilogue. - table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore); - table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, _, _>); - table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, _, _>); - table[LOG2 as usize] = Instruction::new(additional_limit_ext::log::<2, _, _>); - table[LOG3 as usize] = Instruction::new(additional_limit_ext::log::<3, _, _>); - table[LOG4 as usize] = Instruction::new(additional_limit_ext::log::<4, _, _>); - table[CREATE as usize] = Instruction::new(forward_gas_ext::create); - table[CREATE2 as usize] = Instruction::new(forward_gas_ext::create2); - table[CALL as usize] = Instruction::new(volatile_data_ext::call); - table[CALLCODE as usize] = Instruction::new(volatile_data_ext::call_code); - table[DELEGATECALL as usize] = Instruction::new(volatile_data_ext::delegate_call); - table[STATICCALL as usize] = Instruction::new(volatile_data_ext::static_call); + table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore::); + table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, true, _, _>); + table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, true, _, _>); + table[LOG2 as usize] = Instruction::new(additional_limit_ext::log::<2, true, _, _>); + table[LOG3 as usize] = Instruction::new(additional_limit_ext::log::<3, true, _, _>); + table[LOG4 as usize] = Instruction::new(additional_limit_ext::log::<4, true, _, _>); + table[CREATE as usize] = Instruction::new(forward_gas_ext::create::); + table[CREATE2 as usize] = Instruction::new(forward_gas_ext::create2::); + table[CALL as usize] = Instruction::new(volatile_data_ext::call::); + table[CALLCODE as usize] = Instruction::new(volatile_data_ext::call_code::); + table[DELEGATECALL as usize] = + Instruction::new(volatile_data_ext::delegate_call::); + table[STATICCALL as usize] = Instruction::new(volatile_data_ext::static_call::); table[SELFDESTRUCT as usize] = - Instruction::new(volatile_data_ext::selfdestruct_with_beneficiary_guard); + Instruction::new(volatile_data_ext::selfdestruct_with_beneficiary_guard::); table } @@ -839,10 +843,12 @@ macro_rules! run_inner_instruction_or_abort { /// /// Halts — returning from the enclosing handler — when the settlement surfaces a limit exceed, /// including one latched earlier by a non-compute mutation site. The restore has already happened -/// on that path, so the frame result carries true gas. No-op before REX7. +/// on that path, so the frame result carries true gas. No-op when `$cp` is false: frozen-spec +/// tables instantiate the shared handlers with `CHECKPOINT = false` so the compiler drops this +/// body entirely. macro_rules! checkpoint_prologue { - ($context:expr) => { - if $context.host.spec_id().is_enabled(MegaSpecId::REX7) { + ($context:expr, $cp:expr) => { + if $cp { let exceeding_result = { let mut additional_limit = $context.host.additional_limit().borrow_mut(); let remaining = $context.interpreter.gas.remaining(); @@ -871,12 +877,11 @@ macro_rules! checkpoint_prologue { /// Only applies when the frame keeps executing. A checkpoint that published an action has either /// suspended into a child frame (the resume clamps in `AdditionalLimit::before_frame_run`) or ended /// the frame (the frame's final result restores instead), and clamping either would strand hidden -/// gas across the boundary. No-op before REX7. +/// gas across the boundary. No-op when `$cp` is false (the `action().is_none()` check is also +/// dropped); frozen-spec tables instantiate the shared handlers with `CHECKPOINT = false`. macro_rules! checkpoint_epilogue { - ($context:expr) => { - if $context.host.spec_id().is_enabled(MegaSpecId::REX7) && - $context.interpreter.bytecode.action().is_none() - { + ($context:expr, $cp:expr) => { + if $cp && $context.interpreter.bytecode.action().is_none() { let mut additional_limit = $context.host.additional_limit().borrow_mut(); let hide = additional_limit.checkpoint_clamp_amount($context.interpreter.gas.remaining()); @@ -944,13 +949,15 @@ macro_rules! record_checkpoint_body_compute_gas { /// afterwards, so this is invisible there. /// /// Returns `Err(OutOfGas)` from the enclosing handler when the frame cannot afford the charge, -/// exactly as a bare `gas!` would — with nothing debited and so nothing to exclude. No-op before -/// REX7, where nothing measures against a segment. +/// exactly as a bare `gas!` would — with nothing debited and so nothing to exclude. When `$cp` is +/// false the exclude is dropped: nothing on a frozen spec measures against a segment. macro_rules! charge_storage_gas { - ($context:expr, $amount:expr) => {{ + ($context:expr, $amount:expr, $cp:expr) => {{ let amount: u64 = $amount; gas!($context.interpreter, amount); - $context.host.additional_limit().borrow_mut().exclude_storage_gas_from_segment(amount); + if $cp { + $context.host.additional_limit().borrow_mut().exclude_storage_gas_from_segment(amount); + } amount }}; } @@ -986,10 +993,9 @@ macro_rules! charge_storage_gas { /// reached on the non-halt path; without the return, a halt here would let a later `compute_gas!` /// add gas to the tracker after the OOG was already set. macro_rules! record_storage_compute_gas { - ($context:expr, $gas_before:expr, $storage_charged:expr, $opcode:expr) => {{ + ($context:expr, $gas_before:expr, $storage_charged:expr, $opcode:expr, $cp:expr) => {{ let spec = $context.host.spec_id(); let is_rex6 = spec.is_enabled(MegaSpecId::REX6); - let is_checkpoint_accounting = spec.is_enabled(MegaSpecId::REX7); let gas_after = $context.interpreter.gas.remaining(); // The per-opcode `$gas_before` window applies on every spec: under checkpoint accounting // the plain segment ahead of this opcode was already settled by @@ -1001,7 +1007,7 @@ macro_rules! record_storage_compute_gas { // before dispatch, or an outer volatile wrapper — does so ahead of the prologue, so under // checkpoint accounting it is already inside the settled segment and adding it back here // would bill it twice. - let mut gas_used = if is_checkpoint_accounting { + let mut gas_used = if $cp { $gas_before.saturating_sub(gas_after).saturating_sub($storage_charged) } else { (const { static_gas($opcode) } + $gas_before.saturating_sub(gas_after)) @@ -1040,7 +1046,7 @@ macro_rules! record_storage_compute_gas { let mut additional_limit = $context.host.additional_limit().borrow_mut(); // Re-open the settlement window at this opcode's exit before recording, so neither a // halt here nor the frame-final settlement can bill this segment twice. - if is_checkpoint_accounting { + if $cp { additional_limit.sync_checkpoint_baseline(gas_after); } if additional_limit.record_compute_gas(gas_used) { @@ -1187,7 +1193,7 @@ mod mini_rex { table[MSTORE as usize] = Instruction::new(compute_gas_ext::mstore); table[MSTORE8 as usize] = Instruction::new(compute_gas_ext::mstore8); table[SLOAD as usize] = Instruction::new(compute_gas_ext::sload); - table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore); + table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore::); table[JUMP as usize] = Instruction::new(compute_gas_ext::jump); table[JUMPI as usize] = Instruction::new(compute_gas_ext::jumpi); table[PC as usize] = Instruction::new(compute_gas_ext::pc); @@ -1266,15 +1272,15 @@ mod mini_rex { table[SWAP15 as usize] = Instruction::new(compute_gas_ext::swap15); table[SWAP16 as usize] = Instruction::new(compute_gas_ext::swap16); - table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, _, _>); - table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, _, _>); - table[LOG2 as usize] = Instruction::new(additional_limit_ext::log::<2, _, _>); - table[LOG3 as usize] = Instruction::new(additional_limit_ext::log::<3, _, _>); - table[LOG4 as usize] = Instruction::new(additional_limit_ext::log::<4, _, _>); + table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, false, _, _>); + table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, false, _, _>); + table[LOG2 as usize] = Instruction::new(additional_limit_ext::log::<2, false, _, _>); + table[LOG3 as usize] = Instruction::new(additional_limit_ext::log::<3, false, _, _>); + table[LOG4 as usize] = Instruction::new(additional_limit_ext::log::<4, false, _, _>); - table[CREATE as usize] = Instruction::new(forward_gas_ext::create); - table[CREATE2 as usize] = Instruction::new(forward_gas_ext::create2); - table[CALL as usize] = Instruction::new(forward_gas_ext::call); + table[CREATE as usize] = Instruction::new(forward_gas_ext::create::); + table[CREATE2 as usize] = Instruction::new(forward_gas_ext::create2::); + table[CALL as usize] = Instruction::new(forward_gas_ext::call::); table[CALLCODE as usize] = Instruction::new(compute_gas_ext::call_code); table[DELEGATECALL as usize] = Instruction::new(compute_gas_ext::delegate_call); table[STATICCALL as usize] = Instruction::new(compute_gas_ext::static_call); @@ -1359,6 +1365,9 @@ pub mod forward_gas_ext { /// is used by `CREATE` / `CREATE2`, whose table entries dispatch straight here; the CALL family /// is wrapped once more by `volatile_data_ext::wrap_call_volatile_check`, which owns the /// epilogue so that it lands after the detention cap that wrapper installs. + /// + /// Generated handlers are const-generic over `CHECKPOINT`. Frozen tables instantiate `false` + /// so the epilogue body is compiled out; the REX7 table instantiates `true`. macro_rules! wrap_gas_cap { ($fn_name:ident, $opcode_name:expr, $wrapped_fn:path, $has_transfer_logic:expr) => { wrap_gas_cap!(@inner $fn_name, $opcode_name, $wrapped_fn, $has_transfer_logic, false); @@ -1370,6 +1379,7 @@ pub mod forward_gas_ext { #[doc = concat!("`", $opcode_name, "` opcode with 98/100 gas forwarding rule.")] #[inline] pub fn $fn_name< + const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -1451,7 +1461,7 @@ pub mod forward_gas_ext { _ => {} } if $checkpoint_tail { - checkpoint_epilogue!(context); + checkpoint_epilogue!(context, CHECKPOINT); } inner_outcome } @@ -1481,15 +1491,41 @@ pub mod forward_gas_ext { false } - wrap_gas_cap!(call, "CALL", storage_gas_ext::call, check_call_has_transfer); - wrap_gas_cap!(call_code, "CALLCODE", storage_gas_ext::call_code, check_call_has_transfer); - wrap_gas_cap!(delegate_call, "DELEGATECALL", storage_gas_ext::delegate_call, no_transfer); - wrap_gas_cap!(static_call, "STATICCALL", storage_gas_ext::static_call, no_transfer); wrap_gas_cap!( - @checkpoint_tail create, "CREATE", storage_gas_ext::create::, no_transfer + call, + "CALL", + storage_gas_ext::call::, + check_call_has_transfer + ); + wrap_gas_cap!( + call_code, + "CALLCODE", + storage_gas_ext::call_code::, + check_call_has_transfer + ); + wrap_gas_cap!( + delegate_call, + "DELEGATECALL", + storage_gas_ext::delegate_call::, + no_transfer + ); + wrap_gas_cap!( + static_call, + "STATICCALL", + storage_gas_ext::static_call::, + no_transfer + ); + wrap_gas_cap!( + @checkpoint_tail create, + "CREATE", + storage_gas_ext::create::, + no_transfer ); wrap_gas_cap!( - @checkpoint_tail create2, "CREATE2", storage_gas_ext::create::, no_transfer + @checkpoint_tail create2, + "CREATE2", + storage_gas_ext::create::, + no_transfer ); } @@ -1825,6 +1861,7 @@ pub mod volatile_data_ext { /// SELFDESTRUCT-specific hook. #[inline] pub fn selfdestruct_with_beneficiary_guard< + const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -1861,7 +1898,7 @@ pub mod volatile_data_ext { } run_inner_instruction_or_abort!( - super::storage_gas_ext::selfdestruct, + super::storage_gas_ext::selfdestruct::, context, inner_outcome ); @@ -1968,6 +2005,7 @@ pub mod volatile_data_ext { #[doc = concat!("`", stringify!($opcode), "` opcode with volatile data access disabled check for beneficiary.")] #[inline] pub fn $fn_name< + const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -2057,7 +2095,7 @@ pub mod volatile_data_ext { // or depth rejection pushes 0 and lets the frame keep running). The epilogue is what // keeps the following plain segment bounded, and it sits after the cap above so a CALL // that just marked beneficiary access clamps against the detained headroom. - checkpoint_epilogue!(context); + checkpoint_epilogue!(context, CHECKPOINT); inner_outcome } }; @@ -2065,10 +2103,22 @@ pub mod volatile_data_ext { // Conditionally volatile CALL-like opcodes — volatile only when targeting the block // beneficiary. These wrap forward_gas_ext handlers with a pre-execution beneficiary check. - wrap_call_volatile_check!(call, CALL, forward_gas_ext::call); - wrap_call_volatile_check!(static_call, STATICCALL, forward_gas_ext::static_call); - wrap_call_volatile_check!(delegate_call, DELEGATECALL, forward_gas_ext::delegate_call); - wrap_call_volatile_check!(call_code, CALLCODE, forward_gas_ext::call_code); + wrap_call_volatile_check!(call, CALL, forward_gas_ext::call::); + wrap_call_volatile_check!( + static_call, + STATICCALL, + forward_gas_ext::static_call:: + ); + wrap_call_volatile_check!( + delegate_call, + DELEGATECALL, + forward_gas_ext::delegate_call:: + ); + wrap_call_volatile_check!( + call_code, + CALLCODE, + forward_gas_ext::call_code:: + ); /* Checkpoint variants of the volatile handlers (REX7+). @@ -2099,14 +2149,14 @@ pub mod volatile_data_ext { if context.host.volatile_access_disabled() { revert_volatile_access_disabled!(context, $opcode, $access_type); } - checkpoint_prologue!(context); + checkpoint_prologue!(context, true); let gas_before = context.interpreter.gas.remaining(); charge_static_gas!(context, $opcode); run_inner_instruction_or_abort!($original_fn, context, inner_outcome); record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); - checkpoint_epilogue!(context); + checkpoint_epilogue!(context, true); inner_outcome } }; @@ -2134,14 +2184,14 @@ pub mod volatile_data_ext { ); } } - checkpoint_prologue!(context); + checkpoint_prologue!(context, true); let gas_before = context.interpreter.gas.remaining(); run_inner_instruction_or_abort!($original_fn, context, inner_outcome); charge_static_gas!(context, $opcode); record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); - checkpoint_epilogue!(context); + checkpoint_epilogue!(context, true); inner_outcome } }; @@ -2233,14 +2283,14 @@ pub mod volatile_data_ext { if target == ORACLE_CONTRACT_ADDRESS && context.host.volatile_access_disabled() { revert_volatile_access_disabled!(context, SLOAD, VolatileDataAccessType::Oracle); } - checkpoint_prologue!(context); + checkpoint_prologue!(context, true); let gas_before = context.interpreter.gas.remaining(); run_inner_instruction_or_abort!(instructions::host::sload, context, inner_outcome); charge_static_gas!(context, SLOAD); record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); - checkpoint_epilogue!(context); + checkpoint_epilogue!(context, true); inner_outcome } @@ -2259,14 +2309,14 @@ pub mod volatile_data_ext { VolatileDataAccessType::Beneficiary ); } - checkpoint_prologue!(context); + checkpoint_prologue!(context, true); let gas_before = context.interpreter.gas.remaining(); charge_static_gas!(context, SELFBALANCE); run_inner_instruction_or_abort!(instructions::host::selfbalance, context, inner_outcome); record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); - checkpoint_epilogue!(context); + checkpoint_epilogue!(context, true); inner_outcome } } @@ -2294,6 +2344,7 @@ pub mod additional_limit_ext { /// /// Refunds data/KV when slot reset to original value. pub fn sstore< + const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -2315,7 +2366,11 @@ pub mod additional_limit_ext { let loaded_data = SStoreResult { original_value, present_value, new_value }; // Execute the original SSTORE instruction - run_inner_instruction_or_abort!(storage_gas_ext::sstore, context, inner_outcome); + run_inner_instruction_or_abort!( + storage_gas_ext::sstore::, + context, + inner_outcome + ); // KV update bomb and data bomb (only when first writing non-zero value to originally zero // slot): check if the number of key-value updates or the total data size will exceed the @@ -2329,7 +2384,7 @@ pub mod additional_limit_ext { } drop(additional_limit); // REX7: re-clamp once every dimension this opcode touches has been recorded. - checkpoint_epilogue!(context); + checkpoint_epilogue!(context, CHECKPOINT); inner_outcome } @@ -2344,6 +2399,7 @@ pub mod additional_limit_ext { /// MB). Halts when data limit exceeded. pub fn log< const N: usize, + const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -2356,7 +2412,11 @@ pub mod additional_limit_ext { let len = as_usize_or_fail!(context.interpreter, len); // Execute the original LOG instruction - run_inner_instruction_or_abort!(storage_gas_ext::log::, context, inner_outcome); + run_inner_instruction_or_abort!( + storage_gas_ext::log::, + context, + inner_outcome + ); // Record the size of the log topics and data. If the total data size exceeds the limit, we // halt. @@ -2369,7 +2429,7 @@ pub mod additional_limit_ext { } drop(additional_limit); // REX7: re-clamp once every dimension this opcode touches has been recorded. - checkpoint_epilogue!(context); + checkpoint_epilogue!(context, CHECKPOINT); inner_outcome } } @@ -2455,6 +2515,7 @@ pub mod storage_gas_ext { ($fn_name:ident, $opcode:ident, $raw_fn:path, $has_transfer_logic:expr, $select_addr:path) => { #[doc = concat!("`", stringify!($opcode), "` opcode implementation modified from `revm` with compute gas tracking and dynamically-scaled storage gas costs.")] pub fn $fn_name< + const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -2462,7 +2523,7 @@ pub mod storage_gas_ext { ) -> InstructionExecResult { // REX7: settle the open segment and restore the clamp before any gas observation, // so the storage charge and the body's 63/64 forwarding math see the true counter. - checkpoint_prologue!(context); + checkpoint_prologue!(context, CHECKPOINT); // Captured at the very top so the single compute window covers all of the // opcode's compute work. let gas_before = context.interpreter.gas.remaining(); @@ -2505,7 +2566,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(new_account_storage_gas); - charge_storage_gas!(context, new_account_storage_gas - drained) + charge_storage_gas!(context, new_account_storage_gas - drained, CHECKPOINT) } else { 0 }; @@ -2519,7 +2580,8 @@ pub mod storage_gas_ext { context, gas_before, storage_charged, - opcode::$opcode + opcode::$opcode, + CHECKPOINT ); inner_outcome } @@ -2702,6 +2764,7 @@ pub mod storage_gas_ext { /// circuits to [`create_rex6`] at the top; the body below is the pre-REX6 path, which can /// assume all features up to and including `MINI_REX` are enabled. pub fn create< + const CHECKPOINT: bool, WIRE: InterpreterTypes, const IS_CREATE2: bool, H: HostExt + ContextTr + JournalInspectTr + ?Sized, @@ -2714,7 +2777,7 @@ pub mod storage_gas_ext { // compute-gas recording taken after the body completes (see `create_rex6`), instead of // the pre-REX6 split `resize_gas` recording handled below. if spec.is_enabled(MegaSpecId::REX6) { - return create_rex6::(context); + return create_rex6::(context); } // Inspect the creator and compute the created address. REX5+ records the CREATE2 @@ -2764,7 +2827,7 @@ pub mod storage_gas_ext { context, inner_outcome ); - record_storage_compute_gas!(context, gas_before, 0, create_opcode(IS_CREATE2)); + record_storage_compute_gas!(context, gas_before, 0, create_opcode(IS_CREATE2), CHECKPOINT); // Pre-REX5 late-record path for the CREATE2 initcode memory-expansion gas. // Preserved verbatim for replay parity: pre-REX5 keeps the original "skip on inner @@ -2795,6 +2858,7 @@ pub mod storage_gas_ext { /// REX6 implies REX5 (and REX), so the REX5 operand validation and the contract-creation /// storage-gas path are taken unconditionally here. fn create_rex6< + const CHECKPOINT: bool, WIRE: InterpreterTypes, const IS_CREATE2: bool, H: HostExt + ContextTr + JournalInspectTr + ?Sized, @@ -2817,7 +2881,7 @@ pub mod storage_gas_ext { // REX7: settle the open segment and restore the clamp before any gas observation, so the // memory expansion, the storage charge and the body's forwarding math see the true counter. - checkpoint_prologue!(context); + checkpoint_prologue!(context, CHECKPOINT); // Captured before any gas movement so the single compute window covers the wrapper-side // CREATE2 memory expansion as well as the inner opcode. @@ -2851,7 +2915,8 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(create_contract_storage_gas); - let storage_charged = charge_storage_gas!(context, create_contract_storage_gas - drained); + let storage_charged = + charge_storage_gas!(context, create_contract_storage_gas - drained, CHECKPOINT); // Run the raw inner create opcode (no `compute_gas_ext` wrapper — REX6 records compute gas // once below). @@ -2875,7 +2940,8 @@ pub mod storage_gas_ext { context, gas_before, storage_charged, - create_opcode(IS_CREATE2) + create_opcode(IS_CREATE2), + CHECKPOINT ); inner_outcome } @@ -2894,13 +2960,14 @@ pub mod storage_gas_ext { /// This alternative implementation of `LOG` is only used when the `MINI_REX` spec is enabled. pub fn log< const N: usize, + const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ?Sized, >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { // REX7: settle the open segment and restore the clamp before any gas observation. - checkpoint_prologue!(context); + checkpoint_prologue!(context, CHECKPOINT); // Captured at the very top so the single compute window covers the inner opcode. let gas_before = context.interpreter.gas.remaining(); let Some(len) = context.interpreter.stack.inspect::<1>() else { @@ -2932,12 +2999,15 @@ pub mod storage_gas_ext { // The `gas_or_fail!` above is the storage-gas charge, so it gets the same segment // exclusion `charge_storage_gas!` applies at every other charge site: the raw opcode below // can halt (a static frame rejects `LOG` outright) before the recording that would - // otherwise subtract it. - context - .host - .additional_limit() - .borrow_mut() - .exclude_storage_gas_from_segment(storage_charged); + // otherwise subtract it. Frozen specs skip the exclude — nothing measures against a + // segment. + if CHECKPOINT { + context + .host + .additional_limit() + .borrow_mut() + .exclude_storage_gas_from_segment(storage_charged); + } // Run the raw opcode and record compute gas once after the body completes (canonical // metering order). Byte-equivalent to the pre-REX6 per-`N` `compute_gas_ext::logK` @@ -2945,7 +3015,13 @@ pub mod storage_gas_ext { // consumes EVM gas. The wrapper is only ever instantiated for `N` in `0..=4`, so the // generic `instructions::host::log::` covers every valid call site. run_inner_instruction_or_abort!(instructions::host::log::, context, inner_outcome); - record_storage_compute_gas!(context, gas_before, storage_charged, opcode::LOG0 + N as u8); + record_storage_compute_gas!( + context, + gas_before, + storage_charged, + opcode::LOG0 + N as u8, + CHECKPOINT + ); inner_outcome } @@ -2965,13 +3041,14 @@ pub mod storage_gas_ext { /// enabled, so we can safely assume that all features before and including Mini-Rex are /// enabled. pub fn sstore< + const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { // REX7: settle the open segment and restore the clamp before any gas observation. - checkpoint_prologue!(context); + checkpoint_prologue!(context, CHECKPOINT); // Captured at the very top so the single compute window covers the inner opcode. let gas_before = context.interpreter.gas.remaining(); // The address to the underlying execution contract state @@ -3006,7 +3083,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(sstore_set_storage_gas); - charge_storage_gas!(context, sstore_set_storage_gas - drained) + charge_storage_gas!(context, sstore_set_storage_gas - drained, CHECKPOINT) } else { 0 }; @@ -3016,7 +3093,13 @@ pub mod storage_gas_ext { // every spec because nothing between `gas_before` and the storage charge above consumes // EVM gas. run_inner_instruction_or_abort!(instructions::host::sstore, context, inner_outcome); - record_storage_compute_gas!(context, gas_before, storage_charged, opcode::SSTORE); + record_storage_compute_gas!( + context, + gas_before, + storage_charged, + opcode::SSTORE, + CHECKPOINT + ); inner_outcome } @@ -3041,6 +3124,7 @@ pub mod storage_gas_ext { /// sees — via the REX6-gated arm below; pre-REX6 records nothing for an existing target. The /// rest of the body, and all ≤REX5 behavior, is unchanged. pub fn selfdestruct< + const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -3049,7 +3133,7 @@ pub mod storage_gas_ext { // REX7: settle the open segment and restore the clamp before any gas observation — the // beneficiary-creation storage charge below and the inner opcode both run on the true // counter, which is what keeps the storage charge outside every compute window. - checkpoint_prologue!(context); + checkpoint_prologue!(context, CHECKPOINT); // Inside a static frame, revm's inner SELFDESTRUCT halts on the // static-context check without changing state. Skip the mega host work below @@ -3099,7 +3183,7 @@ pub mod storage_gas_ext { }; let drained = context.host.additional_limit().borrow_mut().try_consume_storage_stipend(cost); - charge_storage_gas!(context, cost - drained); + charge_storage_gas!(context, cost - drained, CHECKPOINT); // Record resource usage for new beneficiary account context.host.additional_limit().borrow_mut().on_selfdestruct_new_account(); @@ -3480,11 +3564,11 @@ pub mod compute_gas_ext { pub fn gas_checkpoint( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { - checkpoint_prologue!(context); + checkpoint_prologue!(context, true); let gas_before = context.interpreter.gas.remaining(); run_inner_instruction_or_abort!(instructions::system::gas, context, inner_outcome); record_checkpoint_body_compute_gas!(context, gas_before); - checkpoint_epilogue!(context); + checkpoint_epilogue!(context, true); inner_outcome } } From 12380c85b5c4b6d65f1283ae98a5dfbf325c7325 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Thu, 13 Aug 2026 11:46:34 +0800 Subject: [PATCH 39/43] fix(evm): rebuild spec-latched limit state on cfg spec migration with_cfg / with_cfg_unpinned now reconstruct AdditionalLimit from the new spec and the already-configured runtime limits so tracker latch bits stay aligned with MegaContext.spec. --- crates/mega-evm/src/evm/context.rs | 218 ++++++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 4 deletions(-) diff --git a/crates/mega-evm/src/evm/context.rs b/crates/mega-evm/src/evm/context.rs index a81e6f33..5957551b 100644 --- a/crates/mega-evm/src/evm/context.rs +++ b/crates/mega-evm/src/evm/context.rs @@ -367,6 +367,10 @@ impl MegaContext { /// specification, it automatically applies appropriate contract size limits /// if they are not already set in the configuration. /// + /// A spec change rebuilds the additional-limit trackers from the new spec and the + /// already-configured runtime limits so spec-latched state stays aligned. An unchanged + /// spec leaves the existing tracker in place. + /// /// # `tx_chain_id_check` is pinned off /// /// revm 40 flipped the `CfgEnv::tx_chain_id_check` default from `false` to `true` — a gate @@ -417,8 +421,16 @@ impl MegaContext { /// [`with_cfg_unpinned`](Self::with_cfg_unpinned): both adopt the caller's configuration the /// same way, and differ only in whether `tx_chain_id_check` is pinned to the revm-27 `false` /// or taken as the caller provided it. + /// + /// A spec change rebuilds [`AdditionalLimit`] from the new spec and the already-configured + /// runtime limits, so spec-latched tracker state stays aligned with [`Self::spec`]. Limits + /// already set by [`with_tx_runtime_limits`](Self::with_tx_runtime_limits) are kept; they are + /// not replaced by the new spec's defaults. An unchanged spec leaves the existing tracker in + /// place. fn apply_cfg(mut self, cfg: CfgEnv, intent: CfgIntent) -> Self { - self.spec = cfg.spec; + let new_spec = cfg.spec; + let spec_changed = new_spec != self.spec; + self.spec = new_spec; self.inner = self.inner.with_cfg(cfg.into_op_cfg()); if intent == CfgIntent::Pinned { self.inner.cfg.tx_chain_id_check = false; @@ -433,6 +445,10 @@ impl MegaContext { Some(constants::mini_rex::MAX_INITCODE_SIZE); } } + if spec_changed { + let limits = self.additional_limit.borrow().limits; + self.additional_limit = Rc::new(RefCell::new(AdditionalLimit::new(self.spec, limits))); + } self } @@ -923,15 +939,15 @@ impl IntoMegaethCfgEnv for CfgEnv { mod tests { use super::*; - use alloy_primitives::address; + use alloy_primitives::{address, Address, Bytes, U256}; use revm::{ - context::CfgEnv, + context::{tx::TxEnvBuilder, CfgEnv}, context_interface::cfg::{GasId, GasParams}, database::EmptyDB, primitives::hardfork::SpecId, }; - use crate::TestExternalEnvs; + use crate::{test_utils::MemoryDatabase, MegaTransactionNew as _, TestExternalEnvs}; /// A gas schedule an embedder could install: the spec table with one entry moved off its /// mainnet value. Distinct from every `GasParams::new_spec(..)` table, so a conversion that @@ -1269,6 +1285,200 @@ mod tests { } } + /// Compute limit tight enough that a leftover REX7 V0 clamp is visible in receipt gas. + const CFG_MIGRATION_COMPUTE_LIMIT: u64 = 50_000; + /// Transaction gas limit used by the `PUSH0 STOP` migration probe. + const CFG_MIGRATION_TX_GAS_LIMIT: u64 = 1_000_000; + const CFG_MIGRATION_CALLER: Address = address!("0000000000000000000000000000000000300000"); + const CFG_MIGRATION_CONTRACT: Address = address!("0000000000000000000000000000000000300001"); + /// `PUSH0 STOP` — a compute-only body, so a leftover V0 clamp shows up as receipt gas. + const CFG_MIGRATION_CODE: [u8; 2] = [0x5f, 0x00]; + + #[derive(Debug, PartialEq, Eq)] + struct CfgMigrationOutcome { + success: bool, + gas_used: u64, + compute_gas: u64, + } + + fn cfg_migration_limits() -> EvmTxRuntimeLimits { + EvmTxRuntimeLimits::from_spec(MegaSpecId::REX7) + .with_tx_compute_gas_limit(CFG_MIGRATION_COMPUTE_LIMIT) + } + + fn cfg_migration_db() -> MemoryDatabase { + MemoryDatabase::default() + .account_balance(CFG_MIGRATION_CALLER, U256::from(10).pow(U256::from(18))) + .account_code(CFG_MIGRATION_CONTRACT, Bytes::from_static(&CFG_MIGRATION_CODE)) + } + + fn run_cfg_migration_tx( + mut context: MegaContext, + ) -> CfgMigrationOutcome { + context.modify_chain(|chain| { + chain.operator_fee_scalar = Some(U256::ZERO); + chain.operator_fee_constant = Some(U256::ZERO); + }); + + let tx = TxEnvBuilder::default() + .caller(CFG_MIGRATION_CALLER) + .call(CFG_MIGRATION_CONTRACT) + .gas_limit(CFG_MIGRATION_TX_GAS_LIMIT) + .build_fill(); + let mut tx = crate::MegaTransaction::new(tx); + tx.enveloped_tx = Some(Bytes::new()); + + let mut evm = crate::MegaEvm::new(context); + let result = + alloy_evm::Evm::transact_raw(&mut evm, tx).expect("cfg-migration probe must execute"); + let compute_gas = evm.ctx.additional_limit.borrow().get_usage().compute_gas; + CfgMigrationOutcome { + success: result.result.is_success(), + gas_used: result.result.tx_gas_used(), + compute_gas, + } + } + + /// Spec-latched tracker bits must follow `with_cfg`, using the already-configured limits. + #[test] + fn test_with_cfg_rebuilds_latched_limit_state_when_spec_changes() { + let limits = cfg_migration_limits(); + + let rex7_to_rex6 = MegaContext::new(EmptyDB::default(), MegaSpecId::REX7) + .with_tx_runtime_limits(limits) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX6)); + let rex6_direct = + MegaContext::new(EmptyDB::default(), MegaSpecId::REX6).with_tx_runtime_limits(limits); + + assert_eq!(rex7_to_rex6.mega_spec(), MegaSpecId::REX6); + assert_eq!( + rex7_to_rex6.additional_limit.borrow().checkpoint_accounting(), + rex6_direct.additional_limit.borrow().checkpoint_accounting(), + ); + assert!( + !rex7_to_rex6.additional_limit.borrow().checkpoint_accounting(), + "REX6 must not latch checkpoint accounting" + ); + assert_eq!(rex7_to_rex6.additional_limit.borrow().limits, limits); + + let rex6_to_rex7 = MegaContext::new(EmptyDB::default(), MegaSpecId::REX6) + .with_tx_runtime_limits(limits) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX7)); + let rex7_direct = + MegaContext::new(EmptyDB::default(), MegaSpecId::REX7).with_tx_runtime_limits(limits); + + assert_eq!(rex6_to_rex7.mega_spec(), MegaSpecId::REX7); + assert_eq!( + rex6_to_rex7.additional_limit.borrow().checkpoint_accounting(), + rex7_direct.additional_limit.borrow().checkpoint_accounting(), + ); + assert!( + rex6_to_rex7.additional_limit.borrow().checkpoint_accounting(), + "REX7 must latch checkpoint accounting" + ); + assert_eq!(rex6_to_rex7.additional_limit.borrow().limits, limits); + + let via_unpinned = MegaContext::new(EmptyDB::default(), MegaSpecId::REX7) + .with_tx_runtime_limits(limits) + .with_cfg_unpinned(CfgEnv::new_with_spec(MegaSpecId::REX6)); + assert!( + !via_unpinned.additional_limit.borrow().checkpoint_accounting(), + "with_cfg_unpinned must rebuild latched limit state on a spec change" + ); + assert_eq!(via_unpinned.additional_limit.borrow().limits, limits); + } + + /// Same-spec `with_cfg` must not replace the additional-limit `Rc`. + #[test] + fn test_with_cfg_same_spec_keeps_additional_limit_identity() { + let limits = cfg_migration_limits(); + let context = + MegaContext::new(EmptyDB::default(), MegaSpecId::REX6).with_tx_runtime_limits(limits); + let before = Rc::clone(&context.additional_limit); + + let context = context.with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX6)); + + assert!(Rc::ptr_eq(&before, &context.additional_limit)); + assert_eq!(context.additional_limit.borrow().limits, limits); + assert!(!context.additional_limit.borrow().checkpoint_accounting()); + } + + #[test] + fn test_with_cfg_rex7_to_rex6_matches_direct_rex6_when_limits_applied_first() { + let limits = cfg_migration_limits(); + + let mut migrated_db = cfg_migration_db(); + let migrated = run_cfg_migration_tx( + MegaContext::new(&mut migrated_db, MegaSpecId::REX7) + .with_tx_runtime_limits(limits) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX6)), + ); + + let mut direct_db = cfg_migration_db(); + let direct = run_cfg_migration_tx( + MegaContext::new(&mut direct_db, MegaSpecId::REX6).with_tx_runtime_limits(limits), + ); + + assert_eq!(migrated, direct); + } + + #[test] + fn test_with_cfg_rex6_to_rex7_matches_direct_rex7_when_limits_applied_first() { + let limits = cfg_migration_limits(); + + let mut migrated_db = cfg_migration_db(); + let migrated = run_cfg_migration_tx( + MegaContext::new(&mut migrated_db, MegaSpecId::REX6) + .with_tx_runtime_limits(limits) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX7)), + ); + + let mut direct_db = cfg_migration_db(); + let direct = run_cfg_migration_tx( + MegaContext::new(&mut direct_db, MegaSpecId::REX7).with_tx_runtime_limits(limits), + ); + + assert_eq!(migrated, direct); + } + + #[test] + fn test_with_cfg_rex7_to_rex6_matches_direct_rex6_when_limits_applied_after() { + let limits = cfg_migration_limits(); + + let mut migrated_db = cfg_migration_db(); + let migrated = run_cfg_migration_tx( + MegaContext::new(&mut migrated_db, MegaSpecId::REX7) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX6)) + .with_tx_runtime_limits(limits), + ); + + let mut direct_db = cfg_migration_db(); + let direct = run_cfg_migration_tx( + MegaContext::new(&mut direct_db, MegaSpecId::REX6).with_tx_runtime_limits(limits), + ); + + assert_eq!(migrated, direct); + } + + #[test] + fn test_with_cfg_rex6_to_rex7_matches_direct_rex7_when_limits_applied_after() { + let limits = cfg_migration_limits(); + + let mut migrated_db = cfg_migration_db(); + let migrated = run_cfg_migration_tx( + MegaContext::new(&mut migrated_db, MegaSpecId::REX6) + .with_cfg(CfgEnv::new_with_spec(MegaSpecId::REX7)) + .with_tx_runtime_limits(limits), + ); + + let mut direct_db = cfg_migration_db(); + let direct = run_cfg_migration_tx( + MegaContext::new(&mut direct_db, MegaSpecId::REX7).with_tx_runtime_limits(limits), + ); + + assert_eq!(migrated, direct); + } + /// Sharing SALT env handles between parent and sandbox must not merge their bucket caches. #[test] fn test_shared_salt_env_keeps_dynamic_gas_cache_isolated() { From fc0fab97dae85f1f64f1d1d0c81cc337667d7f76 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Thu, 13 Aug 2026 15:07:34 +0800 Subject: [PATCH 40/43] docs(evm): replace the V0 design codename with the spec term gas clamp --- AGENTS.md | 2 +- crates/mega-evm/src/evm/context.rs | 4 ++-- crates/mega-evm/src/evm/instructions.rs | 10 ++++----- crates/mega-evm/src/limit/compute_gas.rs | 4 ++-- crates/mega-evm/src/limit/limit.rs | 21 ++++++++++--------- .../tests/rex7/checkpoint_settlement.rs | 4 ++-- .../tests/rex7/{v0_clamp.rs => gas_clamp.rs} | 2 +- crates/mega-evm/tests/rex7/gas_leakage.rs | 2 +- crates/mega-evm/tests/rex7/main.rs | 6 +++--- 9 files changed, 28 insertions(+), 27 deletions(-) rename crates/mega-evm/tests/rex7/{v0_clamp.rs => gas_clamp.rs} (99%) diff --git a/AGENTS.md b/AGENTS.md index 88a83c4c..f09299a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ MegaETH separates EVM gas into two independent dimensions tracked during executi - **Compute gas**: Measures pure computational cost. Through REX6 every opcode's gas consumption is recorded via wrapped instructions in `evm/instructions.rs` — `compute_gas_ext::*` for plain opcodes and `storage_gas_ext::*` for storage-affecting opcodes (SSTORE, LOG, CALL-family, CREATE/CREATE2, SELFDESTRUCT) — both invoking the shared `record_storage_compute_gas!` primitive after the opcode body completes. - REX7 settles compute gas at checkpoints (storage-gas opcodes, CALL/CREATE family, volatile opcodes, `GAS`, frame entry/resume/exit) rather than after every plain opcode, and enforces limits inside plain segments with a V0 gas clamp. + REX7 settles compute gas at checkpoints (storage-gas opcodes, CALL/CREATE family, volatile opcodes, `GAS`, frame entry/resume/exit) rather than after every plain opcode, and enforces limits inside plain segments with a gas clamp. A REX7 frame that ends in an exceptional halt splits its remaining budget: the work it performed before failing settles through the ordinary enforcing path, while the remainder it destroyed goes into a lane of `ComputeGasTracker` that the reported total and block accounting include but no limit comparison sees — destroyed gas is not work performed, and enforcing it would turn an EVM halt into a resource-limit failure with the gas rescued. The destroyed half is read from the frame's final result after action processing, so revm's post-action create rejects are covered; storage gas a checkpoint body charged before aborting belongs to neither half. The split crosses the transaction boundary: `MegaTransactionOutcome` carries the destroyed part alongside the reported total, and `BlockLimiter` keeps `block_compute_gas_used` (reported) separate from `block_compute_gas_enforced` (the counter block admission compares). diff --git a/crates/mega-evm/src/evm/context.rs b/crates/mega-evm/src/evm/context.rs index 5957551b..df428977 100644 --- a/crates/mega-evm/src/evm/context.rs +++ b/crates/mega-evm/src/evm/context.rs @@ -1285,13 +1285,13 @@ mod tests { } } - /// Compute limit tight enough that a leftover REX7 V0 clamp is visible in receipt gas. + /// Compute limit tight enough that a leftover REX7 gas clamp is visible in receipt gas. const CFG_MIGRATION_COMPUTE_LIMIT: u64 = 50_000; /// Transaction gas limit used by the `PUSH0 STOP` migration probe. const CFG_MIGRATION_TX_GAS_LIMIT: u64 = 1_000_000; const CFG_MIGRATION_CALLER: Address = address!("0000000000000000000000000000000000300000"); const CFG_MIGRATION_CONTRACT: Address = address!("0000000000000000000000000000000000300001"); - /// `PUSH0 STOP` — a compute-only body, so a leftover V0 clamp shows up as receipt gas. + /// `PUSH0 STOP` — a compute-only body, so a leftover gas clamp shows up as receipt gas. const CFG_MIGRATION_CODE: [u8; 2] = [0x5f, 0x00]; #[derive(Debug, PartialEq, Eq)] diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index c25d23f8..ceeed327 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -171,8 +171,8 @@ use revm::{ /// the volatile opcodes, and frame entry / resume / exit. Per-transaction totals are unchanged /// for a transaction that stays inside every limit and never halts exceptionally; a frame that /// does halt exceptionally additionally reports the budget it destroyed, which is enforced -/// against nothing. Enforcement inside a plain segment is the V0 gas clamp, which stops the -/// crossing opcode before it executes; an exceed detected by a settlement instead surfaces at the +/// against nothing. Enforcement inside a plain segment is the gas clamp, which stops the crossing +/// opcode before it executes; an exceed detected by a settlement instead surfaces at the /// checkpoint that settled it rather than at the opcode that crossed the limit. /// - Volatile opcodes: `volatile_data_ext::*_checkpoint` (raw instruction + segment settlement + /// detention cap) in place of the `compute_gas_ext` delegation @@ -657,7 +657,7 @@ mod rex7 { table[SELFBALANCE as usize] = Instruction::new(volatile_data_ext::selfbalance_checkpoint); table[SLOAD as usize] = Instruction::new(volatile_data_ext::sload_checkpoint); - // V0 gas-clamp enforcement: `GAS` has to be a checkpoint so the clamp is restored before + // Gas-clamp enforcement: `GAS` has to be a checkpoint so the clamp is restored before // the counter is observed. table[GAS as usize] = Instruction::new(compute_gas_ext::gas_checkpoint); @@ -870,7 +870,7 @@ macro_rules! checkpoint_prologue { }; } -/// REX7 checkpoint epilogue: re-applies the V0 gas clamp from the freshly settled usage — including +/// REX7 checkpoint epilogue: re-applies the gas clamp from the freshly settled usage — including /// any detention cap the checkpoint just installed — and re-opens the settlement window on the /// clamped counter. /// @@ -3556,7 +3556,7 @@ pub mod compute_gas_ext { /// `GAS` as a REX7 checkpoint. /// - /// `GAS` has to be a checkpoint under V0 clamp enforcement even though it charges nothing but + /// `GAS` has to be a checkpoint under gas-clamp enforcement even though it charges nothing but /// its static gas: the prologue hands the clamp-hidden gas back before the raw instruction /// reads the counter, so the value pushed on the stack is the true remaining and the clamp /// stays invisible to any transaction that never exceeds a limit. diff --git a/crates/mega-evm/src/limit/compute_gas.rs b/crates/mega-evm/src/limit/compute_gas.rs index 4dffb7c9..8d55fbee 100644 --- a/crates/mega-evm/src/limit/compute_gas.rs +++ b/crates/mega-evm/src/limit/compute_gas.rs @@ -6,7 +6,7 @@ use super::{ }; use crate::{JournalInspectTr, MegaSpecId}; -/// The constraint that bounds the V0 gas clamp for one plain-opcode segment. +/// The constraint that bounds the gas clamp for one plain-opcode segment. /// /// Captured when the clamp is applied, so a clamp-induced out-of-gas can be classified against the /// constraint that was in force at the time rather than against whatever the tracker looks like @@ -151,7 +151,7 @@ impl ComputeGasTracker { self.frame_tracker.tx_limit() } - /// Returns the constraint the V0 gas clamp must bind to at this point in the transaction. + /// Returns the constraint the gas clamp must bind to at this point in the transaction. /// /// The headroom is the tighter of the current frame's remaining compute budget (Rex4+) and /// the TX-level remaining under the effective (possibly detained) limit — the same pair diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index ea6233a2..5c17cd26 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -123,7 +123,7 @@ pub struct AdditionalLimit { /// checkpoint prologue and body recording. checkpoint_baseline: u64, - /// V0 gas-clamp enforcement (REX7+): the clamp in force for the plain-opcode segment the + /// Gas-clamp enforcement (REX7+): the clamp in force for the plain-opcode segment the /// current frame is inside, so that revm's own per-opcode gas checks enforce the compute /// headroom at no per-opcode cost. /// @@ -144,7 +144,7 @@ pub struct AdditionalLimit { clamp_latched_detained: bool, } -/// A V0 gas clamp in force for one plain-opcode segment (REX7+). +/// A gas clamp in force for one plain-opcode segment (REX7+). /// /// The clamp is a lifecycle, not an amount. It is recorded exactly while it **binds** — while the /// interpreter's true remaining gas was at or above the compute headroom when the segment opened — @@ -288,7 +288,7 @@ impl AdditionalLimit { self.clamp.take().map_or(0, |clamp| clamp.hidden) } - /// Applies the V0 gas clamp for the segment that starts at `remaining`, and returns the amount + /// Applies the gas clamp for the segment that starts at `remaining`, and returns the amount /// the caller must debit from the interpreter's counter. /// /// The clamp is recorded — and the segment therefore enforces the compute limit — whenever the @@ -341,7 +341,7 @@ impl AdditionalLimit { self.compute_gas.detained_limit() < self.compute_gas.base_tx_limit(); } - /// Finalises what the frame's own result decides about the clamp: restores any outstanding V0 + /// Finalises what the frame's own result decides about the clamp: restores any outstanding /// clamp into the result's gas and latches a clamp-induced out-of-gas as the compute exceed it /// stands for. /// @@ -355,9 +355,10 @@ impl AdditionalLimit { /// every checkpoint prologue takes it before its body. An out-of-gas exit from such a segment /// is a clamp artifact: the true counter held `hidden` more gas than the interpreter could see, /// and the crossing opcode was stopped at the clamp boundary *before executing* — exactly the - /// V0 enforcement point. When the crossing opcode would have exceeded the true remaining as - /// well, the compute classification still wins: the two are indistinguishable here, and - /// attributing the halt to the resource limit keeps the sender's remaining gas refundable. + /// gas-clamp enforcement point. When the crossing opcode would have exceeded the true + /// remaining as well, the compute classification still wins: the two are indistinguishable + /// here, and attributing the halt to the resource limit keeps the sender's remaining gas + /// refundable. pub(crate) fn settle_frame_final_result(&mut self, result: &mut InterpreterResult) { if !self.checkpoint_accounting { return; @@ -526,7 +527,7 @@ impl AdditionalLimit { access_type: VolatileDataAccess, ) -> Option { // `is_detained_exceed` covers per-opcode enforcement, where usage crossed the detained - // limit. `clamp_latched_detained` covers V0 clamp enforcement, where the crossing opcode + // limit. `clamp_latched_detained` covers gas-clamp enforcement, where the crossing opcode // was stopped before executing and usage therefore stays at or below the limit. (self.compute_gas.is_detained_exceed() || self.clamp_latched_detained).then(|| { MegaHaltReason::VolatileDataAccessOutOfGas { @@ -923,7 +924,7 @@ impl AdditionalLimit { )); } - // Checkpoint accounting: apply the V0 gas clamp and open the settlement window at the + // Checkpoint accounting: apply the gas clamp and open the settlement window at the // frame's clamped gas. This hook runs both at frame entry and at every resume after a child // frame's outcome — including the gas it returned — has been merged back into this frame's // interpreter, so the window always starts at an instruction boundary with the @@ -1137,7 +1138,7 @@ impl AdditionalLimit { /// the frame-exit delta cannot see that destroyed budget on any other classification. The /// result's own gas can: by the time this runs, /// [`settle_frame_final_result`](Self::settle_frame_final_result) has handed back whatever the - /// V0 clamp was hiding and the code-deposit storage charge has been taken, so + /// clamp was hiding and the code-deposit storage charge has been taken, so /// `result.gas().remaining()` is exactly what the frame still held and will not get to keep. /// /// Runs **after** action processing, which is the first point the classification is final: diff --git a/crates/mega-evm/tests/rex7/checkpoint_settlement.rs b/crates/mega-evm/tests/rex7/checkpoint_settlement.rs index 2dde394f..e68714df 100644 --- a/crates/mega-evm/tests/rex7/checkpoint_settlement.rs +++ b/crates/mega-evm/tests/rex7/checkpoint_settlement.rs @@ -13,7 +13,7 @@ //! The two places where the models are *not* identical are pinned at the bottom of this file: //! a limit crossing inside a plain-opcode segment halts *before* the crossing opcode rather than //! after it, and a frame that halts out of gas settles its burned remainder as compute gas. The -//! enforcement mechanism behind the first — the V0 gas clamp — has its own suite in `v0_clamp`. +//! enforcement mechanism behind the first — the gas clamp — has its own suite in `gas_clamp`. use crate::common::{ transact, transact_default, transact_with_bucket_capacity, Outcome, CALLEE, CALLER, CONTRACT, @@ -470,7 +470,7 @@ fn plain_run_then_sstore_code(pairs: usize, include_sstore: bool) -> Bytes { /// The one enforcement difference this model has: a compute-gas crossing inside a plain-opcode /// segment is not caught *at* the crossing opcode — nothing is metered there — but *before* it, by -/// the V0 gas clamp, which leaves the interpreter only as much visible gas as the compute headroom +/// the gas clamp, which leaves the interpreter only as much visible gas as the compute headroom /// allows. Both specs halt, and both halt in the middle of the plain run without ever reaching the /// SSTORE checkpoint downstream; REX6 executes the crossing opcode and records it, so its usage /// ends up over the limit, while REX7 stops one opcode earlier and its usage stays at the limit. diff --git a/crates/mega-evm/tests/rex7/v0_clamp.rs b/crates/mega-evm/tests/rex7/gas_clamp.rs similarity index 99% rename from crates/mega-evm/tests/rex7/v0_clamp.rs rename to crates/mega-evm/tests/rex7/gas_clamp.rs index 2e8c2343..8b4a6112 100644 --- a/crates/mega-evm/tests/rex7/v0_clamp.rs +++ b/crates/mega-evm/tests/rex7/gas_clamp.rs @@ -1,4 +1,4 @@ -//! REX7 V0 gas-clamp enforcement. +//! REX7 gas-clamp enforcement. //! //! Plain opcodes under checkpoint accounting record nothing, so nothing checks a limit while a //! plain segment runs. Enforcement instead comes from the interpreter itself: at every checkpoint diff --git a/crates/mega-evm/tests/rex7/gas_leakage.rs b/crates/mega-evm/tests/rex7/gas_leakage.rs index 7cb35001..6a46d902 100644 --- a/crates/mega-evm/tests/rex7/gas_leakage.rs +++ b/crates/mega-evm/tests/rex7/gas_leakage.rs @@ -1,7 +1,7 @@ //! REX7: the three gas-leakage paths, exercised with a clamp outstanding. //! //! Any mechanism that hides, grants or adjusts gas per frame has to be unwound on every way out of -//! a frame, or system-held gas leaks back to the parent or the sender. The V0 clamp is such a +//! a frame, or system-held gas leaks back to the parent or the sender. The gas clamp is such a //! mechanism — it hides part of the interpreter's gas — and the three paths that have to handle it //! are the ones the leakage checklist names: //! diff --git a/crates/mega-evm/tests/rex7/main.rs b/crates/mega-evm/tests/rex7/main.rs index dee25dd0..4219947b 100644 --- a/crates/mega-evm/tests/rex7/main.rs +++ b/crates/mega-evm/tests/rex7/main.rs @@ -2,8 +2,8 @@ //! //! - `checkpoint_settlement` — checkpoint compute-gas settlement: per-transaction totals stay //! bit-identical to per-opcode recording, and the two places where the models diverge. -//! - `v0_clamp` — V0 gas-clamp enforcement: a crossing opcode is stopped before it executes, and -//! the resulting out-of-gas is restored and reclassified by the constraint that bound the clamp. +//! - `gas_clamp` — gas-clamp enforcement: a crossing opcode is stopped before it executes, and the +//! resulting out-of-gas is restored and reclassified by the constraint that bound the clamp. //! - `clamp_classification` — which constraint a clamp binds to, including the exact-value case, //! and the ABI payload / halt fields a clamp-induced exceed reports. //! - `checkpoint_families` — one parity case per checkpoint opcode the REX7 table wires, so the set @@ -32,9 +32,9 @@ mod clamp_classification; mod common; mod double_exceed_corner; mod exceptional_halt; +mod gas_clamp; mod gas_leakage; mod interceptor_resume; mod latch_surfacing; mod modexp_gas; mod parity_shapes; -mod v0_clamp; From f849383f0b135b27e577d47111adec58dc65de42 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Thu, 13 Aug 2026 15:27:13 +0800 Subject: [PATCH 41/43] refactor(evm): restore runtime checkpoint gating, matching upstream revm idiom Drop the T12 const CHECKPOINT monomorphization and return the REX7 checkpoint macros and handlers to a runtime spec.is_enabled(REX7) gate. Frame-exit settlement is unconditional again; AdditionalLimit's checkpoint_accounting flag is the single source of truth. Gas-clamp wording from the later rename is kept. --- crates/mega-evm/src/evm/execution.rs | 4 +- crates/mega-evm/src/evm/instructions.rs | 272 ++++++++---------------- 2 files changed, 95 insertions(+), 181 deletions(-) diff --git a/crates/mega-evm/src/evm/execution.rs b/crates/mega-evm/src/evm/execution.rs index e6e4b5ee..53c4718f 100644 --- a/crates/mega-evm/src/evm/execution.rs +++ b/crates/mega-evm/src/evm/execution.rs @@ -459,9 +459,7 @@ impl MegaEvm { // REX7: hand any clamp-hidden gas back to the result and latch a clamp-induced // out-of-gas as the compute exceed it stands for, before the code-deposit charge below // observes the result's gas. - if ctx.spec.is_enabled(MegaSpecId::REX7) { - ctx.additional_limit.borrow_mut().settle_frame_final_result(interpreter_result); - } + ctx.additional_limit.borrow_mut().settle_frame_final_result(interpreter_result); // Charge storage gas cost for the number of bytes if frame.data.is_create() && interpreter_result.is_ok() { diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index ceeed327..3bdcc130 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -313,10 +313,9 @@ mod rex { let mut table = mini_rex::instruction_table::(); // Mini-Rex mistakenly not modifying these three call-like opcodes. They are fixed in Rex - table[CALLCODE as usize] = Instruction::new(forward_gas_ext::call_code::); - table[DELEGATECALL as usize] = - Instruction::new(forward_gas_ext::delegate_call::); - table[STATICCALL as usize] = Instruction::new(forward_gas_ext::static_call::); + table[CALLCODE as usize] = Instruction::new(forward_gas_ext::call_code); + table[DELEGATECALL as usize] = Instruction::new(forward_gas_ext::delegate_call); + table[STATICCALL as usize] = Instruction::new(forward_gas_ext::static_call); table } @@ -424,12 +423,10 @@ mod rex4 { let mut table = rex3::instruction_table::(); // Rex4: CALL-like opcodes check for beneficiary volatile access disabled. - table[CALL as usize] = Instruction::new(volatile_data_ext::call::); - table[STATICCALL as usize] = - Instruction::new(volatile_data_ext::static_call::); - table[DELEGATECALL as usize] = - Instruction::new(volatile_data_ext::delegate_call::); - table[CALLCODE as usize] = Instruction::new(volatile_data_ext::call_code::); + table[CALL as usize] = Instruction::new(volatile_data_ext::call); + table[STATICCALL as usize] = Instruction::new(volatile_data_ext::static_call); + table[DELEGATECALL as usize] = Instruction::new(volatile_data_ext::delegate_call); + table[CALLCODE as usize] = Instruction::new(volatile_data_ext::call_code); // Rex4: SELFDESTRUCT checks for beneficiary volatile access. table[SELFDESTRUCT as usize] = Instruction::new(volatile_data_ext::selfdestruct); @@ -483,7 +480,7 @@ mod rex5 { // REX5: SELFDESTRUCT charges storage gas for new beneficiary accounts, // gated behind the beneficiary-volatile guard. table[SELFDESTRUCT as usize] = - Instruction::new(volatile_data_ext::selfdestruct_with_beneficiary_guard::); + Instruction::new(volatile_data_ext::selfdestruct_with_beneficiary_guard); table } @@ -663,21 +660,20 @@ mod rex7 { // Storage-gas and frame-spawning checkpoints: the Rex6 handler chains unchanged. Under // Rex7 they open with a checkpoint prologue and close with an epilogue. - table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore::); - table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, true, _, _>); - table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, true, _, _>); - table[LOG2 as usize] = Instruction::new(additional_limit_ext::log::<2, true, _, _>); - table[LOG3 as usize] = Instruction::new(additional_limit_ext::log::<3, true, _, _>); - table[LOG4 as usize] = Instruction::new(additional_limit_ext::log::<4, true, _, _>); - table[CREATE as usize] = Instruction::new(forward_gas_ext::create::); - table[CREATE2 as usize] = Instruction::new(forward_gas_ext::create2::); - table[CALL as usize] = Instruction::new(volatile_data_ext::call::); - table[CALLCODE as usize] = Instruction::new(volatile_data_ext::call_code::); - table[DELEGATECALL as usize] = - Instruction::new(volatile_data_ext::delegate_call::); - table[STATICCALL as usize] = Instruction::new(volatile_data_ext::static_call::); + table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore); + table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, _, _>); + table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, _, _>); + table[LOG2 as usize] = Instruction::new(additional_limit_ext::log::<2, _, _>); + table[LOG3 as usize] = Instruction::new(additional_limit_ext::log::<3, _, _>); + table[LOG4 as usize] = Instruction::new(additional_limit_ext::log::<4, _, _>); + table[CREATE as usize] = Instruction::new(forward_gas_ext::create); + table[CREATE2 as usize] = Instruction::new(forward_gas_ext::create2); + table[CALL as usize] = Instruction::new(volatile_data_ext::call); + table[CALLCODE as usize] = Instruction::new(volatile_data_ext::call_code); + table[DELEGATECALL as usize] = Instruction::new(volatile_data_ext::delegate_call); + table[STATICCALL as usize] = Instruction::new(volatile_data_ext::static_call); table[SELFDESTRUCT as usize] = - Instruction::new(volatile_data_ext::selfdestruct_with_beneficiary_guard::); + Instruction::new(volatile_data_ext::selfdestruct_with_beneficiary_guard); table } @@ -843,12 +839,10 @@ macro_rules! run_inner_instruction_or_abort { /// /// Halts — returning from the enclosing handler — when the settlement surfaces a limit exceed, /// including one latched earlier by a non-compute mutation site. The restore has already happened -/// on that path, so the frame result carries true gas. No-op when `$cp` is false: frozen-spec -/// tables instantiate the shared handlers with `CHECKPOINT = false` so the compiler drops this -/// body entirely. +/// on that path, so the frame result carries true gas. No-op before REX7. macro_rules! checkpoint_prologue { - ($context:expr, $cp:expr) => { - if $cp { + ($context:expr) => { + if $context.host.spec_id().is_enabled(MegaSpecId::REX7) { let exceeding_result = { let mut additional_limit = $context.host.additional_limit().borrow_mut(); let remaining = $context.interpreter.gas.remaining(); @@ -877,11 +871,12 @@ macro_rules! checkpoint_prologue { /// Only applies when the frame keeps executing. A checkpoint that published an action has either /// suspended into a child frame (the resume clamps in `AdditionalLimit::before_frame_run`) or ended /// the frame (the frame's final result restores instead), and clamping either would strand hidden -/// gas across the boundary. No-op when `$cp` is false (the `action().is_none()` check is also -/// dropped); frozen-spec tables instantiate the shared handlers with `CHECKPOINT = false`. +/// gas across the boundary. No-op before REX7. macro_rules! checkpoint_epilogue { - ($context:expr, $cp:expr) => { - if $cp && $context.interpreter.bytecode.action().is_none() { + ($context:expr) => { + if $context.host.spec_id().is_enabled(MegaSpecId::REX7) && + $context.interpreter.bytecode.action().is_none() + { let mut additional_limit = $context.host.additional_limit().borrow_mut(); let hide = additional_limit.checkpoint_clamp_amount($context.interpreter.gas.remaining()); @@ -949,15 +944,13 @@ macro_rules! record_checkpoint_body_compute_gas { /// afterwards, so this is invisible there. /// /// Returns `Err(OutOfGas)` from the enclosing handler when the frame cannot afford the charge, -/// exactly as a bare `gas!` would — with nothing debited and so nothing to exclude. When `$cp` is -/// false the exclude is dropped: nothing on a frozen spec measures against a segment. +/// exactly as a bare `gas!` would — with nothing debited and so nothing to exclude. No-op before +/// REX7, where nothing measures against a segment. macro_rules! charge_storage_gas { - ($context:expr, $amount:expr, $cp:expr) => {{ + ($context:expr, $amount:expr) => {{ let amount: u64 = $amount; gas!($context.interpreter, amount); - if $cp { - $context.host.additional_limit().borrow_mut().exclude_storage_gas_from_segment(amount); - } + $context.host.additional_limit().borrow_mut().exclude_storage_gas_from_segment(amount); amount }}; } @@ -993,9 +986,10 @@ macro_rules! charge_storage_gas { /// reached on the non-halt path; without the return, a halt here would let a later `compute_gas!` /// add gas to the tracker after the OOG was already set. macro_rules! record_storage_compute_gas { - ($context:expr, $gas_before:expr, $storage_charged:expr, $opcode:expr, $cp:expr) => {{ + ($context:expr, $gas_before:expr, $storage_charged:expr, $opcode:expr) => {{ let spec = $context.host.spec_id(); let is_rex6 = spec.is_enabled(MegaSpecId::REX6); + let is_checkpoint_accounting = spec.is_enabled(MegaSpecId::REX7); let gas_after = $context.interpreter.gas.remaining(); // The per-opcode `$gas_before` window applies on every spec: under checkpoint accounting // the plain segment ahead of this opcode was already settled by @@ -1007,7 +1001,7 @@ macro_rules! record_storage_compute_gas { // before dispatch, or an outer volatile wrapper — does so ahead of the prologue, so under // checkpoint accounting it is already inside the settled segment and adding it back here // would bill it twice. - let mut gas_used = if $cp { + let mut gas_used = if is_checkpoint_accounting { $gas_before.saturating_sub(gas_after).saturating_sub($storage_charged) } else { (const { static_gas($opcode) } + $gas_before.saturating_sub(gas_after)) @@ -1046,7 +1040,7 @@ macro_rules! record_storage_compute_gas { let mut additional_limit = $context.host.additional_limit().borrow_mut(); // Re-open the settlement window at this opcode's exit before recording, so neither a // halt here nor the frame-final settlement can bill this segment twice. - if $cp { + if is_checkpoint_accounting { additional_limit.sync_checkpoint_baseline(gas_after); } if additional_limit.record_compute_gas(gas_used) { @@ -1193,7 +1187,7 @@ mod mini_rex { table[MSTORE as usize] = Instruction::new(compute_gas_ext::mstore); table[MSTORE8 as usize] = Instruction::new(compute_gas_ext::mstore8); table[SLOAD as usize] = Instruction::new(compute_gas_ext::sload); - table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore::); + table[SSTORE as usize] = Instruction::new(additional_limit_ext::sstore); table[JUMP as usize] = Instruction::new(compute_gas_ext::jump); table[JUMPI as usize] = Instruction::new(compute_gas_ext::jumpi); table[PC as usize] = Instruction::new(compute_gas_ext::pc); @@ -1272,15 +1266,15 @@ mod mini_rex { table[SWAP15 as usize] = Instruction::new(compute_gas_ext::swap15); table[SWAP16 as usize] = Instruction::new(compute_gas_ext::swap16); - table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, false, _, _>); - table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, false, _, _>); - table[LOG2 as usize] = Instruction::new(additional_limit_ext::log::<2, false, _, _>); - table[LOG3 as usize] = Instruction::new(additional_limit_ext::log::<3, false, _, _>); - table[LOG4 as usize] = Instruction::new(additional_limit_ext::log::<4, false, _, _>); + table[LOG0 as usize] = Instruction::new(additional_limit_ext::log::<0, _, _>); + table[LOG1 as usize] = Instruction::new(additional_limit_ext::log::<1, _, _>); + table[LOG2 as usize] = Instruction::new(additional_limit_ext::log::<2, _, _>); + table[LOG3 as usize] = Instruction::new(additional_limit_ext::log::<3, _, _>); + table[LOG4 as usize] = Instruction::new(additional_limit_ext::log::<4, _, _>); - table[CREATE as usize] = Instruction::new(forward_gas_ext::create::); - table[CREATE2 as usize] = Instruction::new(forward_gas_ext::create2::); - table[CALL as usize] = Instruction::new(forward_gas_ext::call::); + table[CREATE as usize] = Instruction::new(forward_gas_ext::create); + table[CREATE2 as usize] = Instruction::new(forward_gas_ext::create2); + table[CALL as usize] = Instruction::new(forward_gas_ext::call); table[CALLCODE as usize] = Instruction::new(compute_gas_ext::call_code); table[DELEGATECALL as usize] = Instruction::new(compute_gas_ext::delegate_call); table[STATICCALL as usize] = Instruction::new(compute_gas_ext::static_call); @@ -1365,9 +1359,6 @@ pub mod forward_gas_ext { /// is used by `CREATE` / `CREATE2`, whose table entries dispatch straight here; the CALL family /// is wrapped once more by `volatile_data_ext::wrap_call_volatile_check`, which owns the /// epilogue so that it lands after the detention cap that wrapper installs. - /// - /// Generated handlers are const-generic over `CHECKPOINT`. Frozen tables instantiate `false` - /// so the epilogue body is compiled out; the REX7 table instantiates `true`. macro_rules! wrap_gas_cap { ($fn_name:ident, $opcode_name:expr, $wrapped_fn:path, $has_transfer_logic:expr) => { wrap_gas_cap!(@inner $fn_name, $opcode_name, $wrapped_fn, $has_transfer_logic, false); @@ -1379,7 +1370,6 @@ pub mod forward_gas_ext { #[doc = concat!("`", $opcode_name, "` opcode with 98/100 gas forwarding rule.")] #[inline] pub fn $fn_name< - const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -1461,7 +1451,7 @@ pub mod forward_gas_ext { _ => {} } if $checkpoint_tail { - checkpoint_epilogue!(context, CHECKPOINT); + checkpoint_epilogue!(context); } inner_outcome } @@ -1491,41 +1481,15 @@ pub mod forward_gas_ext { false } + wrap_gas_cap!(call, "CALL", storage_gas_ext::call, check_call_has_transfer); + wrap_gas_cap!(call_code, "CALLCODE", storage_gas_ext::call_code, check_call_has_transfer); + wrap_gas_cap!(delegate_call, "DELEGATECALL", storage_gas_ext::delegate_call, no_transfer); + wrap_gas_cap!(static_call, "STATICCALL", storage_gas_ext::static_call, no_transfer); wrap_gas_cap!( - call, - "CALL", - storage_gas_ext::call::, - check_call_has_transfer - ); - wrap_gas_cap!( - call_code, - "CALLCODE", - storage_gas_ext::call_code::, - check_call_has_transfer - ); - wrap_gas_cap!( - delegate_call, - "DELEGATECALL", - storage_gas_ext::delegate_call::, - no_transfer - ); - wrap_gas_cap!( - static_call, - "STATICCALL", - storage_gas_ext::static_call::, - no_transfer - ); - wrap_gas_cap!( - @checkpoint_tail create, - "CREATE", - storage_gas_ext::create::, - no_transfer + @checkpoint_tail create, "CREATE", storage_gas_ext::create::, no_transfer ); wrap_gas_cap!( - @checkpoint_tail create2, - "CREATE2", - storage_gas_ext::create::, - no_transfer + @checkpoint_tail create2, "CREATE2", storage_gas_ext::create::, no_transfer ); } @@ -1861,7 +1825,6 @@ pub mod volatile_data_ext { /// SELFDESTRUCT-specific hook. #[inline] pub fn selfdestruct_with_beneficiary_guard< - const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -1898,7 +1861,7 @@ pub mod volatile_data_ext { } run_inner_instruction_or_abort!( - super::storage_gas_ext::selfdestruct::, + super::storage_gas_ext::selfdestruct, context, inner_outcome ); @@ -2005,7 +1968,6 @@ pub mod volatile_data_ext { #[doc = concat!("`", stringify!($opcode), "` opcode with volatile data access disabled check for beneficiary.")] #[inline] pub fn $fn_name< - const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -2095,7 +2057,7 @@ pub mod volatile_data_ext { // or depth rejection pushes 0 and lets the frame keep running). The epilogue is what // keeps the following plain segment bounded, and it sits after the cap above so a CALL // that just marked beneficiary access clamps against the detained headroom. - checkpoint_epilogue!(context, CHECKPOINT); + checkpoint_epilogue!(context); inner_outcome } }; @@ -2103,22 +2065,10 @@ pub mod volatile_data_ext { // Conditionally volatile CALL-like opcodes — volatile only when targeting the block // beneficiary. These wrap forward_gas_ext handlers with a pre-execution beneficiary check. - wrap_call_volatile_check!(call, CALL, forward_gas_ext::call::); - wrap_call_volatile_check!( - static_call, - STATICCALL, - forward_gas_ext::static_call:: - ); - wrap_call_volatile_check!( - delegate_call, - DELEGATECALL, - forward_gas_ext::delegate_call:: - ); - wrap_call_volatile_check!( - call_code, - CALLCODE, - forward_gas_ext::call_code:: - ); + wrap_call_volatile_check!(call, CALL, forward_gas_ext::call); + wrap_call_volatile_check!(static_call, STATICCALL, forward_gas_ext::static_call); + wrap_call_volatile_check!(delegate_call, DELEGATECALL, forward_gas_ext::delegate_call); + wrap_call_volatile_check!(call_code, CALLCODE, forward_gas_ext::call_code); /* Checkpoint variants of the volatile handlers (REX7+). @@ -2149,14 +2099,14 @@ pub mod volatile_data_ext { if context.host.volatile_access_disabled() { revert_volatile_access_disabled!(context, $opcode, $access_type); } - checkpoint_prologue!(context, true); + checkpoint_prologue!(context); let gas_before = context.interpreter.gas.remaining(); charge_static_gas!(context, $opcode); run_inner_instruction_or_abort!($original_fn, context, inner_outcome); record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); - checkpoint_epilogue!(context, true); + checkpoint_epilogue!(context); inner_outcome } }; @@ -2184,14 +2134,14 @@ pub mod volatile_data_ext { ); } } - checkpoint_prologue!(context, true); + checkpoint_prologue!(context); let gas_before = context.interpreter.gas.remaining(); run_inner_instruction_or_abort!($original_fn, context, inner_outcome); charge_static_gas!(context, $opcode); record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); - checkpoint_epilogue!(context, true); + checkpoint_epilogue!(context); inner_outcome } }; @@ -2283,14 +2233,14 @@ pub mod volatile_data_ext { if target == ORACLE_CONTRACT_ADDRESS && context.host.volatile_access_disabled() { revert_volatile_access_disabled!(context, SLOAD, VolatileDataAccessType::Oracle); } - checkpoint_prologue!(context, true); + checkpoint_prologue!(context); let gas_before = context.interpreter.gas.remaining(); run_inner_instruction_or_abort!(instructions::host::sload, context, inner_outcome); charge_static_gas!(context, SLOAD); record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); - checkpoint_epilogue!(context, true); + checkpoint_epilogue!(context); inner_outcome } @@ -2309,14 +2259,14 @@ pub mod volatile_data_ext { VolatileDataAccessType::Beneficiary ); } - checkpoint_prologue!(context, true); + checkpoint_prologue!(context); let gas_before = context.interpreter.gas.remaining(); charge_static_gas!(context, SELFBALANCE); run_inner_instruction_or_abort!(instructions::host::selfbalance, context, inner_outcome); record_checkpoint_body_compute_gas!(context, gas_before, detention_tail); apply_compute_gas_limit!(context); - checkpoint_epilogue!(context, true); + checkpoint_epilogue!(context); inner_outcome } } @@ -2344,7 +2294,6 @@ pub mod additional_limit_ext { /// /// Refunds data/KV when slot reset to original value. pub fn sstore< - const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -2366,11 +2315,7 @@ pub mod additional_limit_ext { let loaded_data = SStoreResult { original_value, present_value, new_value }; // Execute the original SSTORE instruction - run_inner_instruction_or_abort!( - storage_gas_ext::sstore::, - context, - inner_outcome - ); + run_inner_instruction_or_abort!(storage_gas_ext::sstore, context, inner_outcome); // KV update bomb and data bomb (only when first writing non-zero value to originally zero // slot): check if the number of key-value updates or the total data size will exceed the @@ -2384,7 +2329,7 @@ pub mod additional_limit_ext { } drop(additional_limit); // REX7: re-clamp once every dimension this opcode touches has been recorded. - checkpoint_epilogue!(context, CHECKPOINT); + checkpoint_epilogue!(context); inner_outcome } @@ -2399,7 +2344,6 @@ pub mod additional_limit_ext { /// MB). Halts when data limit exceeded. pub fn log< const N: usize, - const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -2412,11 +2356,7 @@ pub mod additional_limit_ext { let len = as_usize_or_fail!(context.interpreter, len); // Execute the original LOG instruction - run_inner_instruction_or_abort!( - storage_gas_ext::log::, - context, - inner_outcome - ); + run_inner_instruction_or_abort!(storage_gas_ext::log::, context, inner_outcome); // Record the size of the log topics and data. If the total data size exceeds the limit, we // halt. @@ -2429,7 +2369,7 @@ pub mod additional_limit_ext { } drop(additional_limit); // REX7: re-clamp once every dimension this opcode touches has been recorded. - checkpoint_epilogue!(context, CHECKPOINT); + checkpoint_epilogue!(context); inner_outcome } } @@ -2515,7 +2455,6 @@ pub mod storage_gas_ext { ($fn_name:ident, $opcode:ident, $raw_fn:path, $has_transfer_logic:expr, $select_addr:path) => { #[doc = concat!("`", stringify!($opcode), "` opcode implementation modified from `revm` with compute gas tracking and dynamically-scaled storage gas costs.")] pub fn $fn_name< - const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -2523,7 +2462,7 @@ pub mod storage_gas_ext { ) -> InstructionExecResult { // REX7: settle the open segment and restore the clamp before any gas observation, // so the storage charge and the body's 63/64 forwarding math see the true counter. - checkpoint_prologue!(context, CHECKPOINT); + checkpoint_prologue!(context); // Captured at the very top so the single compute window covers all of the // opcode's compute work. let gas_before = context.interpreter.gas.remaining(); @@ -2566,7 +2505,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(new_account_storage_gas); - charge_storage_gas!(context, new_account_storage_gas - drained, CHECKPOINT) + charge_storage_gas!(context, new_account_storage_gas - drained) } else { 0 }; @@ -2580,8 +2519,7 @@ pub mod storage_gas_ext { context, gas_before, storage_charged, - opcode::$opcode, - CHECKPOINT + opcode::$opcode ); inner_outcome } @@ -2764,7 +2702,6 @@ pub mod storage_gas_ext { /// circuits to [`create_rex6`] at the top; the body below is the pre-REX6 path, which can /// assume all features up to and including `MINI_REX` are enabled. pub fn create< - const CHECKPOINT: bool, WIRE: InterpreterTypes, const IS_CREATE2: bool, H: HostExt + ContextTr + JournalInspectTr + ?Sized, @@ -2777,7 +2714,7 @@ pub mod storage_gas_ext { // compute-gas recording taken after the body completes (see `create_rex6`), instead of // the pre-REX6 split `resize_gas` recording handled below. if spec.is_enabled(MegaSpecId::REX6) { - return create_rex6::(context); + return create_rex6::(context); } // Inspect the creator and compute the created address. REX5+ records the CREATE2 @@ -2827,7 +2764,7 @@ pub mod storage_gas_ext { context, inner_outcome ); - record_storage_compute_gas!(context, gas_before, 0, create_opcode(IS_CREATE2), CHECKPOINT); + record_storage_compute_gas!(context, gas_before, 0, create_opcode(IS_CREATE2)); // Pre-REX5 late-record path for the CREATE2 initcode memory-expansion gas. // Preserved verbatim for replay parity: pre-REX5 keeps the original "skip on inner @@ -2858,7 +2795,6 @@ pub mod storage_gas_ext { /// REX6 implies REX5 (and REX), so the REX5 operand validation and the contract-creation /// storage-gas path are taken unconditionally here. fn create_rex6< - const CHECKPOINT: bool, WIRE: InterpreterTypes, const IS_CREATE2: bool, H: HostExt + ContextTr + JournalInspectTr + ?Sized, @@ -2881,7 +2817,7 @@ pub mod storage_gas_ext { // REX7: settle the open segment and restore the clamp before any gas observation, so the // memory expansion, the storage charge and the body's forwarding math see the true counter. - checkpoint_prologue!(context, CHECKPOINT); + checkpoint_prologue!(context); // Captured before any gas movement so the single compute window covers the wrapper-side // CREATE2 memory expansion as well as the inner opcode. @@ -2915,8 +2851,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(create_contract_storage_gas); - let storage_charged = - charge_storage_gas!(context, create_contract_storage_gas - drained, CHECKPOINT); + let storage_charged = charge_storage_gas!(context, create_contract_storage_gas - drained); // Run the raw inner create opcode (no `compute_gas_ext` wrapper — REX6 records compute gas // once below). @@ -2940,8 +2875,7 @@ pub mod storage_gas_ext { context, gas_before, storage_charged, - create_opcode(IS_CREATE2), - CHECKPOINT + create_opcode(IS_CREATE2) ); inner_outcome } @@ -2960,14 +2894,13 @@ pub mod storage_gas_ext { /// This alternative implementation of `LOG` is only used when the `MINI_REX` spec is enabled. pub fn log< const N: usize, - const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ?Sized, >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { // REX7: settle the open segment and restore the clamp before any gas observation. - checkpoint_prologue!(context, CHECKPOINT); + checkpoint_prologue!(context); // Captured at the very top so the single compute window covers the inner opcode. let gas_before = context.interpreter.gas.remaining(); let Some(len) = context.interpreter.stack.inspect::<1>() else { @@ -2999,15 +2932,12 @@ pub mod storage_gas_ext { // The `gas_or_fail!` above is the storage-gas charge, so it gets the same segment // exclusion `charge_storage_gas!` applies at every other charge site: the raw opcode below // can halt (a static frame rejects `LOG` outright) before the recording that would - // otherwise subtract it. Frozen specs skip the exclude — nothing measures against a - // segment. - if CHECKPOINT { - context - .host - .additional_limit() - .borrow_mut() - .exclude_storage_gas_from_segment(storage_charged); - } + // otherwise subtract it. + context + .host + .additional_limit() + .borrow_mut() + .exclude_storage_gas_from_segment(storage_charged); // Run the raw opcode and record compute gas once after the body completes (canonical // metering order). Byte-equivalent to the pre-REX6 per-`N` `compute_gas_ext::logK` @@ -3015,13 +2945,7 @@ pub mod storage_gas_ext { // consumes EVM gas. The wrapper is only ever instantiated for `N` in `0..=4`, so the // generic `instructions::host::log::` covers every valid call site. run_inner_instruction_or_abort!(instructions::host::log::, context, inner_outcome); - record_storage_compute_gas!( - context, - gas_before, - storage_charged, - opcode::LOG0 + N as u8, - CHECKPOINT - ); + record_storage_compute_gas!(context, gas_before, storage_charged, opcode::LOG0 + N as u8); inner_outcome } @@ -3041,14 +2965,13 @@ pub mod storage_gas_ext { /// enabled, so we can safely assume that all features before and including Mini-Rex are /// enabled. pub fn sstore< - const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { // REX7: settle the open segment and restore the clamp before any gas observation. - checkpoint_prologue!(context, CHECKPOINT); + checkpoint_prologue!(context); // Captured at the very top so the single compute window covers the inner opcode. let gas_before = context.interpreter.gas.remaining(); // The address to the underlying execution contract state @@ -3083,7 +3006,7 @@ pub mod storage_gas_ext { .additional_limit() .borrow_mut() .try_consume_storage_stipend(sstore_set_storage_gas); - charge_storage_gas!(context, sstore_set_storage_gas - drained, CHECKPOINT) + charge_storage_gas!(context, sstore_set_storage_gas - drained) } else { 0 }; @@ -3093,13 +3016,7 @@ pub mod storage_gas_ext { // every spec because nothing between `gas_before` and the storage charge above consumes // EVM gas. run_inner_instruction_or_abort!(instructions::host::sstore, context, inner_outcome); - record_storage_compute_gas!( - context, - gas_before, - storage_charged, - opcode::SSTORE, - CHECKPOINT - ); + record_storage_compute_gas!(context, gas_before, storage_charged, opcode::SSTORE); inner_outcome } @@ -3124,7 +3041,6 @@ pub mod storage_gas_ext { /// sees — via the REX6-gated arm below; pre-REX6 records nothing for an existing target. The /// rest of the body, and all ≤REX5 behavior, is unchanged. pub fn selfdestruct< - const CHECKPOINT: bool, WIRE: InterpreterTypes, H: HostExt + ContextTr + JournalInspectTr + ?Sized, >( @@ -3133,7 +3049,7 @@ pub mod storage_gas_ext { // REX7: settle the open segment and restore the clamp before any gas observation — the // beneficiary-creation storage charge below and the inner opcode both run on the true // counter, which is what keeps the storage charge outside every compute window. - checkpoint_prologue!(context, CHECKPOINT); + checkpoint_prologue!(context); // Inside a static frame, revm's inner SELFDESTRUCT halts on the // static-context check without changing state. Skip the mega host work below @@ -3183,7 +3099,7 @@ pub mod storage_gas_ext { }; let drained = context.host.additional_limit().borrow_mut().try_consume_storage_stipend(cost); - charge_storage_gas!(context, cost - drained, CHECKPOINT); + charge_storage_gas!(context, cost - drained); // Record resource usage for new beneficiary account context.host.additional_limit().borrow_mut().on_selfdestruct_new_account(); @@ -3564,11 +3480,11 @@ pub mod compute_gas_ext { pub fn gas_checkpoint( context: InstructionContext<'_, H, WIRE>, ) -> InstructionExecResult { - checkpoint_prologue!(context, true); + checkpoint_prologue!(context); let gas_before = context.interpreter.gas.remaining(); run_inner_instruction_or_abort!(instructions::system::gas, context, inner_outcome); record_checkpoint_body_compute_gas!(context, gas_before); - checkpoint_epilogue!(context, true); + checkpoint_epilogue!(context); inner_outcome } } From 62cd04222090d8334975fb247dfbfc5e21c3d78f Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Fri, 14 Aug 2026 01:34:16 +0800 Subject: [PATCH 42/43] refactor(limit): fold checkpoint/clamp state into a CheckpointTracker --- crates/mega-evm/src/evm/context.rs | 16 +-- crates/mega-evm/src/evm/instructions.rs | 2 +- crates/mega-evm/src/limit/checkpoint.rs | 162 ++++++++++++++++++++++++ crates/mega-evm/src/limit/limit.rs | 107 ++++------------ crates/mega-evm/src/limit/mod.rs | 1 + 5 files changed, 199 insertions(+), 89 deletions(-) create mode 100644 crates/mega-evm/src/limit/checkpoint.rs diff --git a/crates/mega-evm/src/evm/context.rs b/crates/mega-evm/src/evm/context.rs index df428977..52c31651 100644 --- a/crates/mega-evm/src/evm/context.rs +++ b/crates/mega-evm/src/evm/context.rs @@ -1352,11 +1352,11 @@ mod tests { assert_eq!(rex7_to_rex6.mega_spec(), MegaSpecId::REX6); assert_eq!( - rex7_to_rex6.additional_limit.borrow().checkpoint_accounting(), - rex6_direct.additional_limit.borrow().checkpoint_accounting(), + rex7_to_rex6.additional_limit.borrow().rex7_enabled(), + rex6_direct.additional_limit.borrow().rex7_enabled(), ); assert!( - !rex7_to_rex6.additional_limit.borrow().checkpoint_accounting(), + !rex7_to_rex6.additional_limit.borrow().rex7_enabled(), "REX6 must not latch checkpoint accounting" ); assert_eq!(rex7_to_rex6.additional_limit.borrow().limits, limits); @@ -1369,11 +1369,11 @@ mod tests { assert_eq!(rex6_to_rex7.mega_spec(), MegaSpecId::REX7); assert_eq!( - rex6_to_rex7.additional_limit.borrow().checkpoint_accounting(), - rex7_direct.additional_limit.borrow().checkpoint_accounting(), + rex6_to_rex7.additional_limit.borrow().rex7_enabled(), + rex7_direct.additional_limit.borrow().rex7_enabled(), ); assert!( - rex6_to_rex7.additional_limit.borrow().checkpoint_accounting(), + rex6_to_rex7.additional_limit.borrow().rex7_enabled(), "REX7 must latch checkpoint accounting" ); assert_eq!(rex6_to_rex7.additional_limit.borrow().limits, limits); @@ -1382,7 +1382,7 @@ mod tests { .with_tx_runtime_limits(limits) .with_cfg_unpinned(CfgEnv::new_with_spec(MegaSpecId::REX6)); assert!( - !via_unpinned.additional_limit.borrow().checkpoint_accounting(), + !via_unpinned.additional_limit.borrow().rex7_enabled(), "with_cfg_unpinned must rebuild latched limit state on a spec change" ); assert_eq!(via_unpinned.additional_limit.borrow().limits, limits); @@ -1400,7 +1400,7 @@ mod tests { assert!(Rc::ptr_eq(&before, &context.additional_limit)); assert_eq!(context.additional_limit.borrow().limits, limits); - assert!(!context.additional_limit.borrow().checkpoint_accounting()); + assert!(!context.additional_limit.borrow().rex7_enabled()); } #[test] diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index 3bdcc130..7da79e15 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -3457,7 +3457,7 @@ pub mod compute_gas_ext { // `storage_gas_ext::selfdestruct`, which also restored the clamp; the window is re-opened // here so the frame's final settlement cannot bill this body a second time. let gas_used = pre_charged + gas_before.saturating_sub(gas_after); - if additional_limit.checkpoint_accounting() { + if additional_limit.rex7_enabled() { additional_limit.sync_checkpoint_baseline(gas_after); } if !additional_limit.record_compute_gas_all_dims(gas_used) { diff --git a/crates/mega-evm/src/limit/checkpoint.rs b/crates/mega-evm/src/limit/checkpoint.rs new file mode 100644 index 00000000..8f43ee15 --- /dev/null +++ b/crates/mega-evm/src/limit/checkpoint.rs @@ -0,0 +1,162 @@ +//! REX7+ checkpoint settlement and gas-clamp state. +//! +//! Holds the spec latch, the open-segment interpreter-gas baseline, the clamp +//! in force for the current plain-opcode segment, and the detention-attribution +//! flag for a clamp-induced out-of-gas. Cross-tracker orchestration (reading +//! compute-gas headroom, latching `has_exceeded_limit`) stays on +//! [`AdditionalLimit`](super::AdditionalLimit). + +use super::compute_gas::ClampBinding; +use crate::MegaSpecId; + +/// Tracks REX7+ checkpoint-accounting and gas-clamp state for one transaction. +#[derive(Debug, Clone)] +pub(crate) struct CheckpointTracker { + /// REX7+: whether compute gas settles at checkpoints rather than per opcode. + /// + /// When set, plain opcodes run unwrapped and record nothing; the interpreter's own gas + /// counter is read at each checkpoint and the whole segment since the previous one is + /// recorded in a single call. + rex7_enabled: bool, + + /// Interpreter gas remaining at the start of the current unsettled segment — the previous + /// checkpoint, or the frame entry / resume that opened the window. Only meaningful while a + /// frame is running and only when [`rex7_enabled`](Self::rex7_enabled) is active. Re-synced + /// at every [`before_frame_run`](super::AdditionalLimit::before_frame_run) (which covers both + /// frame entry and every resume after a child frame's outcome is merged back) and at every + /// checkpoint prologue and body recording. + baseline: u64, + + /// Gas-clamp enforcement (REX7+): the clamp in force for the plain-opcode segment the + /// current frame is inside, so that revm's own per-opcode gas checks enforce the compute + /// headroom at no per-opcode cost. + /// + /// Present only while the current frame is inside a plain segment: every checkpoint takes it + /// before running its body — so CALL forwarding, `GAS` and storage charges observe the true + /// counter — and re-applies it on the way out, and the frame's final result takes it via + /// [`settle_frame_final_result`](super::AdditionalLimit::settle_frame_final_result). + clamp: Option, + + /// Whether a clamp-induced out-of-gas was latched while gas detention was the binding TX-level + /// constraint. + /// + /// [`ComputeGasTracker::is_detained_exceed`] requires `used > detained_limit`, which a + /// clamp-stopped transaction never reaches — the crossing opcode is stopped before it + /// executes, so usage stays at or below the limit. The halt-reason attribution consults + /// this flag instead, keeping the reported reason `VolatileDataAccessOutOfGas` exactly as + /// per-opcode enforcement reports it. + latched_detained: bool, +} + +/// A gas clamp in force for one plain-opcode segment (REX7+). +/// +/// The clamp is a lifecycle, not an amount. It is recorded exactly while it **binds** — while the +/// interpreter's true remaining gas was at or above the compute headroom when the segment opened — +/// and a `hidden` of zero is a binding clamp whose two budgets happened to coincide, not the +/// absence of one. When the frame's own gas would run out ahead of the compute headroom no clamp +/// is recorded at all, and an out-of-gas inside that segment stays the EVM's own. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ClampState { + /// Interpreter gas hidden from the interpreter for this segment. + pub(crate) hidden: u64, + /// The constraint the clamp was bound to, captured at the moment it was applied. + pub(crate) binding: ClampBinding, +} + +impl CheckpointTracker { + pub(crate) fn new(spec: MegaSpecId) -> Self { + Self { + rex7_enabled: spec.is_enabled(MegaSpecId::REX7), + baseline: 0, + clamp: None, + latched_detained: false, + } + } + + pub(crate) fn reset(&mut self) { + self.baseline = 0; + self.clamp = None; + self.latched_detained = false; + } + + /// Whether compute gas settles at checkpoints (REX7+) rather than per opcode. + #[inline] + pub(crate) fn rex7_enabled(&self) -> bool { + self.rex7_enabled + } + + /// Interpreter gas remaining at the start of the current unsettled segment. + #[inline] + pub(crate) fn baseline(&self) -> u64 { + self.baseline + } + + /// Re-opens the settlement window at `remaining`, without recording anything. + #[inline] + pub(crate) fn sync_baseline(&mut self, remaining: u64) { + self.baseline = remaining; + } + + /// Moves the open segment's baseline down by `amount` of `MegaETH` storage gas just charged to + /// the interpreter, so the charge sits outside the segment rather than inside it. + /// + /// A checkpoint body normally subtracts its own storage charge when it closes its measurement + /// window. A body that aborts — a static-context `LOG`, a `SELFDESTRUCT` whose inner + /// instruction runs out of gas — never reaches that subtraction, and the frame-exit settlement + /// that follows would then bill the charge as compute. Excluding it from the baseline as it is + /// charged makes the exclusion hold on both paths; on the normal path the body's own window + /// re-syncs the baseline afterwards, so this is invisible there. + /// + /// No-op before REX7, where nothing measures against a baseline. + #[inline] + pub(crate) fn exclude_storage_gas_from_segment(&mut self, amount: u64) { + if self.rex7_enabled { + self.baseline = self.baseline.saturating_sub(amount); + } + } + + /// Takes the outstanding clamp so the caller can hand its hidden gas back to the interpreter, + /// returning that amount. + #[inline] + pub(crate) fn restore_hidden(&mut self) -> u64 { + self.clamp.take().map_or(0, |clamp| clamp.hidden) + } + + /// Whether a clamp is currently outstanding. + #[inline] + pub(crate) fn has_clamp(&self) -> bool { + self.clamp.is_some() + } + + /// Records the clamp in force for the segment that starts now. + #[inline] + pub(crate) fn set_clamp(&mut self, hidden: u64, binding: ClampBinding) { + self.clamp = Some(ClampState { hidden, binding }); + } + + /// Takes the outstanding clamp, if any. + #[inline] + pub(crate) fn take_clamp(&mut self) -> Option { + self.clamp.take() + } + + /// Whether a clamp-induced out-of-gas was latched under a detained TX-level constraint. + #[inline] + pub(crate) fn latched_detained(&self) -> bool { + self.latched_detained + } + + /// Records whether the just-latched clamp exceed was under a detained TX-level constraint. + #[inline] + pub(crate) fn set_latched_detained(&mut self, latched: bool) { + self.latched_detained = latched; + } + + /// Returns the unsettled segment usage and re-opens the window at `remaining`. + #[inline] + pub(crate) fn take_segment(&mut self, remaining: u64) -> u64 { + let gas_used = self.baseline.saturating_sub(remaining); + self.baseline = remaining; + gas_used + } +} diff --git a/crates/mega-evm/src/limit/limit.rs b/crates/mega-evm/src/limit/limit.rs index 5c17cd26..284cd154 100644 --- a/crates/mega-evm/src/limit/limit.rs +++ b/crates/mega-evm/src/limit/limit.rs @@ -13,7 +13,7 @@ use revm::{ }; use super::{ - compute_gas, data_size, frame_limit::TxRuntimeLimit, kv_update, state_growth, + checkpoint, compute_gas, data_size, frame_limit::TxRuntimeLimit, kv_update, state_growth, storage_call_stipend, }; use crate::{ @@ -108,55 +108,8 @@ pub struct AdditionalLimit { /// A tracker for the `STORAGE_CALL_STIPEND` granted to value-transferring calls (REX4+). pub(crate) storage_call_stipend: storage_call_stipend::StorageCallStipendTracker, - /// REX7+: whether compute gas settles at checkpoints rather than per opcode. - /// - /// When set, plain opcodes run unwrapped and record nothing; the interpreter's own gas - /// counter is read at each checkpoint and the whole segment since the previous one is - /// recorded in a single call. - checkpoint_accounting: bool, - - /// Interpreter gas remaining at the start of the current unsettled segment — the previous - /// checkpoint, or the frame entry / resume that opened the window. Only meaningful while a - /// frame is running and only when [`checkpoint_accounting`](Self::checkpoint_accounting) is - /// active. Re-synced at every [`before_frame_run`](Self::before_frame_run) (which covers both - /// frame entry and every resume after a child frame's outcome is merged back) and at every - /// checkpoint prologue and body recording. - checkpoint_baseline: u64, - - /// Gas-clamp enforcement (REX7+): the clamp in force for the plain-opcode segment the - /// current frame is inside, so that revm's own per-opcode gas checks enforce the compute - /// headroom at no per-opcode cost. - /// - /// Present only while the current frame is inside a plain segment: every checkpoint takes it - /// before running its body — so CALL forwarding, `GAS` and storage charges observe the true - /// counter — and re-applies it on the way out, and the frame's final result takes it via - /// [`settle_frame_final_result`](Self::settle_frame_final_result). - clamp: Option, - - /// Whether a clamp-induced out-of-gas was latched while gas detention was the binding TX-level - /// constraint. - /// - /// [`ComputeGasTracker::is_detained_exceed`] requires `used > detained_limit`, which a - /// clamp-stopped transaction never reaches — the crossing opcode is stopped before it - /// executes, so usage stays at or below the limit. The halt-reason attribution consults - /// this flag instead, keeping the reported reason `VolatileDataAccessOutOfGas` exactly as - /// per-opcode enforcement reports it. - clamp_latched_detained: bool, -} - -/// A gas clamp in force for one plain-opcode segment (REX7+). -/// -/// The clamp is a lifecycle, not an amount. It is recorded exactly while it **binds** — while the -/// interpreter's true remaining gas was at or above the compute headroom when the segment opened — -/// and a `hidden` of zero is a binding clamp whose two budgets happened to coincide, not the -/// absence of one. When the frame's own gas would run out ahead of the compute headroom no clamp -/// is recorded at all, and an out-of-gas inside that segment stays the EVM's own. -#[derive(Clone, Copy, Debug)] -struct ClampState { - /// Interpreter gas hidden from the interpreter for this segment. - hidden: u64, - /// The constraint the clamp was bound to, captured at the moment it was applied. - binding: compute_gas::ClampBinding, + /// A tracker for REX7+ checkpoint settlement and gas-clamp state. + pub(crate) checkpoint: checkpoint::CheckpointTracker, } /// The usage of the additional limits. @@ -184,10 +137,7 @@ impl AdditionalLimit { kv_update: kv_update::KVUpdateTracker::new(spec, limits.tx_kv_updates_limit), compute_gas: compute_gas::ComputeGasTracker::new(spec, limits.tx_compute_gas_limit), storage_call_stipend: storage_call_stipend::StorageCallStipendTracker::new(spec), - checkpoint_accounting: spec.is_enabled(MegaSpecId::REX7), - checkpoint_baseline: 0, - clamp: None, - clamp_latched_detained: false, + checkpoint: checkpoint::CheckpointTracker::new(spec), } } } @@ -229,15 +179,13 @@ impl AdditionalLimit { self.data_size.reset(); self.kv_update.reset(); self.storage_call_stipend.reset(); - self.checkpoint_baseline = 0; - self.clamp = None; - self.clamp_latched_detained = false; + self.checkpoint.reset(); } /// Whether compute gas settles at checkpoints (REX7+) rather than per opcode. #[inline] - pub(crate) fn checkpoint_accounting(&self) -> bool { - self.checkpoint_accounting + pub(crate) fn rex7_enabled(&self) -> bool { + self.checkpoint.rex7_enabled() } /// Interpreter gas remaining at the start of the current unsettled segment. @@ -247,7 +195,7 @@ impl AdditionalLimit { /// unwrapped plain opcode executed since the previous checkpoint. #[inline] pub(crate) fn checkpoint_baseline(&self) -> u64 { - self.checkpoint_baseline + self.checkpoint.baseline() } /// Re-opens the settlement window at `remaining`, without recording anything. @@ -256,7 +204,7 @@ impl AdditionalLimit { /// call this once it has recorded, so a later settlement cannot bill the segment twice. #[inline] pub(crate) fn sync_checkpoint_baseline(&mut self, remaining: u64) { - self.checkpoint_baseline = remaining; + self.checkpoint.sync_baseline(remaining); } /// Moves the open segment's baseline down by `amount` of `MegaETH` storage gas just charged to @@ -272,9 +220,7 @@ impl AdditionalLimit { /// No-op before REX7, where nothing measures against a baseline. #[inline] pub(crate) fn exclude_storage_gas_from_segment(&mut self, amount: u64) { - if self.checkpoint_accounting { - self.checkpoint_baseline = self.checkpoint_baseline.saturating_sub(amount); - } + self.checkpoint.exclude_storage_gas_from_segment(amount); } /// Takes the outstanding clamp so the caller can hand its hidden gas back to the interpreter, @@ -285,7 +231,7 @@ impl AdditionalLimit { /// segment. #[inline] pub(crate) fn checkpoint_restore_hidden(&mut self) -> u64 { - self.clamp.take().map_or(0, |clamp| clamp.hidden) + self.checkpoint.restore_hidden() } /// Applies the gas clamp for the segment that starts at `remaining`, and returns the amount @@ -302,7 +248,7 @@ impl AdditionalLimit { /// instead). #[inline] pub(crate) fn checkpoint_clamp_amount(&mut self, remaining: u64) -> u64 { - debug_assert!(self.clamp.is_none(), "clamp applied while a clamp is outstanding"); + debug_assert!(!self.checkpoint.has_clamp(), "clamp applied while a clamp is outstanding"); if !self.has_exceeded_limit.within_limit() { return 0; } @@ -310,7 +256,7 @@ impl AdditionalLimit { let Some(hidden) = remaining.checked_sub(binding.headroom) else { return 0; }; - self.clamp = Some(ClampState { hidden, binding }); + self.checkpoint.set_clamp(hidden, binding); hidden } @@ -337,8 +283,10 @@ impl AdditionalLimit { // Preserve the volatile-detention attribution: when the binding TX-level constraint at // clamp time was the detained limit, the halt must classify as `VolatileDataAccessOutOfGas` // exactly as per-opcode enforcement classifies it. - self.clamp_latched_detained = !binding.frame_local && - self.compute_gas.detained_limit() < self.compute_gas.base_tx_limit(); + self.checkpoint.set_latched_detained( + !binding.frame_local && + self.compute_gas.detained_limit() < self.compute_gas.base_tx_limit(), + ); } /// Finalises what the frame's own result decides about the clamp: restores any outstanding @@ -360,10 +308,10 @@ impl AdditionalLimit { /// here, and attributing the halt to the resource limit keeps the sender's remaining gas /// refundable. pub(crate) fn settle_frame_final_result(&mut self, result: &mut InterpreterResult) { - if !self.checkpoint_accounting { + if !self.checkpoint.rex7_enabled() { return; } - if let Some(clamp) = self.clamp.take() { + if let Some(clamp) = self.checkpoint.take_clamp() { result.gas.erase_cost(clamp.hidden); // `MemoryOOG` is the same gas shortage reported from the memory-expansion path; every // other result either is unrelated to gas or cannot arise from a plain opcode. @@ -527,9 +475,9 @@ impl AdditionalLimit { access_type: VolatileDataAccess, ) -> Option { // `is_detained_exceed` covers per-opcode enforcement, where usage crossed the detained - // limit. `clamp_latched_detained` covers gas-clamp enforcement, where the crossing opcode + // limit. `latched_detained` covers gas-clamp enforcement, where the crossing opcode // was stopped before executing and usage therefore stays at or below the limit. - (self.compute_gas.is_detained_exceed() || self.clamp_latched_detained).then(|| { + (self.compute_gas.is_detained_exceed() || self.checkpoint.latched_detained()).then(|| { MegaHaltReason::VolatileDataAccessOutOfGas { access_type, limit: self.compute_gas.detained_limit(), @@ -931,14 +879,14 @@ impl AdditionalLimit { // interpreter's counter in its real, post-merge state. No clamp can be outstanding // here: every suspension point (the CALL / CREATE checkpoint prologue) and every // frame end restores it first. - if self.checkpoint_accounting { - debug_assert!(self.clamp.is_none(), "frame resumed with a clamp outstanding"); + if self.checkpoint.rex7_enabled() { + debug_assert!(!self.checkpoint.has_clamp(), "frame resumed with a clamp outstanding"); let hide = self.checkpoint_clamp_amount(frame.interpreter.gas.remaining()); if hide > 0 { let clamped = frame.interpreter.gas.record_regular_cost(hide); debug_assert!(clamped, "clamp amount exceeds remaining gas"); } - self.checkpoint_baseline = frame.interpreter.gas.remaining(); + self.checkpoint.sync_baseline(frame.interpreter.gas.remaining()); } None } @@ -998,13 +946,12 @@ impl AdditionalLimit { // frame spend the same headroom a second time. What such a frame additionally destroys — // the budget it never gets to spend — is settled after action processing, outside // enforcement, by `settle_exceptional_halt_burn`. - if self.checkpoint_accounting { + if self.checkpoint.rex7_enabled() { if let InterpreterAction::Return(_) = action { let remaining = frame.interpreter.gas.remaining(); - let gas_used = self.checkpoint_baseline.saturating_sub(remaining); + let gas_used = self.checkpoint.take_segment(remaining); let _ = self.record_compute_gas_unguarded(gas_used); self.refresh_latched_compute_usage(); - self.checkpoint_baseline = remaining; } } @@ -1159,7 +1106,7 @@ impl AdditionalLimit { /// [`settle_frame_final_result`](Self::settle_frame_final_result) latches earlier in this /// frame exit. fn settle_exceptional_halt_burn(&mut self, result: &FrameResult) { - if !self.checkpoint_accounting || + if !self.checkpoint.rex7_enabled() || self.limit_exceeded() || result.instruction_result().is_ok_or_revert() { diff --git a/crates/mega-evm/src/limit/mod.rs b/crates/mega-evm/src/limit/mod.rs index a536565a..fe0a986e 100644 --- a/crates/mega-evm/src/limit/mod.rs +++ b/crates/mega-evm/src/limit/mod.rs @@ -1,6 +1,7 @@ use alloy_primitives::Bytes; use alloy_sol_types::SolError; +mod checkpoint; mod compute_gas; mod data_size; mod frame_limit; From b9323d51c31497a8b435f0e840de95ad45288a16 Mon Sep 17 00:00:00 2001 From: RealiCZ Date: Fri, 14 Aug 2026 02:04:58 +0800 Subject: [PATCH 43/43] refactor(evm): align the leftover checkpoint_accounting local with the rex7 naming --- crates/mega-evm/src/evm/instructions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/mega-evm/src/evm/instructions.rs b/crates/mega-evm/src/evm/instructions.rs index 7da79e15..b70ae4d3 100644 --- a/crates/mega-evm/src/evm/instructions.rs +++ b/crates/mega-evm/src/evm/instructions.rs @@ -989,7 +989,7 @@ macro_rules! record_storage_compute_gas { ($context:expr, $gas_before:expr, $storage_charged:expr, $opcode:expr) => {{ let spec = $context.host.spec_id(); let is_rex6 = spec.is_enabled(MegaSpecId::REX6); - let is_checkpoint_accounting = spec.is_enabled(MegaSpecId::REX7); + let is_rex7 = spec.is_enabled(MegaSpecId::REX7); let gas_after = $context.interpreter.gas.remaining(); // The per-opcode `$gas_before` window applies on every spec: under checkpoint accounting // the plain segment ahead of this opcode was already settled by @@ -1001,7 +1001,7 @@ macro_rules! record_storage_compute_gas { // before dispatch, or an outer volatile wrapper — does so ahead of the prologue, so under // checkpoint accounting it is already inside the settled segment and adding it back here // would bill it twice. - let mut gas_used = if is_checkpoint_accounting { + let mut gas_used = if is_rex7 { $gas_before.saturating_sub(gas_after).saturating_sub($storage_charged) } else { (const { static_gas($opcode) } + $gas_before.saturating_sub(gas_after)) @@ -1040,7 +1040,7 @@ macro_rules! record_storage_compute_gas { let mut additional_limit = $context.host.additional_limit().borrow_mut(); // Re-open the settlement window at this opcode's exit before recording, so neither a // halt here nor the frame-final settlement can bill this segment twice. - if is_checkpoint_accounting { + if is_rex7 { additional_limit.sync_checkpoint_baseline(gas_after); } if additional_limit.record_compute_gas(gas_used) {