Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
56cfdc5
bench: add cheap-opcode interpreter hotloop workload to transact
RealiCZ Aug 11, 2026
c520902
feat(rex7): settle compute gas at checkpoints
RealiCZ Aug 11, 2026
035426b
test(rex7): pin REX6/REX7 checkpoint settlement parity
RealiCZ Aug 11, 2026
9d7d2a1
feat(rex7): enforce compute limits with the V0 gas clamp
RealiCZ Aug 11, 2026
dc541a5
test(rex7): cover V0 gas-clamp enforcement
RealiCZ Aug 11, 2026
603af84
fix(rex7): apply the detention cap on a frame-local checkpoint exceed
RealiCZ Aug 11, 2026
fc53eed
docs(rex7): document checkpoint compute gas accounting
RealiCZ Aug 11, 2026
66dd956
test(rex7): cover interceptor and precompile resume settlement
RealiCZ Aug 11, 2026
40d7ce9
test(rex7): pin where a latched non-compute exceed surfaces
RealiCZ Aug 11, 2026
4f7e6dd
test(rex7): cover the three gas-leakage paths under an active clamp
RealiCZ Aug 11, 2026
4a20994
test(rex7): extend REX6/REX7 parity to system-path transaction shapes
RealiCZ Aug 11, 2026
dad83f7
test(rex7): sweep the double-exceed corner across the knife edge
RealiCZ Aug 11, 2026
789d67c
test(rex7): add a parity case for every checkpoint opcode
RealiCZ Aug 11, 2026
0fdb576
test(rex7): satisfy clippy doc-markdown in the new suites
RealiCZ Aug 11, 2026
e481864
test(rex7): describe the new suites in the module docs
RealiCZ Aug 11, 2026
efc378c
test(rex7): engage the clamp in the frame-local corner sweep
RealiCZ Aug 11, 2026
6648de8
docs(rex7): correct halt-field actual/limit contract and top-frame ti…
RealiCZ Aug 11, 2026
ab45e02
fix(rex7): bind the V0 gas clamp on an explicit lifecycle
RealiCZ Aug 11, 2026
4964765
fix(rex7): settle every exceptional halt's burned remainder as compute
RealiCZ Aug 11, 2026
1b3008e
fix(rex7): report the binding budget in a clamp-induced exceed
RealiCZ Aug 11, 2026
a3d41e1
fix(rex7): report the final compute usage in a clamp-induced halt
RealiCZ Aug 11, 2026
4a4f53c
test(rex7): compare state in the shared parity assertion
RealiCZ Aug 11, 2026
c3a8230
docs(rex7): align the carve-out and clamp rules with the fixed behavior
RealiCZ Aug 11, 2026
9188680
docs(rex7): correct the frame-exit hook docstrings after the split
RealiCZ Aug 11, 2026
3ba8273
docs: note the REX7 exceptional-halt burn lane in AGENTS.md
RealiCZ Aug 11, 2026
d7ba3cc
test(rex7): explain the callee loop size in the payload case
RealiCZ Aug 11, 2026
ae0ad80
fix(rex7): enforce the work an exceptionally halted frame performed
RealiCZ Aug 12, 2026
113fd7b
fix(rex7): keep an aborted checkpoint's storage charge out of compute
RealiCZ Aug 12, 2026
9c6b609
fix(rex7): keep the sandbox's compute-gas split across the merge
RealiCZ Aug 12, 2026
201fa4e
docs(rex7): state what a clamp-induced exceed's actual can exceed
RealiCZ Aug 12, 2026
db8a414
test(rex7): pin which half of an exceptional frame enforces
RealiCZ Aug 12, 2026
b16e94a
docs(rex7): specify the executed / destroyed split of an exceptional …
RealiCZ Aug 12, 2026
476c760
fix(rex7): stop enforcing destroyed compute gas at the block limit
RealiCZ Aug 12, 2026
432c4ce
test(rex7): admit a cheap transaction behind a destroyed remainder
RealiCZ Aug 12, 2026
bcff49c
docs: split block compute accounting into reported and enforced
RealiCZ Aug 12, 2026
cbf4f4d
docs: say which compute counter a block ComputeGasLimit rejection rep…
RealiCZ Aug 12, 2026
54d95b4
docs(rex7): qualify precision invariant to exclude exceptional-halt f…
RealiCZ Aug 12, 2026
6881366
Merge remote-tracking branch 'origin/cz/chore/upgrade-revm-40' into c…
RealiCZ Aug 12, 2026
0047ff0
Merge remote-tracking branch 'origin/cz/chore/upgrade-revm-40' into c…
RealiCZ Aug 13, 2026
d35d293
perf(evm): monomorphize checkpoint gating off the frozen-spec hot path
RealiCZ Aug 13, 2026
12380c8
fix(evm): rebuild spec-latched limit state on cfg spec migration
RealiCZ Aug 13, 2026
fc0fab9
docs(evm): replace the V0 design codename with the spec term gas clamp
RealiCZ Aug 13, 2026
f849383
refactor(evm): restore runtime checkpoint gating, matching upstream r…
RealiCZ Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ 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 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).
Expand Down Expand Up @@ -222,7 +226,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.
Expand Down
48 changes: 47 additions & 1 deletion crates/mega-evm/benches/transact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <iterations>
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);
7 changes: 7 additions & 0 deletions crates/mega-evm/src/block/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ where
data_size,
kv_updates,
compute_gas_used,
compute_gas_destroyed,
state_growth_used,
},
} = result;
Expand All @@ -616,13 +617,19 @@ 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,
da_size,
data_size,
kv_updates,
compute_gas_used,
compute_gas_destroyed,
state_growth_used,
depositor.is_some(),
);
Expand Down
127 changes: 119 additions & 8 deletions crates/mega-evm/src/block/limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
}
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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,
}),
}));
Expand Down Expand Up @@ -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,
Expand All @@ -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,
) {
Expand All @@ -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.
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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(
Expand All @@ -1023,6 +1061,7 @@ mod tests {
u64::MAX,
u64::MAX,
u64::MAX,
0,
u64::MAX,
false,
);
Expand All @@ -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);
}

Expand All @@ -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}"
);
}
}
8 changes: 7 additions & 1 deletion crates/mega-evm/src/block/result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -280,6 +284,7 @@ mod tests {
data_size: 1,
kv_updates: 2,
compute_gas_used: 3,
compute_gas_destroyed: 1,
state_growth_used: 4,
};

Expand All @@ -301,6 +306,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]
Expand Down
Loading
Loading