Repository navigation
Conversation
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.
🧬 Mutation testing — ✅ PASSNothing to test — no mutants were generated on the changed lines. |
Merging this PR will degrade performance by 6.57%
|
| 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)
Footnotes
-
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. ↩
|
Label check:
The other labels ( |
|
Label check The Since this PR makes extensive changes to the Satin engine itself (gas schedule, block executor, SALT pricing, system contract interceptors), Other labels look right: |
|
Label check Current labels:
|
|
Label check:
|
|
Label check Current labels: The previous Remaining issue:
|
🧬 Mutation testing — ✅ PASSDiff mutation score: 100.0% (215/215 viable mutants killed)
No new test gaps introduced by this change. 🎉 |
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).MegaBlockExecutorbecomes alloy-evm'sBlockExecutorover aMegaEvm, the shape alloy-op-evm'sOpBlockExecutorhas 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.MegaETHactually 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.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_originatedwith 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.rsandsrc/evm/mod.rs, with two series each, and every series is merged with--no-ffso 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), andcrates/mega-evm/tests/_pending/README.md. The second merge (0bdd941b) resolvedAGENTS.mdand the generated ledger too, plussrc/evm/context.rsandsrc/evm/mod.rs— oneuselist each, andwith_dyn_precompilesplaced 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.rsasserted 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.rsgave 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.rsbudgeted 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.
warm_storage_read_costcold_account_additional_costcold_storage_additional_costcold_storage_costtransfer_value_costnew_account_costnew_account_cost_for_selfdestructsstore_staticsstore_set_without_load_costsstore_reset_without_cold_load_costsstore_set_refundsstore_reset_refundsstore_clearing_slot_refundcreatetx_create_costtx_access_list_address_costtx_access_list_storage_key_costThree 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 valueCALLcreating its recipient pays 25,000 regular and 183,600 state where Amsterdam pays 0 regular, aCREATE32,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_costalone prices nothing, EIP-2780 charging a creation throughtx_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_cost0 → 9,000,tx_create_access_cost0 → 12,000,code_deposit_cost200 → 0,tx_floor_cost_base_gas21,000 → 12,000,tx_floor_cost_per_token10 → 16,tx_floor_token_zero_byte_multiplier1 → 4,tx_access_list_floor_byte_multiplier0 → 4,tx_eip7702_regular_gas25,000 → 7,816,tx_eip7702_regular_refund12,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_gasandcreate_state_gas), 1 → 1,530 (code_deposit_state_gas), 23 → 35,190 (tx_eip7702_state_gas_bytecode), andcode_deposit_history_gasstays 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_cfgoverwrites each of these whatever the caller passed, with a named test per switch:gas_paramsto the Satin schedule;enable_amsterdam_eip8037on, for the state dimension and the reservoir;enable_amsterdam_eip2780on, without which a creation's state gas has no phase that charges it;tx_gas_limit_capat 200,000,000, the execution cap, gas above it becoming reservoir and the transaction's own limit staying uncapped;enable_amsterdam_eip7708off;system_call_state_gas_margin_in_reservoiroff;limit_contract_code_sizeat 524,288 (512 KiB),limit_contract_initcode_sizeat 1,048,576 (1 MiB).The Amsterdam opcodes and the intrinsic numbers
The ungated
DUPN,SWAPN,EXCHANGEandSLOTNUMare 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'skarst()set with KZG point evaluation repriced tokzg_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::PrecompilesandEvmFactory::Precompilesbecome alloy-evm'sPrecompilesMapwhere they wereOpPrecompiles, andMegaEvm's trait impls takeDB: alloy_evm::Databasewhere they tookDB: revm::Database— the associated type was documented as provisional in wave 1, and the bound narrows because alloy-evm implementsPrecompileProviderforPrecompilesMaponly over its ownDatabase. The new public items are the schedule and byte-price accessors, the precompile set, the dynamic-precompile route andconstants::{MAX_CONTRACT_SIZE, MAX_INITCODE_SIZE}.The pricing table and the op-revm baseline
crates/mega-evm/tests/satin/pricing-table.mdlists 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 -> 1135,026 = 37,106 + 97,920;SSTORE 0 -> 1 -> 029,770 against 37,212 regular; a valueCALLto an empty account 232,921 = 49,321 + 183,600;LOG1with 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 usedis the receipt's figure, net of refunds and at least the floor, hence the0 -> 1 -> 0row 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 withUPDATE_SATIN_PRICING_TABLE=1 cargo test -p mega-evm --test satin.tests/satin/equivalence.rsruns the same transaction throughMegaEvmand op-revm'sOpEvmon the sameCfgEnv, comparing everyResultGasfield, the logs and the state; both arms take theCfgEnvtheMegaContextholds, 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 intotests/satin/intrinsic.rs, 7 intotests/satin/precompile_gas.rsandschedule.rs, 7 into the unit tests ofsrc/evm/precompiles.rsandfactory.rs; five emptied parked files are gone and the ledger's total drops 747 → 713. Every row is arewriteexcept 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, andgas_usedwhere 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.
docs(evm): say what the pressed-back entries do to state creationdocs(satin): name the intrinsic recipient charge as EIP-2780's ownconstants.rsnamed the wrong reader of the state-gas constantsdocs: say the tests read the two state-gas constantssatin-price-overridepath was never runtest(evm): run the byte-price override through a child test processtest(satin): cover the EIP-3541 rejection of a deployed 0xEF byteThe 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_GASandACCOUNT_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 aMegaEvm;MegaBlockExecutionCtxcarries the parent hash, the parent beacon block root,extra_data,no_user_tx_activation_blockandBlockLimits.MegaHardforks:OpHardforksplus the MegaETH forks,mega_fork_activationthe 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&Specand clone nothing per block.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.MegaEvmrather than anyEvm.run_transaction(execute with the hash and sizes the caller reports),commit_transaction_outcome(re-check gas, sizes and footprint, then commit),finish_with_countersand the block-hash record's accessors;finishreturns no requests.The three block rules, and their alloy-op-evm counterparts
no_user_tx_activation_block, set fromMegaHardforks::admits_only_deposits, which asks the MegaETH forks andOpHardforks::is_no_user_tx_activation_blocktogether; a user transaction is refused before anything runs.estimated_da_size× the L1 block contract's footprint gas scalar, held to the block's gas limit and reported asblob_gas_used, the estimate read throughop_revm::estimate_tx_compressed_sizeso 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 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'sMegaGasUsage, andMegaBlockExecutionResultcarries it and the data-size / write-recordLimitUsagebesideBlockExecutionResult; 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,MegaTxLimitExceededErrorsaying it can never be included andMegaBlockLimitExceededErrorthat this block has no room. Deposits are exempt from both data-availability dimensions.The stub hook points
apply_pre_execution_changesruns 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}.rsandsrc/evm/{state,mod}.rs, 28 intotests/block/{limits,factory,block_hashes,schedule}.rs. The two KV-limit rows ofblock_limits.rsstay 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.
fix(block): price a block's transactions with its own L1 infofix(block): refuse a rewriting inspector at every entry pointfix(block): install the block's transaction limits in the constructorfix(block): re-check the footprint when a transaction commits lateStatecachefix(evm): make the block-hash record a per-block read set--lockedbench(block): give every storage write a slot of its ownThe gate is now checked at the pre-block changes, a transaction, a commit and the end of the block,
commit_transactionexcepted because its signature cannot fail; the--lockedrewrite 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>, whichMegaContextoverrides. Every site that gives a charge back re-prices the recordedStateGasCharge { id, site, units }through it rather than reading a second table, so a refill cancels its charge exactly whatever the price was;AGENTS.mdcarries 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
mscales the state dimension only; the regular ledger is the same at every multiplier. At the minimum bucketm = 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, whileequivalence.rsgains the other side, one crowded bucket taking Satin away from op-revm in the state ledger alone, by exactly the multiplier. The pricing table'sSALT scalingsection measures it:SSTORE 0 -> 1costs 135,026 / 232,946 / 820,466 atm = 1 / 2 / 8, state 97,920 / 195,840 / 783,360 with regular fixed at 37,106; a valueCALLto an empty account 232,921 / 416,521 / 1,518,121, state 183,600 / 367,200 / 1,468,800 with regular 49,321. A capacity belowMIN_BUCKET_SIZEis a broken backend, not a cheap bucket: a failed lookup of its own (BucketError::BelowMinimum), never a round up tom = 1and neverm = 0, which would make state gas free.The seven call surfaces
All reach the same code path.
SSTOREonto a slot that was zero is priced by the slot's bucket;CALLcarrying value to an account that does not exist, and the EIP-2780 runtime phase's recipient or creation target, by the account's;SELFDESTRUCTmoving a balance to an account that does not exist, by the beneficiary's;CREATE/CREATE2metadata, 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.CALLCODEis 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::sloaddoes 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
CALLthat 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."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 chargesentry 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, andsalt_delegation.rs, where deciding whether a delegated target exists follows the one hop EIP-7702 allows and no further; the last 4 run insalt_deposit.rsas 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'sCALLCODEcannot 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.test(satin): park the system transaction exemption row againSELFDESTRUCTis a seventh charge site with no testtest(satin): pin the SELFDESTRUCT beneficiary as a state gas charge sitedocs: separate the two ways a state gas charge comes backtest(satin): pin that a deposit transaction is not system-originatedc30ccdec) warns undercargo checkThe
SELFDESTRUCTcode was already right, at 183,600 / 367,200 / 1,468,800 form = 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.
v40.0.3-mega.2;chore(deps): move the revm fork to the tag that fixes first-frame CREATECREATEport lost its differing-nonces regressiontest(satin): tell the authority's nonce from the delegate's in a delegated creationtest(satin): settle a crowded upfront charge through the synthetic helpersalt_refund.rs's headerdocs(satin): say which refunds re-price and which roll back by amountF1 in numbers: the fork derived the pricing site of a top-level
CREATEfromtx.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 atCALLER.create(5)while the charge is priced inCALLER.create(0)'s bucket, so atm = 8the 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.2derives the site from the pre-increment journal nonce frame creation reads, andtest(satin): price a deposit creation where it deploys, not where its envelope saysports 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
keylessDeploycall into a creation to native keyless deployment.The dispatch order
MegaEvm::interceptruns inframe_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:evm/execution.rs): onlyCALLandSTATICCALLreach the dispatch.CALLCODEandDELEGATECALLrun 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.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.The six contracts
…0001sendHint(bytes32,bytes), a side effectKeylessDeploy…0003keylessDeploy(bytes,uint256), depth 0 onlyNoEtherTransfer(), after the overheadMegaAccessControl…0004NonZeroTransfer()MegaLimitControl…0005remainingComputeGas()NonZeroTransfer()The Timestamp wrapper (
…0002) and theSequencerRegistry(…0006) intercept nothing. Fall-through otherwise differs per contract: the control contracts revert withNotIntercepted()from their fallback,KeylessDeployhas none, so an undeclared selector reverts with empty data while akeylessDeploycall reaches the method body'sNotIntercepted(), and the Oracle's other selectors are methods it runs. The control contracts answer what the common execution layer knows:falsefor the access query, no effect from the switches, the forwarded regular gas forremainingComputeGas().The Oracle's hint is a side effect rather than an answer: the payload goes to
OracleEnv::on_hintand 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; aSTATICCALLdoes forward, and the payload is counted toward the transaction's data size before it is decoded.keylessDeployis 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 toMegaEvm::rewrite_keyless, which native keyless deployment fills in; until then it reverts withNotIntercepted(), 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 bareGas::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 whyintercepttakes the frame mutably.The system-address transaction
A legacy transaction from
MEGA_SYSTEM_ADDRESSto a whitelisted contract (the Oracle) is the protocol's own: no signature, no fee. The engine stampsMEGA_SYSTEM_TRANSACTION_SOURCE_HASHon 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.rsgains its first four deliberate divergences, each pinning both sides: a call an interceptor answers, where op-revm runs the bytecode into itsNotIntercepted()revert; akeylessDeploytransaction, 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 ofsrc/system/tx.rsand the reservoir cases insynthetic_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: twokeylessDeployrows 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.
keylessDeployABIuint64, notuint256docs(system): name the keylessDeploy ABI the contract declaresfix(system): word the system-address refusal as the rule it statestest(system): pin the hint that crosses the data-size limittest(satin): pin the keyless overhead against op-revmThe 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.
test(satin): assert what every interceptor path answers at both limitsNotIntercepted()everywheredocs(system): say what a selector that is not intercepted runs intodocs(system): separate the hint rule carried over from the new onetest(satin): name the floor the keyless receipts are measured againstComparing 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 definessystem::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_txconsults it withMEGA_SYSTEM_ADDRESS, the addressevm/execution.rspromotes 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. Intests/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 atm = 1andm = 8, reads no capacity, and draws exactly the schedule's two entries; the same call through the system-call entry point costs the same. Intests/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 payingentry 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.
test(block): run the pre-block calls against a crowded SALT regiontest_utils/scenario.rswas called untouchedinfranote belowBoth 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-parentfor the four merges — the gas table (abc3c104), the block executor (0bdd941b), SALT pricing (19ba2319) and the interceptors (e44b59bd) — andgit log <merge>^1..<merge>for each series. Every commit passescargo check --workspace --all-targets --locked, with two exceptions recorded rather than rewritten away. Three compile with warnings —c30ccdecand17f96564leave an unused import each,7923b9eatwo deprecated accessors, each repaired by thestyle:commit after it — so "passescargo 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'sMerge branch …subjects: the repository writes merges that way, and the wave lands by squash, so the subject reachingmainis 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.
cargo fmt --all --check,cargo sort --check --workspace --grouped …cargo clippy --workspace --all-features --locked(lib, tests, benches)cargo test --workspace --locked--features satin-price-override,test-utils, variables unsetcargo check -p mega-evm --target riscv64imac…, no default featurescargo tree -i revm --lockedv40.0.3-mega.2; no duplicatescargo bench:transact,corpus,factory,blocktransactnpx prettier --check '*.md' 'docs/**/*.md'git diff --check 438783a8..HEADdiff 438783a8inframutation gateThe 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 notests/blocktarget, 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, whate44b59bdmeasures. 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.transactbench438783a8)empty_transaction/satinempty_transaction/op_revmether_transfer/satinether_transfer/op_revmdeep_calls/satindeep_calls/op_revmstorage_writes/satinstorage_writes/op_revmlogs/satinlogs/op_revmstorage_writesis the workload that moves (+3%, both arms): 200 fresh slots now draw state gas the Osaka schedule priced at zero.deep_callsandlogsare flat, and the two small transactions moved in both directions (noise). New on this branch:intercepted_calls, 200STATICCALLs 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; andsalt_storage_writesandsalt_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). Theblockbench runs two workloads of 64 transactions, transfers and storage writes, throughMegaBlockExecutorend to end.factory/create_evmis 768 ns andcreate_evm_with_inspector813 ns, andcorpusruns 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:
CREATE32,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.constants.rssays so and the schedule reads them throughSatinPrices, so a repricing is a constant change plus a regenerated pricing table.cphbis carried and installable but prices nothing: the schedule'scode_deposit_history_gasentry stays at zero until history gas lands.MegaEvmFactorybuilder is handedMegaSpecId, which is alwaysSATINtoday. 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:
EnrichedMegaTxtrust boundary.run_transactionuses 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.validate_block_gasand 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.BLOCKHASH. Recording in the Host is what makes the record a per-block read set, andBLOCKHASHis not in either bench workload, so the CodSpeed report does not price it. It is oneBTreeMapinsert on an opcode that reads the chain's history, which is already a database read.SALT pricing:
The system contract interceptors:
isVolatileDataAccessDisabled()answersfalseand 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.SequencerRegistrycan rotate it, and reading the rotated address out of the registry's storage belongs to system contract deployment; until thenMEGA_SYSTEM_ADDRESSis what the promotion and the deposit-caller charge compare against.keylessDeployoverhead 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_originatednow 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.is_system_originatedand 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.sendHintvalue condition is not on the specification page yet.docs/spec/system-contracts/oracle.mdlists 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 touchesdocs/spec/.MEGA_SYSTEM_ADDRESSwould 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.is_system_originateddoes not ask who the caller is. It matches the reserved source hash whatever the caller, whilevalidate_and_promotereturns 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.