Skip to content

feat!: the Satin gas table, block executor, SALT pricing and system contract interceptors - #386

Draft
RealiCZ wants to merge 98 commits into
cz/feat/satin-t2-skeletonfrom
cz/feat/satin-w2
Draft

RealiCZ wants to merge 98 commits into
cz/feat/satin-t2-skeletonfrom
cz/feat/satin-w2

Conversation

@RealiCZ

@RealiCZ RealiCZ commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Wave 2 of the Satin engine: four series on one branch, all on top of the pull request that laid the Satin skeleton (base cz/feat/satin-t2-skeleton).

  • The Satin gas table — the schedule the engine prices with, the configuration switches the spec fixes around it, and the precompile set it runs. Until now the engine executed on the Osaka schedule, which prices state gas at zero, so EIP-8037 was switched on but no transaction ever drew a state charge. It does now.
  • The block executor — MegaBlockExecutor becomes alloy-evm's BlockExecutor over a MegaEvm, the shape alloy-op-evm's OpBlockExecutor has for an OP chain, with the three block rules the Karst base brings, the block-level limits a node configures, and the three gas ledgers the common execution layer defined but nothing filled.
  • SALT pricing — the state gas the table above prices becomes what MegaETH actually charges: an EIP-8037 state charge costs the schedule's entry times the capacity of the SALT bucket it lands in. Until now the schedule's number was the whole price; it is the price at the minimum bucket now, and a crowded region costs proportionally more.
  • System contract interceptors — the six system contracts get their addresses, their bytecode and their ABIs, four of them get the interceptor that answers a call instead of running the contract's code, and the system address gets the transaction it maintains the protocol's state with.

A fifth commit on top wires the one seam the two lanes left between them. SALT pricing decided system origin on its own, matching only the EIP-4788 system-address caller, because the sequencer's system transaction had no definition yet; the interceptor series defines it. The context now consults system::is_system_originated with the address the promotion uses, the private duplicate goes, and the three rows the pending ledger parked waiting for exactly this are ported.

The branch also moves the revm fork pin to v40.0.3-mega.2, which prices a creation transaction's first frame at the address the sender's account nonce derives — the one the frame actually deploys at — rather than at the address the envelope nonce derives, which a deposit's unvalidated nonce can make a different account entirely.

The branch carries four merge commits. The two lanes were developed in parallel on their own lane branches, over files that were disjoint except for two import lists in src/evm/context.rs and src/evm/mod.rs, with two series each, and every series is merged with --no-ff so its commits stay readable as one series rather than being interleaved by date. The four merges land in this order: the gas table (abc3c104), the block executor (0bdd941b), SALT pricing (19ba2319) and the interceptors (e44b59bd), each merged into the integration branch, so the first-parent chain is that sequence of merges and the commits of each series hang off its second parent.

The lanes met in seven files across the second and fourth merges, all of them additively. The fourth merge (e44b59bd) resolved five: AGENTS.md (the module table and the notes on how Satin executes), crates/mega-evm/README.md, benches/transact.rs (both lanes added workloads, and the block lane's two probe addresses were moved clear of the gas lane's), tests/satin/equivalence.rs (both lanes added cases where Satin leaves op-revm on purpose), and crates/mega-evm/tests/_pending/README.md. The second merge (0bdd941b) resolved AGENTS.md and the generated ledger too, plus src/evm/context.rs and src/evm/mod.rs — one use list each, and with_dyn_precompiles placed beside the block lane's runtime-limit methods; no logic on either side, and both resolutions keep both lanes' lines once. The generated ledger was regenerated from every lane's inputs rather than hand-resolved, so its counts are the merged ones, and each lane's copy of the generator was reconciled into one that reads all of them.

Three fixtures of the block lane were written while the Osaka schedule still priced state gas at zero, and the second merge commit adjusts them to what the Satin table charges — they are the only behavioral consequence of the two lanes meeting, and none of them changes engine code:

  • tests/block/counters.rs asserted the state ledger reads zero. It now asserts the ledger is drawn and still refuses nothing, which is what the test's name and doc comment always claimed.
  • tests/block/rules.rs gave a 20,000-byte transaction a 900,000 gas limit, below the calldata floor the Satin table now puts it at (1,295,000). The scalar and the gas limit are raised so the data-availability footprint is still the inclusive bound the test is about.
  • benches/block.rs budgeted 100,000 gas per transfer and 200,000 per storage write; the first is below the 183,600 state gas a new account now draws, the second covers a slot's first write. Both workloads get 300,000.

Between them the four series port 215 parked rows of the test inventory into real test targets and retire 8; _pending/ holds 524 tests, from 747.


The Satin gas table

The schedule

The seventeen entries pressed back to Osaka

EIP-8038 raises the price of reaching state; EIP-8037 lowers the regular price of creating it, having moved that charge onto the state dimension. Satin takes neither move: all seventeen keep their Osaka value.

Gas id Osaka (= Satin) Amsterdam (not taken)
warm_storage_read_cost 100 100
cold_account_additional_cost 2,500 2,900
cold_storage_additional_cost 2,000 2,000
cold_storage_cost 2,100 2,000
transfer_value_cost 9,000 11,300
new_account_cost 25,000 0
new_account_cost_for_selfdestruct 25,000 9,000
sstore_static 100 100
sstore_set_without_load_cost 19,900 10,000
sstore_reset_without_cold_load_cost 2,800 10,000
sstore_set_refund 19,900 10,000
sstore_reset_refund 2,800 10,000
sstore_clearing_slot_refund 4,800 11,616
create 32,000 12,000
tx_create_cost 32,000 12,000
tx_access_list_address_cost 2,400 4,180
tx_access_list_storage_key_cost 1,900 4,048

Three of them — warm_storage_read_cost, cold_storage_additional_cost, sstore_static — EIP-8038 left where they were; the rule is "every entry EIP-8038 touched", not "every entry that differs". Four — new_account_cost, create, tx_create_cost, sstore_set_without_load_cost — EIP-8037 cut because it had moved that charge onto the state dimension, so pressing them back charges state creation twice over: a value CALL creating its recipient pays 25,000 regular and 183,600 state where Amsterdam pays 0 regular, a CREATE 32,000 and 183,600 against 12,000, a slot's first write 22,100 and 97,920 against 12,100, the state charge being the same on both. Whether that is the intended economics is the first open item. tx_create_cost alone prices nothing, EIP-2780 charging a creation through tx_create_access_cost, so the measured create fixed part is 24,000, not 44,000.

The devnet-8 entries kept

Osaka → Satin: tx_account_write_cost 0 → 9,000, tx_create_access_cost 0 → 12,000, code_deposit_cost 200 → 0, tx_floor_cost_base_gas 21,000 → 12,000, tx_floor_cost_per_token 10 → 16, tx_floor_token_zero_byte_multiplier 1 → 4, tx_access_list_floor_byte_multiplier 0 → 4, tx_eip7702_regular_gas 25,000 → 7,816, tx_eip7702_regular_refund 12,500 → 0 — the EIP-2780 decomposition and the floor keeping their devnet-8 numbers even where they derive from an EIP-8038 constant.

The state entries

Each is an EIP-8037 byte count times the cost per state byte, so dividing recovers the byte count the EIP names: 64 bytes → 97,920 (sstore_set_state_gas), 120 → 183,600 (new_account_state_gas and create_state_gas), 1 → 1,530 (code_deposit_state_gas), 23 → 35,190 (tx_eip7702_state_gas_bytecode), and code_deposit_history_gas stays 0 until history gas prices it. At the provisional price these land on Amsterdam's own numbers, that price being Glamsterdam's; a test at another price shows these five move and the other 45 do not.

The switches and the size limits

MegaContext::with_cfg overwrites each of these whatever the caller passed, with a named test per switch: gas_params to the Satin schedule; enable_amsterdam_eip8037 on, for the state dimension and the reservoir; enable_amsterdam_eip2780 on, without which a creation's state gas has no phase that charges it; tx_gas_limit_cap at 200,000,000, the execution cap, gas above it becoming reservoir and the transaction's own limit staying uncapped; enable_amsterdam_eip7708 off; system_call_state_gas_margin_in_reservoir off; limit_contract_code_size at 524,288 (512 KiB), limit_contract_initcode_size at 1,048,576 (1 MiB).

The Amsterdam opcodes and the intrinsic numbers

The ungated DUPN, SWAPN, EXCHANGE and SLOTNUM are installed before the metering wrappers go in, since installing them afterwards would replace a wrapped entry with an unwrapped one; their static gas is the same on every spec. Measured intrinsics, history off: an empty call to an existing account 15,000, a value transfer to one 21,000, a self-transfer 12,000, a creation transaction's fixed part (less the created account's state gas) 24,000. The 15,000 is EIP-2780's sender base of 12,000 plus its own fixed 3,000 for the recipient, a separate number from the schedule's 2,600 cold-account access.

The floor and the cap

The EIP-7976 floor is sender base + recipient charge + value charge + 64 × (calldata bytes), with EIP-7981 adding the access list's bytes at the same rate: 1,280 gas per address, 2,048 per storage key. The per-item intrinsic charge stays at its Osaka value (2,400 and 1,900), so a one-address access list is charged the larger intrinsic figure and from eight storage keys on the floor takes over. Measured against the execution cap, the floor bounds the calldata a transaction may carry: 3,124,765 bytes (floor 199,999,960) is admitted, 3,124,766 (floor 200,000,024) is rejected.

The precompile set and the public surface

satin_precompiles() is op-revm's karst() set with KZG point evaluation repriced to kzg_point_evaluation::GAS_COST = 100,000, flat whatever the caller forwarded; a call that fails in verification burns everything forwarded, and one forwarding less is out of gas first, including at upstream's 50,000.

Public surface (breaking). Evm::Precompiles and EvmFactory::Precompiles become alloy-evm's PrecompilesMap where they were OpPrecompiles, and MegaEvm's trait impls take DB: alloy_evm::Database where they took DB: revm::Database — the associated type was documented as provisional in wave 1, and the bound narrows because alloy-evm implements PrecompileProvider for PrecompilesMap only over its own Database. The new public items are the schedule and byte-price accessors, the precompile set, the dynamic-precompile route and constants::{MAX_CONTRACT_SIZE, MAX_INITCODE_SIZE}.

The pricing table and the op-revm baseline

crates/mega-evm/tests/satin/pricing-table.md lists every named gas id at the Osaka, Amsterdam and Satin price, and measures nine probes (gas used, then regular + state where they differ): an empty call 15,000; a value transfer to an existing account 21,000; a self-transfer 12,000; SSTORE 0 -> 1 135,026 = 37,106 + 97,920; SSTORE 0 -> 1 -> 0 29,770 against 37,212 regular; a value CALL to an empty account 232,921 = 49,321 + 183,600; LOG1 with 32 data bytes 16,018; a create transaction deploying 0 bytes 207,687 = 24,087 + 183,600; one deploying 32 bytes 256,668 = 24,108 + 232,560. gas used is the receipt's figure, net of refunds and at least the floor, hence the 0 -> 1 -> 0 row below its own regular column. A test reads the table back and asserts Satin differs from Amsterdam in exactly the fourteen pressed-back entries whose prices differ; regenerate with UPDATE_SATIN_PRICING_TABLE=1 cargo test -p mega-evm --test satin.

tests/satin/equivalence.rs runs the same transaction through MegaEvm and op-revm's OpEvm on the same CfgEnv, comparing every ResultGas field, the logs and the state; both arms take the CfgEnv the MegaContext holds, so the SSTORE case now asserts 97,920 state gas on both sides.

Ported rows

34 rows ported, none retired or parked: 12 into tests/satin/contract_size.rs, 8 into tests/satin/intrinsic.rs, 7 into tests/satin/precompile_gas.rs and schedule.rs, 7 into the unit tests of src/evm/precompiles.rs and factory.rs; five emptied parked files are gone and the ledger's total drops 747 → 713. Every row is a rewrite except the seven KZG ones, taking its expectation from this series: the Satin size limits, the EIP-7976 floor numbers, the rejection / out-of-gas boundary, and gas_used where the legacy row asserted compute gas.

Review round

A zero-context review returned approve-with-fixes: every measured number is what the settled design says, and all six findings were documentation accuracy or test fidelity.

Finding Class Closed by
The press-back was documented as EIP-8038's repricing alone docs docs(evm): say what the pressed-back entries do to state creation
Two tests called the intrinsic 3,000 a cold access docs docs(satin): name the intrinsic recipient charge as EIP-2780's own
constants.rs named the wrong reader of the state-gas constants docs docs: say the tests read the two state-gas constants
The satin-price-override path was never run test test(evm): run the byte-price override through a child test process
Two ported rows lost their scenario test test(satin): cover the EIP-3541 rejection of a deployed 0xEF byte
Two commits carry two concerns each hygiene recorded; not rewritten

The round asked no questions. Two decisions: the pressed-back list is unchanged, and the schedule still derives its state entries from the EIP-8037 byte counts rather than reading SLOT_STATE_GAS and ACCOUNT_STATE_GAS, which would be the measurement override the feature exists to allow.


The block executor

The executor interface and the chain-spec bound

MegaBlockExecutor::new(evm, ctx, spec, receipt_builder) takes a MegaEvm; MegaBlockExecutionCtx carries the parent hash, the parent beacon block root, extra_data, no_user_tx_activation_block and BlockLimits.

  • The chain-spec bound is MegaHardforks: OpHardforks plus the MegaETH forks, mega_fork_activation the one required method and the rest defaults over it, so a node's chain spec needs no wrapper type; #[auto_impl(&, Box, Arc)] lets the factory hand out &Spec and clone nothing per block.
  • The activation flag is passed, not derived, admits_only_deposits(parent_timestamp, block_timestamp) needing a timestamp the executor does not have; the block's gas limit is the block environment's — what consensus holds the block to, and the budget the data-availability footprint is held to.
  • The constructor installs the block's transaction-level limits on the EVM, so every route runs the block's transactions under them, which is why it is stated over a MegaEvm rather than any Evm.
  • Beyond the trait: run_transaction (execute with the hash and sizes the caller reports), commit_transaction_outcome (re-check gas, sizes and footprint, then commit), finish_with_counters and the block-hash record's accessors; finish returns no requests.

The three block rules, and their alloy-op-evm counterparts

  • An activation block admits only deposits — alloy-op-evm's no_user_tx_activation_block, set from MegaHardforks::admits_only_deposits, which asks the MegaETH forks and OpHardforks::is_no_user_tx_activation_block together; a user transaction is refused before anything runs.
  • The data-availability footprint is a block limit — estimated_da_size × the L1 block contract's footprint gas scalar, held to the block's gas limit and reported as blob_gas_used, the estimate read through op_revm::estimate_tx_compressed_size so engine and node agree, the scalar read per transaction so the block's own L1 info transaction may set it, zero with no scalar in state, deposits exempt. alloy-op-evm gates this on Jovian; Satin applies it unconditionally, having no spec gates.
  • The L1 block info is read by the first transaction that prices against it — block execution pre-loads nothing, and op-revm's handler fetches when it deducts the first non-deposit caller, after the block's own L1 info deposit has committed; an empty L1 block contract reads as zeroes rather than an error.

The operator fee is op-revm's Karst formula (gas × scalar × 100), pinned against the pre-Jovian one that would divide it to zero.

The counters and the block policy

BlockGasCounters { execution, state, history } is filled from every committed transaction's MegaGasUsage, and MegaBlockExecutionResult carries it and the data-size / write-record LimitUsage beside BlockExecutionResult; the header's gas used stays the sum of the receipts, the blob gas used is the block's footprint. Execution is the only gas ledger with a block limit: history has none by design, and the state ledger and write-record count are accumulated without enforcement until the state-gas and the state-growth and KV limits land. For a dimension only execution reveals, the policy is the legacy engine's — accumulate, pack the transaction that crosses, refuse every later one — bounding the overshoot at one transaction per dimension; a limit known in advance refuses a transaction before it runs, MegaTxLimitExceededError saying it can never be included and MegaBlockLimitExceededError that this block has no room. Deposits are exempt from both data-availability dimensions.

The stub hook points

apply_pre_execution_changes runs the admission gate, the block-hash record's reset and the EIP-2935 and EIP-4788 calls, then names two hook points that are empty today: system contract deployment at the Satin activation, and the pre-block system calls. Every pre-block helper returns its state and commits nothing (block/eips.rs), so a witness generator sees each step's read and write set.

Ported rows

53 rows ported, none retired: 25 into the unit tests of src/block/{hardfork,chain,limit,result,helpers,eips}.rs and src/evm/{state,mod}.rs, 28 into tests/block/{limits,factory,block_hashes,schedule}.rs. The two KV-limit rows of block_limits.rs stay parked, since what that limit counts is undecided. The ledger's total drops to 694.

Review round

A zero-context review returned seven findings, all confirmed against the code, and asked no questions.

Finding Severity Closed by
The L1 info deposit could not refresh the fee inputs high fix(block): price a block's transactions with its own L1 info
The admission gate was bypassable high fix(block): refuse a rewriting inspector at every entry point
The direct constructor ignored the runtime limits medium fix(block): install the block's transaction limits in the constructor
The delayed commit skipped the footprint medium fix(block): re-check the footprint when a transaction commits late
The block-hash record was the State cache medium fix(evm): make the block-hash record a per-block read set
Three commits did not build --locked low the branch was rewritten, folding the lock hunk into its commit
The storage-write bench wrote the same value low bench(block): give every storage write a slot of its own

The gate is now checked at the pre-block changes, a transaction, a commit and the end of the block, commit_transaction excepted because its signature cannot fail; the --locked rewrite left the tree byte-for-byte identical.


SALT pricing

The hook, and why there is only one

One hook prices every EIP-8037 state charge: the fork's Host::state_gas_price(id, site) -> Option<u64>, which MegaContext overrides. Every site that gives a charge back re-prices the recorded StateGasCharge { id, site, units } through it rather than reading a second table, so a refill cancels its charge exactly whatever the price was; AGENTS.md carries the invariant — add the charge where the state lands, never price it. Four refills re-price (SSTORE's restore, an upfront charge for a call or creation that added no leaf, the transaction-level refund, a synthetic result's settlement), while a failing frame rolls back by the amounts it recorded (rollback_state_gas); both cancel exactly, a multiplier being read once per transaction.

The formula

state gas = schedule entry x m,   m = bucket capacity / MIN_BUCKET_SIZE

m scales the state dimension only; the regular ledger is the same at every multiplier. At the minimum bucket m = 1, so the schedule's own numbers stand — 97,920 for a slot, 183,600 for an account leaf, 183,600 for a creation, 1,530 per deposited byte, 35,190 for an EIP-7702 delegation — and nothing moved when this series landed: the op-revm baseline and the pricing table are unchanged, while equivalence.rs gains the other side, one crowded bucket taking Satin away from op-revm in the state ledger alone, by exactly the multiplier. The pricing table's SALT scaling section measures it: SSTORE 0 -> 1 costs 135,026 / 232,946 / 820,466 at m = 1 / 2 / 8, state 97,920 / 195,840 / 783,360 with regular fixed at 37,106; a value CALL to an empty account 232,921 / 416,521 / 1,518,121, state 183,600 / 367,200 / 1,468,800 with regular 49,321. A capacity below MIN_BUCKET_SIZE is a broken backend, not a cheap bucket: a failed lookup of its own (BucketError::BelowMinimum), never a round up to m = 1 and never m = 0, which would make state gas free.

The seven call surfaces

All reach the same code path. SSTORE onto a slot that was zero is priced by the slot's bucket; CALL carrying value to an account that does not exist, and the EIP-2780 runtime phase's recipient or creation target, by the account's; SELFDESTRUCT moving a balance to an account that does not exist, by the beneficiary's; CREATE / CREATE2 metadata, when the address does not exist, by the created address's; an EIP-7702 authority, leaf and delegation bytes, by the authority's; a code deposit, per byte, by the deployed address's. CALLCODE is the non-entry: it sends value to the caller's own account, which exists, so it adds no leaf and asks no price.

The system-origin rule

A system-originated transaction prices at the minimum bucket and reads no capacity, so a state change the protocol mandates cannot be priced out by a region it does not control. It matches the EIP-4788 / EIP-2935 system-address caller, every transaction through a system-call entry point (MegaEvm::system_call_*), which is how the pre-block calls are issued, and the sequencer's own system transaction before or after promotion — that arm through the seam commit below. A user's deposit is deliberately not matched, a deposit being a shape a user can produce.

The failure path

A failed capacity lookup reports nothing and records its cause in the context error, as Host::sload does for a failed database read; every charge site bails out and the transaction surfaces the cause. It is deliberately not a fall back to the schedule's price — a price the engine could not look up is never replaced by one nobody chose — and not the out-of-gas halt the EIP-2780 runtime phase uses for a real shortfall, which the recorded cause tells apart.

The refund matrix

  • Rollback — a slot restored by the same opcode, a frame that reverted, a creation that deployed nothing, a CALL that could not transfer, the whole transaction reverting: the charge comes back at the price it was made at, so net state gas is zero at every multiplier, each arm controlled by the same program surviving and paying the crowded price.
  • Authorization persistence — an applied EIP-7702 delegation outlives a frame reverting after it, and so does the state gas it paid: the charge is still there, scaled.
  • Pricing failure — nothing is charged and nothing refunded; the recorded cause reaches the caller.

"A refill never exceeds the charge" has a structural answer: a bucket's capacity is read once per transaction, so a charge and the refill undoing it cannot disagree, and a test asserts that read count.

Ported rows

67 rows ported and 8 retired; none stays parked. The legacy engine charged base x (m - 1), so its minimum bucket cost nothing where Satin charges entry x m — the rewrite every scaling row takes. Beyond the seven surfaces they add the writes the multiplier has nothing to scale, CREATE2, the multiplier over a wide range, and salt_delegation.rs, where deciding whether a delegated target exists follows the one hop EIP-7702 allows and no further; the last 4 run in salt_deposit.rs as of the re-pin, the sender at nonce 5 and the envelope at 0 so the candidates fall in different buckets. Retired: four rows guarding an account inspection on the pricing path, which Satin's CALLCODE cannot reach and the hook does not do, and four pinning a helper at a legacy spec boundary a single-spec engine has no gate for.

Review round

A zero-context review returned approve-with-fixes, finding no reachable wrong outcome: every charge and refill reaches the hook at the same (id, site) under one multiplier read, and every failure it could construct fails closed.

Finding Severity Closed by
A ported ledger row is a different scenario; the gap is three rows medium test(satin): park the system transaction exemption row again
SELFDESTRUCT is a seventh charge site with no test medium test(satin): pin the SELFDESTRUCT beneficiary as a state gas charge site
The refill invariant overstated: one of the four rolls back by amount low docs: separate the two ways a state gas charge comes back
"A deposit is not system-originated" had no test low test(satin): pin that a deposit transaction is not system-originated
An intermediate commit (c30ccdec) warns under cargo check low recorded; not rewritten

The SELFDESTRUCT code was already right, at 183,600 / 367,200 / 1,468,800 for m = 1 / 2 / 8; only the arms were missing. Three questions: a capacity below the minimum bucket is now a failed lookup rather than the cheapest price there is (fix(external): fail a capacity below the minimum bucket closed); the OP L1-attributes deposit stays priced by the user rule, an open item below; and the four rows guarding an account inspection are retired, a row waiting for a mechanism that will never want it never closing.

Second review round

A second zero-context review returned block, on a defect that is real and was not in this repository, plus three gaps in what the tests prove. The defect is fixed in the fork and this branch is re-pinned to the release carrying it, so nothing is left open.

Finding Severity Closed by
F1: the first frame of a deposit creation is priced at the wrong address high the fork's v40.0.3-mega.2; chore(deps): move the revm fork to the tag that fixes first-frame CREATE
The delegated-CREATE port lost its differing-nonces regression medium test(satin): tell the authority's nonce from the delegate's in a delegated creation
The refund matrix did not reach synthetic settlement medium test(satin): settle a crowded upfront charge through the synthetic helper
The first round's corrections did not reach salt_refund.rs's header low docs(satin): say which refunds re-price and which roll back by amount

F1 in numbers: the fork derived the pricing site of a top-level CREATE from tx.nonce() while frame creation deploys at the sender's journal nonce, and op-revm does not validate a deposit's nonce — with an envelope nonce of 0 and a state nonce of 5 the frame deploys at CALLER.create(5) while the charge is priced in CALLER.create(0)'s bucket, so at m = 8 the actual target's crowded bucket charges the minimum 183,600 instead of 1,468,800, and a failing lookup there is never consulted. The refunds re-price the address that was charged, so they cancel consistently: the defect is the attribution, not a leak. v40.0.3-mega.2 derives the site from the pre-increment journal nonce frame creation reads, and test(satin): price a deposit creation where it deploys, not where its envelope says ports the four rows; against the previous tag five of the six fail on those numbers.

Two questions: the four legacy spec-gate rows are retired now, a single-spec engine having no gate to port them to; and the first-frame creation site was fixed by the fork owner before this branch merges, the correction belonging where the address is chosen.


System contract interceptors

The six system contracts get their addresses, bytecode and ABIs, four get the interceptor that answers a call instead of running the contract's code, and the system address gets the transaction it maintains the protocol's state with. None of the contracts' semantics lands here: the control contracts' answers belong to detention and compute gas, the deployment at the fork to system contract deployment, and turning a keylessDeploy call into a creation to native keyless deployment.

The dispatch order

MegaEvm::intercept runs in frame_init, after the latch and the depth guard and before the keyless rewrite, each step cheaper than the next, the first two running on every call:

  1. The scheme guard (evm/execution.rs): only CALL and STATICCALL reach the dispatch. CALLCODE and DELEGATECALL run the callee's code in the caller's context, where a system contract's semantics would apply to the wrong account, so they are refused before any interceptor, and a creation reaches none.
  2. The address (system/intercept.rs): the six addresses share their first 19 bytes, so one comparison against that prefix decides whether any interceptor can be concerned; the last byte names the contract.
  3. The selector, peeked from the first four bytes without materialising the rest of the input; admission is those four bytes alone, trailing bytes and all, and a selector a contract does not intercept falls through to the deployed bytecode.
  4. The value policy, per method and after the selector matched, so a value-bearing call to an unknown selector still falls through.

The six contracts

Contract Address Intercepted Value policy
Oracle …0001 sendHint(bytes32,bytes), a side effect not payable; the bytecode reverts
KeylessDeploy …0003 keylessDeploy(bytes,uint256), depth 0 only NoEtherTransfer(), after the overhead
MegaAccessControl …0004 the three volatile-data-access methods NonZeroTransfer()
MegaLimitControl …0005 remainingComputeGas() NonZeroTransfer()

The Timestamp wrapper (…0002) and the SequencerRegistry (…0006) intercept nothing. Fall-through otherwise differs per contract: the control contracts revert with NotIntercepted() from their fallback, KeylessDeploy has none, so an undeclared selector reverts with empty data while a keylessDeploy call reaches the method body's NotIntercepted(), and the Oracle's other selectors are methods it runs. The control contracts answer what the common execution layer knows: false for the access query, no effect from the switches, the forwarded regular gas for remainingComputeGas().

The Oracle's hint is a side effect rather than an answer: the payload goes to OracleEnv::on_hint and the bytecode then runs. Two conditions admit it — the call was forwarded gas, the legacy engine's own rule, and it carries no value, which Satin adds — and neither says anything about the frame afterwards, an admitted hint being irreversible; a STATICCALL does forward, and the payload is counted toward the transaction's data size before it is decoded.

keylessDeploy is dispatched only at depth 0, so its answer is never read off an inner caller's return data. A dispatched call pays 100,000 regular gas before anything else and is handed to MegaEvm::rewrite_keyless, which native keyless deployment fills in; until then it reverts with NotIntercepted(), and a call not forwarded the overhead is answered out of gas.

The synthetic-result contract

Every answer is a synthetic_call_result: the forwarded gas untouched, the caller's reservoir carried, the calling opcode's upfront state-gas flags kept, so the caller settles it exactly as it settles a frame revm ran. An answer built from a bare Gas::new(limit) would carry a reservoir of zero, and the caller adopting it would empty the transaction's state-gas pool and bill the sender for all of it; every interceptor path is pinned at a 100M gas limit and at 1B, where the pool is 800M. An interceptor that lets the frame run may charge it instead by taking gas off the frame's limit — KeylessDeploy's overhead, and why intercept takes the frame mutably.

The system-address transaction

A legacy transaction from MEGA_SYSTEM_ADDRESS to a whitelisted contract (the Oracle) is the protocol's own: no signature, no fee. The engine stamps MEGA_SYSTEM_TRANSACTION_SOURCE_HASH on it, zeroes its gas price and runs it as a deposit. A deposit is unvalidated by construction, so before promoting one the engine validates what still matters (system/tx.rs::validate_and_promote): the callee is whitelisted and the transaction creates nothing, the chain id is the chain's, the nonce is the system address's, and that address carries no code (EIP-3607) — each honouring the switch a user transaction obeys, and reading the address without warming it.

The account a deposit-like transaction creates for its caller is charged the account-creation state gas in the pre-execution phase, before EIP-2780 charges a recipient's, which makes the charge exactly once; a transaction that cannot pay it is an out-of-gas halt. A system transaction's history ledger is zero — the exemption the history-gas design gives the protocol's maintenance — pinned so it survives when history gas lands.

The op-revm baseline

tests/satin/equivalence.rs gains its first four deliberate divergences, each pinning both sides: a call an interceptor answers, where op-revm runs the bytecode into its NotIntercepted() revert; a keylessDeploy transaction, answered with that revert by both while Satin spends exactly the 100,000 overhead more; a legacy transaction from the system address, which Satin runs as a fee-free deposit and op-revm refuses for want of a balance to pay the fee; and a deposit whose sender does not exist yet, whose account Satin charges the account-creation state gas for and op-revm nothing. Every ordinary transaction still matches field for field, and the keyless case compares what each transaction spent, op-revm's receipt being lifted to the EIP-7623 floor.

Ported rows

61 rows ported, none retired or parked; eight emptied parked files are gone and the ledger's total drops 660 → 599. They run in a target of its own, tests/system/ (74 tests), plus the unit tests of src/system/tx.rs and the reservoir cases in synthetic_frame_gas.rs, each contract carrying the four boundaries the repository rules ask for. Three rows change what they expect, by the Satin rule rather than a fix: two keylessDeploy rows pinned a short payload falling through, where Satin admits on the four selector bytes alone.

Review rounds

The first round returned five findings; the gate it asked for is in the test plan.

Finding Class Closed by
A module doc named the keylessDeploy ABI uint64, not uint256 docs docs(system): name the keylessDeploy ABI the contract declares
The system-address refusal named the whitelist for a shape problem docs fix(system): word the system-address refusal as the rule it states
A hint crossing the data-size limit had no test test test(system): pin the hint that crosses the data-size limit
A fourth op-revm divergence was unnamed test test(satin): pin the keyless overhead against op-revm
The mutation gate had never been run for the series gate run and reported with the wave gate

The second returned approve-with-fixes, finding no reachable wrong outcome in the dispatch, the value and gas policies, the synthetic result, the keyless charge, the promotion or the hint path.

Finding Severity Closed by
The pool matrix asserted gas and not outcomes medium test(satin): assert what every interceptor path answers at both limits
Fall-through was overstated as NotIntercepted() everywhere low docs(system): say what a selector that is not intercepted runs into
The Oracle's comparison with the legacy engine overstated it low docs(system): separate the hint rule carried over from the new one
The baseline claimed two receipt facts it did not assert low test(satin): name the floor the keyless receipts are measured against
Two commits compile with warnings low recorded; the branch is not rewritten

Comparing gas alone let a fall-through into deployed bytecode satisfy a row meant to pin an interceptor's answer; every row now states the outcome it expects at both limits. Two questions: nothing in the engine authenticates a system sender, by design, which is an open item for the node integration review; and the series' 93-mutant run needs no reproduction, the whole-wave run superseding it.


The seam between the two lanes

SALT pricing exempts a system-originated transaction from the bucket multiplier, but when that series landed it could match only the EIP-4788 system-address caller and a transaction run through a system-call entry point: the sequencer's own system transaction had no definition in the tree, and the predicate was private to evm/context.rs. The interceptor series defines system::is_system_originated(tx, system_address) — the system-address caller, the source hash a promoted system transaction carries, and the whitelisted system-address call before promotion — and the seam commit joins them: MegaContext::on_new_tx consults it with MEGA_SYSTEM_ADDRESS, the address evm/execution.rs promotes with, the system-call flag stays the other arm, and the private duplicate is deleted.

Ported rows

Three rows of _pending/rex6/system_tx_metering_exemption.rs, parked for exactly this, with two control arms. In tests/satin/salt.rs: a legacy call from the system address to the whitelisted Oracle, promoted to a deposit, pays the same state gas and the same whole receipt at m = 1 and m = 8, reads no capacity, and draws exactly the schedule's two entries; the same call through the system-call entry point costs the same. In tests/block/salt.rs: the block's pre-block phase, with the EIP-2935 and EIP-4788 contracts' real code installed, commits both calls at the minimum bucket and at a hundred thousand times it. The controls — a user transaction into a bucket crowded the same way still paying entry x m, and the first user transaction of that block unable to afford the slot it writes — fail without the seam.

The merge review round

Two zero-context reviews of the four merge commits and the seam returned approve-with-fixes. Both replayed every merge against its parents and found the resolutions to be exact unions, with nothing lost or duplicated.

Finding Closed by
The three seam ports narrowed their originals test(block): run the pre-block calls against a crowded SALT region
The union proof named the wrong common ancestor the Summary and the test plan below
Stale lane claims: bench budgets, headings, a warning-free claim the Summary, the commits note and the open items
The four merge subjects are not conventional recorded as an accepted exception
test_utils/scenario.rs was called untouched the infra note below

Both narrowed ports are restored, the promoted-transaction one by test(satin): check what the promoted system transaction left behind. Two questions: the merge subjects are not reworded, and the pre-block row is restored in its original's shape rather than staying parked, the state-hook API it asserted through no longer existing in this alloy-evm.


Commits

The branch is read with git log --first-parent for the four merges — the gas table (abc3c104), the block executor (0bdd941b), SALT pricing (19ba2319) and the interceptors (e44b59bd) — and git log <merge>^1..<merge> for each series. Every commit passes cargo check --workspace --all-targets --locked, with two exceptions recorded rather than rewritten away. Three compile with warnings — c30ccdec and 17f96564 leave an unused import each, 7923b9ea two deprecated accessors, each repaired by the style: commit after it — so "passes cargo check" held for them while "warning-free" did not; warning-free is the standard from here and holds for the seam, the merges and the fourteen later commits. The four merge commits keep git's Merge branch … subjects: the repository writes merges that way, and the wave lands by squash, so the subject reaching main is this title.

Test plan

Run on the final head: all four series, the seam commit, the eleven commits of the two review rounds and the three of the re-pin.

Check Result
cargo fmt --all --check, cargo sort --check --workspace --grouped … exit 0
cargo clippy --workspace --all-features --locked (lib, tests, benches) exit 0, no warnings
cargo test --workspace --locked 575 passed, 1 ignored doctest
the same with --features satin-price-override,test-utils, variables unset 571 passed, 2 ignored
cargo check -p mega-evm --target riscv64imac…, no default features exit 0
cargo tree -i revm --locked one revm, at v40.0.3-mega.2; no duplicates
cargo bench: transact, corpus, factory, block exit 0 each; 18 in transact
npx prettier --check '*.md' 'docs/**/*.md' all matched files formatted
git diff --check 438783a8..HEAD exit 0
whole-wave mutation gate, diff 438783a8 PASS: 460 mutants, 333 caught, 0 survived, 127 unviable
infra mutation gate PASS: 39 mutants, 34 caught, 0 survived, 3 suppressed
CJK, work-item-code and internal-vocabulary sweeps no hits
the pending ledger, regenerated README up to date; 524 pending

The 575 are 198 unit + 62 block + 235 satin + 74 system + 5 + 1 system-contracts; the 571 are 200 unit + 62 block + 235 satin + 74 system, and no suppression was written for the wave. The numbers compose exactly: the shared base of the SALT pricing and interceptor series is 0bdd941b, the tree after the first two merges, which measures 380 (the wave-1 head has no tests/block target, so it cannot be this base); the gas lane's second series reached 464 and the block lane's 473, so the merged tree is 464 + 473 − 380 = 557, what e44b59bd measures. The seam's three rows take it to 560, the interceptor round adds four, the merge round five and the re-pin six, for 575.

Benchmarks

The numbers move, and they are meant to: the schedule the benches run under is a different schedule. Read the CodSpeed report for the instruction-count delta; the figures below are one laptop's wall-clock, Criterion's median of 100 samples, both arms of a pair sharing one CfgEnv.

transact bench base (438783a8) this branch
empty_transaction/satin 1.87 µs 1.61 µs
empty_transaction/op_revm 1.92 µs 1.57 µs
ether_transfer/satin 2.70 µs 1.67 µs
ether_transfer/op_revm 1.66 µs 1.62 µs
deep_calls/satin 24.51 µs 24.43 µs
deep_calls/op_revm 22.01 µs 22.34 µs
storage_writes/satin 20.50 µs 21.09 µs
storage_writes/op_revm 17.27 µs 17.99 µs
logs/satin 7.53 µs 7.52 µs
logs/op_revm 6.72 µs 6.61 µs

storage_writes is the workload that moves (+3%, both arms): 200 fresh slots now draw state gas the Osaka schedule priced at zero. deep_calls and logs are flat, and the two small transactions moved in both directions (noise). New on this branch: intercepted_calls, 200 STATICCALLs an interceptor answers, 25.0 µs against op-revm's 61.4 µs running the bytecode; system_address_misses, the same address with a selector it does not intercept, 70.3 µs against 68.5 µs, the ~2.6% being what 200 calls pay for a dispatch that declines; and salt_storage_writes and salt_new_accounts, each run against a minimal environment and one holding every bucket at eight times the minimum, with a check that the crowded arm pays eight times the state gas — both within noise locally (≈4.1 µs and ≈5.8 µs per transaction). The block bench runs two workloads of 64 transactions, transfers and storage writes, through MegaBlockExecutor end to end. factory/create_evm is 768 ns and create_evm_with_inspector 813 ns, and corpus runs between 1.38 µs (system_call) and 19.79 µs (ecrecover); those base-side runs were cut short. All four targets exit 0.


Open items

The gas table:

  • Whether state creation should be charged on both dimensions — a pricing decision, not a defect. The pressed-back list is the settled one and this series implements it exactly; four of its entries are ones EIP-8037 moved onto the state dimension, so at these prices a new account costs 25,000 regular and 183,600 state, a CREATE 32,000 and 183,600, a slot's first write 22,100 and 97,920. Devnet-8 charges those four entries 0 (new_account_cost), 12,000 (create), 12,000 (tx_create_cost) and 10,000 (sstore_set_without_load_cost) against the same state charge. The team decides whether the four keep Osaka's regular price or take devnet-8's; either way it is one edit to the list plus a regenerated pricing table, and every number above is already pinned by a test that would fail loudly.
  • The byte prices, the execution cap and the size limits are provisional until the economics sign-off; constants.rs says so and the schedule reads them through SatinPrices, so a repricing is a constant change plus a regenerated pricing table.
  • cphb is carried and installable but prices nothing: the schedule's code_deposit_history_gas entry stays at zero until history gas lands.
  • The MegaEvmFactory builder is handed MegaSpecId, which is always SATIN today. The parameter is kept because the node's RPC calls it that way; it becomes meaningful again when a second spec exists.

The block executor:

  • The EnrichedMegaTx trust boundary. run_transaction uses the hash and sizes the caller reports without checking them against the encoding in release builds, so an understated size would pass a limit that should have refused it; a debug build cross-checks them and trips there. It is the sequencer's own mempool figures, and the type says so — but it is a trust boundary a reviewer should agree with.
  • The state-gas and write-record block limits are accumulated and not enforced. The counters and the check's insertion point are here; the enforcement belongs to the state-gas limits and to the state-growth and KV limits, and the two parked KV rows wait for them.
  • A schedule without Canyon or Regolith is not a chain Satin runs. Two receipt tests build one anyway, because the two receipt fields are read from the schedule and that wiring is worth pinning; if the reviewer would rather not have a configuration the chain cannot reach in the suite, the alternative is a signed suppression for the two mutants. Satin implements none of alloy-op-evm's other pre-Regolith deposit rules (its validate_block_gas and the gas a pre-Regolith deposit does not get back), because Karst is past Regolith; those two tests read receipt fields only and assert nothing about gas.
  • The DA footprint scalar is read per transaction (a state read per transaction, cached by the database). The fork states the rule that way — the block's own L1 info transaction may set the scalar — but it is a per-transaction read on the hot path, and a block-level read would be cheaper if the rule allowed it.
  • The block-hash record costs one map insert per BLOCKHASH. Recording in the Host is what makes the record a per-block read set, and BLOCKHASH is not in either bench workload, so the CodSpeed report does not price it. It is one BTreeMap insert on an opcode that reads the chain's history, which is already a database read.

SALT pricing:

  • Whether the L1-attributes deposit should be system-originated — a question for the specification, not a defect. The block's own L1 info transaction is produced by the protocol, not by a user, but the system-origin predicate does not match it: it is a deposit, and deposits are deliberately not exempt, because a deposit is a shape a user can produce. So it prices at the real multipliers of whatever it writes. That costs nothing today, because it overwrites slots that are already set; it draws a state charge only when it writes a slot that was zero — which is what an upgrade adding a field to the L1 block contract does — and at that moment a crowded bucket makes a mandated write more expensive inside a gas limit the transaction cannot raise. It would join the system-origin rule alongside the sequencer's own system transaction if the team wants it there; that is one branch in the predicate and a sentence in the dual-gas model page.

The system contract interceptors:

  • What the two control contracts answer is provisional. isVolatileDataAccessDisabled() answers false and the two switches change nothing, because the tracker that would remember them is detention's; remainingComputeGas() answers the forwarded regular gas rather than a compute ledger's remainder. Both are documented as such in the code, and a contract calling them on this engine gets an answer that is true of the engine as it stands — but they are the two places where a later mechanism changes an answer a caller can read.
  • One of the Oracle's two hint rules is new. The zero-gas suppression is the legacy engine's own and is carried over; the value suppression is what Satin adds, and it is what stops a value-bearing call that reverts in the bytecode from leaving the service a hint its sender never sent. Neither rule says anything about the frame that runs afterwards — an admitted hint is a synchronous, irreversible side effect, and a hint forwarded with one gas reaches the service before the frame runs out of gas. The rows that pin hint metering belong to the oracle and control contracts and stay parked, so this series pins the rules and they will pin the metering.
  • The system address is the constant. The SequencerRegistry can rotate it, and reading the rotated address out of the registry's storage belongs to system contract deployment; until then MEGA_SYSTEM_ADDRESS is what the promotion and the deposit-caller charge compare against.
  • The keylessDeploy overhead is charged by shrinking the frame's gas limit. That is what makes the caller pay it whatever the frame then does, and it is why the dispatch takes the frame mutably. When native keyless deployment turns the frame into a creation, the creation inherits the already-charged limit.
  • is_system_originated now has one caller, and waits for its second. It is the predicate that tells the protocol's own work from a user's — the pre-block calls and a system-address transaction, before or after the promotion — and it exists here because the promotion is what makes the distinction expressible. SALT pricing reads it as of the seam commit below, so the exemption it names is pinned as a price; history gas is the caller still to come, and until it lands a system transaction's history ledger is pinned only as zero.
  • The engine classifies system senders; it does not authenticate them. is_system_originated and the promotion read inputs the node has already authenticated: a legacy transaction whose signature recovers to the system address is the sequencer's by key custody, and a deposit-shaped transaction carrying the reserved source hash is not a shape the mempool admits, because deposits reach the engine only from the node's own derivation. The legacy engine draws the boundary in the same place. Nothing here is a defect, but SALT pricing already prices by this predicate and history gas will exempt by it, so the node's transaction admission is where the guarantee has to hold — one for the node integration review rather than for this pull request.
  • The sendHint value condition is not on the specification page yet. docs/spec/system-contracts/oracle.md lists the admission conditions of a hint and does not include the no-value rule this series implements. The page belongs to the specification-update series; nothing in this wave touches docs/spec/.
  • The already-promoted refusal also refuses an L1 deposit whose aliased sender is the system address. A deposit that arrives carrying a source hash is refused because its type is not legacy, and the check does not first ask whose deposit it is; an L1 deposit whose aliased sender happened to equal MEGA_SYSTEM_ADDRESS would be refused with it. The legacy engine refuses the same shape the same way, and no aliased L1 sender can be that address in practice, so this is recorded rather than changed.
  • The source-hash arm of is_system_originated does not ask who the caller is. It matches the reserved source hash whatever the caller, while validate_and_promote returns early for a caller that is not the system address — so a deposit handed to the executor with that source hash from another caller would be exempt from the bucket multiplier without ever being refused. This is the legacy engine's shape exactly, and the shape is not buildable through the mempool or L1 derivation, which is where the guarantee actually holds; it belongs with the trust-boundary item above rather than being a defect of its own.

The schedule is Amsterdam's with the seventeen entries EIP-8038 repriced pressed
back to their Osaka values and the EIP-8037 state-gas entries rebuilt from
MegaETH's cost per state byte. The byte prices are an input, so a measurement
build can run other ones through the std-only satin-price-override feature or
the MEGA_SATIN_CPSB / MEGA_SATIN_CPHB environment variables; off by default.

Nothing installs the schedule yet.
Adds the schedule block execution reads a chain through: `MegaHardforks`
over `OpHardforks`, the `MegaHardforkConfig` that carries the Ethereum,
Optimism and MegaETH forks together, and `HardforkParams` for the values a
fork needs from the chain configuration.

A schedule is checked once, when it is loaded: `validate_schedule` refuses a
fork registered on a block number or total difficulty, which the
timestamp-scoped resolution would never activate, and `require_params` is the
rule a fork registers so a schedule that activates it without its parameters
fails at load rather than at the fork's first block. Satin requires no
parameters yet, so the registration point is a named block in
`validate_schedule`.

`spec_id` answers `Option<MegaSpecId>`: before the Satin activation a chain
runs its pre-Satin engine, which this crate does not execute.

The chain table now builds the schedules it describes, so the two cannot
drift: `mainnet_hardforks`, `testnet_hardforks` and the unknown-chain
fallback all come from `CHAIN_ACTIVATIONS`. Satin stays unscheduled on both
known chains; an unknown chain runs `FALLBACK_RUNG` from genesis, pinned here
rather than taken from `MegaSpecId::default`.

Ports the 17 parked unit rows of the schedule and the chain table.
`BLOCKHASH` reads bypass the journal, so a witness built from a transaction's
state alone misses them. `BlockHashes` reads the record revm's `State`
already keeps, and `MegaEvm` exposes it, so block execution and the node's
stateless witness share one source. Clearing the record attributes the next
reads to one transaction; it is a cache, so clearing changes no execution
result.

Ports the two parked rows of the state wrapper.
…limits

MegaContext::with_cfg now writes the Satin schedule, the EIP-8037 and EIP-2780
switches, the 200M execution cap, the EIP-7708 and system-call-margin switches
that stay off, and MegaETH's 512 KiB contract / 1 MiB initcode limits, whatever
the caller passed. The instruction table activates the Amsterdam opcodes the
base spec gates off, before the metering wrappers go in.

The tests that were written against the Osaka schedule move with it: the op-revm
baseline pins the Satin table on both engines, and the cases whose gas limits no
longer cover the state gas a new slot or account draws are raised.
`BlockLimits` is what a node configures; `BlockLimiter` is what one block
keeps while it runs. A limit known before execution — a transaction's gas
limit, encoded size and data-availability size, and what each adds to the
block — refuses the transaction before it runs. A limit only execution
reveals is accumulated at commit and checked before the next transaction, so
the transaction that crosses is still packed and the ones after it are
refused.

Of the three gas ledgers only execution has a block limit. History has none.
The state ledger and the write-record count are accumulated with nowhere to
be enforced yet: the state-gas block limit arrives with the state-gas limits,
the write-record limit with the state-growth and KV limits.

A deposit is exempt from both data-availability dimensions and adds nothing
to the block's, since the chain cannot censor it.

`MegaTxLimitExceededError` and `MegaBlockLimitExceededError` say which of the
two happened: the transaction can never be included, or this block has no
room for it.

Ports the seven parked rows of the block limits and the limit errors.
The block-level limits are stated over a transaction's hash, its encoded size
and its data-availability size, none of which is part of the transaction
environment. `MegaTransactionExt` is where block execution reads them, with
defaults that recompute both sizes from the encoding.

`EnrichedMegaTx` carries them already computed, for a caller that has them —
a sequencer's own mempool. The values are trusted as they are and never
checked against the encoding, so an understated one would pass a limit it
should have been refused by; the type documents that and `new_slow` is the
answer anywhere else.

The data-availability estimate is the fork's own, read through op-revm, so
the engine and the node agree on the figure by construction.

Ports the three parked rows of the transaction helpers.
The set is op-revm's Karst set with KZG point evaluation repriced to 100,000.
It is carried as an alloy-evm PrecompilesMap, so a node's RPC can add or replace
an address through MegaEvmFactory::with_dyn_precompiles_builder.

Public surface: MegaEvm's and MegaEvmFactory's Precompiles associated type is
PrecompilesMap instead of OpPrecompiles, and the trait impls on MegaEvm take
alloy-evm's Database bound, which the map's provider requires.
Adds the calls a block makes around its transactions: the EIP-2935 block
hashes call, the EIP-4788 beacon root call, and the post-block balance
increments. Each helper runs its call and hands back the state it produced,
and none of them commits — the node's witness generator has to see the read
and write set of every step, which a helper committing straight to the
database would hide.

Ports the parked row of the pre-block call helpers.
`MegaBlockExecutor` is alloy-evm's `BlockExecutor` over a `MegaEvm`, and
`MegaBlockExecutorFactory` is what a node builds one from. Both mirror
alloy-op-evm's shape for an OP chain, with the rules the Karst base brings and
the counters the common execution layer defines.

The three block rules:

- A block in which a fork activates admits only deposit transactions. The
  caller sets the flag from the chain's schedule (`admits_only_deposits`), and
  a user transaction in such a block is refused before it executes.
- A non-deposit transaction's data-availability footprint is its compressed
  size times the footprint gas scalar the L1 block contract holds, accumulated
  against the block's gas limit and reported as the block's blob gas used. The
  scalar is read per transaction, as the fork states the rule: the block's own
  L1 info transaction may set it. With no scalar in state it is zero and the
  rule costs nothing.
- The L1 block info is read once, at the start of the block. An empty L1 block
  contract reads as zeroes rather than as an error, and a caller that placed
  its own info for this block keeps it — the read is conditioned exactly as
  op-revm's handler conditions its own, so the two never disagree.

The executor fills the block's three gas ledgers from every transaction, and
`finish_with_counters` reports them beside the receipts; the header's gas used
keeps its meaning as the sum of the receipts.

A rewriting inspector cannot reach a block: `apply_pre_execution_changes`
refuses one, and the factory's trusted-inspector constructor is the only way
an inspected transaction is executed in a block.

`apply_pre_execution_changes` names two hook points that are empty today:
system contract deployment and the pre-block system calls.
…l transactions

Four test targets under tests/satin: the intrinsic numbers and the state gas a new
slot, a new account and deployed code draw; the code-size limits on a creation
transaction and on a CREATE; the EIP-7976 calldata floor, the EIP-7981 access-list
bytes and the calldata the execution cap admits; and what a KZG evaluation costs,
including every way its verification can fail.

The Amsterdam opcodes are covered by running them: without the table-level
activation the same bytecode halts.
`run_transaction` is the entry point for a caller that already knows a
transaction's hash and sizes — a sequencer whose mempool computed them at
insertion — and it skips the compression estimate the size limits are stated
over. The figures are used as they are, so an understated one would pass a
limit it should have been refused by; a debug build cross-checks them against
a fresh recompute and trips there instead.

`commit_transaction_outcome` re-checks the block's admission before
committing, which is what a builder that executes candidates and then picks
among them needs; the trait's commit-condition path goes through it.

`MegaTransactionExt` drops the `Encodable2718` bound its two size methods
carried, so the trait alone is what the executor reads a transaction through.
pricing-table.md lists every named schedule entry at the Osaka, the Amsterdam and
the Satin price, and nine probe transactions with what each spent by ledger; the
test renders it and compares it to the checked-in file.

The 34 rows the pending ledger owes the Satin gas table run in real test targets
now and are deleted from _pending: the code-size limits, the calldata floor and
the validation boundaries, the KZG price and the dynamic precompile builder.
The module table gains the schedule, the byte prices and the precompile set; the
execution notes say what the schedule changes, which Amsterdam opcodes the table
activates and how a measurement build reprices; the constants table names the
schedule as their reader. Prettier reflowed the module table's column widths.

REVIEW.md gains the rule that the pricing table is generated, not hand-edited.
A test target of its own for block execution, with the three rules each on
its own fixture: an activation block that refuses a user transaction and
executes a deposit; a data-availability footprint that is inert without a
scalar, is reported as the block's blob gas with one, and refuses a
transaction that does not fit the block's budget; an L1 block info read that
answers zeroes on an empty contract and leaves a caller's own info for this
block alone. The operator fee pins the Karst formula, which multiplies where
the earlier one divides to zero.

The counters: a block sums each ledger of its transactions, the result
carries the three of them next to the footprint, the execution dimension
packs the transaction that crosses its limit and refuses the next, and the
state and history ledgers refuse nothing.

The admission gate: a declared observer reaches a block, an undeclared
inspector is refused before the block's first call, and a disabled one is
admitted because it rewrites nothing.

Ports the 23 parked rows of block execution: the block limits, the deposit
data-availability exemption, the accessed block hashes, the factory's runtime
limits, the canonical schedules and the params validation at load. The two
rows whose KV limit semantics are undecided stay parked.
Two workloads of 64 transactions each — value transfers and storage writes —
run through `MegaBlockExecutor` with the block's limits unset, so what the
numbers carry is the executor's own per-transaction work: the admission
checks, the size estimates, the counters, the receipt and the commit. The
measurement covers the whole block a node executes, from the pre-block calls
to the finish.

Each workload also runs once outside the measurement, so a block that does
not execute fails the bench rather than being timed.
The one-byte vector lands on the Osaka 500-gas minimum, which distinguishes the
entry from Berlin's 200 but not the formula from the floor. A 32-byte vector
with a full 32-byte exponent costs 4,080 — the multiplication complexity times
the iteration count, with the divisor EIP-7883 removes.
The module table's `block/` row, the bench list and the test targets name what
is there now; the executor's three block rules and its two empty hook points
are stated where the engine's behavior is described. The per-fork parameter
rule and the pre-block helper rule are no longer conditional: both have code
to point at.
…swer

Two mutation survivors of the provider: warm_addresses returning an empty set,
and contains answering the same for every address. The first is observable in
gas — a call to a precompile pays no cold-account surcharge where a call to a
plain address does; the second has no caller inside revm's execution path, so a
direct test covers what a node's RPC asks.
…ed files

The rows ported out of these two files took their trailing content with
them and left the files ending in a blank line, which `git diff --check`
reports. Both files keep the rows that stay parked.
The precompile builder is a closure with no Debug, so the impl reports it as a
flag; nothing asserted the output, which left the impl's body unexercised.
The diff-scoped mutation gate reported 25 survivors on this series, every
one of them a property no test read:

- The pre-block calls. Neither the EIP-2935 nor the EIP-4788 call was
  observed by a test: the two contracts have no code in the test database, so
  a call that ran and a call that did not left the same state behind. A new
  test target plants a recorder at both addresses and pins what each call
  records, that a chain before Prague or before Cancun makes the call it has
  not reached, that the genesis block makes neither, and the two refusals of
  EIP-4788 — a genesis block with a non-zero root, and a Cancun block with
  none.
- The receipts. A deposit's nonce and receipt version follow the chain's
  schedule, which a test now reads on a chain past Regolith and Canyon and on
  one past neither, and a block's receipts are read while it is built and
  again from its result.
- The limits. Every dimension of the pre-execution check is an inclusive
  bound, one case per dimension: what exactly fills the limit — or what the
  block has left of it — is admitted and one unit more is refused. The block's
  available gas is what its refusal reports. The data-availability footprint
  gets the same boundary through the executor: a footprint that exactly fills
  the block's budget is admitted and leaves the block with none.
- The schedules. The canonical chain schedules register every fork, including
  the ones the chain has not scheduled, which is what attaching a fork's
  parameters needs; removing one fork leaves the rest of the schedule alone.
- The transaction helpers. A recovered borrow of an envelope reports the same
  hash and sizes as the envelope and as a recovered envelope.
Block execution fetched the L1 block info at the end of
`apply_pre_execution_changes`, from the parent block's state, and stamped it
as the info of this block. op-revm's handler reloads only when the info is
for another block, and returns early for deposits before that check, so every
user transaction of the block was priced with the values from before the
block's own L1 info deposit.

alloy-op-evm's executor pre-loads no info: op-revm fetches it when it deducts
the caller of the first non-deposit transaction, which is after the L1 info
deposit has committed. Do the same and fetch nothing before the block's
transactions. A caller that placed its own info for this block still keeps it,
which is op-revm's own condition. The data-availability footprint reads the
scalar from storage per transaction and is unchanged, so the two paths now
read the same block's values.
The gate was checked once, in `apply_pre_execution_changes`. A disabled
rewriting inspector passed that check and could then be enabled through
`set_inspector_enabled` on the EVM the `BlockExecutor` trait hands out, and a
caller that never ran the pre-execution changes was never checked at all.

alloy-evm's `BlockExecutorFactory` asks for an executor for every
`I: Inspector`, so the refusal cannot be a bound on the type. Check it at
every entry point instead — the pre-block changes, the execution of a
transaction, the commit of an outcome and the end of the block — before
anything changes. The trusted constructor stays the compile-time proof that
an inspector writes nothing back; the generic one is checked at every call.
`MegaBlockExecutor::new` set up the block limiter but left the EVM's
transaction-level limits alone, so a node that used the documented
constructor ran the block's transactions under whatever limits the EVM it
built happened to carry — unlimited, for an EVM straight from the factory —
while the two factory routes installed them.

Install them in the constructor, which every route goes through, and drop the
two installs the factory made.
`commit_transaction_outcome` re-ran the block's ordinary admission check but
never looked at the outcome's data-availability footprint, so two candidates
that each fit the block's footprint budget when they were simulated could both
commit and carry the block's blob gas used past the budget.

Check the footprint against what the block has left, before any counter,
receipt or state moves, and share the check with the one the execution path
already made.
The record was revm's `State` cache read back out. The cache outlives one
block, so a database reused over a range of blocks, or one whose cache was
filled in advance, reported hashes the block being executed never asked for —
and nothing emptied it when a block started.

Record the reads where they happen instead: the Host keeps a `BlockHashRecord`
in the context, block execution empties it when the block starts, and the
getters read that. The record cannot be reset through the `BlockExecutor`
trait's database bound, which alloy-evm fixes at `StateDB`, so keeping it
beside the cache is also what makes the reset expressible. The cache keeps
serving what it holds, so nothing is fetched twice.
The workload stored the same value in the same slot 64 times, so only the
first transaction changed storage: the other 63 wrote back the value the slot
already held, the write record was taken back, and what the bench measured was
an unchanged-value SSTORE.

Each transaction now names its own slot in its calldata, and the run outside
the measurement asserts that every transaction of both workloads succeeded and
kept a write record — which the old workload fails.
…ngine adds

The zero-gas suppression is the legacy engine's own and is kept; the
value suppression is what is new. Positive gas is not a promise that the
bytecode runs either: a hint forwarded with one gas reaches the service
and the frame then runs out of gas, which a test now pins. An admitted
hint is a synchronous, irreversible side effect whatever the frame does
afterwards.
The case asserted that the two receipts differ by less than the charge
without saying why. It now pins the floor itself, computed by revm from
the schedule both engines run on: Satin's receipt is its own raw spend,
and op-revm's is the transaction's EIP-7623 floor.
…gated creation

The port left both nonces at zero, where the address the authority's own nonce
derives and the address the delegate's nonce derives from the authority are the
same address, so the arm could not see a creation priced at the delegate's
nonce. Give the two accounts different nonces and crowd the three candidate
addresses in turn: the authority's own, the one the delegate's nonce would
derive from it, and the one the delegate's own address derives. Only the first
is read, and it is where the frame deploys.
…lper

The refund matrix left two routes unpinned at a non-minimum capacity. The
public settlement helper was only exercised over a plain context on the flat
schedule, so nothing proved it gives a SALT-priced upfront charge back; and the
one deposit arm returns 0xEF, which validation refuses before a deposit charge
is ever made, so no rolled-back deposit charge was covered either.

Charge a call and a creation through the pricing hook at eight times the
minimum bucket, carry the site and the upfront flags into a synthetic failure
and settle it: both pools land where they started, the state ledger nets zero
and one capacity read priced charge and refund alike, whether the reservoir
covered the charge or it spilled onto regular gas. Beside it, a creation whose
init code writes a crowded slot and then cannot pay for the bytes it returns:
both charges are made, priced and then rolled back, with a control whose
deposit fits and keeps all three. The 0xEF arm says what it actually covers.
The refund tests' header still described all three refund moments as re-pricing
the charge through the hook. Four sites do — the restore, a caller's upfront
charge, the transaction-level refund and a synthetic settlement — while a frame
that fails rolls its own charges back by the amounts it recorded, asking for no
price at all. Both cancel exactly, for different reasons; the header now says
which is which.
Four parked rows pin a helper at a legacy spec boundary: it is served from one
rung and asserts below it, so a tightened or loosened gate fails them. Satin is
a single spec and has no gate of its own, so no later mechanism can port them.
They move to the retired table with that reason and leave the parked file,
which then holds nothing and goes with them.
The arm compared only the state ledger and the capacity reads across the two
bucket sizes, so it could have passed on a transaction that priced correctly
and wrote nothing. Read the Oracle slot back out of the state the transaction
produced, and compare the whole receipt rather than its state ledger alone.
The ported row drove the system-call entry point directly, which the two arms
beside it already pin; what its original pinned was the block's own pre-block
phase surviving a crowded region, through the executor and the two contracts'
own code. Drive it there: the EIP-2935 and EIP-4788 calls run at the minimum
bucket and at a hundred thousand times it, their ring-buffer slots hold the
parent hash and the beacon root either way, the block's ledgers agree and no
capacity is read at all. A user transaction in the same block is the control:
it reads a capacity and cannot afford the one slot it writes.

The entry-point arm keeps its scenario under a name that says what it drives.
The helper's doc comment said both pools come back to where they were before
the charge. The reservoir does; the regular pool comes back higher, because the
frame's own forwarded gas returns with the spill. Say which is which.
The pinned tag derived a creation transaction's pricing site from the envelope
nonce, while frame creation derives the deployed address from the caller's
journal nonce. The two agree for an ordinary transaction, whose nonce is
validated against the account's, and part for a deposit, whose nonce is not:
the charge then landed in the bucket of an address the transaction never
deploys at, priced by a capacity nothing it wrote lives under. The new tag
reads the same journal nonce both phases use and keeps that address for the
refund.

All twelve patch entries move together, or the build resolves a second revm;
the lockfile moves with exactly those twelve. The op-revm pin stays where it
is: it takes revm through this patch block and overrides nothing on this path.
… envelope says

Four rows were parked on a fork that derived a creation transaction's pricing
site from the envelope nonce. The nonce a deposit carries is never compared to
the sender's account, so the charge could land in the bucket of an address the
transaction does not deploy at: the leaf it really adds was priced by a region
it does not live in, and a region it does live in went unread.

The rows come back in this engine's shape, with the sender at nonce 5 and the
envelope at 0. The deployed address's bucket prices the leaf and is read once;
the envelope's address is never priced, never read and never written; a
capacity only the deployed address's bucket cannot report fails the
transaction, and the same failure at the envelope's address is never asked for;
a reverting creation gives the charge back in the bucket it was made in. The
control runs both envelope shapes at equal nonces, where there is one address
to derive and the two agree.

The old fork passes only that control.
"Create target" reads either way now that the two candidate addresses are
pinned apart: say the one the transaction deploys at.
@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 21, 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 21, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 6.57%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 6 regressed benchmarks
✅ 19 untouched benchmarks
🆕 10 new benchmarks
⏩ 385 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ empty_transaction/satin 43.7 µs 47.8 µs -8.46%
❌ system_call 49.8 µs 53.3 µs -6.56%
❌ two_txs 89.1 µs 95.3 µs -6.53%
❌ sstore_set 69.6 µs 74.3 µs -6.37%
❌ calldata_floor 69.4 µs 74 µs -6.23%
❌ create 86 µs 90.8 µs -5.26%
🆕 storage_writes N/A 2.1 ms N/A
🆕 transfers N/A 2 ms N/A
🆕 intercepted_calls/op_revm N/A 501.4 µs N/A
🆕 intercepted_calls/satin N/A 264.7 µs N/A
🆕 salt_new_accounts/crowded N/A 96.4 µs N/A
🆕 salt_new_accounts/satin N/A 95.9 µs N/A
🆕 salt_storage_writes/crowded N/A 78.5 µs N/A
🆕 salt_storage_writes/satin N/A 78.2 µs N/A
🆕 system_address_misses/op_revm N/A 497.2 µs N/A
🆕 system_address_misses/satin N/A 553.1 µs N/A

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-w2 (b16d1b3) with cz/feat/satin-t2-skeleton (438783a)

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 21, 2026

Copy link
Copy Markdown
Contributor

Label check: spec:new looks incorrect on this PR.

spec:new is described as "Introduces a new MegaSpecId variant." The SATIN variant is defined in crates/mega-evm/src/evm/spec.rs, which already exists on this PR's base branch (cz/feat/satin-t2-skeleton) and is untouched by this diff — this PR only adds mechanisms (gas table, block executor, SALT pricing, system contract interceptors) on top of the existing single-spec skeleton, per its own summary ("Wave 2 of the Satin engine... on top of the pull request that laid the Satin skeleton"). Per AGENTS.md, SATIN is "the unstable spec under active development," so spec:unstable ("Changes to the unstable spec") is the label that matches, not spec:new.

The other labels (api:breaking, comp:core, dependencies, agent) all check out against the diff and the PR summary.

@claude

claude Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Label check

The spec:new label looks incorrect. Per AGENTS.md, MegaSpecId has a single rung, SATIN, and this PR builds on top of the base branch (cz/feat/satin-t2-skeleton) that already introduced it — the diff here only uses MegaSpecId::SATIN / MegaHardfork::Satin (e.g. in evm/mod.rs, block/hardfork.rs, block/chain.rs); it doesn't add a new MegaSpecId variant. spec:new ("Introduces a new MegaSpecId variant") doesn't apply here.

Since this PR makes extensive changes to the Satin engine itself (gas schedule, block executor, SALT pricing, system contract interceptors), spec:unstable ("Changes to the unstable spec") is the label that fits instead.

Other labels look right: api:breaking (the PR body documents the Precompiles/Database bound changes as breaking), comp:core (all changes are in crates/mega-evm), and dependencies (the revm fork pin bump in Cargo.toml/Cargo.lock).

@claude

claude Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Label check

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

  • spec:new ("Introduces a new MegaSpecId variant") looks incorrect here. This PR builds out mechanisms (gas table, block executor, SALT pricing, system contract interceptors) for the existing SATIN rung — crates/mega-evm/src/evm/spec.rs, where MegaSpecId is defined, isn't touched by this diff, and the PR description never mentions adding a new variant. Per AGENTS.md, Satin is "the unstable spec under active development... its behavior may change until it is sealed," which matches spec:unstable ("Changes to the unstable spec") rather than spec:new. Suggest swapping spec:new → spec:unstable.
  • api:breaking, comp:core, and dependencies (Cargo.lock/Cargo.toml revm fork bump) all look correctly applied.

@codecov

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

@claude

claude Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Label check:

api:breaking, comp:core, dependencies, and agent all look correct given the public-surface changes (Precompiles associated types, DB bound narrowing), the core-crate scope, the revm fork re-pin in Cargo.toml/Cargo.lock, and the AI-authored provenance.

@claude

claude Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Label check

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

The previous spec:new → spec:unstable swap has been made — that's correct now, since this PR only changes the behavior of the existing, still-unstable SATIN spec (gas table, block executor, SALT pricing, system contract interceptors) rather than adding a new MegaSpecId variant.

Remaining issue:

  • comp:doc and comp:misc look missing. This PR makes substantial non-core changes: root AGENTS.md (+59/-18), crates/mega-evm/README.md (+47/-3), crates/mega-evm/src/external/AGENTS.md (+6/-1), a new crates/mega-evm/src/system/AGENTS.md (+42), and crates/mega-evm/tests/_pending/README.md (+98/-51) — all doc content, matching comp:doc ("Changes in the documentation"). It also touches .github/workflows/benchmark.yml and root REVIEW.md, the same kind of repo-infra files that earned comp:misc ("Changes to the miscellaneous part of this repo") on PR feat!: restart mega-evm as the Satin skeleton on the revm 40 forks #385, the base "Satin skeleton" PR this one builds on and the closest precedent for this line of work.

api:breaking, comp:core, dependencies, and agent all look correct given the public-surface changes, the core-crate scope, the revm/op-revm fork re-pin in Cargo.toml/Cargo.lock, and the AI-authored provenance.

@github-actions

Copy link
Copy Markdown

🧬 Mutation testing — ✅ PASS

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

  • caught: 215
  • survived (real gaps): 0
  • timed out (inconclusive): 0
  • suppressed (equivalent/dead-code): 0
  • unviable: 245 · 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