Skip to content

feat!: the Satin execution engine on the revm 40 forks - #386

Open
RealiCZ wants to merge 206 commits into
cz/feat/satin-review-basefrom
cz/feat/satin-w2
Open

RealiCZ wants to merge 206 commits into
cz/feat/satin-review-basefrom
cz/feat/satin-w2

Conversation

@RealiCZ

@RealiCZ RealiCZ commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Satin is MegaETH's new execution engine: mega-evm 2.0, on the MegaETH forks of revm 40.0.3 and op-revm 20.0.0, running one unstable spec, SATIN. It restates MegaETH's execution rules on Optimism Karst (Ethereum Osaka): EIP-8037 regular and state gas under a 200,000,000 execution cap, state gas scaled by the SALT bucket it lands in, a history-gas ledger for the bytes a transaction appends, data-size, KV-update and state-growth limits whose crossing reverts only the frame that crosses it, gas detention, native keyless deployment, the live system address read by the transaction itself, and the witness a stateless validator re-executes a block from. The legacy engine on main keeps executing every block before Satin activates.

Do not merge this pull request until it is retargeted to satin. #407 has merged into it, so the branch holds the whole engine. Its base, cz/feat/satin-review-base, exists only for the review (see "How to review").

This pull request holds the whole engine. The stacked pull requests have merged into it, and their descriptions carry the detail:

Each linked description covers its series as it was merged, and later series changed parts of it: #404 removed the KV and state-gas limits #387 added, fixed the limits per spec rather than as #399's chain parameters, and replaced #388's latched stop with a stop the caller can catch; the engine no longer deploys the system contracts #387 deployed, and only checks them in the block where Satin activates; #407 restored the KV-update and state-growth limits, counted on Satin's write records and state growth, removed #390's inspector settlement and admission gate in favour of revm's own inspected lifecycle, and replaced #388's instruction wrappers with the fork's hooks. Review against the final tree, the specification and the review guide, not against these descriptions.

How to review

  • The guide: .claude/skills/satin-review/SKILL.md on this branch gives the scope, the design changes feat!: meter Satin's writes and frame starts in the revm fork's hooks, on a per-frame byte pool #407 made to what the stacked descriptions say, the module map with the invariants to check, the reading order, how to rate a finding, the revert-or-halt rule with its deliberate exceptions, and the decisions already made. It is removed before this pull request merges.
  • The range: this pull request's own diff. GitHub shows the whole engine, the skeleton and every later series. The local equivalent is git diff a8f8c7c9...cz/feat/satin-w2. Most of its deletions are the legacy engine the skeleton removed: skim them, or hide them with --diff-filter=d. Judge the final tree, not commit by commit.
  • The review base: this pull request targets cz/feat/satin-review-base, which is satin before the skeleton (a8f8c7c9, the CI-only commit before it). The skeleton merged into satin on its own, so against satin GitHub would show the engine without it; against the review base it shows all of it. The review base exists only for the review: this pull request is retargeted to satin before it merges, and must never be merged into the review base.
  • Nothing is still moving: feat!: meter Satin's writes and frame starts in the revm fork's hooks, on a per-frame byte pool #407 has merged into this branch, every module is at its final state, and the guide's "Design changes a reviewer must know" lists what feat!: meter Satin's writes and frame starts in the revm fork's hooks, on a per-frame byte pool #407 changed in what the earlier descriptions say.
  • The specification: docs/spec/upgrades/satin.md states each Satin rule against Rex6; review the code against it. AGENTS.md holds the implementation notes, and REVIEW.md the repository's review rubric.

What is still to come

Nothing more in this pull request. #407 (cz/feat/satin-w9) has merged into this branch with the rest of the engine: inspectors on revm's own inspected lifecycle, a rewritten result taken as given, one fixed gas budget for the pre-block calls, the cleanups, the KV-update and state-growth limits with their 98/100 frame shares, a creator's nonce record counted on the creator when its creation returns, and the same-transaction destruction table.

This pull request is retargeted to satin and merges there. After it, the fork pins move to the merged commits of the fork pull requests, and to tags once the line is final; follow-up pull requests on satin simplify the engine further, reading more of its accounting off revm's journal, and take up the performance work.

Out of scope

bin/mega-evme, bin/mega-t8n, crates/mega-state-test and crates/state-test
still target the legacy engine. Their sources stay untouched; they rejoin
the workspace when they are ported to Satin.
Delete the 274 integration tests the test inventory marks legacy-only or
retired (125 + 149) and move the 691 that survive into Satin under
tests/_pending/, unchanged apart from the deletions. _pending has no
main.rs, so Cargo does not build it; its README names the owning
mechanism for every file. compute_gas/snapshot.txt goes with the
per-opcode counter it pinned (D40).
revm 27.1 -> 40.0.3 with all twelve revm crates patched to the megaeth
fork (rev 43485bf7), op-revm 8.1 -> 20.0.0 from the OP monorepo revision
the node locks (f67d87cd) patched to the megaeth op-revm fork (rev
817e36e9), alloy-evm 0.15 -> 0.36, alloy-op-evm 0.15 -> 0.32,
revm-inspectors 0.27 -> 0.40, alloy 1.x -> 2.x, alloy-primitives 1.6.
The workspace becomes 2.0.0-alpha.1 (path-dependency versions in
lockstep) and MSRV 1.94 (op-revm).

The legacy core cannot build on revm 40, so its sources leave in the
same commit: src/, the two examples and every bench target (the test
gates rebuild them; the bench inputs under benches/data and
benches/fixtures stay). The 181 inline unit tests the test inventory
keeps move to tests/_pending/src/; the 107 it marks legacy-only or
retired go with the code. The 11 mutants suppressions on limit/* go
with limit/.
…tion table

Satin is a single-spec engine: MegaSpecId has one rung, SATIN, running on
Karst / Osaka. Display and FromStr use "Satin" and serde the variant name
"SATIN", the same forms the legacy specs use. Legacy names (Equivalence ..
Rex7) fail to parse with ParseMegaSpecError::Legacy and are never mapped to
Satin. The discriminant continues after the legacy ladder (12).

MegaHardfork has the single fork Satin. The activation table carries the
mainnet and testnet chain ids with the Satin timestamp unset.

The three spec tests the test inventory assigns to the Satin skeleton are
ported here (the Isthmus/Prague mapping test now pins Karst/Osaka).
ExternalEnvFactory, ExternalEnvTypes, ExternalEnvs, SaltEnv, OracleEnv,
EmptyExternalEnv, the bucket hasher and TestExternalEnvs come back from the
legacy core as they were, with their seven unit tests. gas.rs
(DynamicGasCost) stays out: it belongs to SALT pricing.
constants.rs holds the Satin placeholders in one table, each marked
provisional: CPSB 1,530 (slot 97,920, account 183,600), CPHB 88, the
200,000,000 execution cap and the Rex6 data-size limits.

types.rs names the transaction, halt reason, error and envelope types.
The transaction is alloy-op-evm's OpTx, which carries the signed-tx
conversions the node's block executor uses, so the core now depends on
alloy-op-evm (its op-revm edge resolves to the op-revm fork).
MemoryDatabase, ErrorInjectingDatabase, BytecodeBuilder, GasInspector and
right_pad_bytes come back from the legacy core; the only change is the
DatabaseCommit signature, which takes EvmState on revm 40. transact() and
the BytecodeBuilder execution tests follow with the engine.
MegaContext wraps op-revm's context shape (MegaTransaction, L1BlockInfo)
and holds the MegaSpecId configuration next to the OpSpecId one op-revm
executes on; both are written together. The spec fixes Karst on the Osaka
gas table, EIP-8037 and EIP-2780 on, a 200M tx_gas_limit_cap, and EIP-7708
and the system-call reservoir margin off, whatever configuration the caller
passes. Every Host and context method delegates to the wrapped context.

MegaEvm wraps OpEvm over MegaContext with op-revm's precompiles and
implements revm's execution, inspection and system-call traits and
alloy-evm's Evm; MegaEvmFactory implements alloy-evm's EvmFactory and
feeds each EVM the external environments of its block.

Tests: an empty transaction, a value transfer and one SSTORE run through
MegaEvm and through op-revm's OpEvm on the same CfgEnv and agree on every
ResultGas field, the logs and the state; the empty transaction leaves
exactly the gas above the 200M cap in the reservoir. Twelve of the
unit tests the test inventory assigns to the Satin skeleton are ported;
four that need later work are parked under the common execution layer,
the Satin gas table, SALT pricing and the block executor with the reason
in the pending README. test_utils::transact now takes the spec and the
gas limit.
…nly modules

The RLP decoding, signer recovery, deploy-address derivation and the
IKeylessDeploy error ABI move from the retired sandbox module with their
fourteen unit tests. Satin has one spec, so decode_keyless_tx drops its
spec argument and always rejects trailing bytes (the Rex5 rule); the
pre-Rex5 acceptance test goes. ParentBudgetExceeded goes too: it named the
sandbox preflight, which the native CREATE sub-frame (native keyless
deployment) retires.
…eleton

limit/ and access/ are empty modules whose doc line names the mechanisms
that fill them (the observe-stage-commit layer and the abort protocol, the
data-size limit, detention, the state-gas limits and the KV count).
MegaBlockExecutor only owns its EVM for now; block execution lands on it
with the block executor.
Re-export the upstream crates consumers must build against (revm,
op-revm, alloy-evm, alloy-op-evm, alloy-primitives, alloy-hardforks,
alloy-consensus, alloy-sol-types, op-alloy-consensus), keep the short
aliases (SpecId, Evm, EvmFactory, Context, BlockExecutor, ...), and warn
on unused crate dependencies again. derive_more is only used by the test
utilities, so it becomes an optional dependency of the test-utils feature.
Times ExecuteEvm::transact for an empty transaction and an ether transfer
through MegaEvm and through op-revm's OpEvm on the same CfgEnv. It keeps
one bench target alive until the test gates rebuild the suite.
The diff-scoped cargo-mutants run over this series left three survivors.
Two were test gaps: InspectEvm::set_inspector and MegaContext::modify_chain
now have tests that observe them. The third, From<MegaSpecId> for
EthSpecId -> Default::default(), is equivalent while revm's default spec
is Osaka, the spec SATIN maps to; it gets a line suppression, which stays
live and turns into a no-op when a revm upgrade moves the default.
AGENTS.md now describes the satin branch: one spec (SATIN on Karst), the
fork patches, the module table with the mechanism that fills each module,
the _pending test layout, and the rule that today's main is the legacy
engine and Satin is the only active spec. The unchanged conventions
(test_ prefix, no_std, determinism, cargo sort, lint and format, one
sentence per line, Prettier) stay as they were. The crate README follows,
and REVIEW.md points at the parked mutation tests.
replay-bench builds state-test, which is no longer a workspace member:
its bench job is disabled with if: false until state-test is ported to
Satin (compare needs it and is skipped with it). The benchmark matrix
shrinks to transact, the only bench target until the test gates rebuild
the suite.

The suppression-hygiene step grepped the spec-gate plan in a pipeline; on
a single-spec engine the pack finds no gates, grep exits 1 and pipefail
failed the step. The plan now runs on its own, so a planner failure still
fails, and only the grep tolerates an empty result.
The data-only keyless module dropped the variant because its kind field
was the legacy LimitKind. It comes back with the raw kind code of the
IKeylessDeploy error, so every error the interface declares still encodes
and decodes; the Satin meaning of the code is left to the limit
mechanisms that define the resource dimensions.
test_utils gains zero_fee_l1_block_info() and op_transaction(), which
the unit tests, the equivalence tests, the bench and transact() now
share instead of four copies. The equivalence tests also check the gas
table, EIP-7708 and the reservoir margin, and pin that the SSTORE draws no
state gas on the Osaka table. The external-env getter test now observes
the factory it was given, and the with_cfg test is named for what it
checks.
AGENTS.md gets back two rules later mechanisms need (only CALL and
STATICCALL reach interceptor dispatch; per-fork params are validated at
load time) and says plainly that state gas is priced at zero until the
Satin gas table lands; the crate README says the same and keeps one
sentence per line. REVIEW.md's two pointers to moved or deleted files
name both branches. The constants docs no longer cite an outside
document. The pending README lists the 36 tests ported in place, which
is why its counts sit below the test inventory's, and the manifest says
why the bench inputs stay.
The spec-gate grep accepted any exit status; it now accepts only 1 (no
match), so a real grep failure (exit 2) still fails the step.
RealiCZ added a commit that referenced this pull request Oct 22, 2026
Brings the review guide's update for #386. The guide's four conflicting
passages keep this branch's facts (the forks pinned by rev, block execution
admitting any inspector); the rest of the update merges as is and is
refreshed for this branch's code when it merges into cz/feat/satin-w2.
@RealiCZ RealiCZ changed the title feat!: the Satin gas table, block executor, SALT pricing and system contract interceptors feat!: the Satin execution engine on the revm 40 forks Oct 22, 2026
@RealiCZ RealiCZ added comp:misc Changes to the miscellaneous part of this repo comp:doc Changes in the documentation labels Oct 22, 2026
RealiCZ and others added 2 commits October 23, 2026 10:25
…, on a per-frame byte pool (#407)

Co-authored-by: mega-putin <mega-putin[bot]@users.noreply.github.com>
Co-authored-by: William Cheung <git@troublor.xyz>
…om the journal once revm decided it

The records, the state growth and the transfer log a start made on its frame are read off the
journal against the start's accounts as they stood before revm decided it. The prediction stays,
for now, for the two answers that keep nothing to read and as a debug check that the two agree.
An answer that failed kept nothing, revm having taken back what its start moved, so it is held to
nothing and its result is revm's: a value call to a precompile that fails burns its forward as any
failed precompile does, rather than being answered with a limit's stop. A precompile call gas
detention answers from its price before revm runs it moved nothing, so it is held to the price
alone: the stop is the compute stop, whatever the records its value transfer would have made.
What a start counts is read off the journal once revm decided it, so the prediction made before
revm's decision, its debug check against the journal and the growth predictors go. The records a
start's opcode prices stay, without the transfer log, which costs no history.
A start's records are counted from what revm did to its accounts. An answer that failed kept
nothing and is held to nothing, its result the precompile's failure; a precompile answered from
its price before its value moves is held to the price alone, the compute stop.
… state what a precompile's writes do to a start

One rule reads what changed of an account since before revm decided a start, for a start's
accounts and for a creator alike, and a start counts itself in one step. A node's own precompile
that writes the start's accounts in its answer has those writes read with the start, which revm
runs it inside; the docs say so, the spec's detention answer is a MUST, and stale prose about the
start is restated.
@flyq

flyq commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

High-level review (1/4): the legacy engine will run on the revm fork in the node, and does not build there yet

This is about how #386 fits with the other repos, not about its own diff. I think it is a release prerequisite for Satin.

The root [patch.crates-io] replaces revm 40.0.3 for the whole dependency graph. A node that links both engines therefore has exactly one revm 40.0.3, the fork, and the legacy engine (Rex6) will replay every pre-Satin block on the fork's revm and op-revm. The dependency lines already line up for this: legacy main, Satin and mega-reth develop are all on alloy 2.0.5, alloy-evm 0.36, revm 40.0.3 and OP monorepo f67d87c.

Two problems:

  1. Legacy main does not compile against the fork. The v112..v117 gas core changed three Handler methods that legacy main overrides or delegates:

    • first_frame_input(evm, gas_limit, reservoir) -> Result<FrameInit> became (evm, gas: &mut GasTracker) -> Result<Option<FrameInit>> (fork, v112)
    • last_frame_result(evm, _original_reservoir, frame_result) became (evm, frame_result, parent_gas: &mut GasTracker) (fork, v112)
    • refund(..) now returns Result<(), _> (fork, v112)

    Legacy main uses all three (refund, first_frame_input, last_frame_result). So the planned legacy 1.8.x line has to be a port, not a frozen copy.

  2. Nothing sets legacy_journal_semantics. The fork's CfgEnv::legacy_journal_semantics exists for the legacy engine, and it defaults to false, which is the picked behaviour (the cold-slot original_value re-baseline and the CodeChange revert). Neither legacy main nor mega-reth sets it. Satin pins it to false (context.rs#L617). Unless something turns it on, the legacy engine's EIP-2200 gas can differ from the blocks it replays. The journal finalize-on-error pick (upstream #3780) has no switch at all, and the fork's tracking policy takes upstream fixes that change results.

Suggestion: treat "legacy ported to the fork API" and "Satin" as one release unit. The node, or legacy 1.8.x itself, sets legacy_journal_semantics = true for every pre-Satin spec. Make it a prerequisite to replay the full mainnet and testnet history with legacy 1.8.x on the fork, block by block (state root and receipts), and to do the same in the stateless validator. Nothing in this repo covers that combination today: mega-evme's legacy path links the released 1.7.1 on revm 27.

@flyq

flyq commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

High-level review (2/4): the revm fork carries MegaETH semantics, not only hooks

The fork is described as "upstream v112 + thin hooks", and its own Rule 2 says it "adds hooks and data; it does not implement MegaETH semantics". At the pinned fd230a34 it is 101 commits over v112:

The pieces that implement MegaETH semantics inside revm's own forwarding and settlement are:

  • three resource pools on GasTracker
  • the reservoir kept-percent share
  • the start-charge refund
  • the history allowance
  • the history-gas dimension and withheld gas from the earlier commits

One upstream public function also changes meaning. handle_reservoir_remaining_gas / absorb_returned_reservoir now add the child's reservoir to the caller's instead of replacing it. Results do not change at the default parameters, but a consumer that builds a child frame by hand counts the reservoir twice; revm#76 names Foundry's deployCode and run_inspected_frame. That conflicts with Rule 1 ("do not change the meaning of existing accessors") and currently passes as an api:exception.

What this costs:

  • The future move to revm 41/43 means re-applying these semantics on top of a changed gas core.
  • Little of it can go upstream, so the fork keeps diverging.
  • Downstream tools (Foundry, anvil) cannot reuse the fork as it is.

Suggestion: classify the fork commits in MEGAETH-FORK.md as the plan for the next base-line move:

  • (a) upstream gas-core picks, which go away at 43;
  • (b) generic hooks worth proposing upstream: write_completed, frame_starting, frame_run_result, Precompile::required_gas, admit_code_deposit, the failed-charge record;
  • (c) MegaETH semantics: pools, reservoir share, history gas and allowance, start charge, withheld gas.

For (c), it is worth checking whether an extension slot that is opaque to revm could replace the named semantics, with mega-evm forwarding and settling it through the hooks.

@flyq

flyq commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

High-level review (3/4): the larger frame result is a structural cost; measure it before Satin is sealed

megaeth-labs/revm#76's own callgrind numbers, for a consumer that sets nothing:

  • a transfer: +6.2%
  • each CALL in a 64-CALL transaction: +17.9%

Most of this comes from size, not from the hooks themselves. FrameResult grows from 208 to 296 bytes and passes the size the compiler copies inline, so its moves through the handler's Result / ItemOrResult wrappers become memcpy calls. GasTracker grows from 88 to 144 bytes.

That baseline does not include mega-evm's own per-frame work: reading the journal before and after every frame start (FrameStart::of), net-change write records, the detention records, and the registry read every transaction makes.

Because it comes from the data layout (the pools and the start charge sit on GasTracker and move with every FrameResult), later tuning is unlikely to win it back.

Suggestion:

  • Before Satin is sealed, compare Rex6 and Satin throughput on recorded mainnet blocks (or the block / corpus benches under CodSpeed), and agree on a budget.
  • If Satin is over the budget, consider moving the pools and the start charge out of FrameResult, for example into a side table in the context keyed by frame index.

@flyq

flyq commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

High-level review (4/4): Satin's rules depend on EIP-8037 at a pinned commit and on revm's journal internals

EIP-8037. satin.md §4 applies EIP-8037 as of c406bdb2ff (2026-09-04). It then takes some later commits (3ff5cff17a, part of 9dcd72754f, dbc6d457cb) and declines others (d7087803ca). On top of that come Satin's own departures: the 98/100 reservoir share and the destruction-table refill. Glamsterdam was postponed because 8037 is still under discussion.

Suggestion: write down an explicit "Satin's 8037 profile" with a freeze point. Later upstream changes to 8037 then become inputs to the next spec, instead of something the fork's gas core tracks.

revm journal internals. The write-record definition (§10) depends on "the account's original info, which revm sets at the account's first load in the transaction". revm's journal behaviour is therefore part of consensus. The fork's tracking policy, however, takes "fixes that change execution results … verbatim", with no spec gate. Its first journal switch (legacy_journal_semantics) exists only because of the legacy engine.

Suggestion: once Satin is active, any fork pick that changes an execution result is a hard fork. Add a rule to the fork's "Tracking upstream" section: after Satin is sealed, either such a pick lands behind a switch that a MegaSpecId turns on (the pattern legacy_journal_semantics already uses), or only picks that change no result are taken.

This branch has not been deployed

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants