Skip to content

refactor(revm): drop custom SLOAD/SSTORE overrides in favour of native instructions - #194

Merged
panos-xyz merged 1 commit into
fix/reth-2.5.2-upgrade-followupsfrom
refactor/remove-sload-sstore-overrides
Sep 7, 2026
Merged

refactor(revm): drop custom SLOAD/SSTORE overrides in favour of native instructions#194
panos-xyz merged 1 commit into
fix/reth-2.5.2-upgrade-followupsfrom
refactor/remove-sload-sstore-overrides

Conversation

@panos-xyz

Copy link
Copy Markdown
Contributor

Summary

Deletes sload_morph / sstore_morph from crates/revm/src/evm.rs and restores the native EthInstructions for SLOAD (0x54) and SSTORE (0x55). -171 / +14, one file.

Closes #187 — but not with the mechanism that issue proposed. See my analysis there: the handler-level transaction_id sync described in the issue is a no-op on revm 41 (the ids already match; the trigger is the is_cold flag), and implementing it as written reintroduced the divergence at exactly +2800 gas. The real precondition was a revm bump, which #188 delivers.

Third in a stack — merge order matters, and getting it wrong is a chain split:

#188  chore: upgrade reth dependencies to v2.5.2      (brings revm 42)
 └─ #191  fix: close reth 2.5.2 upgrade gaps
     └─ this PR

Why the overrides existed

Token fee deduction leaves the caller's fee-token slot in a specific state, which is the same state morph-geth has:

field value morph-geth equivalent
original_value DB-committed (pre-deduction) GetCommittedState()
present_value fee-deducted GetState()
is_cold true not in accessList

In go-ethereum the EIP-2200 committed value and the EIP-2929 access list are deliberately independent. revm <= 41 conflated them in EvmStorageSlot::mark_warm_with_transaction_id:

let is_cold = self.is_cold_transaction_id(transaction_id);  // tx_id differs || self.is_cold
if is_cold {
    self.original_value = self.present_value;   // one condition drove both gas and re-baseline
}

Charging the 2100 cold cost required setting is_cold, and setting is_cold forced the re-baseline. EIP-2200 then saw a clean slot and charged SSTORE_RESET (2900) instead of the dirty-slot 100 — 2800 gas per write against morph-geth. The overrides existed purely to undo that.

Why they are no longer needed

bluealloy/revm#3746 (released in revm-state 42.0.0, still present in 43.0.0) keys the re-baseline on the transaction boundary instead of on the cold flag, while leaving the returned is_cold — and therefore the cold gas charge — untouched. The committed value now survives an explicit mark_cold(), so native SLOAD/SSTORE produce geth-equivalent gas on their own.

Verification

Controlled comparison against the golden e2e test, one variable between the rows:

tree morph_tx_v0_token_fee_transfer_to_fee_token_contract_gas_regression
revm 41 + overrides 48_128 — previous state
revm 41 − overrides 50_928 — +2800, the divergence the overrides hid
revm 42 − overrides 48_128 — this PR

The middle row matters as much as the last: it proves that test is a real tripwire and not a permanently green assertion. It is therefore left untouched as the gate for this change.

Full local run on the pushed commit:

cargo nextest run --workspace                     644 passed, 0 failed
cargo nextest run -p morph-node -E 'binary(it)'   101 passed, 0 failed
cargo clippy --all --all-targets -- -D warnings   clean
cargo fmt --all -- --check                        clean

Note CI's Build / Clippy / Run Tests / E2E Tests workflows are gated on pull_request: branches: [main], so they will not run while this PR targets a non-main base. The gates above were run locally on cd881df; they need a real CI run once the stack is retargeted.

Drifts this retires

The copies had already accumulated three divergences from upstream — the concrete evidence that hand-maintaining SSTORE was the wrong trade:

  1. EIP-8037 never mirrored. Upstream sstore_default_gas_accounting charges state_gas! and calls refill_reservoir; sstore_morph had neither. Dormant only because every Morph hardfork maps below SpecId::AMSTERDAM (asserted by test_morph_hardforks_do_not_enable_amsterdam_state_gas).
  2. Stale cold-load threshold. revm 42 moved the SSTORE skip check from cold_storage_additional_cost() (2000) to cold_storage_cost() (2100); sstore_morph still used the old one. Unreachable — the Istanbul reentrancy sentry already requires remaining > call_stipend (2300) before that branch.
  3. Over-broad repair. The original_value restore ran for every cold slot, including slots of an account created within the same transaction, where revm reports ZERO while the DB may hold residual storage. Practically unreachable (SELFDESTRUCT is disabled in Morph), but wrong by construction.

Kept

blockhash_morph (0x40) and the disabled SELFDESTRUCT / BLOBHASH / BLOBBASEFEE opcodes — genuine Morph semantics, unrelated to revm's version. A comment at the registration site now records why 0x54/0x55 are deliberately not overridden, so the workaround does not get reintroduced.

…e instructions

`sload_morph` and `sstore_morph` existed to undo an `original_value` clobber in
revm, not to express any Morph rule. `sstore_morph` in particular re-implemented
the whole upstream SSTORE lifecycle — static-call check, reentrancy sentry,
Berlin cold loading, dynamic gas and refund accounting — to insert one repair in
the middle of it.

Why they were needed: token fee deduction leaves the caller's fee-token slot
with the DB-committed `original_value`, the fee-deducted `present_value` and
`is_cold = true`. That is the same triple morph-geth has, where the EIP-2200
committed value (`GetCommittedState`) and the EIP-2929 access list are
independent. revm <= 41 conflated them in
`EvmStorageSlot::mark_warm_with_transaction_id`, re-baselining
`original_value = present_value` whenever a slot was cold — including a slot
marked cold within the same transaction. EIP-2200 then saw a clean slot and
charged `SSTORE_RESET` (2900) instead of the dirty-slot 100, diverging from
morph-geth by 2800 gas per write.

Why they are no longer needed: bluealloy/revm#3746 (released in revm-state
42.0.0, still present in 43) keys that re-baseline on the transaction boundary
instead of on the cold flag, so the committed value survives an explicit
`mark_cold()`. The native instructions now produce geth-equivalent gas on their
own. Verified as a controlled comparison against the golden e2e test:

  revm 41 + overrides   48_128  (previous state)
  revm 41 - overrides   50_928  (+2800: the divergence these overrides hid)
  revm 42 - overrides   48_128  (this commit)

The middle row also confirms
`morph_tx_v0_token_fee_transfer_to_fee_token_contract_gas_regression` is a real
tripwire rather than a permanently green assertion, so it is left untouched as
the gate for this change.

Removing the copies also retires three drifts that had already accumulated in
them:

- the EIP-8037 branch of upstream `sstore_default_gas_accounting` (`state_gas!`
  plus `refill_reservoir`) was never mirrored; dormant only because every Morph
  hardfork maps below AMSTERDAM.
- the SSTORE cold-load skip threshold stayed at revm 41's
  `cold_storage_additional_cost()` (2000) after upstream 42 moved it to
  `cold_storage_cost()` (2100); unreachable because the Istanbul reentrancy
  sentry already requires `remaining > call_stipend` (2300).
- the `original_value` repair ran for every cold slot, including slots of an
  account created within the same transaction, where revm reports `ZERO` while
  the DB may still hold residual storage.

`blockhash_morph` (0x40) and the disabled SELFDESTRUCT / BLOBHASH / BLOBBASEFEE
opcodes stay: those are genuine Morph semantics, unrelated to revm's version.

Closes #187
@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: c2fb368f-0658-447c-ba97-77cd559f6c7c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@panos-xyz panos-xyz self-assigned this Sep 7, 2026
@panos-xyz
panos-xyz merged commit 322ad1a into fix/reth-2.5.2-upgrade-followups Sep 7, 2026
3 checks passed
@panos-xyz
panos-xyz deleted the refactor/remove-sload-sstore-overrides branch September 7, 2026 07:33
panos-xyz added a commit that referenced this pull request Sep 7, 2026
…gas (#191)

* fix: close reth 2.5.2 upgrade gaps in engine validator and intrinsic gas

Follow-ups to the v2.4.0 -> v2.5.2 upgrade, found while auditing that diff
against the upstream sources.

Forward `EngineValidator::on_canonical_head_changed`. reth 2.5.0 added this
method with an empty default body, so `MorphTreeEngineValidator` — a wrapper
that must delegate every method to `inner` — silently replaced
`BasicEngineValidator`'s implementation (txpool prewarming, opt-in via
`--engine.txpool-prewarming`) with the no-op default instead of failing to
compile. Document that forwarding contract on the type so the next upgrade
re-checks it: a method with a default body is exactly the case the compiler
cannot catch.

Derive the EIP-2780 intrinsic-gas info in `validate_initial_tx_gas` from
`Cfg::is_amsterdam_eip2780_enabled()` the way revm's own handler does, instead
of hardcoding the new `None` argument. `CfgEnv::with_spec_and_mainnet_gas_params`
(used by `MorphEvmConfig`) derives that flag from the spec, so a hardcoded
`None` would silently diverge from upstream intrinsic gas if a Morph hardfork
ever mapped to AMSTERDAM. Behaviour is unchanged today: every Morph hardfork
maps below AMSTERDAM.

Extend `test_morph_hardforks_do_not_enable_amsterdam_state_gas` to assert the
EIP-2780 flag stays off for every Morph hardfork, with a positive control at
AMSTERDAM so the guard cannot become vacuous if the flag stops being
spec-derived.

Restore the intent of `test_eip7702_refund_stays_regular_for_morph_specs`.
revm 42 removed `tx_eip7702_state_refund` / `tx_eip7702_auth_refund`, so the
test had been reduced to a duplicate of the state-gas assertion above it while
keeping a message about refunds, plus a hardcoded 12500. It now asserts the
surviving equivalent — the full per-auth refund equals
`PER_EMPTY_ACCOUNT_COST - PER_AUTH_BASE_COST` — against revm's constants.

Drop `reth-trie-db`: it lost its last use when the `ChangesetCache` parameter
was replaced by `OverlayManager`.

Also record why `evm_call` hands a fresh journal checkpoint to
`Handler::execution` and why its `None` arm is unreachable for Morph.

* refactor(revm): drop custom SLOAD/SSTORE overrides in favour of native instructions (#194)

`sload_morph` and `sstore_morph` existed to undo an `original_value` clobber in
revm, not to express any Morph rule. `sstore_morph` in particular re-implemented
the whole upstream SSTORE lifecycle — static-call check, reentrancy sentry,
Berlin cold loading, dynamic gas and refund accounting — to insert one repair in
the middle of it.

Why they were needed: token fee deduction leaves the caller's fee-token slot
with the DB-committed `original_value`, the fee-deducted `present_value` and
`is_cold = true`. That is the same triple morph-geth has, where the EIP-2200
committed value (`GetCommittedState`) and the EIP-2929 access list are
independent. revm <= 41 conflated them in
`EvmStorageSlot::mark_warm_with_transaction_id`, re-baselining
`original_value = present_value` whenever a slot was cold — including a slot
marked cold within the same transaction. EIP-2200 then saw a clean slot and
charged `SSTORE_RESET` (2900) instead of the dirty-slot 100, diverging from
morph-geth by 2800 gas per write.

Why they are no longer needed: bluealloy/revm#3746 (released in revm-state
42.0.0, still present in 43) keys that re-baseline on the transaction boundary
instead of on the cold flag, so the committed value survives an explicit
`mark_cold()`. The native instructions now produce geth-equivalent gas on their
own. Verified as a controlled comparison against the golden e2e test:

  revm 41 + overrides   48_128  (previous state)
  revm 41 - overrides   50_928  (+2800: the divergence these overrides hid)
  revm 42 - overrides   48_128  (this commit)

The middle row also confirms
`morph_tx_v0_token_fee_transfer_to_fee_token_contract_gas_regression` is a real
tripwire rather than a permanently green assertion, so it is left untouched as
the gate for this change.

Removing the copies also retires three drifts that had already accumulated in
them:

- the EIP-8037 branch of upstream `sstore_default_gas_accounting` (`state_gas!`
  plus `refill_reservoir`) was never mirrored; dormant only because every Morph
  hardfork maps below AMSTERDAM.
- the SSTORE cold-load skip threshold stayed at revm 41's
  `cold_storage_additional_cost()` (2000) after upstream 42 moved it to
  `cold_storage_cost()` (2100); unreachable because the Istanbul reentrancy
  sentry already requires `remaining > call_stipend` (2300).
- the `original_value` repair ran for every cold slot, including slots of an
  account created within the same transaction, where revm reports `ZERO` while
  the DB may still hold residual storage.

`blockhash_morph` (0x40) and the disabled SELFDESTRUCT / BLOBHASH / BLOBBASEFEE
opcodes stay: those are genuine Morph semantics, unrelated to revm's version.

Closes #187
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant