Repository navigation
Conversation
The byte table gains the sizes of a transaction body, an EIP-7702 authorization and an access-list entry, next to the log and write-record sizes the layer already counted, and the engine gains what a byte of history costs: every count times the cost per history byte, read from the same prices the state entries are built from. The schedule gives code_deposit_history_gas the price of one byte, which is the one history charge revm makes itself, so a deployment now pays for the bytes every node has to carry: a thirty-two byte deployment draws 2,816 history gas beside its state gas.
A transaction's body is fixed before it runs — its envelope, the write records its inclusion makes, its calldata, its authorizations and its access list — so its history is part of what a gas limit has to cover for the transaction to be valid: a limit that falls short is rejected before inclusion rather than included as an out-of-gas that burns the whole limit. The charge rides in the EIP-8037 intrinsic state-gas slot, which is the pool it is paid from, reservoir first, so it does not take the execution cap away from computation; post-execution takes it back out of the state gas the result reports. Three kinds of transaction pay none of it: a deposit, a transaction the protocol itself produced and a system call. They run a schedule that prices a deposited byte at zero, so the one history charge revm makes itself is off them too. State gas is charged as usual. The EIP-7623 calldata floor is still computed and validated and no longer decides a bill: history charges the same bytes at a higher rate, so a Satin transaction is above its own floor from the first byte on. The equivalence baseline holds every transaction to op-revm's total plus the history ledger, with the refund cap and the floor comparison following the larger total as they already do for state gas.
The wrapper of SSTORE, LOG0..LOG4 and SELFDESTRUCT already commits what the Host staged once the opcode completed; it now charges the running frame the history the record costs, which is the record's own data size: a log pays for its address, its topics and its data, a storage write and a destructed account's beneficiary for the forty bytes of one write record. A slot written back to its original value leaves no record and takes its charge back at the same price, whichever frame made it. A frame that fails pays for none of it, because the charge unwinds with the frame the way its state gas does. A history charge the frame cannot pay is an ordinary out-of-gas.
A declarative spec per contract and a helper that never commits: matching code is a read-only witness entry, missing code is a create, foreign code is an error. The spec list is the six MegaETH contracts then the EIP-7997 factory. SequencerRegistryConfig carries the seeded roles.
Satin cannot run without the roles the registry is seeded from. A schedule that activates the fork without those params fails when it is loaded, and the unknown-chain fallback attaches placeholder roles so a local chain still starts.
Every block iterates the spec list and commits each witness in order, after the EIP-2935 and EIP-4788 calls. Those two target their own contracts and do not read MegaETH predeploys. A matching deploy is a read-only entry; foreign code fails the block.
The first block installs all seven predeploys with the pinned hashes, nonce 1 and the registry's seeded slots. A second block is seven read-only entries. Foreign code fails the block, missing params fail at load, and a factory call returns the CREATE2 address as 20 bytes.
The rows this mechanism owns move into tests/block/deploy.rs and tests/system/deploy.rs. applyPendingChanges rows stay parked under the pre-block system calls. The pending ledger drops from 524 to 478.
A steady-state arm runs apply_pre_execution_changes on a block whose seven predeploys are already in state. A check before measurement asserts the seven witness entries are read-only.
The six MegaETH contracts and the EIP-7997 factory deploy at the start of every block. The pre-block state-change contract lives next to the helper that implements it.
… makes A value CALL or CALLCODE writes its sender's account and its recipient's; a CREATE writes the creator's nonce and the created account. The caller pays for all of them at its own opcode, after revm computed the gas it forwards, so the frame's budget carries none of them and its allowance stays free for what the recipient does. What the frame does not keep goes back to the caller when it returns: a failing frame's records, and every record of a frame answered without running at all — an interceptor, the depth guard, the latch, an inspector — while a creation's nonce record survives its creation's failure, as the nonce bump does. The two records made outside any frame are charged where pre-execution makes them: one per applied EIP-7702 authority, which outlives a first frame that fails, and the one the transaction's own frame makes, which does not and is given back by the settlement the way EIP-8037 gives back the state gas of an account that frame would have created. One answer decides both the records and the charge, so they cannot disagree, and holds fifteen transactions to it: the history each pays beyond its body, its logs and its deployed code is exactly the write-record count the layer counted.
A failed account load is SystemContractDeployError::Database, and that inner error is what source() returns. Foreign code has no source.
…tory EVM's own CALL_STIPEND buys the recipient of a transfer enough computation to notice it. On a chain that prices the bytes a log appends it buys no log at all, so a receive() hook that emits an event would be unreachable through Solidity's transfer(). The allowance is that stipend's counterpart on the history ledger: one three-topic event carrying one word, at the cost per history byte, granted to the frame a value-transferring CALL or CALLCODE starts below the transaction's own. It is not gas in any sense. It never enters the frame's Gas, so no settlement can hand it back; only the charge a log makes may draw on it, so it cannot buy computation or a write record; and it lives on the frame's lane, so a frame answered without running neither takes one nor leaves one behind. What it pays for is on no ledger, because no pool of the transaction's gas paid it. The tests pin the boundary it exists for — a transfer() reaching a hook that emits one event, and no more than one — and the three ways it could escape a frame: an interceptor answering in the frame's place, a transaction-level stop unwinding it, and the frame returning to its caller.
The transact benchmark gains a calldata arm — a call carrying four kibibytes, which is four kibibytes of its body's history — and every workload is now checked against what it must draw before it is measured: each pays its body, the logging one pays for the bytes its logs append and the calldata one for the bytes it carries. A workload that stopped drawing what it is there to measure would otherwise still benchmark, and measure the wrong thing.
The stipend rows of the legacy engine's rex4 and rex5 suites, the fee recipients' accounting of its rex6 suite and the exemption rows of the limit tracker's own tests — twenty-eight in all — run in the Satin targets now. The stipend rows become the allowance's, one per rule: which scheme is granted one, what it may and may not pay for, and that neither an interceptor's answer, a frame budget's revert nor a frame's return hands one back. The scheme table is a unit test, because a static frame may not log at all and so has nothing an allowance could buy it. The fee recipients' rows become the transaction body's: the accounts a transaction's fees are credited to are four of the five write records the body carries, so nothing reads them, nothing counts them, and a beneficiary that is one of the fee vaults changes nothing. The exemption rows become the history exemption's, which is the one metering a deposit or the protocol's own transaction is excused from.
The module table, the engine's own description and the contracts of the common execution layer now carry the third ledger: the byte table every site is sized by, where each charge is made and given back, the three kinds of transaction that pay none of it, and the allowance a value-transferring call grants. The legacy engine's storage-gas stipend gets a line of its own in the comparison, because the allowance that replaces it is a different thing: it never enters a frame's gas limit, so there is nothing to burn on return.
Three gaps in what the tests reach, and five places where the code said the same thing twice. The exemption tests deployed nothing, so the schedule an exempt transaction runs was never observed: the program they share now deploys thirty-two bytes, and a test holds the difference a user transaction pays for them against the nothing a deposit pays. A frame answered without running was only ever observed failing, where the charge comes back either way; an inspector answering a value call with a success is the one path where the records were never made and the result still succeeds, and a test pins that its caller pays for none of them. And a transaction whose target is an applied authority was counted but not priced, so the history it pays is now held against a transfer to an account that is not one. The five guards on a zero amount are gone. Every one of them stood in front of an operation that is a no-op at zero - refilling no history, charging no history, recording no records, staging no charge - so each only restated what the operation already does, and nothing could tell the two apart.
Give MegaBlockExecutor an optional observer and a source enum so each pre-block state — the EIP-2935 call, the EIP-4788 call, and every system-contract deploy — is visible before it is committed. The sequence is the witness a stateless client needs.
Rotation, resolution and admin-handoff scenarios belong to the pre-block system calls. Upgrade and per-fork-gate rows are retired because a single-spec engine has neither. The minimum-delay seed stays with this mechanism until it is ported.
EIP-7997 requires the CREATE2 factory to hold its runtime and a nonzero nonce. Matching code at nonce 0 is now an error of its own; a nonce greater than one is kept. The six MegaETH contracts are not EIP-7997 and still accept matching code regardless of nonce.
An address with no code but a nonzero nonce is a used account, not a prefunded EOA. The helper now errors instead of resetting the nonce and dropping storage under a created mark. Prefunding with balance alone remains the bootstrap path, which still marks created and clears storage.
Fold min_rotation_delay into SequencerRegistryConfig, reject zero at validate, and seed slot 13. A zero delay disables the reaction window the field exists to guarantee, and the contract has no setter, so a fresh registry cannot be repaired later through this helper. The placeholder uses a ten-block delay.
hardfork_schedule returns SequencerRegistryConfig::placeholder only for a chain ID it does not know. A known chain ID resolves to its own table and never carries that config.
revm's CALL, CALLCODE, CREATE and CREATE2 copy the caller's reservoir into the frame's input, and they do it before the wrapper charges the history of the write records the frame's start makes. A returning frame's reservoir is adopted by its caller rather than merged into it, so the frame handed the charge straight back; a frame answered without running handed it back a second time, because its gas is built from the same field and its empty lane refills the whole charge. Above the execution cap, where the reservoir is what pays, a value call and a nested creation appended their write records for free and the interception, depth-guard and latch paths minted gas. The wrapper now writes the post-charge reservoir into the frame's input. Nothing on this branch ran above the cap with a nested frame, which is where this hid: tests/satin/history_reservoir.rs is that regime, and its module documentation states the rule for the mechanisms that follow. The equivalence baseline gains a nested value call with a reservoir, which would have caught this the day it was written.
CALL, CALLCODE, CREATE and CREATE2 set the frame's input as the interpreter's action inside revm's instruction, and an interpreter halts on an instruction's error only when no action is pending. So the out-of-gas the frame-start charge returned when the caller could not pay it was swallowed: the pending frame was returned anyway, it started, its lane was pushed and its write records were counted, and nothing had been charged for them. A caller keeps a sixty-fourth of what it holds when it forwards gas, so any caller under roughly 450,000 gas kept less than the two records a value call or a nested creation makes cost, and appended them for free. The wrapper now takes the pending action before it returns, the way the limit stop in the same file already does.
…them The history of the write records a frame's start makes is charged at the opcode, on the input revm's instruction built; the records themselves are counted when the lane is pushed, on the input that survived interception and the keyless rewrite. Those are two answers to the same question, and the comment that said they could not disagree was a claim about today's rewrite being the identity rather than a property of the code. The charge now carries the answer it was computed from, and the count checks its own against it. A rewrite that turns a call into a creation changes which records a frame's start makes; until the mechanism that makes such a rewrite reconciles the charge with them, the divergence trips in every debug build instead of mis-charging the caller and mis-splitting the refund its failure gets back.
…parts A record's history bytes are its own data size, so the two counts cannot drift apart at a record. Per transaction they are not the same number, and the byte table read as though they were. Two sites part them, both by decision: an Oracle hint's payload is data size the transaction counts and history it does not pay, because the bytes go to the node's oracle service rather than into a block; and the five write records a transaction's body carries are an upper bound, so a transfer whose recipient is the block beneficiary or a fee vault pays a record the body already bound — an over-charge, never an under-charge. The paired corpus gains one case for each, with the number it is expected to pay written out, so the divergence reads as intended rather than as a defect.
The allowance is drawn before the frame's own gas, so it is a discount on the first 160 bytes of every value-transferring call rather than a fallback for a frame that cannot pay them. A contract calling itself records its own account once and nothing after that, so the grant repeats for as long as gas lasts: about 10,000 gas a turn, some twenty thousand turns inside one transaction at the execution cap, around three megabytes of log bytes on no gas ledger. The bytes stay on the data-size lane, so the limits that meter bytes see all of them; what falls behind is the history gas column. Whether the allowance becomes a fallback, or a block's history column becomes a byte count beside its gas figure, belongs with the block-level accounting. The test records the size of the gap so it is a number on the table rather than a rediscovery.
…ce answers A read the oracle service answered left the slot unloaded, while the database fallback loaded it warm: a later SSTORE to the slot cost the cold access only when the service answered, and the slot never reached the transaction's state a stateless witness is built from. The read now loads the slot through the journal on both paths and returns the service's value when it has one, still priced cold.
…swers A node that replays a block without the oracle service must price and witness it as the node that built it did. State that rule where the Oracle's storage read is described, and that the service's value is returned even over a value the Oracle's frame stored earlier.
…till asks The skipped cold load is decided on the frame's regular gas, the part gas detention withholds included, so a detained frame that holds the gas but may not spend it reads and asks the oracle service, and the read's charge then stops the transaction at the compute limit. Say so where the docs said such a frame asks nothing.
… under detention A read after the Oracle's frame wrote the slot answers the service's value and is priced cold; every read of a nested Oracle frame is cold and asks; a detained frame that holds the cold access but may not spend it asks and is stopped at the compute limit.
Detention takes a value call's whole gas limit, stipend included, off its caller's compute, so the allowance carries the stipend as the callee's own gas does: the callee hears it counted whether or not it is detained.
Only one property carries over from the legacy engine: forwarded gas is not counted. The legacy figure came from a separate compute ledger and could exceed the caller's gas; this one is the caller's spendable regular gas before the forward, which detention caps. A transaction whose sender is the block beneficiary is detained from its start, so a direct call hears the cap.
…ansactions A child that switched volatile-data access off and then halted, on an invalid opcode or out of gas, leaves its sibling free to read; a switch set in one transaction does not carry into the next on the same EVM.
The answer equals the spendable regular gas the caller resumes with, undetained, detained by its own read, by a child's read, as a value-called callee, as a beneficiary sender and after an Oracle read, below and above the execution cap; a caller that acts on it can spend it and is stopped at the compute limit past it.
The callee hears the stipend more than the same callee a call without value started, and the same answer whether or not the transaction is detained.
The execution-gas, data-size and KV budgets each carried their own deposit guard. They are checked together at the end of the pre-execution check, so one early return for a deposit covers all three.
…neficiary A contract's call to an EIP-7702 delegator of the block beneficiary loads the delegate through the Host, which marks the read. The transaction's own frame resolves its delegate through the journal instead, so a transaction sent straight to such a delegator ran the beneficiary's code undetained. Once revm has prepared the first frame, the recipient's delegation is read from the journal as it stands and marked the same way.
🧬 Mutation testing — ✅ PASSNothing to test — no mutants were generated on the changed lines. |
Merging this PR will degrade performance by 1.95%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
|
Label check Current labels: Update since my last check: The gap I flagged before is still open, though: this PR's footprint isn't limited to I'm dropping my earlier Not touching labels myself — flagging for a maintainer to confirm. |
🧬 Mutation testing — ✅ PASSDiff mutation score: 100.0% (309/309 viable mutants killed)
No new test gaps introduced by this change. 🎉 |
Summary
Wave 3 of the Satin engine, on top of the wave-2 pull request (base
cz/feat/satin-w2). It adds eight mechanisms and the execution-spec gate. Each was developed on a side branch and merged with--no-ff, so the first-parent chain has one merge per mechanism.CREATE2factory, idempotently, and hands each pre-block state to an optional observer before it is committed.validate_schedulerejects a chain that schedules Satin without aSequencerRegistryConfig.MegaLimitExceeded.MegaEvmitself. Equivalence mode gates Satin's machinery on the Osaka and Amsterdam fixtures, and every failure must match an entry a registered deviation lists. Satin mode reports results and never fails the job.SLOTNUM. Every value movement emits aTransferlog into the receipt. The log counts as 160 bytes of data size and nothing else.SLOTNUMpushes the block's slot number.MegaLimitExceeded(2, limit)and returns its unspent gas. Until then it runs exactly as it would without the read.MegaAccessControlswitches volatile-data access off for a subtree of frames.MegaLimitControl.remainingComputeGas()answers the caller's spendable regular gas.Dependency. The twelve
[patch.crates-io]revm entries move frommegaeth-labs/revmtagv40.0.3-mega.2tov40.0.3-mega.3. That release adds withheld regular gas to the gas tracker (megaeth-labs/revm#68), which gas detention is built on.cargo tree -i revmshows one revm.Deposits. No block cap refuses a deposit: not execution gas, state gas, data size or KV. Every deposit still counts towards all four.
Breaking.
EvmTxRuntimeLimitsandBlockLimitsgain public fields,MegaGasUsageandBlockGasCountersgainhistory_bytes, andMegaBlockLimitExceededErrorgainsStateGasLimitandKVUpdateLimit. A kind-3 stop'slimitis now in gas. Receipts of value-moving transactions carry transfer logs.Tests. The mechanisms port 342 parked legacy tests and retire 13, so
_pending/drops from 524 to 169.History gas
State gas prices the bytes a transaction adds to the world state; history gas prices the bytes it appends to the chain (
evm/history.rs). Both draw on the EIP-8037 pools, reservoir first, and unwind on the same paths; they are counted apart so a node can report and limit them separately.limit/mod.rs)These are the counts the data-size limit meters, never scaled by SALT, because history bytes live in no bucket. Data size and history part at three sites, each by decision: an Oracle hint's payload and an EIP-7708 transfer log are data size and never history, and the body's five records are an upper bound, so a transfer to the beneficiary or a fee vault pays for a record the body already bound.
SSTORE,LOG0..LOG4andSELFDESTRUCTwrappers charge what the Host staged.CALL,CALLCODE,CREATEandCREATE2charge their caller for the records of the frame they start, out of what the caller kept after forwarding. Deployed code is the schedule'scode_deposit_history_gas, which revm charges inreturn_create.MegaContext::prices_history). An exempt transaction runs a second prebuilt schedule whosecode_deposit_history_gasis zero. The exemption covers history alone: a deposit still prices its state gas by its SALT bucket.CALLorCALLCODEbelow the transaction's own frame grants the frame it starts 160 history bytes, one three-topic event carrying a word. This lets areceive()hook reached through Solidity'stransfer()still emit an event. The allowance never enters the frame'sGas, only a log's charge may draw on it, and what it pays for appears on no ledger.tests/satin/pricing-table.md, an empty call pays 27,280 of history,SSTORE 0 -> 1adds a record's 3,520, and aLOG1over 32 bytes adds 8,448. The EIP-7623 floor is still computed and validated but binds nothing, because history charges the same bytes at 88 where the floor charges 64.tests/satin/equivalence.rsholds every baseline case to op-revm's total plus the history ledger.Rules that changed in review.
tests/satin/history_reservoir.rsmakes the above-cap regime a standing rule: every ledger invariant has a case above the cap, held toreservoir_remaining == gas_limit − cap − state − history.Tests. The suites are
history_gas.rs,history_exemption.rs,history_reservoir.rs,storage_call_stipend.rs,intrinsic.rsandwrite_records.rs, plus the regenerated pricing table. Thetransactbench gains acalldataarm. 28 parked rows are ported.Open.
return_create.test_the_gap_the_allowance_opens_between_bytes_and_gas_is_pinned).System contract deployment
system/deploy.rsis the single spec list the executor iterates.transact_deploy(db, &spec)returns anEvmStateand commits nothing. Every block deploys, in order: Oracle v2.0.0, High-Precision Timestamp,KeylessDeploy,MegaAccessControl,MegaLimitControlandSequencerRegistryv2.0.0 (0x6342…0001to…0006), then the EIP-7997 factory at0x4e59b44847b379578588920cA78FbF26c0B4956C. There is one version of each, with no upgrade path and no per-fork gate.ZeroFactoryNonce: EIP-7997 needs a nonzero nonce, and this path never rewrites oneUsedEmptyAccountForeignCodeBlockExecutor, so the executor carries its own:PreBlockStateObserver, withPreBlockStateSource(Eip2935,Eip4788,SystemContract(address)), installed withset_pre_block_observer.SequencerRegistryConfigis the SatinHardforkParamstype. It carries the three roles,initial_from_blockandmin_rotation_delay, seeded into slots 0–2, 4–6 and 13.validate()rejects a zero address and a zero delay, andvalidate_schedulerequires the config when Satin is scheduled. Slot 13 is seeded because the contract has no setter.MEGA_SYSTEM_ADDRESS, from-block 0, a 10-block delay), and it is the only path that hands them out.Rules that changed in review.
Tests.
tests/system/deploy.rscovers the spec list, the five outcomes and both validation rules.tests/block/deploy.rsreads the executor's own observer on a first and a second block: order, sources, hashes, seeds and zero gas. It also covers foreign code, missing params and theCREATE2round trip.Open.
validate_schedule. The node's chain loader must, and a production chain must never reach the unknown-chain fallback.applyPendingChangesbelong to the pre-block system calls.Block gas accounting
MegaGasUsagesplits a transaction's raw spend into regular, state and history ledgers.block_execution_gas()ismax(total − state − history, floor): history comes out before the floor applies, whereas the fork'sblock_regular_gas_usedkeeps it in.Gasrevm settles, and nothing counts compute beside it. Gas an inspector edits therefore moves the receipt, the ledger and the block figure together.MegaGasUsage::history_bytesandBlockGasCounters::history_bytescount the body, one record per kept write, the kept logs other than transfer logs, and the deposited code. That is the data size the transaction kept, less its Oracle hints' payloads and its transfer logs.history_bytes × cost per history byte = history gas + what allowances paid. An exempt transaction reports neither. The column is the history the schedule prices, not the chain's physical growth.BlockLimits::block_state_gas_limit, held byBlockLimiter(block/limit.rs), is unlimited by default. Only execution shows whether a transaction adds state gas, so a block that has reached its limit refuses, after execution, a transaction that ends with state gas (MegaBlockLimitExceededError::StateGasLimit). A transaction that ends with none still fits, even one whose state gas returned to zero.commit_transactioncannot fail. A builder choosing among executed candidates commits throughcommit_transaction_outcome, which checks the counters again. Both share one check, which the trait's commit asserts in debug builds. Neither checks the state a candidate executed against; re-executing a stale candidate is the builder's job.Rules that changed in review.
Tests.
tests/satin/history_bytes.rscovers the sites, the edge paths and the allowance gaps.tests/satin/compute_gas.rscovers a floor-bound transaction, and an inspector charging 126 gas or handing 86 back, which moves all three figures alike. Every outcome is held to the ledger identities, and above the cap to the reservoir identity.tests/block/counters.rsandtests/block/deposits.rscover the block side.Open.
The data-size limit
AdditionalLimit, per-frame lanes inlimit/frame_limit.rs). A transaction is held toEvmTxRuntimeLimits::tx_data_size_limit. Its own frame gets what the body leaves, each child gets 98/100 of what its parent has left, andframe_data_size_limitcaps every frame further.MegaLimitExceeded(0, budget). A transaction over its limit is latched: it reverts, returns its unspent gas and the reservoir, and setslimit_exceededon the outcome.transaction_body_bytes, inon_new_tx), which is never taken back;SELFDESTRUCT, once the opcode completed;return_createcommits, and only codereturn_createwould deposit. Code starting with0xEFor over the size limit, or from a creation that cannot pay its deposit, fails the creation alone. A crossing turns the return into the stop, so no code is left behind.BlockLimits::default()holds each transaction toTX_DATA_LIMITand the block toBLOCK_DATA_LIMIT, 13,107,200 bytes each.no_limits()clears both, and a bareMegaContextsets none. The block's cap is a packing budget: the transaction that crosses it is packed, the next one is refused, and a deposit never is.Which limit binds first.
tests/satin/data_size.rsderives the boundary for a top-level freshSSTORE. The store is counted at 162,306 gas: 15,000 intrinsic, 27,280 body history, 6 for the pushes, 22,100 regular and 97,920 state. It keeps its record from 165,826.Rules that changed in review.
0xEF-prefixed or oversized output could stop a whole transaction for a creation that fails on its own.Tests.
data_size.rscovers the share at depth, one byte over at every site, a nonce record at exactly the budget and one byte over, refused code, the body stop at the smallest accepted gas limit, and the boundary sweep.data_size_counts.rsandtests/block/limits.rscover the counts and the block cap.transact/data_size_limitruns 200 fresh slots and 200 logs under a limit they exactly reach, one byte short of it, and on op-revm.Open. A body over the limit is included as a revert rather than rejected at validation.
InvalidTransaction::StrinsideOpTxError, and would delete the pre-frame latch.The execution-spec gate
crates/mega-state-testandcrates/state-testrejoin the workspace and run Ethereum's state-test fixtures throughMegaEvm. They use the fork's own fixture types (revm::statetest_types), so this runner and the fork's parse, skip and count the same population.MegaContext::with_neutral_cfg, behindtest-utils, takes every field as given and prices no history.test_utils::neutral_cfg(fork)builds the Osaka and Amsterdam configurations, andneutralize_evminstalls Ethereum's precompiles and static prices.EvmTxRuntimeLimits::no_limits()itself, so no MegaETH limit applies: not data size, KV, state gas or detention.tests/satin/neutral.rsholds the neutralMegaEvmto op-revm and to revm's mainnet EVM.basefee × gas used.INTRINSIC_GAS_TOO_LOW, so for those fixtures a floor shortfall satisfies either name (59 tests).crates/mega-state-test/src/deviations.rs, rendered toDEVIATIONS.md. Each lists every entry it explains, with the hashes Satin produces, and--expect-deviationsrequires each entry to fail exactly as listed. Plain revm reproduces all 39 listed hashes on Satin's base, and each entry passes on its own fork.amsterdam-opcodes-on-osaka(2): Satin runsDUPN,SWAPN,EXCHANGEandSLOTNUM, which Osaka leaves undefined.selfdestruct-burns-on-osaka(37): revm gates EIP-8246 on the Amsterdam spec id, and Satin runs on Karst.exec-spec-satin.ymlreads the fork pin fromCargo.lock, and the fixture releases from the fork'sscripts/run-tests.shat that tag. It runs equivalence mode with pinned executed and skipped counts and--expect-deviations, and writes both modes to the step summary. Theexecution-spec gate on Satinjob always runs.replay-benchstays disabled.c0882c78Every later mechanism left the equivalence summaries byte-identical. The transfer logs moved Satin-mode failures between kinds, because logs are compared before the state root, and changed no pass count.
Rules that changed in review.
Tests. The runner's unit tests,
tests/runner.rs, the CLI'stests/cli.rsandtests/satin/neutral.rs.Scenario::evmbuilds a scenario's EVM once, as a block does, and thecorpusbench builds it in setup.Open.
execution-spec gate on Satina required check onsatinis the repository owner's call.bench/replay/anddocs/mega-evme/commands/replay.mdstill describe the removed bench mode.The state-gas and KV limits
Under EIP-8037 a transaction pays state gas for exactly the state it adds, so the legacy count of new accounts and slots retires and a state-gas limit holds state growth instead. The KV count stays, as the write-record count, with its own limit.
EvmTxRuntimeLimitsgainstx_state_gas_limit,tx_kv_update_limitandframe_kv_update_limit, andBlockLimitsgainsblock_kv_update_limit. All are unlimited by default.limit/state_gas.rs). The limit holds what the transaction holds, net: the state gas charged before its first frame, plus what every frame on the call stack holds, read off revm's per-frame counters. A write-back, a reverted child or a failed creation gives its room back.MegaLimitExceeded(3, limit), with the limit in gas. It is held after each charge, so a charge the frame cannot pay is an out-of-gas whatever the limit.SSTORE), a new beneficiary (SELFDESTRUCT)CALL,CREATEorCREATE2is charged for upfrontMegaEvm::frame_initonce revm has decided the frame: a frame revm refuses gives the charge back and is not held; a built frame returns the stop before its first instruction, and an answered one is rewritten to itreturn_createcharges it, and only oncereturn_createis sure to make that chargeKV × 40 ≤ data sizeholds after every instruction, so at the production data-size caps a transaction or a block keeps at most 327,680 records, and a KV limit binds only below that.block_kv_update_limitis a packing budget.LimitCheck::Exempt, stamped before the body is counted. Every stop comes fromAdditionalLimit::crossed, which answersExemptand never latches. Their usage is still counted and reported. The exempt set is the one history gas exempts, and a user's deposit is held to every limit.Decisions.
StateGrowth, but itslimitis now in gas. A node-side leaf-count override, and keyless deployment'sParentBudgetExceeded(3, …), must read it as gas.deposit_state_gas) retracesreturn_createup to the state charge, but not the history charge that follows it. This matchesSSTOREandSELFDESTRUCT, where the state-gas limit binds before the record's history is charged.Rules that changed in review.
return_createwould never make, so a creation about to run out of gas on the hash or on its state gas could stop the whole transaction.deposit_state_gasnow retracesreturn_createon a copy of the frame's gas.frame_init.Tests.
state_gas_limit.rsholds every site at exactly its state gas and one gas short, below and above the cap. It also covers SALT at m = 2 and 3, the EIP-7702 matrix, and frames revm refuses and decides.deposit_charge.rsruns a creation at the least gas that reachesreturn_create's state charge, and at one gas less.kv_limit.rs,limit_exemption.rs,write_records.rsandtests/block/{limits,deposits,eips}.rscover the rest, andtransact/state_limitsbenches the limits.Open.
MAX_CONTRACT_SIZEallows 512 KiB, whose deployment holds 802,344,240.EIP-7708 transfer logs and
SLOTNUMenable_amsterdam_eip7708 = trueandamsterdam_eip7708_disabled = false, so a caller's configuration cannot take the logs out of a block.Transfer(from, to, amount)from0xff…fe: the transaction's value, a valueCALL, a creation's endowment, and aSELFDESTRUCT's balance moved to another account. revm journals the log itself. It lands in the receipt and its bloom, in execution order among the contracts' own logs.CALLCODE, a call to itself, a value sent to the sender, a deposit's mint (its value is logged), or a destruction to itself (this revm has noBurnlog).TRANSFER_LOG_SIZE), by the rule for aLOG3of one word. It adds no write record and no history gas, is not inhistory_bytes, and draws nothing from the history allowance.AdditionalLimit::on_frame_init, before revm builds the frame. revm commits the checkpoint of a frame it answers without running, so a later count could not take the move back.SELFDESTRUCT, it is counted with the beneficiary's record once the opcode completed. A crossing stops the frame, and its checkpoint takes both back.caller_refuses_start(evm/execution.rs) reads the caller's account from the journal without loading anything.assert_start_as_counted).SLOTNUMpushesBlockEnv::slot_num, and zero when the block leaves it unset.Rules that changed in review.
Tests.
tests/satin/transfer_logs.rscovers eight sites below and above the cap. Each has exact bytes and receipt logs, and a frame budget and a transaction limit one byte short, each stopping where aLOG3of one word stops. It also covers the non-movers, refused starts under both limits, a deposit and a system transaction.tests/block/transfer_logs.rschecks receipts in execution order, with their blooms.evm/history.rsruns nine movement shapes with the switch on and off: the same gas on every ledger and the same state, with 160 bytes of data size per log.transact/value_callsbenches 200 value calls next to op-revm.Open.
Gas detention
MegaETH's parallel executor redoes a transaction whose reads another transaction's writes invalidate. Detention caps what a transaction may still compute once it has read data that changes with every block or transaction. It lives in
access/.EvmTxRuntimeLimitsfield, defaultNUMBER,TIMESTAMP,COINBASE,PREVRANDAO,GASLIMIT,BASEFEE,BLOBBASEFEE,SLOTNUM,BLOCKHASHblock_env_access_compute_gas_limit, 20,000,000SELFDESTRUCTat either end; a sender or recipient that is the beneficiary, a recipient that delegates to it, an applied authority that is itSLOADin the Oracle's own frameoracle_access_compute_gas_limit, 20,000,000BLOBHASHis not volatile, since it reads the transaction's own blob hashes. System-originated transactions and system calls are not detained. A transaction whose two caps are both unlimited is not detained either, sono_limits()turns detention off along with every other per-transaction limit.EvmTxRuntimeLimits::default()andBlockLimits::default()detain.Gas: a frame's limit less what it has left, less any state and history gas that spilled onto regular gas. It is summed over the running frame and every suspended caller, each caller less its child's gas limit (which takes a value call's stipend off), less what halts burned.GAS, the 63/64 forward, theSSTOREsentry, a child's return and the reimbursement.Gas::limit_spendable(limit − compute)at the read, at every frame start and resume (before_frame_run), and afterSSTORE's refill. A detained transaction runs exactly as it would without the read until a regular charge needs the withheld part. Nothing withheld is ever moved or released.WithheldCrossing.Detention::on_frame_endruns inafter_frame_run, afterreturn_createand before the result reaches the caller orlast_frame_result. It turns a frame carrying a crossing into the latch's revert,MegaLimitExceeded(2, limit), with the record's withheld part as its gas.used == limit.hold_precompile) and gets the rest of the forward back (Detention::restore_forward). One priced past the allowance computes nothing and is the stop.settle_answer), and so is an interceptor's charge the allowance cannot pay (Detention::charge_crosses).Detention::note_halt).KECCAK256, the four copies into memory andEXPare wrapped for that alone.VolatileDataAccessDisabled(accessType), charging only the opcode's static gas. A refusedBLOCKHASHnamesBlockHash(7).Decisions.
CALLto anINVALIDchild, would then become a transaction stop.c0882c78). revm resolves the first frame's delegate through the journal, not the Host, so before this only a contract's call to the delegator was marked.MegaContext::mark_beneficiary_delegatenow reads the recipient's delegation from the journal, without loading, once revm has prepared the first frame.Rules that changed in review.
GASand the 63/64 forward used to see only the allowance, and a callee inherited withheld gas as reservoir. The fork's withheld part fixes both, which is why the pin moved.EXP's exponent charge escaped the halt note.BLOCKHASHnamed the block number.Tests.
tests/satin/withheld_gas.rsruns each case twice: with a timestamp read, and with a same-pricedPUSH0. The two runs are equal on the result and every ledger unless the cap stops the first. Where it stops, the test asserts the bill: intrinsic gas plus compute up to the limit.detention.rsandvolatile_access.rscover the rules and the refusals.detained_transfers.rscovers transfer logs at detained frame starts, precompiles, unfunded calls andSELFDESTRUCT, at both tiers.tests/block/detention.rscovers the block side.transact/hashes_and_copiesbenches the new wrappers.Open.
External environments and control contracts
SLOADwhose storage owner is the Oracle loads the slot through the journal, then asksOracleEnv::get_oracle_storage. The service's value wins, even over a value the Oracle's frame stored earlier. An answer ofNoneleaves the loaded value, andEmptyExternalEnvalways answersNone.SSTORE, and it is in the transaction's state, which the witness is built from.MegaAccessControlsteers detention's switch.disableVolatileDataAccess()turns volatile access off for the caller's frame and every frame below it.enableVolatileDataAccess()reverts withDisabledByParent()when called below the frame that switched access off.isVolatileDataAccessDisabled()answers for the caller.sendHintfrom a frame whose volatile access is off is dropped: it is neither forwarded nor counted, and the call runs the contract's bytecode. This is the legacy Rex6 rule, and here it is the only one. A value-bearingsendHintforwards nothing, under the interceptors' value policy.remainingComputeGas()answers the lesser of two figures. The first is the caller's own regular gas, with the call's forward counted back. The second applies once a read set a limit: what detention's limit leaves the transaction. The result is the caller's spendable regular gas before the forward.SLOTNUM's refusal names access type 12 (SLOT_NUM_ACCESS_TYPE), which the deployedVolatileDataAccessTypeenum does not declare. The contracts are unchanged, so their code hashes stay. Solidity handlers must decode the argument asuint8, anddecode_volatile_data_access_disabledis the Rust decoder.tests/block/external_envs.rsruns a block throughExternalEnvFactory. SALT buckets at m = 1, 2 and 8 scale state gas and leave regular gas flat, and the factory is asked once per block.Rules that changed in review.
multiCall([getSlot(k), setSlot(k, v)]), and the slot never reached the witness.Tests.
tests/system/oracle_storage.rs,oracle.rs,access_control.rsandremaining_compute_gas.rs.BLOBHASHis asserted not refused, andSLOTNUMis added; the nestedremainingComputeGasanswer is bounded by its 50M forward.remainingComputeGasanswer equal to the spendable gas the caller resumes with, in eight shapes at both tiers. A caller that spends the answer completes, and one that spends a little more is stopped.transactbench gainsremaining_compute_gasandoracle_reads, with a/servicearm.Open.
docs/spec/system-contracts/mega-limit-control.mdstill states the legacy figure. The Satin spec pages need the new one.How the merges were resolved
The first-parent chain has nine merges.
2dc1efea), block gas accounting (d6450e46), the state-gas and KV limits (949ce6ed), the transfer logs (40e9066f) and the environments (8982ef2f) each branched from the wave's head at the time. Each merge's tree is its branch's.b0c0403a, deployment. It met history gas in three files, all additively:AGENTS.md, the crate README andtests/_pending/README.md, which was regenerated from both branches' inputs.4394a30f, data size into accounting. This merge resolved semantics, not just text:AdditionalLimit::on_create_return).transaction_body_bytes, sizes the body for validation, the history bytes and the limit.d6c7fab3holds every block limit to a deposit, at execution and at both commits.efa56356holds the history bytes to the kept data size at 23 sites.316524c7, the gate. It merged without conflict.1dec2cd7then states that equivalence mode takes MegaETH's limits out as well as its prices.3a9013b8, detention. It conflicted in five files.frame_init, the transfer logs' debug snapshot andhold_precompileare independent, and both are kept. The guard still reads revm's raw answer, beforerestore_forwardandsettle_answerrewrite it.84880009formats the merged imports;cargo fmtfails on the merge commit alone.7d0197a3pins where the two mechanisms meet (detained_transfers.rs).4ebed8a2,e6e19f27,d61b7542andfde099aestate and test the precompile residual and the static-charge bound.After the last merge, a design review compared an independent design of the whole wave with this implementation, and found the two agreeing on every load-bearing mechanism. Two commits follow it:
9ee48c69folds the execution-gas, data-size and KV deposit guards inBlockLimiter::pre_execution_checkinto one early return, without changing behaviour. The state-gas check after execution keeps its own guard.c0882c78detains a transaction sent straight to a delegator of the beneficiary.The review's other follow-ups are listed below.
Open items
Numbers and specification
COST_PER_HISTORY_BYTE(88),COST_PER_STATE_BYTE(1,530) and the detention caps are provisional until the economics sign-off.docs/spec/, which still describe the legacy engine, includingremainingComputeGas()and the Oracle read.Node integration
validate_schedule, and a production chain must never reach the unknown-chain fallback.execution-spec gate on Satina required check.Behaviour
Structure (behaviour-preserving)
SLOADunwrapped outside the Oracle.step_end, which would otherwise observe the pre-charge reservoir above the cap.Fork
Precompile::required_gas(input), to close the precompile residual.return_createsplit into plan and apply, or an admission hook, to remove the deployed-code retrace.Tests still parked (169)
bench/replay/and its CLI page still describe the removed bench mode.Test plan
All results are on
c0882c78unless stated otherwise.cargo fmt --all --check,cargo clippy --workspace --lib --examples --tests --benches --all-features --locked,cargo sort --check …, the riscvno_stdcheck, Prettier,git diff --checkcargo tree -i revm --lockedv40.0.3 (…revm?tag=v40.0.3-mega.3#5ac45ab8)cargo test --workspace --lockedmega-evm; 23 + 12 in the runner; 4 CLI; 5 + 1 in system-contractscargo test -p mega-evm --features satin-price-override,test-utils, with prices unset and at CPSB 2000 / CPHB 100cargo check --workspace --all-targets --lockedon each of the 21 first-parent commits, each from a clean snapshotcargo fmt --checkfails on3a9013b8alone among the detention merge's commits (fixed by84880009)state-test --fork Osaka/--fork Amsterdam, equivalence, release build, with CI's pinned flagsstate-test --mode satincargo bench -p mega-evm --bench <b> -- --testtransact38,corpus13,factory2,block3b16d1b36..fde099aefde099ae..93da3fbe)9ee48c69andc0882c78mutants/and.cargo/are unchanged sinceb16d1b36: no suppression was addedtests/_pending/README.md, regenerated from the ledger and checked against the tree