Skip to content

feat!: the Satin history gas, resource limits, gas detention and the execution-spec gate - #387

Draft
RealiCZ wants to merge 187 commits into
cz/feat/satin-w2from
cz/feat/satin-w3
Draft

RealiCZ wants to merge 187 commits into
cz/feat/satin-w2from
cz/feat/satin-w3

Conversation

@RealiCZ

@RealiCZ RealiCZ commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Wave 3 of the Satin engine, on top of the wave-2 pull request (base cz/feat/satin-w2). It adds eight mechanisms and the execution-spec gate. Each was developed on a side branch and merged with --no-ff, so the first-parent chain has one merge per mechanism.

  • History gas. A transaction now pays for the bytes it appends to the chain: its body, calldata, logs and deployed code, plus one 40-byte record for each account or storage write it keeps. The price is a provisional 88 gas per byte, paid from the EIP-8037 pools, reservoir first. An empty call pays 27,280 more, and the EIP-7623 calldata floor no longer binds. Deposits, the protocol's own transactions and system calls pay nothing.
  • System contract deployment. Every block deploys the six MegaETH system contracts and the EIP-7997 CREATE2 factory, idempotently, and hands each pre-block state to an optional observer before it is committed. validate_schedule rejects a chain that schedules Satin without a SequencerRegistryConfig.
  • Block gas accounting. A block reports its execution, state and history gas, with the history bytes beside them. A node can cap a block's state gas. The compute figure every limit reads is the gas revm settles.
  • Data-size limit. A block executor holds each transaction to 12.5 MiB of data size by default, the block to 12.5 MiB, and each child frame to 98% of what its parent has left. A frame over its budget reverts alone; a transaction over its limit is stopped with MegaLimitExceeded.
  • Execution-spec gate. Ethereum's state-test fixtures run through MegaEvm itself. Equivalence mode gates Satin's machinery on the Osaka and Amsterdam fixtures, and every failure must match an entry a registered deviation lists. Satin mode reports results and never fails the job.
  • State-gas and KV limits. A state-gas limit holds a transaction's state growth, net of refills and failed frames. A KV limit holds its write records per transaction, per frame and per block. All are unlimited unless a node sets them. System-originated transactions and system calls are held to no per-transaction limit.
  • EIP-7708 transfer logs and SLOTNUM. Every value movement emits a Transfer log into the receipt. The log counts as 160 bytes of data size and nothing else. SLOTNUM pushes the block's slot number.
  • Gas detention. Once a transaction reads volatile data (the block environment, the beneficiary's account or the Oracle's storage), it may spend at most 20,000,000 more regular gas by default. A charge past that stops it with MegaLimitExceeded(2, limit) and returns its unspent gas. Until then it runs exactly as it would without the read.
  • External environments and control contracts. The Oracle's storage is read through the node's oracle service, and priced and witnessed the same whichever source answers. MegaAccessControl switches volatile-data access off for a subtree of frames. MegaLimitControl.remainingComputeGas() answers the caller's spendable regular gas.

Dependency. The twelve [patch.crates-io] revm entries move from megaeth-labs/revm tag v40.0.3-mega.2 to v40.0.3-mega.3. That release adds withheld regular gas to the gas tracker (megaeth-labs/revm#68), which gas detention is built on. cargo tree -i revm shows one revm.

Deposits. No block cap refuses a deposit: not execution gas, state gas, data size or KV. Every deposit still counts towards all four.

Breaking. EvmTxRuntimeLimits and BlockLimits gain public fields, MegaGasUsage and BlockGasCounters gain history_bytes, and MegaBlockLimitExceededError gains StateGasLimit and KVUpdateLimit. A kind-3 stop's limit is now in gas. Receipts of value-moving transactions carry transfer logs.

Tests. The mechanisms port 342 parked legacy tests and retire 13, so _pending/ drops from 524 to 169.


History gas

State gas prices the bytes a transaction adds to the world state; history gas prices the bytes it appends to the chain (evm/history.rs). Both draw on the EIP-8037 pools, reservoir first, and unwind on the same paths; they are counted apart so a node can report and limit them separately.

Byte table (limit/mod.rs) Bytes
transaction body 310 = 110 envelope + 5 × 40, the writes every transaction makes: its sender and the four fee accounts
calldata, deployed code 1 per byte
access-list address, storage key 20, 32
EIP-7702 authorization 101
log 32 + 32 per topic + its data
account or storage write record 40

These are the counts the data-size limit meters, never scaled by SALT, because history bytes live in no bucket. Data size and history part at three sites, each by decision: an Oracle hint's payload and an EIP-7708 transfer log are data size and never history, and the body's five records are an upper bound, so a transfer to the beneficiary or a fee vault pays for a record the body already bound.

  • Where it is charged. The body goes in the EIP-8037 intrinsic state-gas slot, which the reservoir pays first, so a large body does not take the 200M execution cap away from computation. The settled result takes it back out of the state gas it reports. A gas limit that cannot cover the body is rejected at validation.
  • The applied authorities' records and the first frame's record are charged in pre-execution. Everything else is charged where the record is made. The SSTORE, LOG0..LOG4 and SELFDESTRUCT wrappers charge what the Host staged. CALL, CALLCODE, CREATE and CREATE2 charge their caller for the records of the frame they start, out of what the caller kept after forwarding. Deployed code is the schedule's code_deposit_history_gas, which revm charges in return_create.
  • Whatever a frame does not keep comes back, at the same price, to whoever paid it. A creation's nonce record survives its failure, and so does its charge.
  • Who pays none. A deposit, a system-originated transaction and a system call pay no history (MegaContext::prices_history). An exempt transaction runs a second prebuilt schedule whose code_deposit_history_gas is zero. The exemption covers history alone: a deposit still prices its state gas by its SALT bucket.
  • The allowance. A value-transferring CALL or CALLCODE below the transaction's own frame grants the frame it starts 160 history bytes, one three-topic event carrying a word. This lets a receive() hook reached through Solidity's transfer() still emit an event. The allowance never enters the frame's Gas, only a log's charge may draw on it, and what it pays for appears on no ledger.
  • Numbers. In tests/satin/pricing-table.md, an empty call pays 27,280 of history, SSTORE 0 -> 1 adds a record's 3,520, and a LOG1 over 32 bytes adds 8,448. The EIP-7623 floor is still computed and validated but binds nothing, because history charges the same bytes at 88 where the floor charges 64. tests/satin/equivalence.rs holds every baseline case to op-revm's total plus the history ledger.

Rules that changed in review.

  • revm's instruction copies the caller's reservoir into the child's input before the frame-start wrapper charges, so the wrapper writes the post-charge reservoir back. Without that write, a nested value call above the cap appended its two records, 7,040 gas, for free.
  • The interpreter halts on an instruction's error only when no action is pending, so a caller that cannot pay drops the pending frame before it fails.
  • tests/satin/history_reservoir.rs makes the above-cap regime a standing rule: every ledger invariant has a case above the cap, held to reservoir_remaining == gas_limit − cap − state − history.

Tests. The suites are history_gas.rs, history_exemption.rs, history_reservoir.rs, storage_call_stipend.rs, intrinsic.rs and write_records.rs, plus the regenerated pricing table. The transact bench gains a calldata arm. 28 parked rows are ported.

Open.

  • The allowance cannot pay for deployed code, whose charge is inside return_create.
  • The allowance is a discount. A contract calling itself with value gets 160 free bytes a turn, for about 10,000 gas a turn. At the execution cap that is some three megabytes of log bytes, close to 280,000,000 of history gas, that no gas ledger carries. The byte column reports it (test_the_gap_the_allowance_opens_between_bytes_and_gas_is_pinned).

System contract deployment

system/deploy.rs is the single spec list the executor iterates. transact_deploy(db, &spec) returns an EvmState and commits nothing. Every block deploys, in order: Oracle v2.0.0, High-Precision Timestamp, KeylessDeploy, MegaAccessControl, MegaLimitControl and SequencerRegistry v2.0.0 (0x6342…0001 to …0006), then the EIP-7997 factory at 0x4e59b44847b379578588920cA78FbF26c0B4956C. There is one version of each, with no upgrade path and no per-fork gate.

At the address Outcome
matching code a read-only entry: nothing touched, re-seeded or re-nonced
matching code at nonce 0, on the factory ZeroFactoryNonce: EIP-7997 needs a nonzero nonce, and this path never rewrites one
absent, or empty code at nonce 0 created with its bytecode, nonce 1 and its seeded slots; a prefunded balance is kept
empty code at a nonzero nonce UsedEmptyAccount
different non-empty code ForeignCode
  • The observer. alloy-evm 0.36 has no state hook on BlockExecutor, so the executor carries its own: PreBlockStateObserver, with PreBlockStateSource (Eip2935, Eip4788, SystemContract(address)), installed with set_pre_block_observer.
  • Every pre-block state reaches the observer before it is committed, nine per block, in execution order. That sequence is the witness a stateless client needs. It cannot be rebuilt from the database afterwards: revm drops untouched accounts from the committed transition, and those read-only entries are what a steady-state block consists of.
  • The registry. SequencerRegistryConfig is the Satin HardforkParams type. It carries the three roles, initial_from_block and min_rotation_delay, seeded into slots 0–2, 4–6 and 13.
  • validate() rejects a zero address and a zero delay, and validate_schedule requires the config when Satin is scheduled. Slot 13 is seeded because the contract has no setter.
  • The unknown-chain fallback attaches placeholder roles (MEGA_SYSTEM_ADDRESS, from-block 0, a 10-block delay), and it is the only path that hands them out.

Rules that changed in review.

  • Pre-block states now reach the observer before the commit. The loop used to commit them unobservably.
  • Matching factory code at nonce 0 is refused.
  • An empty-code account with a used nonce is refused, instead of recreated with its storage dropped.
  • The minimum rotation delay is seeded.

Tests.

  • tests/system/deploy.rs covers the spec list, the five outcomes and both validation rules.
  • tests/block/deploy.rs reads the executor's own observer on a first and a second block: order, sources, hashes, seeds and zero gas. It also covers foreign code, missing params and the CREATE2 round trip.
  • 29 parked rows are ported. 11 are retired: a single-spec engine has no upgrade path or per-fork gate to pin.

Open.

  • No caller in this crate runs validate_schedule. The node's chain loader must, and a production chain must never reach the unknown-chain fallback.
  • Checking a live registry's roles at activation is outside this engine, because matching code deliberately preserves storage.
  • The rotation read and applyPendingChanges belong to the pre-block system calls.

Block gas accounting

  • The ledgers. MegaGasUsage splits a transaction's raw spend into regular, state and history ledgers. block_execution_gas() is max(total − state − history, floor): history comes out before the floor applies, whereas the fork's block_regular_gas_used keeps it in.
  • The regular ledger is read off the Gas revm settles, and nothing counts compute beside it. Gas an inspector edits therefore moves the receipt, the ledger and the block figure together.
  • History bytes. MegaGasUsage::history_bytes and BlockGasCounters::history_bytes count the body, one record per kept write, the kept logs other than transfer logs, and the deposited code. That is the data size the transaction kept, less its Oracle hints' payloads and its transfer logs.
  • So history_bytes × cost per history byte = history gas + what allowances paid. An exempt transaction reports neither. The column is the history the schedule prices, not the chain's physical growth.
  • The state-gas limit. BlockLimits::block_state_gas_limit, held by BlockLimiter (block/limit.rs), is unlimited by default. Only execution shows whether a transaction adds state gas, so a block that has reached its limit refuses, after execution, a transaction that ends with state gas (MegaBlockLimitExceededError::StateGasLimit). A transaction that ends with none still fits, even one whose state gas returned to zero.
  • Two commit paths. alloy-evm's commit_transaction cannot fail. A builder choosing among executed candidates commits through commit_transaction_outcome, which checks the counters again. Both share one check, which the trait's commit asserts in debug builds. Neither checks the state a candidate executed against; re-executing a stale candidate is the builder's job.

Rules that changed in review.

  • The execution-gas and state-gas limits could refuse a deposit; neither does now.
  • The trait's commit skipped the check the checked commit makes.

Tests.

  • tests/satin/history_bytes.rs covers the sites, the edge paths and the allowance gaps.
  • tests/satin/compute_gas.rs covers a floor-bound transaction, and an inspector charging 126 gas or handing 86 back, which moves all three figures alike. Every outcome is held to the ledger identities, and above the cap to the reservoir identity.
  • tests/block/counters.rs and tests/block/deposits.rs cover the block side.
  • 24 parked compute-gas rows are ported. 2 are retired, because Satin cannot configure a compute limit of zero or one.

Open.

  • The block state-gas limit has no number yet. 1,000 slots' worth would be 97,920,000 at the provisional price.
  • The header's gas used stays the sum of the receipts.

The data-size limit

  • The limit (AdditionalLimit, per-frame lanes in limit/frame_limit.rs). A transaction is held to EvmTxRuntimeLimits::tx_data_size_limit. Its own frame gets what the body leaves, each child gets 98/100 of what its parent has left, and frame_data_size_limit caps every frame further.
  • A frame over its budget reverts alone with MegaLimitExceeded(0, budget). A transaction over its limit is latched: it reverts, returns its unspent gas and the reservoir, and sets limit_exceeded on the outcome.
  • Where bytes are counted. Bytes are counted where they are made:
    • the body (transaction_body_bytes, in on_new_tx), which is never taken back;
    • the authorities' records, before the first frame;
    • a frame start's records and transfer log, when the frame starts;
    • a storage write, a log or a SELFDESTRUCT, once the opcode completed;
    • an Oracle hint, before it is forwarded.
  • Deployed code is counted on the creation's own lane before return_create commits, and only code return_create would deposit. Code starting with 0xEF or over the size limit, or from a creation that cannot pay its deposit, fails the creation alone. A crossing turns the return into the stop, so no code is left behind.
  • Frame returns. Every frame return holds the caller to its limits before it runs on. A creation's nonce record, which outlives the creation on its creator's lane, therefore cannot leave the creator over budget.
  • Defaults. BlockLimits::default() holds each transaction to TX_DATA_LIMIT and the block to BLOCK_DATA_LIMIT, 13,107,200 bytes each. no_limits() clears both, and a bare MegaContext sets none. The block's cap is a packing budget: the transaction that crosses it is packed, the next one is refused, and a deposit never is.

Which limit binds first.

  • A record is checked before its history is charged. A record the limit rejects is not charged, and the stop is what its frame reports.
  • At a frame start the caller pays at its opcode, so there an out-of-gas comes first.
  • A body over the limit latches the transaction before pre-execution. No authorization is applied and no record charged, and the first frame's input charges nothing for its start. The first frame is then answered with the stop. Only the account a deposit-like transaction creates for its caller can still halt it.
  • tests/satin/data_size.rs derives the boundary for a top-level fresh SSTORE. The store is counted at 162,306 gas: 15,000 intrinsic, 27,280 body history, 6 for the pushes, 22,100 regular and 97,920 state. It keeps its record from 165,826.

Rules that changed in review.

  • The frame-return check above was added.
  • Only code revm deposits is counted. Before, 0xEF-prefixed or oversized output could stop a whole transaction for a creation that fails on its own.

Tests.

  • data_size.rs covers the share at depth, one byte over at every site, a nonce record at exactly the budget and one byte over, refused code, the body stop at the smallest accepted gas limit, and the boundary sweep.
  • data_size_counts.rs and tests/block/limits.rs cover the counts and the block cap.
  • transact/data_size_limit runs 200 fresh slots and 200 logs under a limit they exactly reach, one byte short of it, and on op-revm.
  • 47 parked rows are ported.

Open. A body over the limit is included as a revert rather than rejected at validation.

  • At production numbers this cannot happen. The intrinsic floor under the 200M cap bounds calldata and access lists to about 3.1 MB. Even the loose sum with the maximum number of authorizations, about 5.7 MB, is below 13,107,200.
  • Rejecting at validation is representable today, as InvalidTransaction::Str inside OpTxError, and would delete the pre-frame latch.

The execution-spec gate

crates/mega-state-test and crates/state-test rejoin the workspace and run Ethereum's state-test fixtures through MegaEvm. They use the fork's own fixture types (revm::statetest_types), so this runner and the fork's parse, skip and count the same population.

  • Equivalence mode is the gate. MegaContext::with_neutral_cfg, behind test-utils, takes every field as given and prices no history. test_utils::neutral_cfg(fork) builds the Osaka and Amsterdam configurations, and neutralize_evm installs Ethereum's precompiles and static prices.
  • The runner installs EvmTxRuntimeLimits::no_limits() itself, so no MegaETH limit applies: not data size, KV, state gas or detention. tests/satin/neutral.rs holds the neutral MegaEvm to op-revm and to revm's mainnet EVM.
  • The runner is stricter than the reference one:
    • an expected exception must be the one the fixture names, and must leave the pre-state;
    • a test name that appears twice fails its file;
    • a transaction the fixture types cannot build is skipped only when the fixture names why.
  • Satin routes the base fee to the Optimism vault. The runner takes it back out of the post-state only when the vault is absent from the pre-state and holds exactly basefee × gas used.
  • The Amsterdam fixtures label some floor shortfalls INTRINSIC_GAS_TOO_LOW, so for those fixtures a floor shortfall satisfies either name (59 tests).
  • Deviations live in crates/mega-state-test/src/deviations.rs, rendered to DEVIATIONS.md. Each lists every entry it explains, with the hashes Satin produces, and --expect-deviations requires each entry to fail exactly as listed. Plain revm reproduces all 39 listed hashes on Satin's base, and each entry passes on its own fork.
    • amsterdam-opcodes-on-osaka (2): Satin runs DUPN, SWAPN, EXCHANGE and SLOTNUM, which Osaka leaves undefined.
    • selfdestruct-burns-on-osaka (37): revm gates EIP-8246 on the Amsterdam spec id, and Satin runs on Karst.
  • CI. exec-spec-satin.yml reads the fork pin from Cargo.lock, and the fixture releases from the fork's scripts/run-tests.sh at that tag. It runs equivalence mode with pinned executed and skipped counts and --expect-deviations, and writes both modes to the step summary. The execution-spec gate on Satin job always runs. replay-bench stays disabled.
Fixtures, at c0882c78 Mode Executed Skipped Passed Deviated Unattributed / unreproduced
main v5.4.0, Osaka equivalence 19,465 52 19,463 2 of 2 0 / 0
devnet v8.1.0, Amsterdam equivalence 15,754 72 15,717 37 of 37 0 / 0
main v5.4.0, Osaka Satin 19,465 52 2,084 — 17,381 failed
devnet v8.1.0, Amsterdam Satin 15,754 72 1,875 — 13,879 failed

Every later mechanism left the equivalence summaries byte-identical. The transfer logs moved Satin-mode failures between kinds, because logs are compared before the state root, and changed no pass count.

Rules that changed in review.

  • A deviation lists its exact entries and hashes, where it used to pin a count. A count let one listed failure that passes hide an unlisted one that fails.
  • The naming rule is scoped to the Amsterdam fixtures.
  • The fixture cache is keyed on the archive URLs.
  • The gate reports in a job that always runs.

Tests. The runner's unit tests, tests/runner.rs, the CLI's tests/cli.rs and tests/satin/neutral.rs. Scenario::evm builds a scenario's EVM once, as a block does, and the corpus bench builds it in setup.

Open.

  • Making execution-spec gate on Satin a required check on satin is the repository owner's call.
  • The gate never exercises MegaETH's prices or limits. Satin mode runs history gas on Satin's schedule, but without SALT or limits, and it never fails.
  • In Satin mode, 65 Osaka entries whose limit Satin's pricing puts below the calldata floor count as wrong exceptions. Counting them as passes is a one-line change.
  • If Satin takes EIP-8246, the fork needs a switch, and the gate then flags exactly the 37 listed entries.
  • bench/replay/ and docs/mega-evme/commands/replay.md still describe the removed bench mode.

The state-gas and KV limits

Under EIP-8037 a transaction pays state gas for exactly the state it adds, so the legacy count of new accounts and slots retires and a state-gas limit holds state growth instead. The KV count stays, as the write-record count, with its own limit. EvmTxRuntimeLimits gains tx_state_gas_limit, tx_kv_update_limit and frame_kv_update_limit, and BlockLimits gains block_kv_update_limit. All are unlimited by default.

  • State gas (limit/state_gas.rs). The limit holds what the transaction holds, net: the state gas charged before its first frame, plus what every frame on the call stack holds, read off revm's per-frame counters. A write-back, a reverted child or a failed creation gives its room back.
  • The limit is per transaction, with no frame budget. A crossing anywhere stops the transaction with MegaLimitExceeded(3, limit), with the limit in gas. It is held after each charge, so a charge the frame cannot pay is an out-of-gas whatever the limit.
Site On a crossing
applied EIP-7702 authorities taken back with the gas they charged; the first frame is answered with the stop
EIP-2780's first-frame recipient or created account the first frame is answered with the stop
a fresh slot (SSTORE), a new beneficiary (SELFDESTRUCT) the frame stops at the opcode
the new account a CALL, CREATE or CREATE2 is charged for upfront held in MegaEvm::frame_init once revm has decided the frame: a frame revm refuses gives the charge back and is not held; a built frame returns the stop before its first instruction, and an answered one is rewritten to it
deployed code held just before return_create charges it, and only once return_create is sure to make that charge
  • Order. Where limits cross at one site, the state gas binds first, then data size, then records. At a frame start, records are held before revm builds the frame, so a frame they stop adds no account. The limit counts state at the price SALT sets, so a crowded bucket reaches it sooner.
  • KV. One record counts per kept account or storage write. Records are deduplicated per frame, taken back by a write-back or a failed frame, and the sender and fee accounts belong to the body, not to records.
  • The KV limit follows the data-size limit's rules in its own unit. The transaction limit latches. The first frame gets what the transaction has left, a child gets 98% of what its parent has left, and an optional cap applies to every frame. KV × 40 ≤ data size holds after every instruction, so at the production data-size caps a transaction or a block keeps at most 327,680 records, and a KV limit binds only below that. block_kv_update_limit is a packing budget.
  • The exemption. A system-originated transaction and a system call, including the EIP-2935 and EIP-4788 pre-block calls, run under the sticky LimitCheck::Exempt, stamped before the body is counted. Every stop comes from AdditionalLimit::crossed, which answers Exempt and never latches. Their usage is still counted and reported. The exempt set is the one history gas exempts, and a user's deposit is held to every limit.

Decisions.

  • Kind 3 stays StateGrowth, but its limit is now in gas. A node-side leaf-count override, and keyless deployment's ParentBudgetExceeded(3, …), must read it as gas.
  • A transaction that writes nothing counts 0 KV; the legacy engine counted 1.
  • The deployed-code pre-check (deposit_state_gas) retraces return_create up to the state charge, but not the history charge that follows it. This matches SSTORE and SELFDESTRUCT, where the state-gas limit binds before the record's history is charged.

Rules that changed in review.

  • Deployed code was held for charges return_create would never make, so a creation about to run out of gas on the hash or on its state gas could stop the whole transaction. deposit_state_gas now retraces return_create on a copy of the frame's gas.
  • The upfront new-account charge was held at the opcode, before revm could refuse the frame and refund it. It is now held in frame_init.
  • The protocol's own work was held to these limits. It is now exempt, as above.
  • 20 tests wrote figures at the default prices. They now read them off the engine and pass at CPSB 2000 / CPHB 100.

Tests.

  • state_gas_limit.rs holds every site at exactly its state gas and one gas short, below and above the cap. It also covers SALT at m = 2 and 3, the EIP-7702 matrix, and frames revm refuses and decides.
  • deposit_charge.rs runs a creation at the least gas that reaches return_create's state charge, and at one gas less.
  • kv_limit.rs, limit_exemption.rs, write_records.rs and tests/block/{limits,deposits,eips}.rs cover the rest, and transact/state_limits benches the limits.
  • 54 parked rows are ported, and 2 are re-owned to detention.

Open.

  • Numbers. Every new limit stays unlimited until the economics decide. 1,000 fresh slots is 97,920,000 of state gas. Satin prices deployed code as state, though, so that limit would cap a creation at 63,880 bytes of code, while MAX_CONTRACT_SIZE allows 512 KiB, whose deployment holds 802,344,240.
  • The first frame. EIP-2780's charge is still held before revm decides the first frame. A 1-wei transaction to the bn254 pairing precompile with invalid input halts using 50,000,000 gas without a limit, but stops using 48,384 under a limit of 183,599.
    • Moving the hold to after revm decides, as inner frames are held, is not enough alone. At depth 0 revm commits the checkpoint of a call it answers, so a value call to a fresh account would stop with its value already moved.
    • The fix needs a rollback boundary around the root call.

EIP-7708 transfer logs and SLOTNUM

  • The logs. The spec fixes enable_amsterdam_eip7708 = true and amsterdam_eip7708_disabled = false, so a caller's configuration cannot take the logs out of a block.
  • Every value movement emits Transfer(from, to, amount) from 0xff…fe: the transaction's value, a value CALL, a creation's endowment, and a SELFDESTRUCT's balance moved to another account. revm journals the log itself. It lands in the receipt and its bloom, in execution order among the contracts' own logs.
  • Nothing is logged where nothing moves to another account: a CALLCODE, a call to itself, a value sent to the sender, a deposit's mint (its value is logged), or a destruction to itself (this revm has no Burn log).
  • What a log counts. A transfer log is data size and nothing else: 160 bytes (TRANSFER_LOG_SIZE), by the rule for a LOG3 of one word. It adds no write record and no history gas, is not in history_bytes, and draws nothing from the history allowance.
  • Where it is counted. The log is counted on the lane of the frame whose journal checkpoint holds the move.
    • At a frame start, it is counted with the frame's records in AdditionalLimit::on_frame_init, before revm builds the frame. revm commits the checkpoint of a frame it answers without running, so a later count could not take the move back.
    • At a SELFDESTRUCT, it is counted with the beneficiary's record once the opcode completed. A crossing stops the frame, and its checkpoint takes both back.
  • Predicting refusals. Counting before revm decides means predicting its decision. caller_refuses_start (evm/execution.rs) reads the caller's account from the journal without loading anything.
    • A start revm refuses there counts nothing, is charged no history, and cannot be stopped by any limit. That covers a value the caller cannot fund, and a creation whose nonce cannot be bumped.
    • A creation onto an occupied address is the one refusal decided after the count, so its bytes can stop it.
    • A debug build asserts after every frame start that revm refused exactly the starts predicted and journaled exactly the log counted (assert_start_as_counted).
  • SLOTNUM pushes BlockEnv::slot_num, and zero when the block leaves it unset.

Rules that changed in review.

  • Starts revm refuses on the caller's account are now charged nothing and stopped by nothing. Before, they were counted, so a limit could stop a start that moves nothing.
  • A creation revm answers without bumping its creator's nonce keeps nothing its start counted.

Tests.

  • tests/satin/transfer_logs.rs covers eight sites below and above the cap. Each has exact bytes and receipt logs, and a frame budget and a transaction limit one byte short, each stopping where a LOG3 of one word stops. It also covers the non-movers, refused starts under both limits, a deposit and a system transaction.
  • tests/block/transfer_logs.rs checks receipts in execution order, with their blooms.
  • A unit test in evm/history.rs runs nine movement shapes with the switch on and off: the same gas on every ledger and the same state, with 160 bytes of data size per log.
  • transact/value_calls benches 200 value calls next to op-revm.

Open.

  • Logs a node-added precompile journals itself are not counted.
  • An indexer rebuilding balances from transfer logs must treat a destruction to itself as a burn.

Gas detention

MegaETH's parallel executor redoes a transaction whose reads another transaction's writes invalidate. Detention caps what a transaction may still compute once it has read data that changes with every block or transaction. It lives in access/.

Volatile data Read by Cap: EvmTxRuntimeLimits field, default
the block environment NUMBER, TIMESTAMP, COINBASE, PREVRANDAO, GASLIMIT, BASEFEE, BLOBBASEFEE, SLOTNUM, BLOCKHASH block_env_access_compute_gas_limit, 20,000,000
the beneficiary's account the account opcodes, the four calls and the EIP-7702 delegate they follow, SELFDESTRUCT at either end; a sender or recipient that is the beneficiary, a recipient that delegates to it, an applied authority that is it the same
the Oracle's storage SLOAD in the Oracle's own frame oracle_access_compute_gas_limit, 20,000,000
  • Scope. BLOBHASH is not volatile, since it reads the transaction's own blob hashes. System-originated transactions and system calls are not detained. A transaction whose two caps are both unlimited is not detained either, so no_limits() turns detention off along with every other per-transaction limit. EvmTxRuntimeLimits::default() and BlockLimits::default() detain.
  • Marks. The Host marks a read where it loads the value, and the opcode's wrapper commits the mark once the opcode completed. A read sets a limit: the transaction's compute at the read, plus the cap. The limit only goes down.
  • Compute is the regular gas spent, read off Gas: a frame's limit less what it has left, less any state and history gas that spilled onto regular gas. It is summed over the running frame and every suspended caller, each caller less its child's gas limit (which takes a value call's stipend off), less what halts burned.
  • A callee runs at most 2,300 gas on a stipend, and each value call costs its caller at least 9,100 of compute. The gas run after a read is therefore at most about 25% over the cap.
  • Enforcement uses the fork's withheld part. A frame's regular gas is a spendable part, the only one a regular charge draws, plus a withheld part. Every other reader sees the sum: GAS, the 63/64 forward, the SSTORE sentry, a child's return and the reimbursement.
  • Detention calls Gas::limit_spendable(limit − compute) at the read, at every frame start and resume (before_frame_run), and after SSTORE's refill. A detained transaction runs exactly as it would without the read until a regular charge needs the withheld part. Nothing withheld is ever moved or released.
  • Classification. A charge that needs the withheld part fails as it would with nothing withheld, and the fork records a WithheldCrossing.
    • Detention::on_frame_end runs in after_frame_run, after return_create and before the result reaches the caller or last_frame_result. It turns a frame carrying a crossing into the latch's revert, MegaLimitExceeded(2, limit), with the record's withheld part as its gas.
    • Compute then reaches the limit, the withheld part goes back to the sender, and the stop reports used == limit.
    • Every other out-of-gas halts and burns as it would without the read.
  • Precompiles. revm runs a precompile inside frame init, against the frame's gas limit. After a read, a precompile forwarded more than its frame's allowance runs on the allowance (hold_precompile) and gets the rest of the forward back (Detention::restore_forward). One priced past the allowance computes nothing and is the stop.
  • The residual: two kinds of precompile call are the stop where, without the read, they would be a failed call their caller survives. One is a precompile priced past its whole forward. The other is one priced between the allowance and its forward, whose input fails a check made after its gas check.
  • Interceptors. An interceptor's answer that spent more than the allowance is stopped the same way (settle_answer), and so is an interceptor's charge the allowance cannot pay (Detention::charge_crosses).
  • Burned gas. An out-of-gas zeroes a frame's gas, so the wrapper of the failing opcode first notes what the frame had (Detention::note_halt). KECCAK256, the four copies into memory and EXP are wrapped for that alone.
  • A failed static charge happens before any wrapper runs, so the frame's leftover counts as compute. That leftover is below the charge's price, at most 4,999 per halting frame, so the stop comes earlier, never later.
  • Refused reads. When volatile access is off (see the control contracts below), the Host refuses the load, and the wrapper reverts the frame with VolatileDataAccessDisabled(accessType), charging only the opcode's static gas. A refused BLOCKHASH names BlockHash (7).

Decisions.

  • The caps are runtime limits, and the defaults keep the legacy engine's values.
  • What a halt burns is not compute. If it were, a child that halts after a read would burn its whole forward into the caller's allowance. A caught failure, such as a CALL to an INVALID child, would then become a transaction stop.
  • A transaction sent straight to an EIP-7702 delegator of the beneficiary is detained from its first frame (c0882c78). revm resolves the first frame's delegate through the journal, not the Host, so before this only a contract's call to the delegator was marked. MegaContext::mark_beneficiary_delegate now reads the recipient's delegation from the journal, without loading, once revm has prepared the first frame.

Rules that changed in review.

  • Any out-of-gas with gas withheld used to be the stop. The classification now reads the fork's crossing record instead.
  • GAS and the 63/64 forward used to see only the allowance, and a callee inherited withheld gas as reservoir. The fork's withheld part fixes both, which is why the pin moved.
  • A precompile used to run against the whole forward; it now runs on the allowance.
  • EXP's exponent charge escaped the halt note.
  • An interceptor's charge on a frame that runs was not counted as compute.
  • A refused BLOCKHASH named the block number.

Tests.

  • tests/satin/withheld_gas.rs runs each case twice: with a timestamp read, and with a same-priced PUSH0. The two runs are equal on the result and every ledger unless the cap stops the first. Where it stops, the test asserts the bill: intrinsic gas plus compute up to the limit.
  • detention.rs and volatile_access.rs cover the rules and the refusals.
  • detained_transfers.rs covers transfer logs at detained frame starts, precompiles, unfunded calls and SELFDESTRUCT, at both tiers.
  • tests/block/detention.rs covers the block side.
  • transact/hashes_and_copies benches the new wrappers.
  • 83 parked rows are ported.

Open.

  • Closing the precompile residual needs the fork to expose a precompile's price.
  • A static-charge halt's leftover past the limit stays on the stop's regular ledger, beside the limit.

External environments and control contracts

  • The Oracle's storage. An SLOAD whose storage owner is the Oracle loads the slot through the journal, then asks OracleEnv::get_oracle_storage. The service's value wins, even over a value the Oracle's frame stored earlier. An answer of None leaves the loaded value, and EmptyExternalEnv always answers None.
  • A node that replays a block without the service must price and witness it as the building node did, so nothing may depend on which source answered. Every read is priced cold, and the slot is loaded on both paths. It is then warm for a later SSTORE, and it is in the transaction's state, which the witness is built from.
  • A frame whose regular gas, withheld part included, cannot pay the cold access asks nothing. A detained frame that holds that gas but may not spend it does ask, and is then stopped.
  • The read is volatile, under the Oracle's cap. A hint reaches the service synchronously, so a read after a hint finds the service already told.
  • MegaAccessControl steers detention's switch.
    • disableVolatileDataAccess() turns volatile access off for the caller's frame and every frame below it.
    • enableVolatileDataAccess() reverts with DisabledByParent() when called below the frame that switched access off.
    • isVolatileDataAccessDisabled() answers for the caller.
  • The caller is the frame one level above the frame the call would start. A transaction that calls the contract directly has no such frame, so disabling changes nothing. The switch clears when the disabling frame ends, however it ends, and at every new transaction.
  • The Oracle hint. A sendHint from a frame whose volatile access is off is dropped: it is neither forwarded nor counted, and the call runs the contract's bytecode. This is the legacy Rex6 rule, and here it is the only one. A value-bearing sendHint forwards nothing, under the interceptors' value policy.
  • remainingComputeGas() answers the lesser of two figures. The first is the caller's own regular gas, with the call's forward counted back. The second applies once a read set a limit: what detention's limit leaves the transaction. The result is the caller's spendable regular gas before the forward.
  • The figure counts regular gas only, so above the execution cap it is at most the cap's share. A value-called callee hears its stipend.
  • One property of the legacy figure carries over: forwarded gas is not counted. The legacy figure came from a separate compute ledger with 98/100 per-frame budgets, and could exceed the caller's gas.
  • SLOTNUM's refusal names access type 12 (SLOT_NUM_ACCESS_TYPE), which the deployed VolatileDataAccessType enum does not declare. The contracts are unchanged, so their code hashes stay. Solidity handlers must decode the argument as uint8, and decode_volatile_data_access_disabled is the Rust decoder.
  • The factory. tests/block/external_envs.rs runs a block through ExternalEnvFactory. SALT buckets at m = 1, 2 and 8 scale state gas and leave regular gas flat, and the factory is asked once per block.

Rules that changed in review.

  • The Oracle read used to leave the slot unloaded when the service answered, as the legacy engine did.
  • A later write then cost 2,100 more with a live service than on a replay without one, which any sender could reach through multiCall([getSlot(k), setSlot(k, v)]), and the slot never reached the witness.
  • The slot is now loaded on both paths.

Tests.

  • 77 legacy rows are ported and none retired, into tests/system/oracle_storage.rs, oracle.rs, access_control.rs and remaining_compute_gas.rs.
  • Three ports record a change: the two value-bearing hint rows are inverted; BLOBHASH is asserted not refused, and SLOTNUM is added; the nested remainingComputeGas answer is bounded by its 50M forward.
  • New tests show the same write cost, ledgers and state with and without a service answer, below and above the cap.
  • New tests hold the remainingComputeGas answer equal to the spendable gas the caller resumes with, in eight shapes at both tiers. A caller that spends the answer completes, and one that spends a little more is stopped.
  • The transact bench gains remaining_compute_gas and oracle_reads, with a /service arm.

Open.

  • docs/spec/system-contracts/mega-limit-control.md still states the legacy figure. The Satin spec pages need the new one.
  • Those pages must also decide whether to state that the service's value wins over the Oracle frame's own write.

How the merges were resolved

The first-parent chain has nine merges.

  • Taken whole (five). History gas (2dc1efea), block gas accounting (d6450e46), the state-gas and KV limits (949ce6ed), the transfer logs (40e9066f) and the environments (8982ef2f) each branched from the wave's head at the time. Each merge's tree is its branch's.
  • b0c0403a, deployment. It met history gas in three files, all additively: AGENTS.md, the crate README and tests/_pending/README.md, which was regenerated from both branches' inputs.
  • 4394a30f, data size into accounting. This merge resolved semantics, not just text:
    • Deployed code is counted once, where the data-size limit counts it, and that count feeds both the limit and the history bytes (AdditionalLimit::on_create_return).
    • One function, transaction_body_bytes, sizes the body for validation, the history bytes and the limit.
    • A frame's return merges its log and code bytes into the caller, then holds the caller to its budget.
    • An edge-path table whose limits were written before the body counted as data size now counts the body.
    • d6c7fab3 holds every block limit to a deposit, at execution and at both commits. efa56356 holds the history bytes to the kept data size at 23 sites.
  • 316524c7, the gate. It merged without conflict. 1dec2cd7 then states that equivalence mode takes MegaETH's limits out as well as its prices.
  • 3a9013b8, detention. It conflicted in five files.
    • In frame_init, the transfer logs' debug snapshot and hold_precompile are independent, and both are kept. The guard still reads revm's raw answer, before restore_forward and settle_answer rewrite it.
    • A bench address both branches took was moved.
    • 84880009 formats the merged imports; cargo fmt fails on the merge commit alone.
    • 7d0197a3 pins where the two mechanisms meet (detained_transfers.rs). 4ebed8a2, e6e19f27, d61b7542 and fde099ae state and test the precompile residual and the static-charge bound.

After the last merge, a design review compared an independent design of the whole wave with this implementation, and found the two agreeing on every load-bearing mechanism. Two commits follow it:

  • 9ee48c69 folds the execution-gas, data-size and KV deposit guards in BlockLimiter::pre_execution_check into one early return, without changing behaviour. The state-gas check after execution keeps its own guard.
  • c0882c78 detains a transaction sent straight to a delegator of the beneficiary.

The review's other follow-ups are listed below.

Open items

Numbers and specification

  • COST_PER_HISTORY_BYTE (88), COST_PER_STATE_BYTE (1,530) and the detention caps are provisional until the economics sign-off.
  • The state-gas, KV and block state-gas limits have no number yet. A state-gas limit must allow for deployed code: 97,920,000 caps code at 63,880 bytes, while a 512 KiB contract holds 802,344,240.
  • The history allowance is a discount that shows on the byte column. A block-level history cap would have to choose between holding the bytes and holding the gas.
  • Whether Satin takes EIP-8246.
  • The Satin pages of docs/spec/, which still describe the legacy engine, including remainingComputeGas() and the Oracle read.

Node integration

  • The chain loader must call validate_schedule, and a production chain must never reach the unknown-chain fallback.
  • Whether to make execution-spec gate on Satin a required check.
  • Whether the header's gas used changes from the sum of the receipts.

Behaviour

  • Hold the first frame's EIP-2780 charge once revm decides the first frame, inside a rollback boundary around the root call.
  • Reject a body over the data-size limit at validation instead of including a revert.
  • Count frame-start writes after revm decides, instead of predicting refusals. This deletes the prediction and its debug guard, and changes one corner: a value call revm answers, whose bytes cross the caller's budget, would revert the caller after the move instead of failing before it.
    • Decide it together with charging a creation's nonce record on the creator's lane at the opcode, a 40-byte corner.
    • Decide both before keyless deployment builds on the prediction.
  • In Satin mode, count the 65 Osaka floor-shortfall entries as passes, and pin a trend for the Satin-mode counts.
  • Count the logs a node-added precompile journals itself.

Structure (behaviour-preserving)

  • Per-frame instruction tables for the Oracle's frame and for the refusing subtree. This moves refusal out of the Host and leaves SLOAD unwrapped outside the Oracle.
  • One per-frame record: fold the state-gas stack and detention's per-frame state into the lane.
  • Charge the intrinsic history on the transaction's gas tracker, instead of moving it in and out of the intrinsic state-gas slot.
  • Inherit the reservoir at the pending frame action, in one place instead of four wrappers. This needs a point before an inspector's step_end, which would otherwise observe the pre-charge reservoir above the cap.

Fork

  • A crossing record that carries the regular gas left before the failed charge.
  • Precompile::required_gas(input), to close the precompile residual.
  • return_create split into plan and apply, or an admission hook, to remove the deployed-code retrace.

Tests still parked (169)

  • The pre-block system calls: 34.
  • Native keyless deployment: 75.
  • The common execution layer: 21.
  • Revert-class aborts: 17.
  • System contract deployment: 11.
  • Inspector support: 4.
  • Awaiting a decision on preload-warm cold charging or 98/100 forwarding: 7.
  • bench/replay/ and its CLI page still describe the removed bench mode.

Test plan

All results are on c0882c78 unless stated otherwise.

Check Result
cargo fmt --all --check, cargo clippy --workspace --lib --examples --tests --benches --all-features --locked, cargo sort --check …, the riscv no_std check, Prettier, git diff --check exit 0 each; clippy reports no warnings
cargo tree -i revm --locked one revm: v40.0.3 (…revm?tag=v40.0.3-mega.3#5ac45ab8)
cargo test --workspace --locked 1,072 passed, 0 failed, 1 ignored doctest: 284 unit + 90 block + 479 satin + 174 system in mega-evm; 23 + 12 in the runner; 4 CLI; 5 + 1 in system-contracts
cargo test -p mega-evm --features satin-price-override,test-utils, with prices unset and at CPSB 2000 / CPHB 100 1,029 passed in both runs (286 unit + 90 + 479 + 174), 2 ignored
cargo check --workspace --all-targets --locked on each of the 21 first-parent commits, each from a clean snapshot warning-free on all 21; cargo fmt --check fails on 3a9013b8 alone among the detention merge's commits (fixed by 84880009)
state-test --fork Osaka / --fork Amsterdam, equivalence, release build, with CI's pinned flags gate passed: 19,465 executed / 52 skipped / 2 deviated, listed; 15,754 / 72 / 37, listed; 0 unattributed, 0 unreproduced
state-test --mode satin report: 2,084 and 1,875 passed
cargo bench -p mega-evm --bench <b> -- --test every benchmark runs: transact 38, corpus 13, factory 2, block 3
diff mutation gate, whole wave b16d1b36..fde099ae PASS: 586 mutants, 487 caught, 99 unviable, 0 missed, 0 timed out
diff mutation gate, the environments (fde099ae..93da3fbe) PASS: 40 mutants, 28 caught, 12 unviable, 0 missed
diff mutation gate, 9ee48c69 and c0882c78 PASS: 8 mutants, 7 caught, 1 unviable, 0 missed
infra mutation gate PASS: 35 of 35 viable mutants caught; the three it reports missed are the suppressions already on the base. mutants/ and .cargo/ are unchanged since b16d1b36: no suppression was added
tests/_pending/README.md, regenerated from the ledger and checked against the tree up to date: 169 pending, from 524

The byte table gains the sizes of a transaction body, an EIP-7702
authorization and an access-list entry, next to the log and write-record
sizes the layer already counted, and the engine gains what a byte of
history costs: every count times the cost per history byte, read from the
same prices the state entries are built from.

The schedule gives code_deposit_history_gas the price of one byte, which
is the one history charge revm makes itself, so a deployment now pays for
the bytes every node has to carry: a thirty-two byte deployment draws
2,816 history gas beside its state gas.
A transaction's body is fixed before it runs — its envelope, the write
records its inclusion makes, its calldata, its authorizations and its
access list — so its history is part of what a gas limit has to cover for
the transaction to be valid: a limit that falls short is rejected before
inclusion rather than included as an out-of-gas that burns the whole
limit. The charge rides in the EIP-8037 intrinsic state-gas slot, which
is the pool it is paid from, reservoir first, so it does not take the
execution cap away from computation; post-execution takes it back out of
the state gas the result reports.

Three kinds of transaction pay none of it: a deposit, a transaction the
protocol itself produced and a system call. They run a schedule that
prices a deposited byte at zero, so the one history charge revm makes
itself is off them too. State gas is charged as usual.

The EIP-7623 calldata floor is still computed and validated and no longer
decides a bill: history charges the same bytes at a higher rate, so a
Satin transaction is above its own floor from the first byte on. The
equivalence baseline holds every transaction to op-revm's total plus the
history ledger, with the refund cap and the floor comparison following
the larger total as they already do for state gas.
The wrapper of SSTORE, LOG0..LOG4 and SELFDESTRUCT already commits what
the Host staged once the opcode completed; it now charges the running
frame the history the record costs, which is the record's own data size:
a log pays for its address, its topics and its data, a storage write and
a destructed account's beneficiary for the forty bytes of one write
record. A slot written back to its original value leaves no record and
takes its charge back at the same price, whichever frame made it.

A frame that fails pays for none of it, because the charge unwinds with
the frame the way its state gas does. A history charge the frame cannot
pay is an ordinary out-of-gas.
A declarative spec per contract and a helper that never commits: matching
code is a read-only witness entry, missing code is a create, foreign code
is an error. The spec list is the six MegaETH contracts then the EIP-7997
factory. SequencerRegistryConfig carries the seeded roles.
Satin cannot run without the roles the registry is seeded from. A schedule
that activates the fork without those params fails when it is loaded, and
the unknown-chain fallback attaches placeholder roles so a local chain
still starts.
Every block iterates the spec list and commits each witness in order,
after the EIP-2935 and EIP-4788 calls. Those two target their own
contracts and do not read MegaETH predeploys. A matching deploy is a
read-only entry; foreign code fails the block.
The first block installs all seven predeploys with the pinned hashes,
nonce 1 and the registry's seeded slots. A second block is seven
read-only entries. Foreign code fails the block, missing params fail
at load, and a factory call returns the CREATE2 address as 20 bytes.
The rows this mechanism owns move into tests/block/deploy.rs and
tests/system/deploy.rs. applyPendingChanges rows stay parked under the
pre-block system calls. The pending ledger drops from 524 to 478.
A steady-state arm runs apply_pre_execution_changes on a block whose
seven predeploys are already in state. A check before measurement
asserts the seven witness entries are read-only.
The six MegaETH contracts and the EIP-7997 factory deploy at the start
of every block. The pre-block state-change contract lives next to the
helper that implements it.
… makes

A value CALL or CALLCODE writes its sender's account and its recipient's;
a CREATE writes the creator's nonce and the created account. The caller
pays for all of them at its own opcode, after revm computed the gas it
forwards, so the frame's budget carries none of them and its allowance
stays free for what the recipient does. What the frame does not keep goes
back to the caller when it returns: a failing frame's records, and every
record of a frame answered without running at all — an interceptor, the
depth guard, the latch, an inspector — while a creation's nonce record
survives its creation's failure, as the nonce bump does.

The two records made outside any frame are charged where pre-execution
makes them: one per applied EIP-7702 authority, which outlives a first
frame that fails, and the one the transaction's own frame makes, which
does not and is given back by the settlement the way EIP-8037 gives back
the state gas of an account that frame would have created.

One answer decides both the records and the charge, so they cannot
disagree, and  holds fifteen transactions
to it: the history each pays beyond its body, its logs and its deployed
code is exactly the write-record count the layer counted.
A failed account load is SystemContractDeployError::Database, and that
inner error is what source() returns. Foreign code has no source.
…tory

EVM's own CALL_STIPEND buys the recipient of a transfer enough
computation to notice it. On a chain that prices the bytes a log appends
it buys no log at all, so a receive() hook that emits an event would be
unreachable through Solidity's transfer(). The allowance is that
stipend's counterpart on the history ledger: one three-topic event
carrying one word, at the cost per history byte, granted to the frame a
value-transferring CALL or CALLCODE starts below the transaction's own.

It is not gas in any sense. It never enters the frame's Gas, so no
settlement can hand it back; only the charge a log makes may draw on it,
so it cannot buy computation or a write record; and it lives on the
frame's lane, so a frame answered without running neither takes one nor
leaves one behind. What it pays for is on no ledger, because no pool of
the transaction's gas paid it.

The tests pin the boundary it exists for — a transfer() reaching a hook
that emits one event, and no more than one — and the three ways it could
escape a frame: an interceptor answering in the frame's place, a
transaction-level stop unwinding it, and the frame returning to its
caller.
The transact benchmark gains a calldata arm — a call carrying four
kibibytes, which is four kibibytes of its body's history — and every
workload is now checked against what it must draw before it is measured:
each pays its body, the logging one pays for the bytes its logs append
and the calldata one for the bytes it carries. A workload that stopped
drawing what it is there to measure would otherwise still benchmark, and
measure the wrong thing.
The stipend rows of the legacy engine's rex4 and rex5 suites, the fee
recipients' accounting of its rex6 suite and the exemption rows of the
limit tracker's own tests — twenty-eight in all — run in the Satin
targets now.

The stipend rows become the allowance's, one per rule: which scheme is
granted one, what it may and may not pay for, and that neither an
interceptor's answer, a frame budget's revert nor a frame's return hands
one back. The scheme table is a unit test, because a static frame may not
log at all and so has nothing an allowance could buy it.

The fee recipients' rows become the transaction body's: the accounts a
transaction's fees are credited to are four of the five write records the
body carries, so nothing reads them, nothing counts them, and a
beneficiary that is one of the fee vaults changes nothing. The exemption
rows become the history exemption's, which is the one metering a deposit
or the protocol's own transaction is excused from.
The module table, the engine's own description and the contracts of the
common execution layer now carry the third ledger: the byte table every
site is sized by, where each charge is made and given back, the three
kinds of transaction that pay none of it, and the allowance a
value-transferring call grants.

The legacy engine's storage-gas stipend gets a line of its own in the
comparison, because the allowance that replaces it is a different thing:
it never enters a frame's gas limit, so there is nothing to burn on
return.
Three gaps in what the tests reach, and five places where the code said
the same thing twice.

The exemption tests deployed nothing, so the schedule an exempt
transaction runs was never observed: the program they share now deploys
thirty-two bytes, and a test holds the difference a user transaction pays
for them against the nothing a deposit pays. A frame answered without
running was only ever observed failing, where the charge comes back
either way; an inspector answering a value call with a success is the one
path where the records were never made and the result still succeeds, and
a test pins that its caller pays for none of them. And a transaction
whose target is an applied authority was counted but not priced, so the
history it pays is now held against a transfer to an account that is not
one.

The five guards on a zero amount are gone. Every one of them stood in
front of an operation that is a no-op at zero - refilling no history,
charging no history, recording no records, staging no charge - so each
only restated what the operation already does, and nothing could tell the
two apart.
Give MegaBlockExecutor an optional observer and a source enum so each
pre-block state — the EIP-2935 call, the EIP-4788 call, and every
system-contract deploy — is visible before it is committed. The
sequence is the witness a stateless client needs.
Rotation, resolution and admin-handoff scenarios belong to the
pre-block system calls. Upgrade and per-fork-gate rows are retired
because a single-spec engine has neither. The minimum-delay seed stays
with this mechanism until it is ported.
EIP-7997 requires the CREATE2 factory to hold its runtime and a nonzero
nonce. Matching code at nonce 0 is now an error of its own; a nonce
greater than one is kept. The six MegaETH contracts are not EIP-7997
and still accept matching code regardless of nonce.
An address with no code but a nonzero nonce is a used account, not a
prefunded EOA. The helper now errors instead of resetting the nonce
and dropping storage under a created mark. Prefunding with balance
alone remains the bootstrap path, which still marks created and
clears storage.
Fold min_rotation_delay into SequencerRegistryConfig, reject zero at
validate, and seed slot 13. A zero delay disables the reaction window
the field exists to guarantee, and the contract has no setter, so a
fresh registry cannot be repaired later through this helper. The
placeholder uses a ten-block delay.
hardfork_schedule returns SequencerRegistryConfig::placeholder only
for a chain ID it does not know. A known chain ID resolves to its own
table and never carries that config.
revm's CALL, CALLCODE, CREATE and CREATE2 copy the caller's reservoir into
the frame's input, and they do it before the wrapper charges the history of
the write records the frame's start makes. A returning frame's reservoir is
adopted by its caller rather than merged into it, so the frame handed the
charge straight back; a frame answered without running handed it back a
second time, because its gas is built from the same field and its empty lane
refills the whole charge. Above the execution cap, where the reservoir is
what pays, a value call and a nested creation appended their write records
for free and the interception, depth-guard and latch paths minted gas.

The wrapper now writes the post-charge reservoir into the frame's input.

Nothing on this branch ran above the cap with a nested frame, which is where
this hid: tests/satin/history_reservoir.rs is that regime, and its module
documentation states the rule for the mechanisms that follow. The equivalence
baseline gains a nested value call with a reservoir, which would have caught
this the day it was written.
CALL, CALLCODE, CREATE and CREATE2 set the frame's input as the interpreter's
action inside revm's instruction, and an interpreter halts on an instruction's
error only when no action is pending. So the out-of-gas the frame-start charge
returned when the caller could not pay it was swallowed: the pending frame was
returned anyway, it started, its lane was pushed and its write records were
counted, and nothing had been charged for them.

A caller keeps a sixty-fourth of what it holds when it forwards gas, so any
caller under roughly 450,000 gas kept less than the two records a value call
or a nested creation makes cost, and appended them for free. The wrapper now
takes the pending action before it returns, the way the limit stop in the same
file already does.
…them

The history of the write records a frame's start makes is charged at the
opcode, on the input revm's instruction built; the records themselves are
counted when the lane is pushed, on the input that survived interception and
the keyless rewrite. Those are two answers to the same question, and the
comment that said they could not disagree was a claim about today's rewrite
being the identity rather than a property of the code.

The charge now carries the answer it was computed from, and the count checks
its own against it. A rewrite that turns a call into a creation changes which
records a frame's start makes; until the mechanism that makes such a rewrite
reconciles the charge with them, the divergence trips in every debug build
instead of mis-charging the caller and mis-splitting the refund its failure
gets back.
…parts

A record's history bytes are its own data size, so the two counts cannot drift
apart at a record. Per transaction they are not the same number, and the byte
table read as though they were. Two sites part them, both by decision: an
Oracle hint's payload is data size the transaction counts and history it does
not pay, because the bytes go to the node's oracle service rather than into a
block; and the five write records a transaction's body carries are an upper
bound, so a transfer whose recipient is the block beneficiary or a fee vault
pays a record the body already bound — an over-charge, never an under-charge.

The paired corpus gains one case for each, with the number it is expected to
pay written out, so the divergence reads as intended rather than as a defect.
The allowance is drawn before the frame's own gas, so it is a discount on the
first 160 bytes of every value-transferring call rather than a fallback for a
frame that cannot pay them. A contract calling itself records its own account
once and nothing after that, so the grant repeats for as long as gas lasts:
about 10,000 gas a turn, some twenty thousand turns inside one transaction at
the execution cap, around three megabytes of log bytes on no gas ledger.

The bytes stay on the data-size lane, so the limits that meter bytes see all
of them; what falls behind is the history gas column. Whether the allowance
becomes a fallback, or a block's history column becomes a byte count beside
its gas figure, belongs with the block-level accounting. The test records the
size of the gap so it is a number on the table rather than a rediscovery.
…ce answers

A read the oracle service answered left the slot unloaded, while the
database fallback loaded it warm: a later SSTORE to the slot cost the
cold access only when the service answered, and the slot never reached
the transaction's state a stateless witness is built from. The read now
loads the slot through the journal on both paths and returns the
service's value when it has one, still priced cold.
…swers

A node that replays a block without the oracle service must price and
witness it as the node that built it did. State that rule where the
Oracle's storage read is described, and that the service's value is
returned even over a value the Oracle's frame stored earlier.
…till asks

The skipped cold load is decided on the frame's regular gas, the part
gas detention withholds included, so a detained frame that holds the
gas but may not spend it reads and asks the oracle service, and the
read's charge then stops the transaction at the compute limit. Say so
where the docs said such a frame asks nothing.
… under detention

A read after the Oracle's frame wrote the slot answers the service's
value and is priced cold; every read of a nested Oracle frame is cold
and asks; a detained frame that holds the cold access but may not spend
it asks and is stopped at the compute limit.
Detention takes a value call's whole gas limit, stipend included, off
its caller's compute, so the allowance carries the stipend as the
callee's own gas does: the callee hears it counted whether or not it is
detained.
Only one property carries over from the legacy engine: forwarded gas is
not counted. The legacy figure came from a separate compute ledger and
could exceed the caller's gas; this one is the caller's spendable
regular gas before the forward, which detention caps. A transaction
whose sender is the block beneficiary is detained from its start, so a
direct call hears the cap.
…ansactions

A child that switched volatile-data access off and then halted, on an
invalid opcode or out of gas, leaves its sibling free to read; a switch
set in one transaction does not carry into the next on the same EVM.
The answer equals the spendable regular gas the caller resumes with,
undetained, detained by its own read, by a child's read, as a
value-called callee, as a beneficiary sender and after an Oracle read,
below and above the execution cap; a caller that acts on it can spend
it and is stopped at the compute limit past it.
The callee hears the stipend more than the same callee a call without
value started, and the same answer whether or not the transaction is
detained.
The execution-gas, data-size and KV budgets each carried their own
deposit guard. They are checked together at the end of the pre-execution
check, so one early return for a deposit covers all three.
…neficiary

A contract's call to an EIP-7702 delegator of the block beneficiary loads
the delegate through the Host, which marks the read. The transaction's
own frame resolves its delegate through the journal instead, so a
transaction sent straight to such a delegator ran the beneficiary's code
undetained. Once revm has prepared the first frame, the recipient's
delegation is read from the journal as it stands and marked the same way.
@RealiCZ RealiCZ added spec:new Introduces a new MegaSpecId variant api:breaking Crate interface change — downstream users must update comp:core Changes to the `mega-evm` core crate dependencies Pull requests that update a dependency file agent Generated by AI agents labels Sep 25, 2026
@github-actions

Copy link
Copy Markdown

🧬 Mutation testing — ✅ PASS

Nothing to test — no mutants were generated on the changed lines.

@codspeed

codspeed Bot commented Sep 25, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 1.95%

⚡ 10 improved benchmarks
❌ 9 regressed benchmarks
✅ 16 untouched benchmarks
🆕 21 new benchmarks
⏩ 385 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ transfers 2 ms 2.7 ms -28.35%
❌ system_address_misses/satin 553 µs 670.4 µs -17.51%
❌ salt_new_accounts/crowded 96.4 µs 115.9 µs -16.81%
❌ salt_new_accounts/satin 95.8 µs 114.9 µs -16.58%
❌ deep_calls/satin 391.5 µs 464.7 µs -15.77%
❌ intercepted_calls/satin 264.7 µs 303.7 µs -12.86%
❌ ether_transfer/satin 47 µs 51.9 µs -9.46%
❌ logs/satin 154.3 µs 169.6 µs -8.98%
❌ storage_writes/satin 338.9 µs 359.7 µs -5.78%
⚡ system_call 53.4 µs 43.3 µs +23.33%
⚡ two_txs 95.5 µs 78.8 µs +21.07%
⚡ sstore_set 74.3 µs 66.7 µs +11.48%
⚡ calldata_floor 74.1 µs 66.5 µs +11.46%
⚡ access_list 80.1 µs 72.8 µs +10.07%
⚡ logs 73.2 µs 67 µs +9.26%
⚡ eip7702_delegation 86.7 µs 79.8 µs +8.64%
⚡ modexp 206.5 µs 190.5 µs +8.4%
⚡ nested_revert 81.7 µs 76.7 µs +6.44%
⚡ ecrecover 307.1 µs 292 µs +5.18%
🆕 steady_pre_block N/A 75 µs N/A
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing cz/feat/satin-w3 (c0882c7) with cz/feat/satin-w2 (b16d1b3)

Open in CodSpeed

Footnotes

  1. 385 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Label check

Current labels: spec:unstable, api:breaking, comp:core, dependencies, agent.

Update since my last check: spec:new → spec:unstable. That now matches wave-2 (#386)'s labels exactly, and it's the better fit on its own merits too: the SATIN spec variant was introduced back in wave 1 (#385, which used spec:new), and this PR only changes behavior of that already-existing unstable spec.

The gap I flagged before is still open, though: this PR's footprint isn't limited to crates/mega-evm (what comp:core covers). It adds two entire new crates outside it — crates/mega-state-test (~30 files: deviations registry, runner, fork/mode/skip logic, types) and crates/state-test (the CLI) — plus CI workflow changes (exec-spec-satin.yml, exec-spec.yml, replay-bench.yml). That's a bigger "outside mega-evm" footprint than wave 2 (#386) had, and comparable to (arguably larger than) what wave 1 (#385) used comp:misc for (mutants config, clippy.toml, scripts). Suggest adding comp:misc alongside comp:core.

I'm dropping my earlier comp:doc suggestion: wave 2 (#386) made a similarly-sized AGENTS.md/REVIEW.md edit without that label, so the convention seems to be that doc updates accompanying an engine change don't get a separate comp:doc unless the PR is doc-focused.

Not touching labels myself — flagging for a maintainer to confirm.

@github-actions

Copy link
Copy Markdown

🧬 Mutation testing — ✅ PASS

Diff mutation score: 100.0% (309/309 viable mutants killed)

  • caught: 309
  • survived (real gaps): 0
  • timed out (inconclusive): 0
  • suppressed (equivalent/dead-code): 0
  • unviable: 301 · timeout total: 0

No new test gaps introduced by this change. 🎉

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Generated by AI agents api:breaking Crate interface change — downstream users must update comp:core Changes to the `mega-evm` core crate dependencies Pull requests that update a dependency file spec:unstable Changes to the unstable spec (currently REX5)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant